ribbit/src/ts/ribbit-editor.ts
2026-04-29 22:15:19 +00:00

757 lines
27 KiB
TypeScript

/*
* 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 <br> and <div> elements in <p> 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 = '<br>';
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 = '<br>';
}
element.replaceWith(p);
// Restore cursor inside the new <p>
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 = '<p><br></p>';
}
}
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 <p> 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 <p>
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 = '<br>';
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(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
};
});
// Apply complete pairs repeatedly until none match
const forbiddenChildren: Record<string, string[]> = {
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
: match[1];
const inner = tag.name === 'boldItalic'
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
: `\x01<${tagName}>${content}</${tagName}>\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 = '<br>';
}
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 = '<br>';
}
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, <em> inside <em>, <strong> inside <code>, etc.
*/
private sanitizeNesting(block: HTMLElement): void {
const rules: Record<string, string[]> = {
'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 = '<p><br></p>';
}
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 };