Created
December 12, 2025 13:50
-
-
Save mike-park/ee923e60390be864a54d99efd85cb4a2 to your computer and use it in GitHub Desktop.
Rich Text Editor Comparison: Inline vs Block
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
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Rich Text Editor Comparison: Inline vs Block</title> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: | |
| -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| background: #f5f5f5; | |
| } | |
| h1 { | |
| text-align: center; | |
| color: #333; | |
| } | |
| .intro { | |
| background: #fff; | |
| padding: 20px; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| border-left: 4px solid #2196f3; | |
| } | |
| .comparison { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| } | |
| .editor-section { | |
| background: #fff; | |
| border-radius: 8px; | |
| padding: 20px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .editor-section h2 { | |
| margin-top: 0; | |
| padding-bottom: 10px; | |
| border-bottom: 2px solid #eee; | |
| } | |
| .editor-section.inline h2 { | |
| border-bottom-color: #4caf50; | |
| } | |
| .editor-section.block h2 { | |
| border-bottom-color: #ff9800; | |
| } | |
| .pros-cons { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| margin-bottom: 15px; | |
| font-size: 14px; | |
| } | |
| .pros, | |
| .cons { | |
| padding: 10px; | |
| border-radius: 4px; | |
| } | |
| .pros { | |
| background: #e8f5e9; | |
| } | |
| .cons { | |
| background: #ffebee; | |
| } | |
| .pros h4, | |
| .cons h4 { | |
| margin: 0 0 8px 0; | |
| font-size: 13px; | |
| } | |
| .pros ul, | |
| .cons ul { | |
| margin: 0; | |
| padding-left: 18px; | |
| } | |
| .pros li, | |
| .cons li { | |
| margin-bottom: 4px; | |
| } | |
| .editor-container { | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| min-height: 300px; | |
| background: #fff; | |
| overflow: hidden; | |
| } | |
| .output-section { | |
| margin-top: 15px; | |
| } | |
| .output-section h4 { | |
| margin: 0 0 8px 0; | |
| font-size: 14px; | |
| color: #666; | |
| } | |
| .output-section pre { | |
| background: #f8f8f8; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| padding: 10px; | |
| font-size: 12px; | |
| overflow-x: auto; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .toolbar-note { | |
| font-size: 12px; | |
| color: #666; | |
| margin-bottom: 10px; | |
| padding: 8px; | |
| background: #f0f0f0; | |
| border-radius: 4px; | |
| } | |
| .summary { | |
| background: #fff; | |
| padding: 20px; | |
| border-radius: 8px; | |
| margin-top: 20px; | |
| } | |
| .summary h2 { | |
| margin-top: 0; | |
| } | |
| .summary table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| .summary th, | |
| .summary td { | |
| padding: 10px; | |
| text-align: left; | |
| border-bottom: 1px solid #eee; | |
| } | |
| .summary th { | |
| background: #f5f5f5; | |
| } | |
| .recommendation { | |
| background: #e3f2fd; | |
| padding: 15px; | |
| border-radius: 4px; | |
| margin-top: 15px; | |
| border-left: 4px solid #2196f3; | |
| } | |
| /* Tiptap Editor Styles */ | |
| .tiptap-toolbar { | |
| display: flex; | |
| gap: 4px; | |
| padding: 8px; | |
| border-bottom: 1px solid #ddd; | |
| flex-wrap: wrap; | |
| } | |
| .tiptap-toolbar button { | |
| padding: 6px 10px; | |
| border: 1px solid #ddd; | |
| background: #fff; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| } | |
| .tiptap-toolbar button:hover { | |
| background: #f0f0f0; | |
| } | |
| .tiptap-toolbar button.is-active { | |
| background: #e3f2fd; | |
| border-color: #2196f3; | |
| } | |
| .tiptap-toolbar .separator { | |
| width: 1px; | |
| background: #ddd; | |
| margin: 0 4px; | |
| } | |
| .ProseMirror { | |
| padding: 16px; | |
| min-height: 250px; | |
| outline: none; | |
| } | |
| .ProseMirror p { | |
| margin: 0 0 0.5em 0; | |
| } | |
| .ProseMirror h1, | |
| .ProseMirror h2, | |
| .ProseMirror h3 { | |
| margin: 0.5em 0; | |
| } | |
| .ProseMirror ul, | |
| .ProseMirror ol { | |
| margin: 0.5em 0; | |
| padding-left: 1.5em; | |
| } | |
| .ProseMirror blockquote { | |
| border-left: 3px solid #ddd; | |
| margin: 0.5em 0; | |
| padding-left: 1em; | |
| color: #666; | |
| } | |
| .ProseMirror p.is-editor-empty:first-child::before { | |
| color: #adb5bd; | |
| content: attr(data-placeholder); | |
| float: left; | |
| height: 0; | |
| pointer-events: none; | |
| } | |
| /* Block Editor Styles */ | |
| .block-editor { | |
| min-height: 280px; | |
| } | |
| .block-item { | |
| display: flex; | |
| align-items: flex-start; | |
| padding: 4px 8px; | |
| margin: 2px 0; | |
| border-radius: 4px; | |
| transition: background 0.15s; | |
| } | |
| .block-item:hover { | |
| background: #f8f8f8; | |
| } | |
| .block-item:hover .block-handle { | |
| opacity: 1; | |
| } | |
| .block-handle { | |
| opacity: 0; | |
| cursor: grab; | |
| padding: 4px 8px; | |
| color: #999; | |
| user-select: none; | |
| transition: opacity 0.15s; | |
| } | |
| .block-handle:hover { | |
| color: #666; | |
| } | |
| .block-content { | |
| flex: 1; | |
| outline: none; | |
| padding: 4px 8px; | |
| } | |
| .block-content:focus { | |
| background: #fff; | |
| } | |
| .block-content[data-type='heading1'] { | |
| font-size: 1.8em; | |
| font-weight: bold; | |
| } | |
| .block-content[data-type='heading2'] { | |
| font-size: 1.4em; | |
| font-weight: bold; | |
| } | |
| .block-content[data-type='bullet'] { | |
| list-style: disc inside; | |
| } | |
| .block-content[data-type='quote'] { | |
| border-left: 3px solid #ddd; | |
| padding-left: 12px; | |
| color: #666; | |
| font-style: italic; | |
| } | |
| .block-add { | |
| opacity: 0; | |
| padding: 4px 8px; | |
| color: #999; | |
| cursor: pointer; | |
| font-size: 18px; | |
| transition: opacity 0.15s; | |
| } | |
| .block-item:hover .block-add { | |
| opacity: 1; | |
| } | |
| .block-add:hover { | |
| color: #2196f3; | |
| } | |
| .slash-menu { | |
| position: absolute; | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| padding: 4px; | |
| display: none; | |
| z-index: 100; | |
| } | |
| .slash-menu.visible { | |
| display: block; | |
| } | |
| .slash-menu-item { | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| } | |
| .slash-menu-item:hover { | |
| background: #f0f0f0; | |
| } | |
| .slash-menu-item strong { | |
| display: block; | |
| } | |
| .slash-menu-item span { | |
| font-size: 12px; | |
| color: #666; | |
| } | |
| .dragging { | |
| opacity: 0.5; | |
| background: #e3f2fd !important; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Rich Text Editor Comparison</h1> | |
| <div class="intro"> | |
| <strong>Purpose:</strong> Compare inline (traditional) vs block-based | |
| (Notion-style) editors. <br /><br /> | |
| <strong>Try it:</strong> Type in both editors. The inline editor (left) | |
| feels like Word/Google Docs. The block editor (right) treats each | |
| paragraph as a draggable block with a handle. <br /><br /> | |
| <strong>Inline editor:</strong> Use toolbar or type Markdown: | |
| <code># heading</code>, <code>**bold**</code>, <code>- list</code><br /> | |
| <strong>Block editor:</strong> Type <code>/</code> for commands, drag | |
| blocks with the handle on the left | |
| </div> | |
| <div class="comparison"> | |
| <!-- Inline Editor (Tiptap) --> | |
| <div class="editor-section inline"> | |
| <h2>Inline Editor (Tiptap/Milkdown style)</h2> | |
| <div class="pros-cons"> | |
| <div class="pros"> | |
| <h4>Pros</h4> | |
| <ul> | |
| <li>Familiar word processor feel</li> | |
| <li>Markdown-native storage</li> | |
| <li>Lighter weight (~150KB)</li> | |
| <li>Simpler mental model</li> | |
| <li>Better for short notes</li> | |
| </ul> | |
| </div> | |
| <div class="cons"> | |
| <h4>Cons</h4> | |
| <ul> | |
| <li>Less visual structure</li> | |
| <li>No drag-and-drop blocks</li> | |
| <li>Harder to reorganize content</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="toolbar-note"> | |
| Toolbar buttons or Markdown shortcuts: # heading, **bold**, *italic*, | |
| - list, > quote | |
| </div> | |
| <div class="editor-container"> | |
| <div class="tiptap-toolbar" id="tiptap-toolbar"> | |
| <button data-action="bold" title="Bold"><b>B</b></button> | |
| <button data-action="italic" title="Italic"><i>I</i></button> | |
| <button data-action="strike" title="Strikethrough"><s>S</s></button> | |
| <div class="separator"></div> | |
| <button data-action="heading1" title="Heading 1">H1</button> | |
| <button data-action="heading2" title="Heading 2">H2</button> | |
| <button data-action="paragraph" title="Paragraph">P</button> | |
| <div class="separator"></div> | |
| <button data-action="bulletList" title="Bullet List"> | |
| • List | |
| </button> | |
| <button data-action="orderedList" title="Ordered List"> | |
| 1. List | |
| </button> | |
| <button data-action="blockquote" title="Quote">" Quote</button> | |
| <div class="separator"></div> | |
| <button data-action="undo" title="Undo">↩</button> | |
| <button data-action="redo" title="Redo">↪</button> | |
| </div> | |
| <div id="tiptap-editor"></div> | |
| </div> | |
| <div class="output-section"> | |
| <h4>Markdown Output:</h4> | |
| <pre id="tiptap-output">Loading editor...</pre> | |
| </div> | |
| </div> | |
| <!-- Block Editor (Custom Demo) --> | |
| <div class="editor-section block"> | |
| <h2>Block Editor (Notion/BlockNote style)</h2> | |
| <div class="pros-cons"> | |
| <div class="pros"> | |
| <h4>Pros</h4> | |
| <ul> | |
| <li>Notion-like UX (familiar to many)</li> | |
| <li>Drag-and-drop blocks</li> | |
| <li>Slash commands (/heading, /list)</li> | |
| <li>Better for structured docs</li> | |
| <li>Built-in AI features</li> | |
| </ul> | |
| </div> | |
| <div class="cons"> | |
| <h4>Cons</h4> | |
| <ul> | |
| <li>React dependency (BlockNote)</li> | |
| <li>Heavier (~300KB+)</li> | |
| <li>Block JSON is primary format</li> | |
| <li>Markdown is secondary export</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="toolbar-note"> | |
| Type "/" for slash commands. Hover left side to see drag handle | |
| (⋮) and add button (+). | |
| </div> | |
| <div class="editor-container"> | |
| <div class="block-editor" id="block-editor"></div> | |
| <div class="slash-menu" id="slash-menu"> | |
| <div class="slash-menu-item" data-type="paragraph"> | |
| <strong>Text</strong><span>Plain text block</span> | |
| </div> | |
| <div class="slash-menu-item" data-type="heading1"> | |
| <strong>Heading 1</strong><span>Large heading</span> | |
| </div> | |
| <div class="slash-menu-item" data-type="heading2"> | |
| <strong>Heading 2</strong><span>Medium heading</span> | |
| </div> | |
| <div class="slash-menu-item" data-type="bullet"> | |
| <strong>Bullet List</strong><span>Bulleted list item</span> | |
| </div> | |
| <div class="slash-menu-item" data-type="quote"> | |
| <strong>Quote</strong><span>Blockquote</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="output-section"> | |
| <h4>Markdown Output:</h4> | |
| <pre id="block-output">Type in the editor above...</pre> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="summary"> | |
| <h2>Comparison Summary</h2> | |
| <table> | |
| <tr> | |
| <th>Aspect</th> | |
| <th>Inline (Tiptap/Milkdown)</th> | |
| <th>Block (BlockNote)</th> | |
| </tr> | |
| <tr> | |
| <td>Primary Storage</td> | |
| <td>Markdown (native) or JSON</td> | |
| <td>JSON blocks (Markdown export)</td> | |
| </tr> | |
| <tr> | |
| <td>Framework</td> | |
| <td>Headless (Angular-friendly)</td> | |
| <td>React (needs wrapping)</td> | |
| </tr> | |
| <tr> | |
| <td>Bundle Size</td> | |
| <td>~100-150KB gzipped</td> | |
| <td>~300KB+ gzipped</td> | |
| </tr> | |
| <tr> | |
| <td>Learning Curve</td> | |
| <td>Low (word processor)</td> | |
| <td>Medium (block paradigm)</td> | |
| </tr> | |
| <tr> | |
| <td>Best For</td> | |
| <td>Notes, comments, short text</td> | |
| <td>Documents, wikis, structured content</td> | |
| </tr> | |
| <tr> | |
| <td>AI Integration</td> | |
| <td>Plugin system (DIY)</td> | |
| <td>Built-in AI blocks</td> | |
| </tr> | |
| <tr> | |
| <td>Email HTML Import</td> | |
| <td>Easier (simpler structure)</td> | |
| <td>Needs block conversion</td> | |
| </tr> | |
| </table> | |
| <div class="recommendation"> | |
| <strong>Recommendation for your use case:</strong><br /> | |
| Given that your content is "notes with a little structure" from email | |
| imports, | |
| <strong>an inline editor (Tiptap or Milkdown)</strong> is likely the | |
| better fit: | |
| <ul> | |
| <li>Markdown-native (your target format)</li> | |
| <li>Simpler to integrate with Angular (no React)</li> | |
| <li>Better for converting messy email HTML</li> | |
| <li>Lighter weight</li> | |
| <li>Closer to your current ngx-editor UX</li> | |
| </ul> | |
| BlockNote would be better if you were building a Notion-like document | |
| editor with complex nested structures. | |
| </div> | |
| </div> | |
| <!-- Tiptap via CDN --> | |
| <script type="module"> | |
| import { Editor } from 'https://esm.sh/@tiptap/core@2.11.5'; | |
| import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.11.5'; | |
| import Placeholder from 'https://esm.sh/@tiptap/extension-placeholder@2.11.5'; | |
| // Simple HTML to Markdown converter | |
| function htmlToMarkdown(html) { | |
| const div = document.createElement('div'); | |
| div.innerHTML = html; | |
| function processNode(node) { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| return node.textContent; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) return ''; | |
| const tag = node.tagName.toLowerCase(); | |
| const children = Array.from(node.childNodes) | |
| .map(processNode) | |
| .join(''); | |
| switch (tag) { | |
| case 'h1': | |
| return `# ${children}\n`; | |
| case 'h2': | |
| return `## ${children}\n`; | |
| case 'h3': | |
| return `### ${children}\n`; | |
| case 'p': | |
| return children ? `${children}\n\n` : '\n'; | |
| case 'strong': | |
| case 'b': | |
| return `**${children}**`; | |
| case 'em': | |
| case 'i': | |
| return `*${children}*`; | |
| case 's': | |
| return `~~${children}~~`; | |
| case 'blockquote': | |
| return `> ${children.trim().replace(/\n/g, '\n> ')}\n`; | |
| case 'ul': | |
| return children; | |
| case 'ol': | |
| return children; | |
| case 'li': | |
| const parent = node.parentElement?.tagName.toLowerCase(); | |
| const prefix = parent === 'ol' ? '1. ' : '- '; | |
| return `${prefix}${children.trim()}\n`; | |
| case 'br': | |
| return '\n'; | |
| default: | |
| return children; | |
| } | |
| } | |
| return processNode(div) | |
| .trim() | |
| .replace(/\n{3,}/g, '\n\n'); | |
| } | |
| const editor = new Editor({ | |
| element: document.getElementById('tiptap-editor'), | |
| extensions: [ | |
| StarterKit, | |
| Placeholder.configure({ | |
| placeholder: | |
| 'Start typing... Use toolbar or Markdown shortcuts like # for headings', | |
| }), | |
| ], | |
| content: ` | |
| <h1>Welcome to Tiptap</h1> | |
| <p>This is an <strong>inline</strong> editor. It feels like a traditional word processor.</p> | |
| <h2>Features</h2> | |
| <ul> | |
| <li>Type naturally, like in Word or Google Docs</li> | |
| <li>Use <strong>Markdown shortcuts</strong> as you type</li> | |
| <li>Toolbar buttons for formatting</li> | |
| </ul> | |
| <blockquote>This is a blockquote - great for email quotes</blockquote> | |
| <p>Try typing <code># </code> at the start of a line for a heading.</p> | |
| `, | |
| onUpdate: ({ editor }) => { | |
| const html = editor.getHTML(); | |
| document.getElementById('tiptap-output').textContent = | |
| htmlToMarkdown(html); | |
| }, | |
| }); | |
| // Initial output | |
| setTimeout(() => { | |
| document.getElementById('tiptap-output').textContent = htmlToMarkdown( | |
| editor.getHTML() | |
| ); | |
| }, 100); | |
| // Toolbar handlers | |
| document.getElementById('tiptap-toolbar').addEventListener('click', e => { | |
| const button = e.target.closest('button'); | |
| if (!button) return; | |
| const action = button.dataset.action; | |
| switch (action) { | |
| case 'bold': | |
| editor.chain().focus().toggleBold().run(); | |
| break; | |
| case 'italic': | |
| editor.chain().focus().toggleItalic().run(); | |
| break; | |
| case 'strike': | |
| editor.chain().focus().toggleStrike().run(); | |
| break; | |
| case 'heading1': | |
| editor.chain().focus().toggleHeading({ level: 1 }).run(); | |
| break; | |
| case 'heading2': | |
| editor.chain().focus().toggleHeading({ level: 2 }).run(); | |
| break; | |
| case 'paragraph': | |
| editor.chain().focus().setParagraph().run(); | |
| break; | |
| case 'bulletList': | |
| editor.chain().focus().toggleBulletList().run(); | |
| break; | |
| case 'orderedList': | |
| editor.chain().focus().toggleOrderedList().run(); | |
| break; | |
| case 'blockquote': | |
| editor.chain().focus().toggleBlockquote().run(); | |
| break; | |
| case 'undo': | |
| editor.chain().focus().undo().run(); | |
| break; | |
| case 'redo': | |
| editor.chain().focus().redo().run(); | |
| break; | |
| } | |
| updateToolbarState(); | |
| }); | |
| function updateToolbarState() { | |
| document.querySelectorAll('#tiptap-toolbar button').forEach(btn => { | |
| const action = btn.dataset.action; | |
| let isActive = false; | |
| switch (action) { | |
| case 'bold': | |
| isActive = editor.isActive('bold'); | |
| break; | |
| case 'italic': | |
| isActive = editor.isActive('italic'); | |
| break; | |
| case 'strike': | |
| isActive = editor.isActive('strike'); | |
| break; | |
| case 'heading1': | |
| isActive = editor.isActive('heading', { level: 1 }); | |
| break; | |
| case 'heading2': | |
| isActive = editor.isActive('heading', { level: 2 }); | |
| break; | |
| case 'bulletList': | |
| isActive = editor.isActive('bulletList'); | |
| break; | |
| case 'orderedList': | |
| isActive = editor.isActive('orderedList'); | |
| break; | |
| case 'blockquote': | |
| isActive = editor.isActive('blockquote'); | |
| break; | |
| } | |
| btn.classList.toggle('is-active', isActive); | |
| }); | |
| } | |
| editor.on('selectionUpdate', updateToolbarState); | |
| editor.on('update', updateToolbarState); | |
| </script> | |
| <!-- Block Editor (Vanilla JS Demo) --> | |
| <script> | |
| (function () { | |
| const blockEditor = document.getElementById('block-editor'); | |
| const slashMenu = document.getElementById('slash-menu'); | |
| const output = document.getElementById('block-output'); | |
| let activeBlock = null; | |
| let draggedBlock = null; | |
| const initialBlocks = [ | |
| { type: 'heading1', content: 'Welcome to Block Editor' }, | |
| { | |
| type: 'paragraph', | |
| content: | |
| 'This is a block-based editor. Each element is a separate draggable block.', | |
| }, | |
| { type: 'heading2', content: 'Features' }, | |
| { | |
| type: 'bullet', | |
| content: 'Hover left side to see drag handle and + button', | |
| }, | |
| { type: 'bullet', content: 'Type / for slash commands' }, | |
| { type: 'bullet', content: 'Drag blocks to reorder them' }, | |
| { | |
| type: 'paragraph', | |
| content: 'Try typing / to see the command menu.', | |
| }, | |
| ]; | |
| function createBlock(type = 'paragraph', content = '') { | |
| const block = document.createElement('div'); | |
| block.className = 'block-item'; | |
| block.draggable = true; | |
| block.innerHTML = ` | |
| <span class="block-add" title="Add block">+</span> | |
| <span class="block-handle" title="Drag to reorder">⋮⋮</span> | |
| <div class="block-content" contenteditable="true" data-type="${type}">${content}</div> | |
| `; | |
| const contentEl = block.querySelector('.block-content'); | |
| contentEl.addEventListener('focus', () => { | |
| activeBlock = block; | |
| }); | |
| contentEl.addEventListener('input', () => { | |
| updateOutput(); | |
| checkSlashCommand(contentEl); | |
| }); | |
| contentEl.addEventListener('keydown', handleKeydown); | |
| contentEl.addEventListener('blur', () => { | |
| setTimeout(() => hideSlashMenu(), 150); | |
| }); | |
| block.querySelector('.block-add').addEventListener('click', () => { | |
| const newBlock = createBlock('paragraph', ''); | |
| block.parentNode.insertBefore(newBlock, block.nextSibling); | |
| newBlock.querySelector('.block-content').focus(); | |
| updateOutput(); | |
| }); | |
| // Drag handlers | |
| block.addEventListener('dragstart', e => { | |
| draggedBlock = block; | |
| block.classList.add('dragging'); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| }); | |
| block.addEventListener('dragend', () => { | |
| block.classList.remove('dragging'); | |
| draggedBlock = null; | |
| updateOutput(); | |
| }); | |
| block.addEventListener('dragover', e => { | |
| e.preventDefault(); | |
| if (draggedBlock && draggedBlock !== block) { | |
| const rect = block.getBoundingClientRect(); | |
| const midY = rect.top + rect.height / 2; | |
| if (e.clientY < midY) { | |
| block.parentNode.insertBefore(draggedBlock, block); | |
| } else { | |
| block.parentNode.insertBefore(draggedBlock, block.nextSibling); | |
| } | |
| } | |
| }); | |
| return block; | |
| } | |
| function handleKeydown(e) { | |
| const contentEl = e.target; | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (slashMenu.classList.contains('visible')) { | |
| const firstItem = slashMenu.querySelector('.slash-menu-item'); | |
| if (firstItem) selectSlashMenuItem(firstItem.dataset.type); | |
| } else { | |
| const newBlock = createBlock('paragraph', ''); | |
| activeBlock.parentNode.insertBefore( | |
| newBlock, | |
| activeBlock.nextSibling | |
| ); | |
| newBlock.querySelector('.block-content').focus(); | |
| updateOutput(); | |
| } | |
| } else if ( | |
| e.key === 'Backspace' && | |
| contentEl.textContent === '' && | |
| blockEditor.children.length > 1 | |
| ) { | |
| e.preventDefault(); | |
| const prev = activeBlock.previousElementSibling; | |
| activeBlock.remove(); | |
| if (prev) prev.querySelector('.block-content').focus(); | |
| updateOutput(); | |
| } else if (e.key === 'Escape') { | |
| hideSlashMenu(); | |
| } else if ( | |
| e.key === 'ArrowDown' && | |
| slashMenu.classList.contains('visible') | |
| ) { | |
| e.preventDefault(); | |
| // Simple: just select first item | |
| } else if ( | |
| e.key === 'ArrowUp' && | |
| slashMenu.classList.contains('visible') | |
| ) { | |
| e.preventDefault(); | |
| } | |
| } | |
| function checkSlashCommand(contentEl) { | |
| const text = contentEl.textContent; | |
| if (text === '/') { | |
| showSlashMenu(contentEl); | |
| } else if (!text.startsWith('/')) { | |
| hideSlashMenu(); | |
| } | |
| } | |
| function showSlashMenu(contentEl) { | |
| const rect = contentEl.getBoundingClientRect(); | |
| const containerRect = blockEditor.getBoundingClientRect(); | |
| slashMenu.style.top = rect.bottom - containerRect.top + 4 + 'px'; | |
| slashMenu.style.left = rect.left - containerRect.left + 'px'; | |
| slashMenu.classList.add('visible'); | |
| } | |
| function hideSlashMenu() { | |
| slashMenu.classList.remove('visible'); | |
| } | |
| function selectSlashMenuItem(type) { | |
| if (!activeBlock) return; | |
| const contentEl = activeBlock.querySelector('.block-content'); | |
| contentEl.textContent = ''; | |
| contentEl.dataset.type = type; | |
| hideSlashMenu(); | |
| contentEl.focus(); | |
| updateOutput(); | |
| } | |
| // Slash menu click handlers | |
| slashMenu.querySelectorAll('.slash-menu-item').forEach(item => { | |
| item.addEventListener('mousedown', e => { | |
| e.preventDefault(); | |
| selectSlashMenuItem(item.dataset.type); | |
| }); | |
| }); | |
| function updateOutput() { | |
| const blocks = Array.from( | |
| blockEditor.querySelectorAll('.block-item') | |
| ); | |
| const markdown = blocks | |
| .map(block => { | |
| const contentEl = block.querySelector('.block-content'); | |
| const type = contentEl.dataset.type; | |
| const text = contentEl.textContent; | |
| switch (type) { | |
| case 'heading1': | |
| return `# ${text}`; | |
| case 'heading2': | |
| return `## ${text}`; | |
| case 'bullet': | |
| return `- ${text}`; | |
| case 'quote': | |
| return `> ${text}`; | |
| default: | |
| return text; | |
| } | |
| }) | |
| .join('\n\n'); | |
| output.textContent = markdown; | |
| } | |
| // Initialize with sample content | |
| initialBlocks.forEach(({ type, content }) => { | |
| blockEditor.appendChild(createBlock(type, content)); | |
| }); | |
| updateOutput(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment