diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 609681b..262408d 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -37,35 +37,68 @@ import { type MacroDef } from './macros'; const EDITING_CONTEXT_CLASS = 'ribbit-editing'; // CSS class prefix for all styled-source block divs. -// Each block div gets one of: md-paragraph, md-h1…md-h6, -// md-blockquote, md-list-item, md-ol-list-item, md-pre. const BLOCK_CLASS_PREFIX = 'md-'; // CSS class applied to all delimiter spans (e.g. the ** in **bold**). -// CSS hides these in viewing state, reveals them in editing state. const DELIM_CLASS = 'md-delim'; // CSS class applied to list-item prefix spans (e.g. "- " or "1. "). -// Kept in textContent so getMarkdown() sees the marker; CSS hides -// it in viewing state and replaces it with a real list-item bullet. const LIST_PREFIX_CLASS = 'md-list-prefix'; -// data- attribute on inline formatting spans, used to identify them -// during selectionchange so we can toggle EDITING_CONTEXT_CLASS. +// data- attribute on inline formatting spans. const INLINE_SPAN_ATTR = 'data-md-span'; +// Characters this implementation inserts purely as internal bookkeeping +// (never something the user typed, never part of real markdown content): +// \u200B — placeholder text node so an empty block-prefix line (e.g. +// "# " with nothing after it) still has a text node for the +// caret to land in. +// \u200C — boundary marker inserted by disambiguateAsteriskRuns +// between two adjacent closing delimiters that would +// otherwise form an ambiguous run (e.g. "***" closing both +// an inner "*" and an outer "**"). +// These are stripped entirely from getMarkdown() output and skipped +// (never counted) by caret-offset math in both directions. \u00A0 is +// handled separately by normalizeLineText below — it is NOT an +// internal marker, since it stands in for a real space the user typed. +const INTERNAL_MARKER_CHARS = '\u200B\u200C'; +const INTERNAL_MARKER_PATTERN = new RegExp(`[${INTERNAL_MARKER_CHARS}]`, 'g'); +const INTERNAL_MARKER_SINGLE = new RegExp(`^[${INTERNAL_MARKER_CHARS}]$`); + +/** + * Remove all internal bookkeeping markers from a string. + * stripInternalMarkers('**bold *italic*\u200C**') // '**bold *italic***' + */ +function stripInternalMarkers(text: string): string { + return text.replace(INTERNAL_MARKER_PATTERN, ''); +} + +/** + * True if a single character is purely internal bookkeeping. + * isInternalMarker('\u200C') // true + */ +function isInternalMarker(character: string): boolean { + return INTERNAL_MARKER_SINGLE.test(character); +} + +/** + * Normalize a block's raw textContent into clean markdown source: + * convert NBSP (inserted for Chromium caret-stability when a line ends + * in a space — never deleted, since it represents a real space the + * user typed) back to a plain space, then strip internal bookkeeping + * markers (never user content, always deleted entirely). + * + * normalizeLineText('#\u00A0Title\u200C') // '# Title' + */ +function normalizeLineText(text: string): string { + return stripInternalMarkers(text.replace(/\u00A0/g, ' ')); +} + // ─── Block classification ───────────────────────────────────────────────────── -// A block rule maps a test against a raw markdown line to a CSS class -// and the length of the prefix that should be wrapped in .md-delim. -// Rules are checked in order; the first match wins. interface BlockRule { - // Name used to build the CSS class: md-{name} name: string; - // Returns the length of the block prefix (e.g. 3 for "## "), - // or null if this rule does not match the line. prefixLength: (line: string) => number | null; - // True if the prefix should use LIST_PREFIX_CLASS instead of DELIM_CLASS. isList?: boolean; } @@ -74,7 +107,6 @@ const BLOCKQUOTE_PATTERN = /^> /; const UNORDERED_LIST_PATTERN = /^[-*+] /; const ORDERED_LIST_PATTERN = /^\d+\. /; -// Block rules in priority order. Paragraph is the implicit fallback. const BLOCK_RULES: BlockRule[] = [ { name: 'pre', @@ -117,13 +149,101 @@ const BLOCK_RULES: BlockRule[] = [ }, ]; +// ─── Inline delimiter disambiguation ─────────────────────────────────────────── + +/** + * Pre-pass over a line of markdown text that disambiguates runs of 3 + * consecutive asterisks (e.g. "**bold *italic***") into two separate, + * unambiguous tokens, separated by a sentinel. + * + * Markdown allows * and ** to nest (one inside the other) but this + * implementation does not support nesting a delimiter inside itself, + * so at most two distinct asterisk-based delimiters can be open at + * once. A run of exactly 3 asterisks therefore always means "open or + * close the most-recently-opened one first, then the other." + * + * Handles both directions of the ambiguity: + * - closing-side: "**bold *italic***" — the trailing run of 3 + * closes italic (innermost) then bold. + * - opening-side: "***italic* bold**" — the leading run of 3 opens + * italic then bold (by convention, single-char delimiter is + * treated as inner/most-recently-opened, matching the closing + * rule, for symmetry). + * + * disambiguateAsteriskRuns('**bold *italic***') + * // '**bold *italic*\u200C**' + */ +function disambiguateAsteriskRuns(line: string): string { + const SENTINEL = '\u200C'; + const ASTERISK_RUN = /\*{1,3}/g; + const stack: ('*' | '**' | '***')[] = []; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + const bold = '**'; + const italic = '*'; + + let lastSequence = ''; + + while ((match = ASTERISK_RUN.exec(line)) !== null) { + result += line.slice(lastIndex, match.index); + const sequence = match[0]; + + if (sequence.length === 3) { + + // split *** into opening ** and * + if (stack.length === 0) { + result += sequence; + stack.push('***'); + + // closing ***, close the stack + } else if (stack.length === 1 && stack[0] === sequence) { + result = result.replace('***', bold + SENTINEL + italic); + result += italic + SENTINEL + bold; + stack.pop(); + + } else if (stack.length === 2) { + const inner = stack.pop(); + const outer = stack.pop(); + result += inner + SENTINEL + outer; + + } else if (stack.length === 1) { + console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`); + + } else { + console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`); + } + } else if (stack.length) { + if (stack[stack.length - 1] === sequence) { + result += stack.pop(); + } else if (stack.length === 1 && stack[0] === '***') { + const opener = stack[0].substring(0, stack[0].length - sequence.length); + result = result.replace('***', opener + SENTINEL + sequence); + result += sequence; + stack[0] = opener as '*' | '**'; + } else { + stack.push(sequence as '*' | '**'); + result += sequence; + } + } else { + stack.push(sequence as '*' | '**'); + result += sequence; + } + + lastIndex = match.index + sequence.length; + } + + result += line.slice(lastIndex); + return result; +} + // ─── RibbitEditor ───────────────────────────────────────────────────────────── /** * Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with - * contentEditable support. The user always edits raw markdown; CSS renders - * it visually. Replaces the old flatten→rebuild pipeline with a per-line - * incremental DOM update [see STYLED_SOURCE_DESIGN.md]. + * contentEditable support. The user always edits raw markdown; CSS + * renders it visually. Per-line incremental DOM update. * * const editor = new RibbitEditor({ editorId: 'my-element' }); * editor.run(); @@ -131,41 +251,14 @@ const BLOCK_RULES: BlockRule[] = [ */ export class RibbitEditor extends Ribbit { - // The formatting span the cursor was last inside. Tracked so we - // can remove EDITING_CONTEXT_CLASS when the cursor moves away. private activeFormattingSpan: HTMLElement | null = null; - /** - * Initialize the editor with view/wysiwyg states, bind DOM events, - * and optionally attach vim keybindings. - * - * const editor = new RibbitEditor({ editorId: 'content' }); - * editor.run(); - * editor.wysiwyg(); // enter styled-source editing - */ run(): void { this.states = { VIEW: 'view', WYSIWYG: 'wysiwyg', }; - if (this.theme.features?.vim) { - // TODO - /* - 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) { @@ -175,8 +268,6 @@ export class RibbitEditor extends Ribbit { this.emitReady(); } - // ── Event binding ────────────────────────────────────────────────────────── - #bindEvents(): void { let debounceTimer: number | undefined; @@ -191,31 +282,23 @@ export class RibbitEditor extends Ribbit { }, 300); }); - /* this.element.addEventListener('keydown', (event: KeyboardEvent) => { if (this.state !== this.states.WYSIWYG) { return; } - this.#dispatchKeydown(event); - }); - */ - - this.element.addEventListener('keydown', (event: KeyboardEvent) => { - if (this.state !== this.states.WYSIWYG) return; if (event.key === 'Backspace') { - // Check if editor is already empty or about to become a bare
const children = Array.from(this.element.children); const onlyChild = children.length === 1 ? children[0] as HTMLElement : null; - const isEmpty = onlyChild && - !onlyChild.textContent!.replace(/\u200B/g, '').trim() && + const isEmpty = onlyChild && + !stripInternalMarkers(onlyChild.textContent!).trim() && onlyChild.querySelector('br'); if (isEmpty) { event.preventDefault(); return; } } - + this.#dispatchKeydown(event); }); @@ -245,29 +328,16 @@ export class RibbitEditor extends Ribbit { }); } - // ── Mode switching ───────────────────────────────────────────────────────── - - /** - * Switch to styled-source editing mode. Renders the current markdown - * as a styled DOM (one block div per line) and enables contentEditable. - * - * editor.wysiwyg(); - * // user now edits markdown directly with CSS rendering - */ wysiwyg(): void { if (this.getState() === this.states.WYSIWYG) { return; } - // Capture markdown before building the styled DOM, so getMarkdown() - // in wysiwyg state reads from the live styled DOM rather than - // sourceMarkdown (which belongs to view state). const markdown = this.getMarkdown(); this.sourceMarkdown = null; this.collaboration?.connect(); this.element.innerHTML = ''; this.element.appendChild(this.#markdownToStyledDOM(markdown)); this.element.contentEditable = 'true'; - // Macro islands are non-editable; their source is in data-source for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) { const htmlMacro = macroElement as HTMLElement; if (htmlMacro.dataset.editable === 'false') { @@ -277,36 +347,15 @@ export class RibbitEditor extends Ribbit { this.setState(this.states.WYSIWYG); } - /** - * Convert the editor's current styled DOM back to markdown. - * In wysiwyg state reads directly from the styled DOM — because - * every delimiter lives in a .md-delim text node, textContent - * always equals the original markdown source [see STYLED_SOURCE_DESIGN.md]. - * In view state delegates to the base class which reads sourceMarkdown. - * - * const markdown = editor.getMarkdown(); // "**hello** world" - */ getMarkdown(): string { if (this.getState() === this.states.WYSIWYG) { - // Each block div's textContent is one markdown line. - // Macro islands emit their data-source value instead. return Array.from(this.element.children) .map((block) => this.#blockToMarkdown(block as HTMLElement)) .join('\n'); } - // VIEW state: delegate to base class, which reads sourceMarkdown - // (set by view() before rendering) or falls back to textContent. return super.getMarkdown(); } - /** - * Insert a DOM node at the current cursor position. Used by toolbar - * buttons and macros to inject content. - * - * const img = document.createElement('img'); - * img.src = '/photo.jpg'; - * editor.insertAtCursor(img); - */ insertAtCursor(node: Node): void { const selection = window.getSelection()!; const range = selection.getRangeAt(0); @@ -318,15 +367,6 @@ export class RibbitEditor extends Ribbit { selection.addRange(range); } - // ── Styled DOM construction ──────────────────────────────────────────────── - - /** - * Convert a full markdown string to a styled-source DocumentFragment. - * One block
per line; inline delimiters wrapped in .md-delim spans. - * Called once on wysiwyg() entry and after paste. - * - * editor.element.appendChild(editor.#markdownToStyledDOM('# Hello\n**bold**')); - */ #markdownToStyledDOM(markdown: string): DocumentFragment { const fragment = document.createDocumentFragment(); for (const line of markdown.split('\n')) { @@ -335,71 +375,6 @@ export class RibbitEditor extends Ribbit { return fragment; } - /** - * Pre-pass over a line of markdown text that disambiguates closing - * runs of 3 consecutive asterisks (e.g. "**bold *italic***") into two - * separate, unambiguous closing tokens, separated by a sentinel. - * - * Markdown allows *and* ** to nest (one inside the other) but never a - * delimiter inside itself, so at most two distinct asterisk-based - * delimiters can be open at once. A run of exactly 3 closing asterisks - * therefore always means "close the most-recently-opened one (top of - * stack), then close the other" — there is no other valid reading. - * - * disambiguateAsteriskRuns('**bold *italic***') - * // '**bold *italic*\u200C**' (sentinel inserted between closers) - */ - #disambiguateAsteriskRuns(line: string): string { - const SENTINEL = '\u200C'; // zero-width non-joiner, distinct from - // the \u200B already used for caret placeholders - const ASTERISK_RUN = /\*{1,3}/g; - const stack: ('*' | '**')[] = []; - let result = ''; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = ASTERISK_RUN.exec(line)) !== null) { - result += line.slice(lastIndex, match.index); - const run = match[0]; - - if (run.length === 3 && stack.length === 2) { - // Ambiguous: split into [top-of-stack closer][sentinel][remaining closer] - const innerCloser = stack.pop()!; // most recently opened - const outerCloser = stack.pop()!; // closes after inner - result += innerCloser + SENTINEL + outerCloser; - } else if (run.length === 3) { - // *** as a single atomic bold-italic token (open or close, - // not part of an ambiguous nested pair) — pass through. - result += run; - } else if (stack.length > 0 && stack[stack.length - 1] === run) { - // Closing the most recently opened delimiter of this exact length - stack.pop(); - result += run; - } else { - // Opening a new delimiter - stack.push(run as '*' | '**'); - result += run; - } - - lastIndex = match.index + run.length; - } - - result += line.slice(lastIndex); - console.log(`DEBUG:\n input: ${line}\n output: ${result}`); - return result; - } - - /** - * Build a single styled block
from one markdown line. - * Classifies the line, wraps the block prefix in a .md-delim span, - * and parses inline formatting for the remaining content. - * - * this.#buildBlock('## Hello **world**') - * //
- * // ## - * // Hello - * //
- */ #buildBlock(line: string): HTMLDivElement { const block = document.createElement('div'); @@ -415,7 +390,6 @@ export class RibbitEditor extends Ribbit { continue; } - // Headings carry their level in the class name [C10] if (rule.name === 'heading') { const match = line.match(HEADING_PATTERN)!; block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`; @@ -437,7 +411,6 @@ export class RibbitEditor extends Ribbit { return block; } - // Fallback: plain paragraph block.className = `${BLOCK_CLASS_PREFIX}paragraph`; block.appendChild(this.#parseInline(line)); return block; @@ -448,8 +421,7 @@ export class RibbitEditor extends Ribbit { span.className = className; span.setAttribute(INLINE_SPAN_ATTR, '1'); - if (span.className == 'md-link') { - // Link: [label](href) + if (span.className === 'md-link') { span.appendChild(this.#makeDelimSpan('[')); const linkTextNode = document.createElement('span'); linkTextNode.className = 'md-link-text'; @@ -459,14 +431,7 @@ export class RibbitEditor extends Ribbit { return span; } - // Delimiter run: **content**, *content*, `content`, ~~content~~ span.appendChild(this.#makeDelimSpan(match.groups!.delimiter)); - - // Recurse: content may itself contain nested delimiters - // (e.g. "*italic*" inside a "**bold ... **" span). Each - // recursive call only ever sees the substring between this - // span's own delimiters, so nesting depth is naturally - // bounded by the original text's structure. span.appendChild(this.#parseInline(match.groups!.content)); if (match.groups!.closer) { span.appendChild(this.#makeDelimSpan(match.groups!.closer)); @@ -475,40 +440,38 @@ export class RibbitEditor extends Ribbit { return span; } - /** - * Parse an inline markdown string into a DocumentFragment of text - * nodes and styled elements. Each span wraps its delimiters - * in .md-delim children so that span.textContent == the original - * markdown source (enabling getMarkdown() = textContent). - * - * this.#parseInline('hello **world** and `code`') - */ #parseInline(text: string): DocumentFragment { - + text = disambiguateAsteriskRuns(text); + const classes: Record = { - '***': 'md-bold-italic', '**': 'md-bold', '*': 'md-italic', '~~': 'md-strikethrough', '`': 'md-code', }; - - // Combined pattern: try a link first, then a delimiter run. - // Named groups are mutually exclusive per match (only one branch fires). + + /* const INLINE_PATTERN = new RegExp( '\\[(?[^\\]]+)\\]\\((?[^)]+)\\)' + '|' + - '(?\\*{1,3}|~~|`)(?.+?)(?\\k|$)', + '(?\\*{1,2}|~~|`)(?.+?)(?\\k|$)', 'g' ); - + */ + + const INLINE_PATTERN = new RegExp( + '\\[(?[^\\]]+)\\]\\((?[^)]+)\\)' + + '|' + + '(?\\*{1,3}|~~|`)(?.+?)(?\\k(?!\\*)|$)', + 'g' + ); + const fragment = document.createDocumentFragment(); let lastIndex = 0; let match: RegExpExecArray | null; - text = this.#disambiguateAsteriskRuns(text); - while ((match = INLINE_PATTERN.exec(text)) !== null) { + console.log(match, match.index, lastIndex); if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } @@ -516,15 +479,14 @@ export class RibbitEditor extends Ribbit { fragment.appendChild(this.#createSpanForMatch(match, className)); lastIndex = match.index + match[0].length; } - + if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } - + return fragment; } - /** Create a with the given text content. */ #makeDelimSpan(text: string): HTMLSpanElement { const span = document.createElement('span'); span.className = DELIM_CLASS; @@ -532,22 +494,14 @@ export class RibbitEditor extends Ribbit { return span; } - // ── Per-line incremental update ──────────────────────────────────────────── - - /** - * On each input event, find the block div containing the cursor, - * read its textContent (which is valid markdown), and rebuild only - * that block's children. The rest of the document is untouched. - * - * Complexity: O(block length) per keystroke, not O(document length). - */ - #updateCurrentBlock(): void { const block = this.#findCurrentBlock(); - if (!block) return; + if (!block) { + return; + } const caretOffset = this.#getCaretOffset(block); - const lineText = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); + const lineText = normalizeLineText(block.textContent!); const newBlock = this.#buildBlock(lineText); block.className = newBlock.className; @@ -556,18 +510,27 @@ export class RibbitEditor extends Ribbit { block.appendChild(newBlock.firstChild); } - // Chromium drops the caret-positioning effect of a trailing plain - // space in some cases; swapping it for a non-breaking space on the - // live text node (never via innerHTML) keeps the caret sticky - // without touching how lineText is computed on the next keystroke. + /* if (lineText.endsWith(' ')) { const lastChild = block.lastChild; - if (lastChild) { + if (lastChild && lastChild.nodeType === 3) { lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0'); } } + */ + if (lineText.endsWith(' ')) { + // Find the last text node in the block (may be nested inside spans) + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let lastTextNode: Text | null = null; + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + lastTextNode = node; + } + if (lastTextNode) { + lastTextNode.textContent = lastTextNode.textContent!.replace(/ $/, '\u00A0'); + } + } - // Place caret after any prefix span, never inside it const prefixSpan = block.firstElementChild; const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) || prefixSpan?.classList.contains(LIST_PREFIX_CLASS)) @@ -588,19 +551,11 @@ export class RibbitEditor extends Ribbit { } else { this.#restoreCaret(block, caretOffset); } - + this.#updateEditingContext(); } - // ── Keyboard handling ────────────────────────────────────────────────────── - - /** - * Handle Enter and Backspace ourselves; route all other keys to the - * block tag's handleKeydown if it has one. - */ #dispatchKeydown(event: KeyboardEvent): void { - // Dispatch to the block tag's own key handler first, so that - // tags like HeadingTag and ListTag can override Enter/Backspace. const block = this.#findCurrentBlock(); if (block) { const selection = window.getSelection(); @@ -636,25 +591,20 @@ export class RibbitEditor extends Ribbit { } } - /** - * Enter: split the current block at the caret into two block divs. - * Before: one div with text "hello|world" - * After: two divs — "hello" and "world" - */ #handleEnter(): void { const block = this.#findCurrentBlock(); - if (!block) return; - - const offset = this.#getCaretOffset(block); - const text = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); + if (!block) { + return; + } - // Detect prefix - const prefixSpan = block.firstElementChild; - const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS); - const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) && + const offset = this.#getCaretOffset(block); + const text = normalizeLineText(block.textContent!); + + const prefixSpan = block.firstElementChild; + const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS); + const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) && block.classList.contains('md-blockquote'); - - // Determine what prefix the next line should carry + let prefix = ''; if (isListPrefix) { const orderedMatch = prefixSpan!.textContent!.match(/^(?\d+)\. /); @@ -668,34 +618,34 @@ export class RibbitEditor extends Ribbit { prefix = prefixSpan!.textContent!; } - //console.log('handleEnter text:', JSON.stringify(text), 'prefix:', JSON.stringify(prefix)); + const currentPrefix = stripInternalMarkers(prefixSpan?.textContent ?? ''); - // Current prefix (before incrementing) - const currentPrefix = prefixSpan?.textContent?.replace(/\u200B/g, '') ?? ''; - - // Double Enter on empty prefixed line exits - if (prefix && (text === currentPrefix || text.trim() === currentPrefix.trim())) { + if ((isListPrefix || isBlockquote) && + (text === currentPrefix || text.trim() === currentPrefix.trim())) { const emptyBlock = this.#buildBlock(''); block.className = emptyBlock.className; block.innerHTML = ''; - while (emptyBlock.firstChild) block.appendChild(emptyBlock.firstChild); + while (emptyBlock.firstChild) { + block.appendChild(emptyBlock.firstChild); + } this.#restoreCaret(block, 0); return; } - + const before = text.slice(0, offset); const after = text.slice(offset); - + const firstBlock = this.#buildBlock(before); const secondBlock = this.#buildBlock(after ? prefix + after : prefix); - + block.className = firstBlock.className; block.innerHTML = ''; - while (firstBlock.firstChild) block.appendChild(firstBlock.firstChild); - + while (firstBlock.firstChild) { + block.appendChild(firstBlock.firstChild); + } + block.after(secondBlock); - - // Place caret after prefix in new block + const newPrefixSpan = secondBlock.firstElementChild; if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) || newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) { @@ -715,11 +665,6 @@ export class RibbitEditor extends Ribbit { } } - /** - * Backspace at offset 0: merge the current block with the previous one. - * Returns true if we handled it (caller should preventDefault), false - * to let the browser handle it normally. - */ #handleBackspace(): boolean { const block = this.#findCurrentBlock(); if (!block) { @@ -736,8 +681,9 @@ export class RibbitEditor extends Ribbit { return false; } - const previousLength = previousBlock.textContent!.length; - const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' '); + const previousLength = stripInternalMarkers(previousBlock.textContent!).length; + const merged = stripInternalMarkers(previousBlock.textContent!) + + normalizeLineText(block.textContent!); const mergedBlock = this.#buildBlock(merged); previousBlock.className = mergedBlock.className; @@ -751,14 +697,6 @@ export class RibbitEditor extends Ribbit { return true; } - // ── Cursor tracking ──────────────────────────────────────────────────────── - - /** - * Walk up from the cursor to find the direct child of the editor - * element that contains it. That is the current block div. - * - * const block = this.#findCurrentBlock(); //
- */ #findCurrentBlock(): HTMLElement | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -774,13 +712,6 @@ export class RibbitEditor extends Ribbit { return null; } - /** - * Walk the block's text nodes to get the total character offset - * from the start of the block to the cursor. Survives DOM rebuilds - * because it works on character counts, not node references. - * - * const offset = this.#getCaretOffset(block); // 7 - */ #getCaretOffset(block: HTMLElement): number { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -792,22 +723,13 @@ export class RibbitEditor extends Ribbit { return range.toString().length; } - /** - * Walk the block's text nodes and place the cursor at the given - * character offset. Called after every block rebuild to restore - * the caret to where the user was typing. - * - * this.#restoreCaret(block, 7); - */ #restoreCaret(block: HTMLElement, offset: number): void { const selection = window.getSelection(); if (!selection) { return; } const range = document.createRange(); - let remaining = offset; - - const placed = this.#walkForCaret(block, range, remaining); + const placed = this.#walkForCaret(block, range, offset); if (!placed) { range.selectNodeContents(block); @@ -819,49 +741,65 @@ export class RibbitEditor extends Ribbit { } /** - * Recursively walk text nodes in the subtree rooted at `node`, - * decrementing `remaining` for each character encountered. When - * remaining reaches zero, sets range.start and returns true. + * Recursively walk text nodes counting only "real" characters + * toward `remaining` — internal bookkeeping markers are skipped + * over entirely and never consume a unit of `remaining`. */ #walkForCaret(node: Node, range: Range, remaining: number): boolean { if (node.nodeType === 3) { const textNode = node as Text; - if (remaining <= textNode.length) { - range.setStart(textNode, remaining); + const text = textNode.textContent!; + + let domOffset = 0; + for (const character of text) { + if (isInternalMarker(character)) { + domOffset += character.length; + continue; + } + if (remaining === 0) { + range.setStart(textNode, domOffset); + range.collapse(true); + return true; + } + remaining -= 1; + domOffset += character.length; + } + + if (remaining === 0) { + range.setStart(textNode, domOffset); range.collapse(true); return true; } + return false; } + let consumed = 0; for (const child of Array.from(node.childNodes)) { if (child.nodeType === 3) { const textNode = child as Text; - if (remaining - consumed <= textNode.length) { - range.setStart(textNode, remaining - consumed); - range.collapse(true); - return true; + const realLength = stripInternalMarkers(textNode.textContent!).length; + if (remaining - consumed <= realLength) { + const placed = this.#walkForCaret(textNode, range, remaining - consumed); + if (placed) { + return true; + } } - consumed += textNode.length; + consumed += realLength; } else { - const childLength = (child.textContent || '').length; - if (remaining - consumed <= childLength) { + const childRealLength = stripInternalMarkers(child.textContent || '').length; + if (remaining - consumed <= childRealLength) { const placed = this.#walkForCaret(child, range, remaining - consumed); if (placed) { return true; } } - consumed += childLength; + consumed += childRealLength; } } return false; } - /** - * On selectionchange, find the nearest ancestor that is an inline - * formatting span and add EDITING_CONTEXT_CLASS to it so CSS reveals - * its delimiters. Remove it from the previous span. - */ #updateEditingContext(): void { this.#clearEditingContext(); @@ -884,7 +822,6 @@ export class RibbitEditor extends Ribbit { } } - /** Remove EDITING_CONTEXT_CLASS from the previously active span. */ #clearEditingContext(): void { if (this.activeFormattingSpan) { this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS); @@ -892,29 +829,19 @@ export class RibbitEditor extends Ribbit { } } - // ── getMarkdown helpers ──────────────────────────────────────────────────── - - /** - * Serialize a single block div to its markdown line. Macro islands - * emit their data-source value; all other blocks emit textContent. - * - * this.#blockToMarkdown(block) // "## Hello **world**" - */ #blockToMarkdown(block: HTMLElement): string { - // Empty block (just a
) → blank line + console.log(block); if (!block.textContent!.trim() && block.querySelector('br')) { + console.log(`'${block.textContent}' appears empty, so returning empty string`); return ''; } - // Macro islands store their source text in data-source if (block.dataset.macro) { return block.dataset.source || ''; } - return block.textContent!.replace(/\u200B/g, '').replace(/\u00A0/g, ' '); - + return normalizeLineText(block.textContent!); } } -// Public API — matches the previous export shape so consumers don't break. export { RibbitEditor as Editor }; export { Ribbit as Viewer }; export { inlineTag }; diff --git a/test/disambiguate.ts b/test/disambiguate.ts new file mode 100644 index 0000000..7426d97 --- /dev/null +++ b/test/disambiguate.ts @@ -0,0 +1,149 @@ +function disambiguateAsteriskRuns(line: string): string { + const SENTINEL = '\u200C'; + const ASTERISK_RUN = /\*{1,3}/g; + const stack: ('*' | '**')[] = []; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + console.log(`DISAMBIGUATE: '${line}'`); + + while ((match = ASTERISK_RUN.exec(line)) !== null) { + + result += line.slice(lastIndex, match.index); + const run = match[0]; + + console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`); + + if (run.length === 3 && stack.length === 2) { + const innerCloser = stack.pop()!; + const outerCloser = stack.pop()!; + result += innerCloser + SENTINEL + outerCloser; + } else if (run.length === 3 && stack.length === 0) { + if (match.index == 0) { + result += '***'; + } else { + result += '*' + SENTINEL + '**'; + stack.push('**'); + stack.push('*'); + } + } else if (run.length === 3) { + result += run; + } else if (stack.length > 0 && stack[stack.length - 1] === run) { + stack.pop(); + result += run; + } else { + stack.push(run as '*' | '**'); + result += run; + } + + lastIndex = match.index + run.length; + } + + result += line.slice(lastIndex); + console.log(`DISAMBIGUATE: '${result}'`); + return result; +} + +function assert(condition, message) { + if (!condition) { throw new Error(message); } +} + +function rewrite(line: string): string { + const SENTINEL = '|' //'\u200C'; + const ASTERISK_RUN = /\*{1,3}/g; + const stack: ('*' | '**' | '***')[] = []; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + const bold = '**'; + const italic = '*'; + + let lastSequence = ''; + + while ((match = ASTERISK_RUN.exec(line)) !== null) { + result += line.slice(lastIndex, match.index); + const sequence = match[0]; + + if (sequence.length === 3) { + + // split *** into opening ** and * + if (stack.length === 0) { + result += sequence; + stack.push('***'); + + // closing ***, close the stack + } else if (stack.length === 1 && stack[0] === sequence) { + result = result.replace('***', bold + SENTINEL + italic); + result += italic + SENTINEL + bold; + stack.pop(); + + } else if (stack.length === 2) { + const inner = stack.pop(); + const outer = stack.pop(); + result += inner + SENTINEL + outer; + + } else if (stack.length === 1) { + console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`); + + } else { + console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`); + } + } else if (stack.length) { + if (stack[stack.length - 1] === sequence) { + result += stack.pop(); + } else if (stack.length === 1 && stack[0] === '***') { + const opener = stack[0].substring(0, stack[0].length - sequence.length); + result = result.replace('***', opener + SENTINEL + sequence); + result += sequence; + stack[0] = opener as '*' | '**'; + } else { + result += sequence; + stack.push(sequence as '*' | '**'); + } + } else { + stack.push(sequence as '*' | '**'); + result += sequence; + } + + lastIndex = match.index + sequence.length; + } + + result += line.slice(lastIndex); + return result; +} + + +function test_it() { + console.log(rewrite('*italic **bold***')); + let cases = [ + { + 'input': '***bold-italic***', + 'expected': '**|*bold-italic*|**' + }, + { + 'input': '***bold** italic*', + 'expected': '*|**bold** italic*', + }, + { + 'input': '***italic* bold**', + 'expected': '**|*italic* bold**', + }, + { + 'input': '**bold, *italic***', + 'expected': '**bold, *italic*|**' + }, + { + 'input': '*italic, **bold***', + 'expected': '*italic, **bold**|*' + }, + ]; + + for (const testCase of cases) { + let result = rewrite(testCase.input); + assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`); + } +}; + +test_it(); diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js index 02cc4b3..3cb9fbf 100644 --- a/test/integration/test_wysiwyg.js +++ b/test/integration/test_wysiwyg.js @@ -125,11 +125,7 @@ async function resetEditor() { */ async function typeString(text) { for (const character of text) { - if (character == ' ') { - await page.keyboard.press('Space'); - } else { - await page.keyboard.insertText(character); - } + await page.keyboard.insertText(character); await page.waitForTimeout(DELAY); } } @@ -284,11 +280,12 @@ async function runTests() { assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`); }); - await test('***bold-italic*** produces md-bold-italic span', async () => { + await test('***bold-italic*** produces md-bold anad md-italic spans', async () => { await resetEditor(); await typeString('***both***'); const html = await getHTML(); - assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`); + assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`); + assert(html.includes('md-italic'), `Expected md-italic span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`); }); @@ -327,12 +324,14 @@ async function runTests() { await typeString(testCase.markdown); const markdown = await getMarkdown(); + const html = await getHTML(); + console.log(`MARKDOWN: ${markdown}`); + console.log(`HTML: ${html}`); assert( markdown === testCase.markdown, `Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"` ); - const html = await getHTML(); const outerIndex = html.indexOf(testCase.outerClass); const innerIndex = html.indexOf(testCase.innerClass); assert(