/* * ribbit-editor.ts — WYSIWYG editing extension for Ribbit. */ import { HopDown } from './hopdown'; import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags'; import { defaultTheme } from './default-theme'; import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { VimHandler } from './vim'; import { type MacroDef } from './macros'; /** * WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes. * * Extends Ribbit with contentEditable support and bidirectional * markdown↔HTML conversion on mode switches. * * Usage: * const editor = new RibbitEditor({ editorId: 'my-element' }); * editor.run(); * editor.wysiwyg(); // switch to WYSIWYG mode * editor.edit(); // switch to source editing mode * editor.view(); // switch to read-only view */ export class RibbitEditor extends Ribbit { private vim?: VimHandler; run(): void { this.states = { VIEW: 'view', EDIT: 'edit', WYSIWYG: 'wysiwyg' }; if (this.theme.features?.vim) { this.vim = new VimHandler((mode) => { if (mode === 'normal') { this.toolbar.disable(); this.element.classList.add('vim-normal'); this.element.classList.remove('vim-insert'); } else { this.toolbar.enable(); this.element.classList.add('vim-insert'); this.element.classList.remove('vim-normal'); } }); } this.#bindEvents(); this.element.classList.add('loaded'); if (this.autoToolbar) { this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); } this.view(); this.emitReady(); } #bindEvents(): void { let debounceTimer: number | undefined; this.element.addEventListener('input', () => { if (this.state !== this.states.WYSIWYG) { return; } this.ensureBlockStructure(); this.transformCurrentBlock(); this.updateEditingContext(); clearTimeout(debounceTimer); debounceTimer = window.setTimeout(() => { this.notifyChange(); }, 300); }); this.element.addEventListener('keydown', (e: KeyboardEvent) => { if (this.state !== this.states.WYSIWYG) { return; } if (e.key === 'Enter') { this.handleEnter(e); } }); this.element.addEventListener('keyup', (e: KeyboardEvent) => { if (this.state !== this.states.WYSIWYG) { return; } if (e.key.startsWith('Arrow')) { this.closeOrphanedSpeculative(); this.updateEditingContext(); } }); this.element.addEventListener('blur', () => { if (this.state !== this.states.WYSIWYG) { return; } this.closeOrphanedSpeculative(); }); this.element.addEventListener('focusout', () => { if (this.state !== this.states.WYSIWYG) { return; } this.closeOrphanedSpeculative(); }); document.addEventListener('click', (e: MouseEvent) => { if (this.state !== this.states.WYSIWYG) { return; } if (!this.element.contains(e.target as Node)) { this.closeAllSpeculative(); } }); document.addEventListener('selectionchange', () => { if (this.state !== this.states.WYSIWYG) { return; } this.closeOrphanedSpeculative(); this.updateEditingContext(); }); } /** * Find the block-level element containing the cursor. */ /** * Ensure the editor contains valid block structure. * Wraps bare
and
elements in

tags. */ private ensureBlockStructure(): void { for (const child of Array.from(this.element.childNodes)) { if (child.nodeType === 1) { const element = child as HTMLElement; if (element.tagName === 'BR') { const p = document.createElement('p'); p.innerHTML = '
'; element.replaceWith(p); } else if (element.tagName === 'DIV') { const p = document.createElement('p'); while (element.firstChild) { p.appendChild(element.firstChild); } if (!p.firstChild) { p.innerHTML = '
'; } element.replaceWith(p); // Restore cursor inside the new

const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const range = document.createRange(); const target = p.lastChild || p; if (target.nodeType === 3) { range.setStart(target, target.textContent?.length || 0); } else { range.selectNodeContents(target); range.collapse(false); } sel.removeAllRanges(); sel.addRange(range); } } } } if (!this.element.firstChild) { this.element.innerHTML = '


'; } } private findCurrentBlock(): HTMLElement | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) { return null; } let node: Node | null = sel.anchorNode; // If cursor is in a text node directly inside the editor, // wrap it in a

first (browsers don't always do this). if (node && node.nodeType === 3 && node.parentNode === this.element) { const p = document.createElement('p'); node.parentNode.insertBefore(p, node); p.appendChild(node); // Restore cursor inside the new

const range = document.createRange(); range.setStart(node, sel.anchorOffset); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); return p; } while (node && node !== this.element) { if (node.nodeType === 1) { const element = node as HTMLElement; if (element.tagName === 'LI' || element.parentNode === this.element) { return element; } } node = node.parentNode; } return null; } /** * Check the current block's text for markdown patterns and * transform the DOM element in-place if a pattern matches. */ private transformCurrentBlock(): void { const block = this.findCurrentBlock(); if (!block) { return; } const text = (block.textContent || '').replace(/\u00A0/g, ' '); // Heading: # through ###### const headingMatch = text.match(/^(#{1,6})\s/); if (headingMatch) { const level = headingMatch[1].length; const targetTag = 'H' + level; if (block.tagName !== targetTag) { this.replaceBlock(block, targetTag, headingMatch[0].length); return; } } // Blockquote: > if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') { this.replaceBlock(block, 'BLOCKQUOTE', 2); return; } // Horizontal rule: --- or *** or ___ if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) { const hr = document.createElement('hr'); const p = document.createElement('p'); p.innerHTML = '
'; block.replaceWith(hr, p); const range = document.createRange(); range.setStart(p, 0); range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); return; } // Unordered list: - or * if (/^[-*]\s/.test(text) && block.tagName !== 'LI') { this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1); return; } // Ordered list: 1. if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') { this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1); return; } // Fenced code: ``` if (text.startsWith('```') && block.tagName !== 'PRE') { const pre = document.createElement('pre'); const code = document.createElement('code'); code.textContent = ''; pre.appendChild(code); block.replaceWith(pre); const range = document.createRange(); range.setStart(code, 0); range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); return; } // Inline transforms: flatten to markdown, transform, rebuild DOM this.transformInline(block); } /** * Convert a block's DOM children to a mixed string where completed * inline elements are preserved as HTML and only speculative/text * content is flattened to markdown. Completed elements are wrapped * in sentinel markers so the regex engine skips them. */ private blockToMarkdown(block: HTMLElement): string { let md = ''; for (const child of Array.from(block.childNodes)) { md += this.nodeToMarkdown(child); } return md; } private nodeToMarkdown(node: Node): string { if (node.nodeType === 3) { return (node.textContent || '').replace(/\u200B/g, ''); } if (node.nodeType !== 1) { return ''; } const element = node as HTMLElement; const specDelim = element.getAttribute('data-speculative'); if (specDelim) { // Speculative: restore opener delimiter + flatten children let inner = ''; for (const child of Array.from(element.childNodes)) { inner += this.nodeToMarkdown(child); } return specDelim + inner; } const tag = this.findTagForElement(element); if (tag?.delimiter) { // Completed element: preserve as HTML, wrapped in sentinels // so the complete-pair regex won't match across it return '\x01' + element.outerHTML + '\x02'; } // Unknown element: flatten children let inner = ''; for (const child of Array.from(element.childNodes)) { inner += this.nodeToMarkdown(child); } return inner; } /** * Find the Tag definition that matches an HTML element. */ private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null { const inlineTags = this.converter.getInlineTags(); for (const tag of inlineTags) { if (!tag.delimiter) continue; if (typeof tag.selector === 'string') { const selectors = tag.selector.split(','); if (selectors.some(s => el.tagName === s.trim())) { return tag; } } } return null; } /** * Flatten the block to markdown, find and apply inline transforms, * then rebuild the DOM from the result. */ private transformInline(block: HTMLElement): void { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; let md = this.blockToMarkdown(block); if (md.replace(/\s/g, '').length < 2) return; const inlineTags = this.converter.getInlineTags(); const sorted = [...inlineTags] .filter(tag => tag.delimiter) .sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50)); // Build regex for each tag with exact-delimiter matching. // [^\x01\x02] prevents matching across preserved HTML elements. const tagRegexes = sorted.map(tag => { const delim = tag.delimiter!; const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return { tag, complete: new RegExp(`(? = { 'strong': ['strong', 'b'], 'em': ['em', 'i'], 'code': ['code', 'strong', 'b', 'em', 'i', 'a'], }; let changed = true; while (changed) { changed = false; for (const { tag, complete } of tagRegexes) { const match = md.match(complete); if (match && match.index !== undefined) { const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase(); // Skip if wrapping would create forbidden nesting const banned = forbiddenChildren[tagName]; if (banned && banned.some(t => match[1].includes('<' + t))) { continue; } const content = tagName === 'code' ? match[1].replace(/&/g, '&').replace(//g, '>') : match[1]; const inner = tag.name === 'boldItalic' ? `\x01<${tagName}>${content}\x02` : `\x01<${tagName}>${content}\x02`; md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length); changed = true; break; } } } // Strip sentinel markers now that complete-pair matching is done md = md.replace(/[\x01\x02]/g, ''); // Check for one unclosed opener (speculative) let speculativeTag: typeof sorted[0] | null = null; let speculativeMatch: RegExpMatchArray | null = null; for (const { tag, open } of tagRegexes) { const match = md.match(open); if (match && match.index !== undefined) { // Make sure this isn't inside an HTML tag we just created const before = md.slice(0, match.index); if (!before.endsWith('<') && !before.endsWith('/')) { speculativeTag = tag; speculativeMatch = match; break; } } } // Rebuild the DOM if (speculativeMatch && speculativeTag) { const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase(); const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length); // Check for forbidden nesting before wrapping const probe = document.createElement('div'); probe.innerHTML = inside; const banned = forbiddenChildren[tagName]; const wouldNest = banned && banned.some(tag => probe.querySelector(tag)); if (!wouldNest) { const before = md.slice(0, speculativeMatch.index!); const wrapper = document.createElement(tagName); wrapper.classList.add('ribbit-editing'); wrapper.setAttribute('data-speculative', speculativeTag.delimiter!); wrapper.innerHTML = inside; this.sanitizeNesting(wrapper); block.innerHTML = ''; if (before) { block.appendChild(document.createTextNode(before)); } block.appendChild(wrapper); // ZWS after wrapper so arrow-right can escape the element block.appendChild(document.createTextNode('\u200B')); // Cursor at end of speculative element this.placeCursorAtEnd(wrapper); } else { // Forbidden nesting — fall through to plain innerHTML block.innerHTML = md; this.sanitizeNesting(block); if (block.lastChild && block.lastChild.nodeType === 1) { block.appendChild(document.createTextNode('\u200B')); } this.placeCursorAtEnd(block); } } else { block.innerHTML = md; this.sanitizeNesting(block); // If the block ends with an HTML element, append a ZWS text // node so the cursor lands outside the element, not inside it. if (block.lastChild && block.lastChild.nodeType === 1) { block.appendChild(document.createTextNode('\u200B')); } this.placeCursorAtEnd(block); } } /** * Place the cursor at the end of an element's content. */ private placeCursorAtEnd(el: HTMLElement): void { const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); // Find the deepest last text node let target: Node = el; while (target.lastChild) { target = target.lastChild; } if (target.nodeType === 3) { range.setStart(target, target.textContent?.length || 0); } else { range.selectNodeContents(target); range.collapse(false); } range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } /** /** * Replace a block element with a new tag, stripping the prefix * and preserving cursor position. */ private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void { const newEl = document.createElement(newTag); const content = (block.textContent || '').slice(prefixLength); if (content) { newEl.textContent = content; } else { newEl.innerHTML = '
'; } block.replaceWith(newEl); newEl.classList.add('ribbit-editing'); // Place cursor at start of content const range = document.createRange(); if (newEl.firstChild && newEl.firstChild.nodeType === 3) { range.setStart(newEl.firstChild, 0); } else { range.setStart(newEl, 0); } range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); } /** * Replace a block element with a list (ul/ol) containing one item. */ private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void { const list = document.createElement(listTag); const li = document.createElement('li'); const content = (block.textContent || '').slice(prefixLength); if (content) { li.textContent = content; } else { li.innerHTML = '
'; } list.appendChild(li); block.replaceWith(list); const range = document.createRange(); if (li.firstChild && li.firstChild.nodeType === 3) { range.setStart(li.firstChild, 0); } else { range.setStart(li, 0); } range.collapse(true); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); } /** * Handle Enter key: strip syntax decorations from the current * block before the browser creates a new line. */ private handleEnter(e: KeyboardEvent): void { const prev = this.element.querySelector('.ribbit-editing'); if (prev) { prev.classList.remove('ribbit-editing'); prev.removeAttribute('data-speculative'); } } /** * Close any speculative elements that the cursor is no longer inside. * Called on every selection change — handles arrow keys, clicks, * tab switches, and any other cursor movement. */ /** * Unwrap a speculative element, replacing it with its children. * An orphaned speculative element was never completed — it should * not become permanent formatting. */ private unwrapSpeculative(element: HTMLElement): void { this.unwrapElement(element); } /** * Replace an element with its children, preserving content. */ private unwrapElement(element: HTMLElement): void { const parent = element.parentNode; if (!parent) { return; } while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); } /** * Remove forbidden nesting from a block element. * For example, inside , inside , etc. */ private sanitizeNesting(block: HTMLElement): void { const rules: Record = { 'STRONG': ['STRONG', 'B'], 'B': ['STRONG', 'B'], 'EM': ['EM', 'I'], 'I': ['EM', 'I'], 'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'], }; let found = true; while (found) { found = false; for (const [parent, forbidden] of Object.entries(rules)) { const parents = block.querySelectorAll(parent.toLowerCase()); for (const parentEl of Array.from(parents)) { for (const tag of forbidden) { const nested = parentEl.querySelector(tag.toLowerCase()); if (nested && nested !== parentEl) { this.unwrapElement(nested as HTMLElement); found = true; } } } } } } private closeAllSpeculative(): void { for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) { this.unwrapSpeculative(element as HTMLElement); } } private closeOrphanedSpeculative(): void { const speculative = this.element.querySelectorAll('[data-speculative]'); if (speculative.length === 0) { return; } const sel = window.getSelection(); const anchor = sel?.anchorNode; for (const el of Array.from(speculative)) { const htmlEl = el as HTMLElement; let inside = false; let node: Node | null = anchor || null; while (node) { if (node === htmlEl) { inside = true; break; } node = node.parentNode; } if (!inside) { this.unwrapSpeculative(htmlEl); } } } /** * Track which formatting element contains the cursor and toggle * the .ribbit-editing class so CSS ::before/::after show delimiters. */ private updateEditingContext(): void { const prev = this.element.querySelector('.ribbit-editing'); if (prev) { prev.classList.remove('ribbit-editing'); } const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) { return; } let node: Node | null = sel.anchorNode; while (node && node !== this.element) { if (node.nodeType === 1) { const el = node as HTMLElement; if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) { el.classList.add('ribbit-editing'); return; } } node = node.parentNode; } } htmlToMarkdown(html?: string): string { return this.converter.toMarkdown(html || this.element.innerHTML); } getMarkdown(): string { if (this.getState() === this.states.EDIT) { let html = this.element.innerHTML; html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<\/div>/ig, '\n'); return decodeHtmlEntities(html); } else if (this.getState() === this.states.WYSIWYG) { return this.htmlToMarkdown(this.element.innerHTML); } return this.element.textContent || ''; } wysiwyg(): void { if (this.getState() === this.states.WYSIWYG) return; const wasEditing = this.getState() === this.states.EDIT; this.vim?.detach(); this.collaboration?.connect(); if (wasEditing && this.collaboration?.isPaused()) { this.collaboration.resume(this.getMarkdown()); } this.element.contentEditable = 'true'; this.element.innerHTML = this.getHTML(); // Ensure there's at least one block element for the cursor if (!this.element.firstElementChild) { this.element.innerHTML = '


'; } Array.from(this.element.querySelectorAll('.macro')).forEach(el => { const macroEl = el as HTMLElement; if (macroEl.dataset.editable === 'false') { macroEl.contentEditable = 'false'; macroEl.style.opacity = '0.5'; } }); this.setState(this.states.WYSIWYG); } edit(): void { if (!this.theme.features?.sourceMode) { return; } if (this.state === this.states.EDIT) return; this.element.contentEditable = 'true'; this.element.innerHTML = encodeHtmlEntities(this.getMarkdown()); this.vim?.attach(this.element); this.collaboration?.connect(); this.collaboration?.pause(this.getMarkdown()); this.setState(this.states.EDIT); } insertAtCursor(node: Node): void { const sel = window.getSelection()!; const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); this.element.focus(); sel.removeAllRanges(); sel.addRange(range); } } // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc. export { RibbitEditor as Editor }; export { Ribbit as Viewer }; export { HopDown }; export { inlineTag }; export { defaultTags, defaultBlockTags, defaultInlineTags }; export { defaultTheme }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; export { ToolbarManager } from './toolbar'; export { VimHandler } from './vim'; export { CollaborationManager } from './collaboration'; export type { MacroDef };