Skip to content

Instantly share code, notes, and snippets.

@mike-park
Created December 12, 2025 13:50
Show Gist options
  • Select an option

  • Save mike-park/ee923e60390be864a54d99efd85cb4a2 to your computer and use it in GitHub Desktop.

Select an option

Save mike-park/ee923e60390be864a54d99efd85cb4a2 to your computer and use it in GitHub Desktop.
Rich Text Editor Comparison: Inline vs Block
<!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">
&#8226; 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">&#8617;</button>
<button data-action="redo" title="Redo">&#8618;</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
(&#8942;) 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">&#8942;&#8942;</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