/* * 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 { 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 { run(): void { this.states = { VIEW: 'view', EDIT: 'edit', WYSIWYG: 'wysiwyg' }; this.#bindEvents(); this.element.classList.add('loaded'); if (this.autoToolbar) { this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); } this.view(); } #bindEvents(): void { let debounceTimer: number | undefined; let lastThrottle = 0; this.element.addEventListener('input', () => { if (this.state === this.states.VIEW) { return; } this.invalidateCache(); const now = Date.now(); if (now - lastThrottle >= 150) { lastThrottle = now; this.refreshPreview(); } clearTimeout(debounceTimer); debounceTimer = window.setTimeout(() => { this.refreshPreview(); this.notifyChange(); }, 150); }); } /** * Re-render the WYSIWYG preview from the current content. * Applies speculative rendering for unclosed inline delimiters * at the cursor position, and uses toHtmlPreview for visible syntax. */ refreshPreview(): void { if (this.state !== this.states.WYSIWYG) { return; } const cursorInfo = this.getCursorInfo(); const text = this.element.textContent || ''; const lines = text.split('\n'); // Speculatively close unclosed delimiters on the cursor line if (cursorInfo) { const inlineTags = this.converter.getInlineTags(); const sorted = [...inlineTags].sort((a, b) => ((a as any).precedence ?? 50) - ((b as any).precedence ?? 50) ); for (const tag of sorted) { if (tag.openPattern && tag.delimiter) { const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset); const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(escaped, 'g'); const count = (before.match(re) || []).length; if (count % 2 === 1) { lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter; break; } } } } const html = this.converter.toHTML(lines.join('\n')); this.updatePreview(html, cursorInfo); } /** * 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; } } /** * Get the cursor's line index and offset within that line. */ private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) { return null; } const range = sel.getRangeAt(0); const preRange = document.createRange(); preRange.selectNodeContents(this.element); preRange.setEnd(range.startContainer, range.startOffset); const absoluteOffset = preRange.toString().length; const text = this.element.textContent || ''; const beforeCursor = text.slice(0, absoluteOffset); const lineIndex = beforeCursor.split('\n').length - 1; const lineStart = beforeCursor.lastIndexOf('\n') + 1; const offset = absoluteOffset - lineStart; return { lineIndex, offset, absoluteOffset }; } /** * Replace the editor's HTML and restore the cursor to its * previous text offset position. */ private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void { this.element.innerHTML = html; if (!cursorInfo) { return; } const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT); let remaining = cursorInfo.absoluteOffset; let node: Text | null; while ((node = walker.nextNode() as Text | null)) { if (remaining <= node.length) { const sel = window.getSelection()!; const range = document.createRange(); range.setStart(node, remaining); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); break; } remaining -= node.length; } this.updateEditingContext(); } htmlToMarkdown(html?: string): string { return this.converter.toMarkdown(html || this.element.innerHTML); } getMarkdown(): string { if (this.cachedMarkdown !== null) { return this.cachedMarkdown; } if (this.getState() === this.states.EDIT) { let html = this.element.innerHTML; html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<\/div>/ig, '\n'); this.cachedMarkdown = decodeHtmlEntities(html); } else if (this.getState() === this.states.WYSIWYG) { this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML); } else { this.cachedMarkdown = this.element.textContent || ''; } return this.cachedMarkdown; } wysiwyg(): void { if (this.getState() === this.states.WYSIWYG) return; this.element.contentEditable = 'true'; this.element.innerHTML = this.getHTML(); 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.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 type { MacroDef };