A Pen by semanticentity on CodePen.
Created
October 10, 2025 21:34
-
-
Save semanticentity/db53d439c5b21fcaa02adc332f60638a to your computer and use it in GitHub Desktop.
WireframeLang Editor v0.0.2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- WIZZY PANEL --> | |
| <div id="wizzy"> | |
| <button data-sn="--- ">New section</button> | |
| <button data-sn=" | ">Column “|”</button> | |
| <button data-sn="--- 50/50 ">2-col 50/50</button> | |
| <button data-sn="--- 33/34/33 ">3-col thirds</button> | |
| <button data-sn="▣ ">CTA ▣</button> | |
| <button data-sn="▸ ">Sub-head ▸</button> | |
| <button data-sn="• ">Bullet •</button> | |
| <button data-sn="[[progress 50]]">Progress</button> | |
| <button data-sn="[[img 400x300 kitten]]">Kitten 🐱</button> | |
| <button id="exportJSON">💾 JSON</button> | |
| <button id="importJSON">📂 Import</button> | |
| <input type="file" id="importFile" accept=".json" style="display:none;"> | |
| <button id="savePNG">💾 PNG</button> | |
| <button id="saveSVG">➰ SVG</button> | |
| <button id="share">🔗 Share</button> | |
| <button id="preview">↻ Preview (WIP)</button> | |
| <button id="pasteJSON">📋 Paste JSON</button> | |
| </div> | |
| <!-- WIZZY PANEL (add after the last <button>) --> | |
| <select id="tpl" style="background: linear-gradient(to bottom, #313639 0%, #1c1c1c 33%, #1d1e22 66%, #101010 99%); border: 0; border-bottom: 1px solid #000; color: #fff; border-radius: 0;"> | |
| <option value="">— Templates —</option> | |
| <option value="hero">Hero section</option> | |
| <option value="markdowndemo">Markdown Demo</option> | |
| <option value="helpModal">Help Modal</option> | |
| <option value="featureCallouts">Feature Callouts (2-up)</option> | |
| <option value="imageGallery">Image Gallery (4-up)</option> | |
| <option value="pricing">Pricing table (3-up)</option> | |
| <option value="comparison">Feature Comparison</option> | |
| <option value="steps">Steps to Start</option> | |
| <option value="nav">Top Navigation</option> | |
| <option value="features">Feature Highlights (cards)</option> | |
| <option value="team">Meet the Team (cards)</option> | |
| <option value="cards">Cards Grid</option> | |
| <option value="heroWorkflow">Hero – Workflow</option> | |
| <option value="value50">50/50 Value Props</option> | |
| <option value="progress3">Three-Step Progress</option> | |
| <option value="featuresCards">Feature Bullets</option> | |
| <option value="highlight">Product Highlight</option> | |
| <option value="useCases">Use-Case Columns</option> | |
| <option value="integrations">Integrations Row</option> | |
| <option value="pricingTable">Pricing 3-up</option> | |
| <option value="kittenDiv">Kitten Divider</option> | |
| </select> | |
| <main id="editor"> | |
| <textarea id="src" spellcheck="false" style="border-radius: 0;"> | |
| ## WireframeLang supports coments | |
| ## Both inside and outside sections | |
| --- WireframeLang Documentation | |
| [[img 860x300 kitten]] | |
| ▸ Overview | |
| • WireframeLang (WFL) lets you create quick visual layouts using plain text. | |
| • Every section starts with three dashes `---`. | |
| • Common UI elements are easy to write and fast to preview. | |
| --- Example: Overview | |
| # Welcome to WireframeLang | |
| • Fast prototyping | |
| • Plain-text layouts | |
| • Instant live preview | |
| ▣ Start building now | |
| --- Sections | |
| ▸ Section Basics | |
| • `--- Section Title` | |
| • `--- 50/50 Title` → Two equal columns. | |
| • `--- 33/33/33 Title` → Three equal columns. | |
| • `--- cards3 Title` → Card layout with 3 cards. | |
| • Use `|wide|` inside sections to make a full-width row. | |
| --- 50/50 50/50 Example | |
| Left side content | Right side content | |
| --- 33/33/33 Example | |
| One third | One third | One third | |
| --- cards3 Example | |
| h3: Card A | |
| h3: Card B | |
| h3: Card C | |
| |wide| Spanning text below the cards. Full width content. | |
| --- Text & Markdown | |
| ▸ Headings | |
| # H1 Example | |
| • `# Heading` → H1 | |
| H2: H2 Example | |
| • `h2 Heading` → H2 | |
| ▸ ▸ Subhead Example | |
| • `▸ Subhead` → H3 | |
| H3: H3 Example | |
| • `H3: Manual Heading` → H3 | |
| ▸ Text Formatting | |
| • `**bold**` → Bold text | |
| • `*italic*` → Italic text | |
| • `~~strike~~` → Strikethrough | |
| • `` `inline code` `` → Inline code snippet | |
| ▸ Lists | |
| • `• Bullet point` | |
| • `1. Numbered item` | |
| ▸ Links | |
| • `[Link Text](https://example.com)` | |
| • `https://example.com` (auto-link) | |
| ▸ Blockquotes (styling tk) | |
| • `> Quoted text here` | |
| --- Layouts and Grids | |
| ▸ Grids | |
| • Use `|` to split content into columns inside 50/50 or 33/33/33 layouts. | |
| ▸ Wide Rows | |
| • Insert `|wide|` before a line to make it span all columns/cards. | |
| --- 50/50 50/50 Split | |
| Left column text | Right column text | |
| |wide| Full width note across both columns. | |
| --- Components | |
| ▸ Buttons and CTAs | |
| • `▣ Call to Action` → Big Button | |
| • `[[button "Small Button"]]` | |
| ▸ Progress Bars | |
| • `[[progress 50]]` → 50% filled bar | |
| ▸ Images | |
| • `[[img 400x300 kitten]]` | |
| • `[[img 400x300 unsplash:nature]]` | |
| • `[[img 400x300 bw]]` | |
| • `[[img 400x300 blur]]` | |
| ▸ Modals | |
| • `[[modal ModalID Open Modal]]` → Button to open a modal. | |
| • Define modal with: | |
| `--- modal "ModalID"` | |
| `---- header` | |
| `Modal Title` | |
| `---- body` | |
| `Modal Content` | |
| `---- footer` | |
| `Modal Footer` | |
| --- Example: Components | |
| ▣ Big Call to Action | |
| [[button "Secondary Button"]] | |
| [[progress 60]] | |
| [[img 400x200 kitten]] | |
| [[modal DemoModal Open Modal Example]] | |
| --- modal "DemoModal" | |
| ---- header | |
| Demo Modal Title | |
| ---- body | |
| Welcome to the demo modal! | |
| • Supports bullets | |
| • Inline elements | |
| ▣ Close me | |
| ---- footer | |
| Footer content | |
| --- Forms | |
| ▸ Form Blocks | |
| • `[[form]]` ... `[[/form]]` to group fields. | |
| ▸ Fields | |
| • `[[input email "Your e-mail"]]` | |
| • `[[password pass "Password"]]` | |
| • `[[textarea message "Your message"]]` | |
| • `[[select country USA|Canada|UK]]` | |
| • `[[checkbox tos "I agree to terms"]]` | |
| • `[[submit "Send"]]` | |
| • `[[upload file "Upload file"]]` | |
| • `[[color favoriteColor "Pick a color"]]` | |
| • `[[range volume "Volume"]]` | |
| ▸ Form Layout | |
| • Use `[[grid]]` and `[[/grid]]` with `|` to split form fields. | |
| --- Example: Forms | |
| [[form]] | |
| [[grid]] | |
| [[input email "Your email"]] | [[password pass "Password"]] | |
| [[select country USA|Canada|UK]] | |
| [[checkbox tos "Accept terms"]] | |
| [[submit "Submit Form"]] | |
| [[/grid]] | |
| [[/form]] | |
| --- Escaping Special Characters | |
| ▸ Escaping Syntax | |
| • `\▸` → literal ▸ | |
| • `\▣` → literal ▣ | |
| • `\•` → literal • | |
| • `\>` → literal > | |
| --- Example: Escaping Characters | |
| \▸ Not a heading | |
| \▣ Not a button | |
| \• Not a bullet | |
| \> Not a blockquote | |
| --- Advanced Features | |
| ▸ Image sources: | |
| • `kitten` → Cute cats | |
| • `unsplash:topic` → Unsplash topics | |
| • `bw` → Black and white | |
| • `blur` → Blurred random | |
| ▸ JSON Import/Export | |
| • Backup and restore wireframes. | |
| ▸ PNG/SVG Export | |
| • Snapshot your layout. | |
| ▸ Share Links | |
| • URL encodes and shares your layout easily. | |
| --- cards2 Example: Advanced Features | |
| |wide| [[progress 25]] | |
| [[img 400x300 blur]] | |
| [[img 400x300 kitten]] | |
| |wide| [[progress 50]] | |
| [[img 400x300 unsplash:nature]] | |
| [[img 400x300 bw]] | |
| |wide| [[progress 100]] | |
| --- Templates Built In | |
| ▸ Templates Provided | |
| • Hero Sections | |
| • Pricing Tables | |
| • Feature Lists | |
| • Galleries | |
| • Steps to Start | |
| • Navigation Bars | |
| ▸ Access | |
| • Use the Templates dropdown inside the editor toolbar. | |
| --- Example: Template Demos | |
| --- Hero Section Example | |
| # Amazing Headline | |
| ▸ Subheading that sells. | |
| [[img 800x400 kitten]] | |
| ▣ Get Started Now | |
| --- Help Section | |
| ▸ Need Assistance? | |
| Click the button below to open the help modal. | |
| [[modal HelpModal Open Help Dialog]] | |
| --- modal "HelpModal" | |
| ---- header | |
| Help & Documentation | |
| ---- body | |
| Welcome to the help section! | |
| **Basic Syntax:** | |
| Use `---` to start new sections. | |
| Use `# H1` or `H1:` for headings. | |
| Use `•` for bullets, `1.` for numbered lists. | |
| **Layouts:** | |
| `--- 50/50 Title` for grids. | |
| `--- cards3 Title` for card layouts. | |
| Use `|wide|` to break out of columns/cards. | |
| **Components:** | |
| `[[img WxH label]]` | |
| `[[button "Text"]]` | |
| `[[progress N]]` | |
| `[[form]] ... [[/form]]` | |
| Check the **Templates** dropdown for more examples! | |
| ---- footer | |
| Need more help? Visit our [Docs Site](https://example.com) (link placeholder). | |
| [[button "Got it!"]] | |
| --- cards2 Feature Callouts | |
| [[img 500x200 bw]] | |
| ▸ Instant Preview | |
| • See changes live as you type. No refresh needed. | |
| ▣ Try the Demo | |
| [[img 500x200 blur]] | |
| ▸ Flexible Layouts | |
| • Mix grids, cards, and flow layouts seamlessly. | |
| ▣ Get Started | |
| --- cards4 Image Gallery | |
| [[img 250x150 kitten]] | |
| [[img 250x151 kitten]] | |
| [[img 250x149 kitten]] | |
| [[img 250x152 kitten]] | |
| [[img 250x150 unsplash:api]] | |
| [[img 250x151 unsplash:api]] | |
| [[img 250x149 unsplash:api]] | |
| [[img 250x152 unsplash:api]] | |
| |wide| [[img 1000x250 kitten]] | |
| --- 33/33/33 Pricing | |
| ▸ Free | ▸ $49/mo | ▸ Enterprise | |
| • Basic | • Up to 5 seats | • Everything | |
| ▣ Sign up | ▣ Buy now | ▣ Contact | |
| --- 33/33/33 Feature Comparison | |
| ▸ Features | ▸ Basic | ▸ Pro | |
| • Unlimited Projects | • ✅ | • ✅ | |
| • Team Access | • ❌ | • ✅ | |
| • Analytics | • ❌ | • ✅ | |
| ▣ Upgrade | ▣ — | ▣ Get Pro | |
| --- 33/33/33 Steps to Start | |
| ▸ Type your idea | ▸ Hit preview | ▸ Share or export | |
| [[img 300x200 kitten]] | |
| [[img 300x200 bw]] | |
| [[img 300x200 blur]] | |
| ▣ Start | ▣ Preview | ▣ Share | |
| --- cards3 Top Nav | |
| ▣ Features | |
| ▣ Pricing | |
| ▣ FAQ | |
| --- cards3 Feature Highlights | |
| ▸ Blazing Fast | |
| • Optimized render engine | |
| [[img 100x100 blur]] | |
| ▸ Dead Simple | |
| • Just type and ship | |
| [[img 100x100 bw]] | |
| ▸ Extensible | |
| • Add your own snippets | |
| [[img 100x100 unsplash:gears]] | |
| --- cards3 Meet the Team | |
| [[img 200x200 unsplash:person]] | |
| ▸ Alex – Product | |
| • Loves clean UIs | |
| [[img 200x200 unsplash:person]] | |
| ▸ Sam – Design | |
| • Fonts are feelings | |
| [[img 200x200 unsplash:person]] | |
| ▸ Riley – Engineering | |
| • Renders fast af | |
| --- cards3 Cards | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| --- Hero Section | |
| [[img 800x300 kitten]] | |
| H2: Welcome to Your New Workflow | |
| ▸ Simplify. Automate. Grow. | |
| • Launch faster with zero-code blocks | |
| • Tailored layouts, fully editable | |
| • Real-time preview built-in | |
| H3: Why wait? | |
| ▣ Get Started Now | |
| --- 50/50 Value Props | |
| ▸ Key Benefits | ▸ Why Choose Us? | |
| • Built for teams | • Real-time collaboration | |
| • Inline editing | • Scales with you | |
| • Export options | • Dedicated support | |
| --- Three-Step Progress | |
| H3: How It Works | |
| [[progress 33]] | |
| ▸ Step 1: Choose a layout | |
| • Pick from grids, cards, or flow. | |
| [[progress 66]] | |
| ▸ Step 2: Add your content | |
| • Use simple text or shortcodes. | |
| [[progress 100]] | |
| ▸ Step 3: Launch! ✨ | |
| • Preview, export, or share. | |
| --- cards3 Features You’ll Love | |
| ▸ Fast setup | |
| • Get started in minutes with intuitive syntax. | |
| [[button "Try Now"]] | |
| ▸ Responsive Design | |
| • Layouts adapt beautifully to any screen size. | |
| [[button "Learn More"]] | |
| ▸ Component Library | |
| • Use built-in components or add your own. | |
| [[button "See Docs"]] | |
| |wide| Power tools meet simplicity. Build beautiful layouts without the fuss. | |
| --- 33/67 Product Highlight | |
| ▸ Spotlight On Speed | |
| • Render complex layouts instantly. | |
| • Optimized for performance. | |
| • Handles large projects smoothly. | |
| | [[img 500x250 unsplash:workspace]] | |
| --- 33/33/33 Use Cases | |
| H3: For Designers | H3: For Developers | H3: For Marketers | |
| • Rapid prototyping | • Snippet injection | • Landing pages fast | |
| • Wireframe mode | • JSON export/import | • Analytics-ready | |
| • Style consistency | • Easy integration | • Campaign mockups | |
| ▣ Explore Design | ▣ Code Examples | ▣ Marketing Guide | |
| --- cards4 Integrations | |
| [[img 80x80 unsplash:api]] | |
| Zapier | |
| [[img 80x80 unsplash:api]] | |
| Webhooks | |
| [[img 80x80 unsplash:api]] | |
| WordPress | |
| [[img 80x80 unsplash:api]] | |
| Notion | |
| --- 33/33/33 Pricing Tiers | |
| H3: Starter | H3: Pro | H3: Team | |
| ▸ Free Forever | ▸ $19 / month | ▸ $99 / month | |
| • 3 Projects | • Unlimited Projects | • Everything in Pro | |
| • Basic Components | • Advanced Components | • Priority Support | |
| • Community Support | • Email Support | • Shared Workspaces | |
| ▣ Free trial | ▣ Pro Plan | ▣ Get Demo | |
| --- Placekitten Divider 😻 | |
| [[img 700x500 kitten]] | |
| --- The End | |
| ▣ You are ready to wireframe! | |
| </textarea> | |
| <div id="stage"></div> | |
| <!-- /modals/modal.html (or inside <body>) --> | |
| <div id="modalOverlay" class="modal-overlay" style="display:none;"> | |
| <div id="modalBox" class="modal-box"> | |
| <div id="modalHeader" class="modal-header"></div> | |
| <div id="modalBody" class="modal-body"></div> | |
| <div id="modalFooter" class="modal-footer"></div> | |
| </div> | |
| </div> | |
| </main> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=PT+Mono&display=swap'); | |
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* ===== grab elements ===== */ | |
| const src = document.getElementById("src"); | |
| const stage = document.getElementById("stage"); | |
| const ta = src; | |
| const pv = stage; | |
| let modals = {}; // 🛠 modal registry | |
| let __currentLine = 0; | |
| window.addEventListener("DOMContentLoaded", render); | |
| src.addEventListener("input", render); | |
| /* ---------- UNIVERSAL INLINE FORMATTER ------------------- */ | |
| /* --- tiny vanilla-Markdown helper ------------------------ */ | |
| function mdInline(s) { | |
| // (Keep existing mdInline function as is) | |
| return ( | |
| s | |
| // code ⇒ <code> | |
| .replace(/`([^`]+)`/g, (_, c) => `<code>${escapeHTML(c)}</code>`) | |
| // bold ⇒ <strong> | |
| .replace( | |
| /\*\*([^*]+)\*\*/g, | |
| (_, c) => `<strong>${escapeHTML(c)}</strong>` | |
| ) | |
| // italics ⇒ <em> | |
| .replace( | |
| /(^|[^*])\*([^*]+)\*(?!\*)/g, | |
| (_, lead, c) => `${lead}<em>${escapeHTML(c)}</em>` | |
| ) | |
| // strike ⇒ <del> | |
| .replace(/~~([^~]+)~~/g, (_, c) => `<del>${escapeHTML(c)}</del>`) | |
| // Markdown links ⇒ <a> | |
| .replace( | |
| /\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g, | |
| (_, txt, url) => | |
| `<a href="${escapeHTML( | |
| url | |
| )}" target="_blank" rel="noopener noreferrer">${escapeHTML(txt)}</a>` | |
| ) | |
| // bare URLs ⇒ <a> (skip ones inside attributes by requiring whitespace or > before) | |
| .replace( | |
| /(^|[\s>])(https?:\/\/[^\s<]+)/g, | |
| (_, lead, url) => | |
| `${lead}<a href="${escapeHTML( | |
| url | |
| )}" target="_blank" rel="noopener noreferrer">${escapeHTML(url)}</a>` | |
| ) | |
| ); | |
| } | |
| /* ── HTML helpers for form wrapper ───────────────────────── */ | |
| // (Keep all existing HTML helper functions: fieldWrap, fieldHTML, etc.) | |
| const fieldWrap = (lab, id, ctrl) => | |
| `<div class="field"><label for="${id}">${lab}</label>${ctrl}</div>`; | |
| const fieldHTML = (type, n, ph) => | |
| fieldWrap( | |
| capitalize(n), | |
| `field-${n}`, | |
| `<input type="${type}" id="field-${n}" name="${n}" placeholder="${ph}">` | |
| ); | |
| const textareaHTML = (n, ph) => | |
| fieldWrap( | |
| capitalize(n), | |
| `field-${n}`, | |
| `<textarea id="field-${n}" name="${n}" placeholder="${ph}"></textarea>` | |
| ); | |
| const fileFieldHTML = (n, l) => | |
| fieldWrap(l, `field-${n}`, `<input type="file" id="field-${n}" name="${n}">`); | |
| const colorFieldHTML = (n, l) => | |
| fieldWrap( | |
| l, | |
| `field-${n}`, | |
| `<input type="color" id="field-${n}" name="${n}">` | |
| ); | |
| const rangeFieldHTML = (n, l) => | |
| fieldWrap( | |
| l, | |
| `field-${n}`, | |
| `<input type="range" id="field-${n}" name="${n}">` | |
| ); | |
| const checkboxHTML = (n, l) => | |
| `<div class="field"><label><input type="checkbox" name="${n}"> ${l}</label></div>`; | |
| function selectHTML(n, r) { | |
| const id = `field-${n}`; | |
| const opts = r | |
| .split("|") | |
| .map((o) => `<option>${escapeHTML(o.trim())}</option>`) | |
| .join(""); | |
| return fieldWrap( | |
| capitalize(n), | |
| id, | |
| `<select id="${id}" name="${n}">${opts}</select>` | |
| ); | |
| } | |
| const buttonHTML = (txt, t = "button") => | |
| `<div class="field"><button type="${t}">${escapeHTML(txt)}</button></div>`; | |
| // ---------- MAIN FORMATTER FUNCTION (WITHOUT LINE NUMBERS) -------- | |
| function fmt(raw) { | |
| // (Keep the existing fmt function logic exactly as it was) | |
| const t = raw.trim(); | |
| if (!t) return " "; | |
| // --------- Heading markdown | |
| const h = t.match(/^(#{1,6})\s+(.*)$/); | |
| if (h) return `<h${h[1].length}>${mdInline(h[2])}</h${h[1].length}>`; | |
| // --------- Horizontal rule | |
| if (/^\s*([*_]\s*){3,}$/.test(t)) return "<hr>"; | |
| // --------- Modal Button | |
| const modalBtn = t.match(/^\[\[modal\s+(\S+)\s+(.+?)\]\]$/i); | |
| if (modalBtn) { | |
| const [, id, label] = modalBtn; | |
| return `<a href="#" class="btn" data-open-modal="${escapeHTML( | |
| id | |
| )}">${escapeHTML(label)}</a>`; | |
| } | |
| // --------- A11Y Forms | |
| const patterns = [ | |
| [ | |
| /^\[\[input\s+(\w+)\s+"([^"]+)"\]\]$/i, | |
| (m) => fieldHTML("text", m[1], m[2]) | |
| ], | |
| [ | |
| /^\[\[number\s+(\w+)\s+"([^"]+)"\]\]$/i, | |
| (m) => fieldHTML("number", m[1], m[2]) | |
| ], | |
| [ | |
| /^\[\[password\s+(\w+)\s+"([^"]+)"\]\]$/i, | |
| (m) => fieldHTML("password", m[1], m[2]) | |
| ], | |
| [ | |
| /^\[\[textarea\s+(\w+)\s+"([^"]+)"\]\]$/i, | |
| (m) => textareaHTML(m[1], m[2]) | |
| ], | |
| [/^\[\[upload\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => fileFieldHTML(m[1], m[2])], | |
| [/^\[\[color\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => colorFieldHTML(m[1], m[2])], | |
| [/^\[\[range\s+(\w+)\s+"([^"]+)"\]\]$/i, (m) => rangeFieldHTML(m[1], m[2])], | |
| [/^\[\[select\s+(\w+)\s+(.+?)\]\]$/i, (m) => selectHTML(m[1], m[2])], | |
| [ | |
| /^\[\[checkbox\s+(\w+)\s+"([^"]+)"\]\]$/i, | |
| (m) => checkboxHTML(m[1], m[2]) | |
| ], | |
| [/^\[\[submit\s+"([^"]+)"\]\]$/i, (m) => buttonHTML(m[1], "submit")], | |
| [/^\[\[button\s+"([^"]+)"\]\]$/i, (m) => buttonHTML(m[1])] | |
| ]; | |
| for (const [re, fn] of patterns) { | |
| const m = t.match(re); | |
| if (m) return fn(m); | |
| } | |
| // --------- Image Markdown | |
| const mdImg = t.match(/^!\[([^\]]*)]\((https?:\/\/[^\s)]+)\)$/); | |
| if (mdImg) { | |
| return `<img crossorigin="anonymous" src="${mdImg[2]}" alt="${escapeHTML( | |
| mdImg[1] | |
| )}" style="display:block;max-width:100%;border:var(--stroke) solid var(--ink);border-radius:var(--r);">`; | |
| } | |
| // --------- Progress Bar | |
| const progress = t.match(/^\[\[progress\s+(\d+)\]\]$/i); | |
| if (progress) { | |
| const pct = Math.max(0, Math.min(100, +progress[1])); | |
| return `<div class="bar"><span style="width:${pct}%"></span></div>`; | |
| } | |
| // --------- Img Shortcode | |
| const img = t.match(/^\[\[img\s+(\d+)x(\d+)(?:\s+([^\]]+))?\]\]$/i); | |
| if (img) { | |
| const [, w, h, labelRaw = ""] = img; | |
| const label = labelRaw.trim().toLowerCase(); | |
| let srcImg = ""; | |
| if (label.startsWith("unsplash:")) { | |
| const topic = encodeURIComponent(label.split(":")[1] || "random"); | |
| const seed = Math.random().toString(36).slice(2); | |
| srcImg = `https://source.unsplash.com/random/${w}x${h}?${topic}&sig=${seed}`; | |
| } else if (label === "bw") { | |
| srcImg = `https://picsum.photos/${w}/${h}?grayscale`; | |
| } else if (label === "blur") { | |
| srcImg = `https://picsum.photos/${w}/${h}?grayscale&blur=3`; | |
| } else if (label === "kitten") { | |
| srcImg = `https://cataas.com/cat?width=${w}&height=${h}`; | |
| } else { | |
| srcImg = `https://placehold.co/${w}x${h}?text=%20`; | |
| } | |
| let fallback = ""; | |
| if (label.startsWith("unsplash:")) { | |
| fallback = ` onerror="this.src='https://picsum.photos/${w}/${h}?random&${Date.now()}'"`; | |
| } else if (label === "kitten") { | |
| fallback = ` onerror="this.src='https://loremflickr.com/${w}/${h}/kitten'"`; | |
| } | |
| return `<img src="${srcImg}"${fallback} alt="${escapeHTML( | |
| label | |
| )}" width="${w}" height="${h}" style="display:block;max-width:100%;border:var(--stroke) solid var(--ink);border-radius:var(--r);">`; | |
| } | |
| // --------- Headings, Lists, CTA Buttons | |
| if (t.startsWith(">")) | |
| return `<blockquote>${mdInline(t.slice(1).trim())}</blockquote>`; | |
| if (t.startsWith("▸")) return `<h3>${mdInline(t.slice(1).trim())}</h3>`; | |
| if (t.startsWith("•")) | |
| return `<ul><li>${mdInline(t.slice(1).trim())}</li></ul>`; | |
| if (t.startsWith("▣")) | |
| return `<a href="#" class="btn">${mdInline(t.slice(1).trim())}</a>`; | |
| if (/^H[1-6]:/i.test(t)) { | |
| const lvl = t[1]; | |
| return `<h${lvl}>${mdInline(t.slice(3).trim())}</h${lvl}>`; | |
| } | |
| // --------- Escaped | |
| if (/^(\\[>▸▣•])/.test(t)) return `<p>${mdInline(t.slice(1))}</p>`; | |
| // --- Default: Paragraph --- | |
| return `<p>${mdInline(t)}</p>`; | |
| } | |
| // -------------- fmtLoc: FORMATTER WRAPPER (adds line numbers) ----- | |
| // This function wraps fmt() and adds the data-line span | |
| function fmtLoc(raw, ln = __currentLine) { | |
| return addLoc(fmt(raw), ln); // Call the original formatter, then wrap | |
| } | |
| /* util: wrap html with data-line */ | |
| // This helper adds the span itself | |
| function addLoc(html, lineNo) { | |
| // Avoid wrapping empty/whitespace-only results | |
| if (!html || !html.trim() || html === " ") return html; | |
| return `<span class="tap-target" data-line="${lineNo}">${html}</span>`; | |
| } | |
| /* === Form-block renderer === */ | |
| // Modify renderFormBlock to use fmtLoc | |
| function renderFormBlock(lines, startLine) { | |
| // Pass startLine if known, though fmtLoc uses global __currentLine | |
| const groups = []; | |
| let buf = []; | |
| const flush = () => { | |
| if (buf.length) { | |
| groups.push(buf); | |
| buf = []; | |
| } | |
| }; | |
| lines.forEach((l) => { | |
| if (l === "") { | |
| flush(); | |
| return; | |
| } | |
| buf.push(l); | |
| }); | |
| flush(); | |
| const rows = groups | |
| .map((g, groupIndex) => { | |
| // Call fmtLoc for each item in the form group | |
| // We *could* try to calculate the exact line, but relying on __currentLine | |
| // being set correctly by parseBody before this runs is simpler for now. | |
| const cells = g | |
| .map((cellContent, cellIndex) => fmtLoc(cellContent)) | |
| .join(""); | |
| // If a group has multiple items, wrap in form-row | |
| return g.length > 1 ? `<div class="form-row">${cells}</div>` : cells; | |
| }) | |
| .join("\n"); | |
| // Add data-line to the form itself, pointing to the [[form]] tag line | |
| // Note: This requires parseBody to pass the startLine of the form block | |
| const formTagLine = | |
| startLine !== undefined ? ` data-line="${startLine}"` : ""; | |
| return `<form class="form"${formTagLine}>\n${rows}\n</form>`; | |
| } | |
| /* === Grid-block renderer === */ | |
| function renderGridBlock(lines, startLine, nCols = null) { | |
| const rows = lines.map((line, idx) => { | |
| if (!line.trim()) return ""; | |
| const parts = smartSplitPipes(line); | |
| const cells = parts | |
| .map(part => `<div>${fmtLoc(part, startLine + idx)}</div>`) | |
| .join(""); | |
| return `<div class="grid-row">${cells}</div>`; | |
| }).join(""); | |
| // nCols → CSS variable (falls back to "auto-fit" when null) | |
| const style = nCols ? ` style="--cols:${nCols}"` : ""; | |
| return `<div class="grid"${style} data-line="${startLine}">${rows}</div>`; | |
| } | |
| /* === parseBody: processes one section’s raw text === */ | |
| function parseBody(text, startLine = 0){ | |
| const out = []; | |
| /* 1 — STATE */ | |
| let inForm = false, formStartLine = -1, buf = []; | |
| let inGrid = false, gridStartLine = -1, gridBuf = []; | |
| let gridCols = null; // <-- lives for the whole parseBody call! | |
| /* 2 — helpers */ | |
| const flushForm = () => { | |
| if (buf.length){ | |
| out.push(renderFormBlock(buf, formStartLine)); | |
| buf.length = 0; | |
| formStartLine = -1; | |
| } | |
| }; | |
| const flushGrid = () => { | |
| if (gridBuf.length){ | |
| out.push(renderGridBlock(gridBuf, gridStartLine, gridCols)); | |
| gridBuf.length = 0; | |
| gridStartLine = -1; | |
| gridCols = null; // reset for the next grid | |
| } | |
| }; | |
| /* 3 — iterate lines */ | |
| text.split(/\r?\n/).forEach((line, idx) => { | |
| const ln = startLine + idx; | |
| __currentLine = ln; | |
| const t = line.trim(); | |
| /* --- GRID tags ----------------------------------- */ | |
| const open = t.match(/^\[\[grid(?:\s+(\d+))?\]\]$/i); | |
| if (open){ | |
| flushGrid(); | |
| inGrid = true; | |
| gridStartLine = ln; | |
| gridCols = open[1] ? +open[1] : null; // null → auto-fit | |
| return; | |
| } | |
| if (/^\[\[\/grid]]$/i.test(t)){ | |
| inGrid = false; | |
| flushGrid(); | |
| return; | |
| } | |
| if (inGrid){ | |
| gridBuf.push(line); | |
| return; | |
| } | |
| /* --- FORM tags ----------------------------------- */ | |
| if (t === '[[form]]'){ | |
| flushForm(); | |
| inForm = true; | |
| formStartLine = ln; | |
| return; | |
| } | |
| if (t === '[[/form]]'){ | |
| inForm = false; | |
| flushForm(); | |
| return; | |
| } | |
| if (inForm){ | |
| buf.push(line); | |
| return; | |
| } | |
| /* --- normal content ------------------------------ */ | |
| if (t) out.push(fmtLoc(line, ln)); | |
| }); | |
| flushForm(); | |
| flushGrid(); | |
| return out.join('\n'); | |
| } | |
| // Small utility functions: | |
| /* util: capitalize first letter */ | |
| function capitalize(word) { | |
| return word.charAt(0).toUpperCase() + word.slice(1); | |
| } | |
| // addLoc is now defined near fmtLoc | |
| /* ---------- PARSER (Main Structure) --------------------------------------- */ | |
| // Modify parse to track line numbers and store structured body items | |
| function parse(txt) { | |
| const lines = txt.split(/\r?\n/); | |
| const sections = []; | |
| let cur = null, | |
| inCode = false, | |
| codeBuf = [], | |
| codeStartLine = -1; // Track start line of code block | |
| // modal scanning states (Keep existing modal logic) | |
| let inModal = false; | |
| let modalID = ""; | |
| let modalParts = { header: [], body: [], footer: [] }; | |
| let modalSection = "body"; | |
| let modalStartLine = -1; // Track modal definition start | |
| const pushCur = () => { | |
| if (cur) sections.push(cur); | |
| }; | |
| const pushCode = () => { | |
| if (codeBuf.length) { | |
| // Store code block with its content and start line | |
| cur.body.push({ | |
| type: "code", | |
| content: codeBuf.join("\n"), | |
| ln: codeStartLine | |
| }); | |
| codeBuf = []; | |
| codeStartLine = -1; | |
| } | |
| }; | |
| lines.forEach((raw, i) => { | |
| // 'i' is the absolute line number (0-based) | |
| const lineNo = i; // Use 'lineNo' for clarity | |
| if (raw.startsWith("##")) return; // comment, skip | |
| // --- Handle Modal Definitions --- | |
| const modalMatch = raw.match(/^---\s*modal\s+"([^"]+)"/i); | |
| if (modalMatch) { | |
| // If ending a previous modal implicitly, store it first | |
| if (inModal) { | |
| modals[modalID] = { ...modalParts, ln: modalStartLine }; // Store with line number | |
| } | |
| inModal = true; | |
| modalID = modalMatch[1]; | |
| modalParts = { header: [], body: [], footer: [] }; | |
| modalSection = "body"; | |
| modalStartLine = lineNo; // Remember where this modal definition started | |
| return; // Stop parsing regular sections while in modal | |
| } | |
| if (inModal) { | |
| if (/^----\s*header/i.test(raw)) { | |
| modalSection = "header"; | |
| return; | |
| } | |
| if (/^----\s*body/i.test(raw)) { | |
| modalSection = "body"; | |
| return; | |
| } | |
| if (/^----\s*footer/i.test(raw)) { | |
| modalSection = "footer"; | |
| return; | |
| } | |
| // Check for end of modal (which is also start of a new normal section) | |
| if (/^---(?!-)/.test(raw)) { | |
| modals[modalID] = { ...modalParts, ln: modalStartLine }; // Store completed modal with line# | |
| inModal = false; | |
| modalID = ""; | |
| // DO NOT return; let this line be processed as a new section marker below | |
| } else { | |
| // Still inside modal definition, add line to current part | |
| modalParts[modalSection].push(raw); | |
| return; // Skip adding to normal section body | |
| } | |
| } | |
| // --- End Modal Handling --- | |
| // --- Handle Fenced Code Blocks --- | |
| if (raw.trim().startsWith("```")) { | |
| if (!cur) { | |
| // Code block before the first '---' section marker | |
| cur = { widths: "", title: "Untitled", body: [], ln: 0 }; // Assign default section at line 0 | |
| } | |
| if (inCode) { | |
| // Ending code block | |
| pushCode(); // pushCode now stores type:'code' and line number | |
| inCode = false; | |
| } else { | |
| // Starting code block | |
| pushCode(); // Push any previous code block first (safety) | |
| inCode = true; | |
| codeStartLine = lineNo; // Remember code block start line | |
| codeBuf = []; // Clear buffer for new block | |
| } | |
| return; // Don't process ``` line further | |
| } | |
| if (inCode) { | |
| codeBuf.push(raw); // Add line to code buffer | |
| return; // Don't process as regular line | |
| } | |
| // --- End Code Block Handling --- | |
| // --- Handle New Section Markers --- | |
| if (/^---/.test(raw)) { | |
| pushCode(); // Finalize any code block before starting new section | |
| pushCur(); // Push the completed previous section | |
| const match = raw | |
| .replace(/^---+/, "") | |
| .trim() | |
| .match(/^((?:\d+\*\d+|\d+(?:\/\d+)*|cards\d+)?)\s*(.*)$/i); | |
| let widths = match?.[1] || ""; | |
| let title = match?.[2] || "Untitled"; | |
| if (/\*/.test(widths)) { | |
| const [w, n] = widths.split("*").map(Number); | |
| if (!isNaN(w) && !isNaN(n)) widths = Array(n).fill(w).join("/"); | |
| // title = title.replace(/^\*\d+\s*/, ""); // Keep title as is | |
| } | |
| // Create new section object, storing its definition line number | |
| cur = { widths, title, body: [], ln: lineNo }; | |
| } else if (cur) { | |
| // --- Regular line within a section --- | |
| // Store line content with its absolute line number | |
| cur.body.push({ type: "line", content: raw, ln: lineNo }); | |
| } else { | |
| // Line appears before the first '---' marker and isn't code/modal | |
| // Treat it as part of an implicit first section | |
| cur = { widths: "", title: "Untitled", body: [], ln: 0 }; | |
| cur.body.push({ type: "line", content: raw, ln: lineNo }); | |
| } | |
| }); // End lines.forEach | |
| // After processing all lines: | |
| pushCode(); // Push any trailing code block | |
| // Handle trailing modal if file ends within modal definition | |
| if (inModal) { | |
| modals[modalID] = { ...modalParts, ln: modalStartLine }; | |
| } | |
| pushCur(); // Push the last section | |
| // Map sections using the modified toHTML | |
| return sections; | |
| } | |
| // turn structured sections into one HTML string | |
| const sectionsToHTML = (arr) => arr.map(toHTML).join("\n"); | |
| /* ---------- SECTION → HTML -------------------------------- */ | |
| // Modify toHTML to work with structured body items and pass line numbers | |
| function toHTML({ widths, title, body, ln: sectionLine }) { | |
| // Receive section's definition line | |
| const inner = []; | |
| // Add section title with its line number | |
| const titleHTML = title | |
| ? `<h2 class="tap-target" data-line="${sectionLine}">${escapeHTML( | |
| title | |
| )}</h2>` | |
| : ""; | |
| const colSpec = widths && /^\d+(?:\/\d+)+$/.test(widths) ? widths : null; | |
| const isCards = widths && /^cards\d+$/i.test(widths); | |
| // Helper to render a body item (line or code) | |
| const renderBodyItem = (item) => { | |
| if (item.type === "code") { | |
| // Wrap pre/code with span using the code block's starting line number | |
| return `<span class="tap-target code-block" data-line="${ | |
| item.ln | |
| }"><pre><code>${escapeHTML(item.content)}</code></pre></span>`; | |
| } else if (item.type === "line") { | |
| // Use parseBody for line content, passing its absolute line number | |
| // parseBody itself will call fmtLoc internally to add spans to formatted elements | |
| return parseBody(item.content, item.ln); | |
| } | |
| return ""; // Should not happen | |
| }; | |
| /* ===== Grid layout ===== */ | |
| if (colSpec) { | |
| const nCols = colSpec.split("/").length; | |
| const rows = []; | |
| let currentRowItems = []; // Store items for the current row | |
| let currentRowHTML = []; // Store HTML for cells in current row | |
| const flushRow = () => { | |
| if (currentRowHTML.length === 0) return; | |
| // Pad row if needed | |
| while (currentRowHTML.length < nCols) { | |
| currentRowHTML.push("<div> </div>"); // Empty cell | |
| } | |
| rows.push(currentRowHTML.join("")); | |
| currentRowItems = []; | |
| currentRowHTML = []; | |
| }; | |
| body.forEach((item) => { | |
| if (item.type === "code") { | |
| flushRow(); // Finish previous row | |
| rows.push(`<div class="wide">${renderBodyItem(item)}</div>`); // Render code block wide | |
| } else if (item.type === "line") { | |
| const trimmed = item.content.trim(); | |
| if (!trimmed) return; // Skip empty lines | |
| if (trimmed.startsWith("|wide|")) { | |
| flushRow(); // Finish previous row | |
| const wideContent = trimmed.replace("|wide|", "").trim(); | |
| // Render wide content using parseBody with the original line number | |
| rows.push( | |
| `<div class="wide">${parseBody(wideContent, item.ln)}</div>` | |
| ); | |
| } else if (trimmed.includes("|") && !trimmed.startsWith("[[")) { | |
| // Avoid splitting form tags like [[select | Option A | Option B]] | |
| flushRow(); // Finish previous row | |
| const cells = smartSplitPipes(trimmed); // Use existing smartSplitPipes | |
| while (cells.length < nCols) cells.push(""); | |
| // Render each cell using parseBody with the original line number | |
| // This ensures elements inside cells get correct line numbers | |
| rows.push( | |
| cells.map((c) => `<div>${parseBody(c, item.ln)}</div>`).join("") | |
| ); | |
| } else { | |
| // Regular item, add to current row buffer | |
| currentRowItems.push(item); | |
| // Render the item and add to HTML buffer | |
| currentRowHTML.push(`<div>${renderBodyItem(item)}</div>`); | |
| if (currentRowHTML.length === nCols) { | |
| flushRow(); // Flush if row is full | |
| } | |
| } | |
| } | |
| }); | |
| flushRow(); // Flush any remaining items in the last row | |
| const gridStyle = `grid-template-columns:${colSpec | |
| .split("/") | |
| .map((p) => (p === "*" ? "1fr" : p + "%")) // Support '*' for fr units maybe? Or just % | |
| .join(" ")};`; | |
| inner.push( | |
| `<div class="cols" style="${gridStyle}">${rows.join("\n")}</div>` | |
| ); | |
| } else if (isCards) { | |
| /* ===== Card layout ===== */ | |
| const nCols = +widths.match(/\d+/)[0] || 1; // Default to 1 if number missing | |
| const openGrid = () => | |
| inner.push( | |
| `<div class="cards cards${nCols}" style="grid-template-columns:repeat(${nCols},1fr);">` | |
| ); | |
| const closeGrid = () => inner.push(`</div>`); | |
| openGrid(); | |
| let cardBuffer = []; // Buffer for lines within a single card | |
| let cardStartLine = -1; // Track the line number of the first item in the card | |
| const flushCard = () => { | |
| if (cardBuffer.length === 0) return; | |
| // Combine buffered lines and render using parseBody with the card's start line | |
| const cardContent = cardBuffer.map((item) => item.content).join("\n"); | |
| // Use the start line of the *first* item in the buffer for the card's div | |
| inner.push( | |
| `<div class="tap-target card-container" data-line="${cardStartLine}">${parseBody( | |
| cardContent, | |
| cardStartLine | |
| )}</div>` | |
| ); | |
| cardBuffer = []; | |
| cardStartLine = -1; | |
| }; | |
| body.forEach((item) => { | |
| if (item.type === "code") { | |
| flushCard(); // Finish previous card | |
| inner.push(`<div class="wide">${renderBodyItem(item)}</div>`); // Render code block wide | |
| } else if (item.type === "line") { | |
| const raw = item.content.trim(); | |
| if (raw.startsWith("|wide|")) { | |
| flushCard(); // Finish previous card | |
| closeGrid(); // Close current cards grid | |
| const wideContent = raw.replace("|wide|", "").trim(); | |
| inner.push( | |
| `<div class="wide">${parseBody(wideContent, item.ln)}</div>` | |
| ); // Render wide content | |
| openGrid(); // Start a new cards grid | |
| } else if (raw === "" && cardBuffer.length > 0) { | |
| // Empty line signifies end of a card | |
| flushCard(); | |
| } else if (raw !== "") { | |
| // Non-empty line, add to card buffer | |
| if (cardBuffer.length === 0) { | |
| cardStartLine = item.ln; // Mark start line of this card | |
| } | |
| cardBuffer.push(item); | |
| } | |
| } | |
| }); | |
| flushCard(); // Flush the last card | |
| closeGrid(); // Close the final grid | |
| } else { | |
| /* ===== Single-column / flow layout ===== */ | |
| const allLines = body.map(b => b.content).join('\n'); | |
| inner.push(parseBody(allLines, body[0]?.ln ?? sectionLine)); | |
| } | |
| // Wrap the entire section content | |
| // Add sectionLine to the section tag itself | |
| return `<section data-line="${sectionLine}">${titleHTML}\n${inner.join( | |
| "\n" | |
| )}</section>`; | |
| } | |
| /* ---------- UTIL ----------------------------------------- */ | |
| function escapeHTML(s){ | |
| if(!s) return ''; | |
| return s.replace(/[&<>]/g, c => ({ | |
| '&':'&', | |
| '<':'<', | |
| '>':'>' | |
| }[c])); | |
| } | |
| function smartSplitPipes(s) { | |
| // (Keep existing smartSplitPipes) | |
| const parts = []; | |
| let cur = "", | |
| depth = 0; // Track [[ ]] nesting | |
| for (let i = 0; i < s.length; i++) { | |
| const ch = s[i]; | |
| const nextCh = s[i + 1]; | |
| if (ch === "[" && nextCh === "[") { | |
| depth++; | |
| cur += ch; // Add the character | |
| } else if (ch === "]" && nextCh === "]") { | |
| // Check depth before decrementing to handle unbalanced brackets gracefully | |
| if (depth > 0) depth--; | |
| cur += ch; // Add the character | |
| } else if (ch === "|" && depth === 0) { | |
| // Split only if not inside [[ ]] | |
| parts.push(cur.trim()); | |
| cur = ""; | |
| } else { | |
| cur += ch; // Add other characters | |
| } | |
| } | |
| if (cur) parts.push(cur.trim()); // Add the last part | |
| return parts; | |
| } | |
| /* ---------- RENDER --------------------------------------- */ | |
| // Modify render to handle modals correctly alongside parsing | |
| function render() { | |
| const text = src.value; // Use full value to get correct line numbers | |
| modals = {}; // Clear previous modal definitions | |
| // scanForModals is now integrated into the main parse function | |
| // 1. Parse the text. This will: | |
| // - Populate the `modals` object. | |
| // - Build sections with structured body items ({type, content, ln}). | |
| // - Call `toHTML` which uses `parseBody`/`fmtLoc` to add `data-line`. | |
| const structured = parse(text); // array of sections | |
| const renderedSectionsHTML = sectionsToHTML(structured); | |
| // 2. Generate hidden modal HTML (if any modals were found during parse) | |
| const hiddenModalsHTML = generateModalsHTML(); // Uses the populated `modals` object | |
| // 3. Update the stage | |
| stage.innerHTML = renderedSectionsHTML + "\n" + hiddenModalsHTML; | |
| } | |
| /* ---------- WIZZY PANEL INSERTS -------------------------- */ | |
| // (Keep existing Wizzy panel logic) | |
| document.getElementById("wizzy").addEventListener("click", (e) => { | |
| const b = e.target.closest("button[data-sn]"); | |
| if (!b) return; | |
| const [st, en] = [src.selectionStart, src.selectionEnd]; | |
| const snippet = b.dataset.sn; | |
| // Insert snippet and move cursor to end of insertion | |
| src.setRangeText(snippet, st, en, "end"); | |
| src.focus(); | |
| render(); // Re-render after insertion | |
| }); | |
| /* ---------- TEMPLATE PICKER ------------------------------ */ | |
| // (Keep existing Template Picker logic) | |
| document.getElementById("tpl").addEventListener("change", (e) => { | |
| const id = e.target.value; | |
| if (!id || !templates[id]) return; // Added check for template existence | |
| const snippet = templates[id]; | |
| const [st, en] = [src.selectionStart, src.selectionEnd]; | |
| const textToInsert = | |
| (st > 0 && src.value[st - 1] !== "\n" ? "\n" : "") + snippet + "\n"; // Add newlines defensively | |
| src.setRangeText(textToInsert, st, en, "end"); | |
| src.focus(); | |
| render(); | |
| e.target.value = ""; // Reset select | |
| }); | |
| /* ---------- TEMPLATE PICKER OPTIONS ---------------------- */ | |
| // (Keep existing templates object) | |
| const templates = { | |
| hero: `--- Hero Section | |
| H2: Big punchy headline | |
| ▸ Supporting sub-copy | |
| [[img 800x300 kitten]] | |
| ▣ Get Started | |
| `, | |
| pricing: `--- 33/33/33 Pricing | |
| ▸ Free | ▸ $49/mo | ▸ Enterprise | |
| • Basic | • Up to 5 seats | • Everything | |
| ▣ Sign up | ▣ Buy now | ▣ Contact sales | |
| `, | |
| comparison: `--- 33/33/33 Feature Comparison | |
| ▸ Features | ▸ Basic | ▸ Pro | |
| • Unlimited Projects | • ✅ | • ✅ | |
| • Team Access | • ❌ | • ✅ | |
| • Analytics | • ❌ | • ✅ | |
| ▣ Upgrade | ▣ — | ▣ Get Pro | |
| `, | |
| steps: `--- 33/33/33 Steps to Start | |
| ▸ Type your idea | ▸ Hit preview | ▸ Share or export | |
| [[img 300x200 kitten]] | |
| [[img 300x200 bw]] | |
| [[img 300x200 blur]] | |
| ▣ Start typing | ▣ Preview | ▣ Share | |
| `, | |
| nav: `--- 25/25/25/25 Top Nav | |
| ▣ Hero | ▣ Features | ▣ Pricing | ▣ FAQ | |
| `, | |
| features: `--- cards3 Feature Highlights | |
| ▸ Blazing Fast | |
| • Optimized render engine | |
| [[img 100x100 blur]] | |
| ▸ Dead Simple | |
| • Just type and ship | |
| [[img 100x100 bw]] | |
| ▸ Extensible | |
| • Add your own snippets | |
| [[img 100x100 unsplash:gears]] | |
| `, | |
| team: `--- cards3 Meet the Team | |
| [[img 200x200 unsplash:person]] | |
| ▸ Alex – Product | |
| • Loves clean UIs | |
| [[img 200x200 unsplash:person]] | |
| ▸ Sam – Design | |
| • Fonts are feelings | |
| [[img 200x200 unsplash:person]] | |
| ▸ Riley – Engineering | |
| • Renders fast af | |
| `, | |
| cards: `--- cards3 Cards | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| [[img 400x200 bw]] | |
| ▸ Post Title | |
| • Lorem ipsum dolor sit amet | |
| ▣ Read | |
| `, | |
| heroWorkflow: `--- Hero Section | |
| [[img 800x300 kitten]] | |
| H2: Welcome to Your New Workflow | |
| ▸ Simplify. Automate. Grow. | |
| • Launch faster with zero-code blocks | |
| • Tailored layouts, fully editable | |
| • Real-time preview built-in | |
| H3: Why wait? | |
| ▣ Get Started Now | |
| `, | |
| value50: `--- 50/50 Value Props | |
| ▸ Key Benefits | ▸ Why Choose Us? | |
| • Built for teams | • Real-time collaboration | |
| • Inline editing | • Scales with you | |
| • Export options | • Dedicated support | |
| `, | |
| progress3: `--- Three-Step Progress | |
| H3: How It Works | |
| [[progress 33]] | |
| ▸ Step 1: Choose a layout | |
| • Pick from grids, cards, or flow. | |
| [[progress 66]] | |
| ▸ Step 2: Add your content | |
| • Use simple text or shortcodes. | |
| [[progress 100]] | |
| ▸ Step 3: Launch! ✨ | |
| • Preview, export, or share. | |
| `, | |
| featuresCards: `--- cards3 Features You’ll Love | |
| ▸ Fast setup | |
| • Get started in minutes with intuitive syntax. | |
| [[button "Try Now"]] | |
| ▸ Responsive Design | |
| • Layouts adapt beautifully to any screen size. | |
| [[button "Learn More"]] | |
| ▸ Component Library | |
| • Use built-in components or add your own. | |
| [[button "See Docs"]] | |
| |wide| Power tools meet simplicity. Build beautiful layouts without the fuss. | |
| `, | |
| highlight: `--- 33/67 Product Highlight | |
| ▸ Spotlight On Speed | |
| • Render complex layouts instantly. | |
| • Optimized for performance. | |
| • Handles large projects smoothly. | |
| | [[img 500x250 unsplash:workspace]] | |
| `, | |
| useCases: `--- 33/33/33 Use Cases | |
| H3: For Designers | H3: For Developers | H3: For Marketers | |
| • Rapid prototyping | • Snippet injection | • Landing pages fast | |
| • Wireframe mode | • JSON export/import | • Analytics-ready | |
| • Style consistency | • Easy integration | • Campaign mockups | |
| ▣ Explore Design | ▣ Code Examples | ▣ Marketing Guide | |
| `, | |
| integrations: `--- cards4 Integrations | |
| [[img 80x80 unsplash:api]] | |
| Zapier | |
| [[img 80x80 unsplash:api]] | |
| Webhooks | |
| [[img 80x80 unsplash:api]] | |
| WordPress | |
| [[img 80x80 unsplash:api]] | |
| Notion | |
| `, | |
| pricingTable: `--- 33/33/33 Pricing Tiers | |
| H3: Starter | H3: Pro | H3: Team | |
| ▸ Free Forever | ▸ $19 / month | ▸ $99 / month | |
| • 3 Projects | • Unlimited Projects | • Everything in Pro | |
| • Basic Components | • Advanced Components | • Priority Support | |
| • Community Support | • Email Support | • Shared Workspaces | |
| ▣ Sign Up Free | ▣ Start Pro Trial | ▣ Talk to Sales | |
| `, | |
| kittenDiv: `--- Placekitten Divider 😻 | |
| [[img 700x500 kitten]] | |
| `, | |
| featureCallouts: `--- cards2 Feature Callouts | |
| [[img 350x200 bw]] | |
| ▸ Instant Preview | |
| • See changes live as you type. No refresh needed. | |
| ▣ Try the Demo | |
| [[img 350x200 blur]] | |
| ▸ Flexible Layouts | |
| • Mix grids, cards, and flow layouts seamlessly. | |
| ▣ Get Started | |
| `, | |
| imageGallery: `--- cards4 Image Gallery | |
| [[img 250x150 kitten]] | |
| [[img 250x151 kitten]] | |
| [[img 250x149 kitten]] | |
| [[img 250x152 kitten]] | |
| [[img 250x150 unsplash:api]] | |
| [[img 250x151 unsplash:api]] | |
| [[img 250x149 unsplash:api]] | |
| [[img 250x152 unsplash:api]] | |
| |wide| [[img 1000x250 kitten]] | |
| `, | |
| markdowndemo: `--- Markdown Demo | |
| # H1 hash heading | |
| ## H2 hash | |
| ### H3 hash | |
| #### H4 hash | |
| ##### H5 hash | |
| ###### H6 hash | |
| H1: Labeled H1 | |
| H2: Labeled H2 | |
| H3: Labeled H3 | |
| H4: Labeled H4 | |
| H5: Labeled H5 | |
| H6: Labeled H6 | |
| ▸ H3 triangle heading | |
| --- 33/33/33 Inline Goodies | |
| **bold text** | *italic text* | ~~strikethrough~~ | |
| \`inline code\` | [Markdown Link](https://example.com) | Plain URL: https://example.com | |
| --- Single-Column Extras | |
| Horizontal rule below: | |
| *** | |
| > Blockquote: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam in dui mauris. | |
| Ordered List: | |
| 1. First item | |
| 2. Second item | |
| 3. Third item | |
| Unordered List: | |
| • Bullet A | |
| • Bullet B | |
| • Nested Bullet B.1 (Note: nested lists not directly supported by simple '•', use indentation in source) | |
| • Bullet C | |
| Progress Bar: | |
| [[progress 75]] | |
| Standard Markdown Image: | |
|  | |
| Shortcode Image: | |
| [[img 300x150 unsplash:nature]] | |
| Fenced Code Block: | |
| \`\`\`javascript | |
| // Example code | |
| function greet(name) { | |
| console.log(\`Hello, \${name}!\`); | |
| } | |
| greet('Wireframe User'); | |
| \`\`\` | |
| Escaped characters: \\> \\▸ \\▣ \\• will render literally. | |
| > This is > not a blockquote start. | |
| `, | |
| helpModal: `--- Help Section | |
| ▸ Need Assistance? | |
| Click the button below to open the help modal. | |
| [[modal HelpModal Open Help Dialog]] | |
| --- modal "HelpModal" | |
| ---- header | |
| Help & Documentation | |
| ---- body | |
| Welcome to the help section! | |
| **Basic Syntax:** | |
| Use \`---\` to start new sections. | |
| Use \`# H1\` or \`H1:\` for headings. | |
| Use \`•\` for bullets, \`1.\` for numbered lists. | |
| **Layouts:** | |
| \`--- 50/50 Title\` for grids. | |
| \`--- cards3 Title\` for card layouts. | |
| Use \`|wide|\` to break out of columns/cards. | |
| **Components:** | |
| \`[[img WxH label]]\` | |
| \`[[button "Text"]]\` | |
| \`[[progress N]]\` | |
| \`[[form]] ... [[/form]]\` | |
| Check the **Templates** dropdown for more examples! | |
| ---- footer | |
| Need more help? Visit our [Docs Site](https://example.com) (link placeholder). | |
| [[button "Got it!"]] | |
| ` | |
| }; | |
| /* ---------- WIREFRAME TO JSON / JSON TO WIREFRAME ------------ */ | |
| // Use the modified parser's structure for export | |
| function wireframeToJSON(txt) { | |
| // Use the main `parse` function, which now returns structured data | |
| // including line numbers if needed (though typically not needed for JSON export) | |
| const sections = parse(txt); // `parse` handles comments, modals, etc. | |
| // Simplify the output for JSON: remove line numbers and structure for clarity | |
| return sections.map((section) => ({ | |
| widths: section.widths, | |
| title: section.title, | |
| body: section.body | |
| .map((item) => { | |
| if (item.type === "code") { | |
| return { type: "code", content: item.content }; | |
| } else if (item.type === "line") { | |
| return { type: "line", content: item.content }; | |
| } | |
| return null; // Should not happen | |
| }) | |
| .filter(Boolean) // Remove nulls if any | |
| })); | |
| } | |
| // (Keep existing JSON export button listener) | |
| document.getElementById("exportJSON").addEventListener("click", () => { | |
| // Generate JSON using the raw text from the editor | |
| const json = wireframeToJSON(src.value); | |
| const blob = new Blob([JSON.stringify(json, null, 2)], { | |
| type: "application/json" | |
| }); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = "wireframe.json"; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); // Clean up | |
| }); | |
| // (Keep existing JSON import listeners) | |
| document.getElementById("importJSON").addEventListener("click", () => { | |
| document.getElementById("importFile").click(); | |
| }); | |
| document.getElementById("importFile").addEventListener("change", (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| try { | |
| const imported = JSON.parse(event.target.result); | |
| // Validate imported structure minimally | |
| if (!Array.isArray(imported)) { | |
| throw new Error("JSON root must be an array of sections."); | |
| } | |
| const newDSL = jsonToWireframeLang(imported); | |
| src.value = newDSL; | |
| render(); | |
| e.target.value = null; // Reset file input | |
| } catch (err) { | |
| alert("Failed to import JSON: " + err); | |
| e.target.value = null; // Reset file input | |
| } | |
| }; | |
| reader.onerror = () => { | |
| alert("Failed to read the file."); | |
| e.target.value = null; // Reset file input | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| // Converts imported JSON structure -> DSL Text | |
| // (Keep existing jsonToWireframeLang function) | |
| function jsonToWireframeLang(json) { | |
| const out = []; | |
| json.forEach((section) => { | |
| // Ensure section is an object and has expected properties | |
| if (typeof section !== "object" || section === null) return; | |
| const title = section.title || "Untitled"; | |
| const widths = section.widths ? ` ${section.widths}` : ""; | |
| out.push(`---${widths} ${title}`); | |
| if (Array.isArray(section.body)) { | |
| section.body.forEach((item) => { | |
| if (typeof item !== "object" || item === null) return; | |
| if (item.type === "line") { | |
| // Ensure content is a string, default to empty if missing/not string | |
| out.push(typeof item.content === "string" ? item.content : ""); | |
| } else if (item.type === "code") { | |
| out.push("```"); | |
| // Ensure content is a string, default to empty if missing/not string | |
| out.push(typeof item.content === "string" ? item.content : ""); | |
| out.push("```"); | |
| } | |
| }); | |
| } | |
| out.push(""); // Add a blank line between sections for readability | |
| }); | |
| return out.join("\n").trim(); // Trim trailing newlines | |
| } | |
| /* ===== Modal Framework (Generic open/close) ===== */ | |
| // (Keep existing openModal, closeModal functions) | |
| function openModal(options) { | |
| const overlay = document.getElementById("modalOverlay"); | |
| const box = document.getElementById("modalBox"); | |
| const header = document.getElementById("modalHeader"); | |
| const body = document.getElementById("modalBody"); | |
| const footer = document.getElementById("modalFooter"); | |
| // Ensure elements exist | |
| if (!overlay || !box || !header || !body || !footer) { | |
| console.error("Modal HTML elements not found!"); | |
| return; | |
| } | |
| // Clear previous content and listeners carefully | |
| header.innerHTML = ""; // Clear previous header | |
| body.innerHTML = ""; // Clear previous body | |
| footer.innerHTML = ""; // Clear previous footer | |
| // Set Title and Add Close Button | |
| const titleSpan = document.createElement("span"); | |
| titleSpan.innerHTML = options.title || "Modal"; // Use innerHTML to allow formatting from modal definition | |
| header.appendChild(titleSpan); | |
| const closeBtn = document.createElement("button"); | |
| closeBtn.id = "modalCloseBtn"; // Ensure unique ID if needed, or use class | |
| closeBtn.innerHTML = "✖"; | |
| closeBtn.style.float = "right"; | |
| closeBtn.style.background = "none"; | |
| closeBtn.style.border = "none"; | |
| closeBtn.style.fontSize = "1.2em"; | |
| closeBtn.style.cursor = "pointer"; | |
| closeBtn.onclick = closeModal; // Attach listener directly | |
| header.appendChild(closeBtn); | |
| // Set Body Content | |
| if (options.bodyHTML) { | |
| body.innerHTML = options.bodyHTML; // Assume safe HTML (e.g., from parsed markdown) | |
| } else if (options.bodyText) { | |
| const p = document.createElement("p"); | |
| p.textContent = options.bodyText; | |
| body.appendChild(p); | |
| } | |
| // Set Footer Buttons (or raw HTML if provided) | |
| if (options.footerHTML) { | |
| footer.innerHTML = options.footerHTML; | |
| // Manually wire buttons if needed, e.g., based on classes or IDs in footerHTML | |
| footer.querySelectorAll("button").forEach((b) => { | |
| // Example: Close modal if a button has class="modal-close" | |
| if (b.classList.contains("modal-close")) { | |
| b.addEventListener("click", closeModal); | |
| } | |
| // Add other specific button logic here if parsing footerHTML | |
| }); | |
| // Wire up any potential form submission logic if footer contains a form | |
| } else if (Array.isArray(options.buttons)) { | |
| options.buttons.forEach((btnConfig) => { | |
| const b = document.createElement("button"); | |
| b.innerHTML = btnConfig.label || "OK"; // Use innerHTML for labels | |
| if (btnConfig.className) b.className = btnConfig.className; | |
| b.addEventListener("click", () => { | |
| if (btnConfig.onClick) btnConfig.onClick(); | |
| if (!btnConfig.noClose) closeModal(); // Close unless specified otherwise | |
| }); | |
| footer.appendChild(b); | |
| }); | |
| } | |
| overlay.style.display = "flex"; // Show the modal | |
| box.focus(); // Focus the modal box for accessibility | |
| } | |
| function closeModal() { | |
| const overlay = document.getElementById("modalOverlay"); | |
| if (overlay) { | |
| overlay.style.display = "none"; | |
| } | |
| } | |
| // ESC close support (Keep existing listener) | |
| document.addEventListener("keydown", (e) => { | |
| const overlay = document.getElementById("modalOverlay"); | |
| // Close only if modal is visible and Escape key is pressed | |
| if (e.key === "Escape" && overlay && overlay.style.display === "flex") { | |
| closeModal(); | |
| } | |
| }); | |
| // (Keep existing Paste JSON logic and listener) | |
| document.getElementById("pasteJSON").addEventListener("click", () => { | |
| openModal({ | |
| title: "Paste Wireframe JSON", | |
| bodyHTML: `<p>Paste the exported JSON content below:</p><textarea id="pasteArea" style="width: 95%; height: 150px; margin-top: 5px;"></textarea>`, | |
| buttons: [ | |
| { | |
| label: "Load JSON", | |
| onClick: () => { | |
| const txt = document.getElementById("pasteArea")?.value.trim(); | |
| if (!txt) { | |
| alert("Paste area is empty."); | |
| return; | |
| } | |
| try { | |
| const imported = JSON.parse(txt); | |
| if (!Array.isArray(imported)) { | |
| throw new Error("JSON root must be an array of sections."); | |
| } | |
| const newDSL = jsonToWireframeLang(imported); | |
| src.value = newDSL; | |
| render(); // Re-render with imported content | |
| // Modal will close via default button behavior | |
| } catch (err) { | |
| alert("Invalid JSON format: " + err); | |
| // Keep modal open on error | |
| } | |
| }, | |
| noClose: false // Close modal on successful load | |
| }, | |
| { | |
| label: "Cancel", | |
| noClose: false // Close modal on cancel | |
| } | |
| ] | |
| }); | |
| // Focus the textarea after the modal is likely rendered | |
| setTimeout(() => document.getElementById("pasteArea")?.focus(), 50); | |
| }); | |
| /* ---------- MODAL EXTENSION (Definition Handling) ------------ */ | |
| // scanForModals is now integrated into the main `parse` function. | |
| // generateModalsHTML uses the `modals` object populated by `parse` | |
| function generateModalsHTML() { | |
| const all = []; | |
| for (const id in modals) { | |
| const modalData = modals[id]; | |
| // Render markdown content from modal parts | |
| // Use parseBody starting at line 0 for content processing within the modal parts | |
| // Pass the modal definition line number (modalData.ln) for the outer container | |
| const headerHTML = parseBody(modalData.header.join("\n"), modalData.ln); // Use modal start line for context | |
| const bodyHTML = parseBody( | |
| modalData.body.join("\n"), | |
| modalData.ln + modalData.header.length + 1 | |
| ); // Estimate body start line | |
| const footerHTML = parseBody( | |
| modalData.footer.join("\n"), | |
| modalData.ln + modalData.header.length + modalData.body.length + 2 | |
| ); // Estimate footer start line | |
| all.push( | |
| // Add data-line to the hidden container itself | |
| `<div class="hidden-modal" id="modal-${id}" style="display:none;" data-definition-line="${modalData.ln}"> | |
| <div class="modal-header-content">${headerHTML}</div> | |
| <div class="modal-body-content">${bodyHTML}</div> | |
| <div class="modal-footer-content">${footerHTML}</div> | |
| </div>` | |
| ); | |
| } | |
| return all.join("\n"); | |
| } | |
| // wireModals connects buttons to the generic openModal | |
| function wireModals() { | |
| // Remove previous listeners to avoid duplication if render is called multiple times | |
| // A more robust approach might involve a different listener management strategy | |
| stage.querySelectorAll("[data-open-modal]").forEach((btn) => { | |
| // Clone and replace to remove old listeners simply | |
| const newBtn = btn.cloneNode(true); | |
| btn.parentNode.replaceChild(newBtn, btn); | |
| // Add listener to the new button | |
| newBtn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| const id = newBtn.dataset.openModal; | |
| const modalDefinition = document.getElementById("modal-" + id); | |
| if (modalDefinition) { | |
| // Extract content from the hidden definition divs | |
| const titleHTML = | |
| modalDefinition.querySelector(".modal-header-content")?.innerHTML || | |
| id; // Fallback title | |
| const bodyHTML = | |
| modalDefinition.querySelector(".modal-body-content")?.innerHTML || ""; | |
| const footerHTML = | |
| modalDefinition.querySelector(".modal-footer-content")?.innerHTML || | |
| ""; // Get raw footer HTML | |
| openModal({ | |
| // Pass the extracted HTML to the generic modal display function | |
| title: titleHTML, | |
| bodyHTML: bodyHTML, | |
| footerHTML: footerHTML // Pass footer HTML directly | |
| // The generic openModal will handle rendering this HTML | |
| // and attaching basic close listeners if needed based on content structure | |
| }); | |
| } else { | |
| console.warn(`Modal definition not found for ID: ${id}`); | |
| // Optionally open a fallback error modal | |
| openModal({ | |
| title: "Error", | |
| bodyText: `Modal content for "${id}" could not be found.` | |
| }); | |
| } | |
| }); | |
| }); | |
| } | |
| /* ---------- DOUBLE-CLICK TO EDIT ------------------------- */ | |
| // colour of the active-line band ↓ (yellow-ish with 40 % alpha) | |
| const HIGHLIGHT_COLOR = "rgba(255, 216, 107, .4)"; | |
| stage.addEventListener("dblclick", (e) => { | |
| const hit = e.target.closest("[data-line]"); | |
| if (!hit) return; | |
| const line = +hit.dataset.line; // Get the 0-based line number | |
| // Calculate character position to jump to | |
| // Split by '\n' which works for both \n and \r\n endings | |
| const lines = src.value.split("\n"); | |
| let pos = 0; | |
| for (let i = 0; i < line; i++) { | |
| // Add length of the line + 1 for the newline character itself | |
| pos += (lines[i] || "").length + 1; | |
| } | |
| src.focus(); | |
| src.setSelectionRange(pos, pos); // Set cursor position | |
| // Scroll the textarea to make the line visible (roughly center it) | |
| const totalLines = lines.length; | |
| const lineHeight = src.scrollHeight / totalLines; | |
| // Calculate target scroll position (line number * line height) | |
| // Subtract half the client height to center, but clamp to bounds | |
| const targetScrollTop = line * lineHeight - src.clientHeight / 3; | |
| src.scrollTop = Math.max( | |
| 0, | |
| Math.min(targetScrollTop, src.scrollHeight - src.clientHeight) | |
| ); | |
| }); | |
| /* ---------- INITIAL RENDER ------------------------------ */ | |
| // Initial Render on Load | |
| render(); | |
| /* ---------- fake caret position ---------- */ | |
| function paintCaret() { | |
| const mirror = document.createElement("div"); | |
| mirror.style.cssText = ` | |
| position:absolute; display:inline; left:-9999px; | |
| white-space:pre-wrap; pointer-events:none; | |
| font:${getComputedStyle(ta).font};`; | |
| mirror.textContent = ta.value.slice(0, ta.selectionStart); | |
| // ⬇︎ wrap the marker in an element we can measure | |
| const mark = document.createElement("span"); | |
| mark.textContent = "\u200b"; // zero-width char | |
| mirror.appendChild(mark); | |
| document.body.appendChild(mirror); | |
| const { left, top } = mark.getBoundingClientRect(); // ← works now | |
| document.body.removeChild(mirror); | |
| const taRect = ta.getBoundingClientRect(); | |
| ta.style.setProperty("--caret-x", `${left - taRect.left + ta.scrollLeft}px`); | |
| ta.style.setProperty("--caret-y", `${top - taRect.top + ta.scrollTop}px`); | |
| } | |
| // Update the fake caret position | |
| ["input", "keyup", "click", "scroll", "focus"].forEach((ev) => | |
| ta.addEventListener(ev, paintCaret) | |
| ); | |
| paintCaret(); // initial position | |
| // === Double-click preview → jump to source === | |
| stage.addEventListener("dblclick", (e) => { | |
| const hit = e.target.closest("[data-line]"); | |
| if (!hit) return; | |
| const line = +hit.dataset.line; // Get 0-based line number | |
| const lines = src.value.split("\n"); | |
| let pos = 0; | |
| for (let i = 0; i < line; i++) { | |
| pos += (lines[i] || "").length + 1; // +1 for newline | |
| } | |
| src.focus(); | |
| src.setSelectionRange(pos, pos); | |
| // (Optional scroll adjustment to center the cursor roughly) | |
| const totalLines = lines.length; | |
| const lineHeight = src.scrollHeight / totalLines; | |
| const targetScrollTop = line * lineHeight - src.clientHeight / 3; | |
| src.scrollTop = Math.max( | |
| 0, | |
| Math.min(targetScrollTop, src.scrollHeight - src.clientHeight) | |
| ); | |
| }); | |
| // one listener handles every modal button | |
| stage.addEventListener('click', e => { | |
| const trigger = e.target.closest('[data-open-modal]'); | |
| if (!trigger) return; | |
| e.preventDefault(); | |
| openModalFromId(trigger.dataset.openModal); | |
| }); | |
| function openModalFromId(id){ | |
| const def = document.getElementById('modal-' + id); | |
| if (!def){ | |
| openModal({title:'Error', bodyText:'Modal "' + id + '" not found.'}); | |
| return; | |
| } | |
| const titleHTML = def.querySelector('.modal-header-content')?.innerHTML || id; | |
| const bodyHTML = def.querySelector('.modal-body-content')?.innerHTML || ''; | |
| const footerHTML = def.querySelector('.modal-footer-content')?.innerHTML || ''; | |
| openModal({title:titleHTML, bodyHTML:bodyHTML, footerHTML:footerHTML}); | |
| } | |
| /* ---------- PNG EXPORT (clone trick) --------------------- */ | |
| // replace with any CORS proxy you trust | |
| function proxied(u){ | |
| return 'https://api.allorigins.win/raw?url=' + encodeURIComponent(u); | |
| } | |
| function savePNG(){ | |
| const ghost = stage.cloneNode(true); | |
| Object.assign(ghost.style,{ | |
| position:'absolute', | |
| left:'-9999px', | |
| top:0, | |
| overflow:'visible', | |
| height:'auto' | |
| }); | |
| // swap Cataas images for proxy copies inside the clone | |
| ghost.querySelectorAll('img').forEach(img=>{ | |
| if(img.src.includes('cataas.com')) img.src = proxied(img.src); | |
| }); | |
| document.body.appendChild(ghost); | |
| html2canvas(ghost,{ | |
| backgroundColor:'#fafafa', | |
| useCORS:true | |
| }).then(cv=>{ | |
| document.body.removeChild(ghost); | |
| const a = document.createElement('a'); | |
| a.download='wireframe.png'; | |
| a.href=cv.toDataURL('image/png'); | |
| a.click(); | |
| }); | |
| } | |
| document.getElementById('savePNG').addEventListener('click',savePNG); | |
| window.addEventListener('keydown',e=>{ | |
| if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();savePNG();} | |
| }); | |
| /* ---------- SHARING -------------------------------------- */ | |
| function applyHash(){ | |
| if (location.hash.startsWith('#wf=')){ | |
| try { src.value = atob(location.hash.slice(4)); } | |
| catch { /* bad hash → ignore */ } | |
| render(); | |
| } | |
| } | |
| window.addEventListener('hashchange', applyHash); | |
| applyHash(); // run on first load | |
| document.getElementById('share').addEventListener('click',()=>{ | |
| location.hash = '#wf=' + btoa(unescape(encodeURIComponent(src.value))); | |
| navigator.clipboard.writeText(location.href) | |
| .then(()=>alert('URL copied — share away!')); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* ========= GLOBAL THEME ========= */ | |
| @import url("https://fonts.googleapis.com/css2?family=Balsamiq+Sans:wght@400;700&display=swap"); | |
| :root { | |
| --ink: #222; | |
| --stroke: 3px; | |
| --r: 7px; | |
| --font: "Balsamiq Sans", cursive; | |
| --accent: #ffd86b; | |
| } | |
| *, | |
| *::before, | |
| *::after { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| *::selection { | |
| background: #ffd86b; | |
| } | |
| #editor #src::selection { | |
| color: #ffd86b; | |
| background: #313639; | |
| } | |
| /* ========= LAYOUT ========= */ | |
| /* 0 - reset */ | |
| /* 1 — page skeleton */ | |
| body { | |
| margin: 0; | |
| font-family: "Balsamiq Sans", cursive; | |
| display: flex; | |
| flex-direction: column; /* column until we switch in media-query */ | |
| height: 100vh; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| background: #e0e0e0; | |
| } | |
| button { | |
| cursor: pointer; | |
| } | |
| /* 2 — sticky toolbar */ | |
| #wizzy { | |
| position: sticky; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| z-index: 5; | |
| background: #fff; | |
| padding: 0.4rem 0.6rem; | |
| display: flex; | |
| gap: 0.4rem; | |
| flex-wrap: wrap; | |
| } | |
| #wizzy button { | |
| font: inherit; | |
| cursor: pointer; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| padding: 0.25rem 0.7rem; | |
| background: #ffd86b; | |
| box-shadow: 2px 2px 0 var(--ink); | |
| } | |
| #wizzy button:active { | |
| transform: translate(2px, 2px); | |
| box-shadow: none; | |
| } | |
| /* 3 — editor / preview container */ | |
| main#editor { | |
| position: relative; | |
| flex: 1; /* fills remaining height */ | |
| display: flex; | |
| flex-direction: column; /* default → stacked */ | |
| min-height: 0; /* allow children to shrink inside flexbox */ | |
| } | |
| /* 4 — textarea + stage styles (work for both orientations) */ | |
| #src { | |
| padding: 1rem; | |
| font-family: "PT Mono", monospace; | |
| border: none; | |
| resize: none; | |
| outline: none; | |
| overflow: auto; | |
| color: #111; | |
| background: #dbdbdb; | |
| background-size: 3px 3px; | |
| font-size: 0.9rem; | |
| white-space: pre-wrap; | |
| line-height: 1.5; | |
| } | |
| #stage { | |
| overflow: auto; | |
| padding: 2rem; | |
| background: #fafafa; | |
| flex: 1; | |
| } | |
| /* 5 — RESPONSIVE BREAKPOINTS | |
| ------------------------------------------------- | |
| • <48rem : stacked (both occupy full width) | |
| • 48–80rem: side-by-side 40 % / 60 % | |
| • ≥80rem : side-by-side 30 % / 70 % | |
| ------------------------------------------------- */ | |
| @media (min-width: 48rem) { | |
| /* ≈ 768 px */ | |
| main#editor { | |
| flex-direction: row; | |
| } | |
| #src { | |
| width: 40%; | |
| height: auto; | |
| border: none; | |
| border-left: var(--stroke) solid var(--ink); | |
| } | |
| #stage { | |
| flex: 1; | |
| } | |
| } | |
| @media (min-width: 80rem) { | |
| /* ≈ 1280 px */ | |
| #src { | |
| width: 30%; | |
| } | |
| #stage { | |
| flex: 1; | |
| } | |
| } | |
| /* ===== “sketch” content styles ===== */ | |
| section { | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| padding: 2rem; | |
| margin-bottom: 2rem; | |
| background: #fff; | |
| } | |
| h2 { | |
| text-align: center; | |
| margin: 0 0 0.7em; | |
| font-size: 1.75rem; | |
| } | |
| h3 { | |
| margin: 1.2em 0 0.4em; | |
| font-size: 1.25rem; | |
| } | |
| p { | |
| margin: 0.5em 0; | |
| } | |
| ul { | |
| list-style: none; | |
| padding-left: 0.8rem; | |
| margin: 0.8em 0; | |
| } | |
| ul li::before { | |
| content: "• "; | |
| } | |
| ol { | |
| list-style: decimal; | |
| padding-left: 1.8rem; | |
| margin: 0.8em 0; | |
| } | |
| ol li { | |
| margin-left: 0.2rem; | |
| } | |
| .btn { | |
| display: inline-block; | |
| padding: 0.5em 1.2em; | |
| background: #ffd86b; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| font-weight: 700; | |
| box-shadow: 3px 3px 0 var(--ink); | |
| text-decoration: none; | |
| color: inherit; | |
| cursor: pointer; | |
| transition: 0.2s; | |
| } | |
| .btn:hover, | |
| .btn:active { | |
| transform: translate(3px, 3px); | |
| box-shadow: none; | |
| transition: 0.15s; | |
| } | |
| /* injected helpers */ | |
| .cols { | |
| display: grid; | |
| gap: 1rem; | |
| } | |
| .cols > div > *:not(:last-child) { | |
| margin-bottom: 0.6rem; | |
| } | |
| .cards { | |
| display: grid; | |
| gap: 1rem; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| container-type: inline-size; | |
| } | |
| .cards > .wide { | |
| grid-column: 1/-1; /* span all columns, no matter how many */ | |
| } | |
| .cols > div { | |
| display: flex; /* default simple */ | |
| flex-direction: column; | |
| } | |
| .grid { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| .grid-row{ | |
| display:grid; | |
| gap:1rem; | |
| grid-template-columns: | |
| repeat(var(--cols,auto-fit),minmax(100px,1fr)); | |
| } | |
| /* but if a div inside cols or cards wants to be a mini-grid */ | |
| .cols > div.grid, | |
| .cards > div.grid { | |
| display: grid; | |
| gap: 0.5rem; | |
| grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); | |
| } | |
| @container (min-width: 600px) { | |
| .cards { | |
| grid-template-columns: repeat(2, 1fr); | |
| } | |
| } | |
| @container (min-width: 900px) { | |
| .cards { | |
| grid-template-columns: repeat(3, 1fr); | |
| } | |
| } | |
| @container (min-width: 1200px) { | |
| .cards { | |
| grid-template-columns: repeat(4, 1fr); | |
| } | |
| } | |
| .placeholder { | |
| border: var(--stroke) dashed var(--ink); | |
| border-radius: var(--r); | |
| aspect-ratio: 4/3; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 700; | |
| } | |
| /* Progress bar */ | |
| .bar { | |
| background: #eee; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: 6px; | |
| height: 14px; | |
| overflow: hidden; | |
| margin: 0.6rem 0; | |
| } | |
| .bar span { | |
| display: block; | |
| height: 100%; | |
| background: #ffd86b; | |
| } | |
| img { | |
| max-width: 100%; | |
| height: auto; | |
| object-fit: contain; | |
| margin: 1rem 0; | |
| } | |
| pre { | |
| background: #f5f5f5; | |
| border: var(--stroke) solid var(--ink); | |
| padding: 1rem; | |
| border-radius: var(--r); | |
| overflow: auto; | |
| font-family: "PT Mono", monospace; | |
| font-size: 0.85rem; | |
| line-height: 1.4; | |
| } | |
| /* PATH: /src/css/forms.css */ | |
| .form { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| } | |
| .form-row { | |
| display: flex; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .form-row > .field { | |
| flex: 1; | |
| min-width: 200px; | |
| } | |
| /* === BASE FIELD WRAPPERS === */ | |
| .field { | |
| display: flex; | |
| flex-direction: column; | |
| margin-bottom: 1rem; | |
| gap: 0.5rem; | |
| } | |
| .field label { | |
| display: block; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| margin-bottom: 0.3rem; | |
| } | |
| /* === GROUPING FIELDS SIDE BY SIDE === */ | |
| .field-group { | |
| display: flex; | |
| flex-direction: row; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .field-group .field { | |
| flex: 1; | |
| min-width: 200px; | |
| } | |
| /* === FORM ELEMENT BASE STYLES === */ | |
| input, select, textarea, button { | |
| font-family: "PT Mono", monospace; | |
| font-size: 0.95rem; | |
| font: inherit; | |
| padding: 0.5rem 0.8rem; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| background: #fff; | |
| box-sizing: border-box; | |
| box-shadow: 2px 2px 0 var(--ink); | |
| transition: all 0.2s ease; | |
| } | |
| /* === INPUT SPECIFIC === */ | |
| .field input, | |
| .field select, | |
| .field textarea { | |
| background: #f5f5f5; | |
| } | |
| /* === CHECKBOX & RADIO TWEAKS === */ | |
| input[type="checkbox"], | |
| input[type="radio"] { | |
| width: auto; | |
| margin-right: 0.5rem; | |
| box-shadow: none; | |
| } | |
| /* === FOCUS & HOVER STATES === */ | |
| input:hover, select:hover, textarea:hover, button:hover { | |
| border-color: #333; | |
| } | |
| input:focus, select:focus, textarea:focus, button:focus { | |
| outline: none; | |
| border-color: dodgerblue; | |
| box-shadow: 0 0 0 2px rgba(30,144,255,0.3); | |
| } | |
| /* === SUBMIT BUTTON === */ | |
| button[type="submit"] { | |
| background: dodgerblue; | |
| color: #fff; | |
| border: none; | |
| } | |
| button[type="submit"]:hover { | |
| background: #1e90ff; | |
| } | |
| /* === OTHER BUTTONS === */ | |
| button:not([type="submit"]) { | |
| background: #eee; | |
| } | |
| button:not([type="submit"]):hover { | |
| background: #ddd; | |
| } | |
| /* === RANGE SLIDER CUSTOM === */ | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| background: #ccc; | |
| height: 6px; | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: dodgerblue; | |
| border-radius: 50%; | |
| border: 2px solid #000; | |
| cursor: pointer; | |
| transition: background 0.2s ease; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| background: dodgerblue; | |
| border-radius: 50%; | |
| border: 2px solid #000; | |
| cursor: pointer; | |
| } | |
| /* === FILE UPLOAD FIELD === */ | |
| input[type="file"] { | |
| background: #fff; | |
| } | |
| /* === COLOR PICKER SPECIFIC === */ | |
| input[type="color"] { | |
| width: 3rem; | |
| height: 3rem; | |
| padding: 0; | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| box-shadow: 0 0 0 2px var(--ink); | |
| border-radius: 0.5rem; | |
| } | |
| /* Make the color swatch obvious */ | |
| input[type="color"]::-webkit-color-swatch-wrapper { | |
| padding: 0; | |
| } | |
| input[type="color"]::-webkit-color-swatch { | |
| border: none; | |
| border-radius: 0.5rem; | |
| } | |
| /* Firefox */ | |
| input[type="color"]::-moz-color-swatch { | |
| border: none; | |
| border-radius: 0.5rem; | |
| } | |
| /* ===== Generic Modal System ===== */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| background: rgba(30, 30, 30, 0.3); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| } | |
| .modal-box { | |
| background: #fff; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| box-shadow: 6px 6px 0 var(--ink); | |
| max-width: 90vw; | |
| max-height: 80vh; | |
| width: 400px; | |
| overflow-y: auto; | |
| font-family: "Balsamiq Sans", cursive; | |
| padding: 1rem; | |
| } | |
| .modal-header { | |
| font-size: 1.5rem; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| } | |
| .modal-body textarea { | |
| width: 100%; | |
| height: 200px; | |
| padding: 0.8rem; | |
| font-family: "PT Mono", monospace; | |
| font-size: 0.9rem; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| background: #dbdbdb; | |
| resize: vertical; | |
| box-sizing: border-box; | |
| margin-bottom: 1rem; | |
| } | |
| .modal-footer { | |
| display: flex; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .modal-footer button { | |
| font: inherit; | |
| background: #ffd86b; | |
| border: var(--stroke) solid var(--ink); | |
| border-radius: var(--r); | |
| box-shadow: 3px 3px 0 var(--ink); | |
| padding: 0.4rem 1rem; | |
| cursor: pointer; | |
| } | |
| .modal-footer button:active { | |
| transform: translate(2px, 2px); | |
| box-shadow: none; | |
| } | |
| #modalCloseBtn { | |
| background: none; | |
| border: none; | |
| font-size: 1.2rem; | |
| line-height: 1; | |
| cursor: pointer; | |
| margin-left: 0.5rem; | |
| color: var(--ink); | |
| } | |
| #modalCloseBtn:hover { | |
| color: darkred; | |
| } | |
| #src{ | |
| caret-color:#ffd86b; | |
| color: #96b38a; | |
| background: #1d1e22; | |
| padding: 1rem; | |
| letter-spacing: 0.1rem; | |
| font-family: "PT Mono", monospace; | |
| font-size: 0.8rem; | |
| line-height: 1.2rem; | |
| } | |
| textarea#src { | |
| min-height: 120px; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment