diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css index e74db91..924acc0 100644 --- a/src/static/ribbit-core.css +++ b/src/static/ribbit-core.css @@ -1,3 +1,151 @@ +/* + * ribbit-core.css — functional editor styles. Always load this. + * + * These styles control editor state visibility and the styled-source + * rendering. They should not be overridden by themes. + * + * Two CSS states (not modes): + * .wysiwyg — contentEditable, delimiters revealed on cursor focus + * .view — read-only, all delimiters hidden, full block styling + * + * The DOM is identical in both states; only CSS changes. + */ + +/* ── Visibility ─────────────────────────────────────────────────────────────── */ + +#ribbit { + display: none; +} + +#ribbit.loaded { + display: block; +} + +/* ── Delimiter visibility ───────────────────────────────────────────────────── */ + +/* + * Delimiters are always present in the DOM as text nodes inside + * .md-delim spans. In view state they are hidden; in wysiwyg state + * they are hidden by default and revealed only for the span the + * cursor is currently inside (.ribbit-editing). + * + * This means getMarkdown() = element.textContent at all times — + * no conversion is needed. + */ + +.md-delim { + display: none; +} + +#ribbit.wysiwyg .ribbit-editing > .md-delim { + display: inline; + opacity: 0.4; + font-weight: normal; + font-style: normal; + font-family: monospace; + font-size: 0.85em; +} + +/* List prefixes use a separate class so CSS can replace them with + real list bullets in view state while keeping them in textContent */ +.md-list-prefix { + display: inline; + opacity: 0.4; + font-family: monospace; + font-size: 0.85em; +} + +#ribbit.view .md-list-prefix { + display: none; +} + +/* ── Inline formatting ──────────────────────────────────────────────────────── */ + +.md-bold, +.md-bold-italic { + font-weight: bold; +} + +.md-italic, +.md-bold-italic { + font-style: italic; +} + +.md-strikethrough { + text-decoration: line-through; +} + +.md-code { + font-family: monospace; +} + +.md-link { + cursor: pointer; +} + +.md-link-text { + text-decoration: underline; +} + +/* ── Block-level styling ────────────────────────────────────────────────────── */ + +/* + * Block divs use .md-{name} classes. In view state they render as + * their visual equivalents. In wysiwyg state they use monospace so + * the user can see the raw markdown while the formatting is applied. + */ + +#ribbit.wysiwyg { + font-family: monospace; + white-space: pre-wrap; +} + +.md-h1 { font-size: 2em; font-weight: bold; } +.md-h2 { font-size: 1.5em; font-weight: bold; } +.md-h3 { font-size: 1.17em; font-weight: bold; } +.md-h4 { font-size: 1em; font-weight: bold; } +.md-h5 { font-size: 0.83em; font-weight: bold; } +.md-h6 { font-size: 0.67em; font-weight: bold; } + +.md-blockquote { + border-left: 3px solid currentColor; + opacity: 0.7; + padding-left: 1em; +} + +/* + * List items: in wysiwyg state the .md-list-prefix span shows the + * raw markdown marker ("- " or "1. "). In view state we hide the + * prefix and use display:list-item to get a real browser bullet. + */ +#ribbit.view .md-list-item { + display: list-item; + margin-left: 1.5em; + list-style-type: disc; +} + +#ribbit.view .md-ol-list-item { + display: list-item; + margin-left: 1.5em; + list-style-type: decimal; +} + +.md-pre { + font-family: monospace; + white-space: pre; +} + +/* ── Vim mode indicators ────────────────────────────────────────────────────── */ + +#ribbit.vim-normal { + cursor: default; + caret-color: transparent; + border-left: 3px solid #4af; +} + +#ribbit.vim-insert { + border-left: 3px solid #4f4; +} /* * ribbit-core.css — functional editor styles. Always load this. * These styles control editor state visibility and behavior. diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 3009f3f..10d06c8 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -1,3 +1,856 @@ +/* + * ribbit-editor.ts — Styled-source editing extension for Ribbit. + * + * The editor is always a markdown text editor. There is no separate + * WYSIWYG mode — the user edits markdown directly, but CSS styling + * makes it look like rendered output. Delimiters (**, *, `, etc.) + * are hidden when the cursor is outside their span, and revealed + * when the cursor enters it. + * + * Two CSS states replace the old three-mode system: + * editing: contentEditable="true", delimiters revealed on focus + * viewing: contentEditable="false", all delimiters hidden + * + * The DOM is identical in both states — only CSS changes. This + * eliminates all conversion-during-editing bugs and removes the + * flatten→rebuild pipeline entirely. + * + * getMarkdown() reads element.textContent directly. Because every + * delimiter character lives in a text node inside a .md-delim span, + * textContent always equals the original markdown source — no + * conversion is needed. + * + * getHTML() runs the existing HopDown tokenizer on demand (export, + * save, API calls) — never during editing. + */ + +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 { Tag } from './types'; +import { type MacroDef } from './macros'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +// CSS class applied to the formatting span the cursor is currently inside. +// CSS uses this to reveal .md-delim children for that span only. +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. +const INLINE_SPAN_ATTR = 'data-md-span'; + +// ─── 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; +} + +const HEADING_PATTERN = /^(?#{1,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', + prefixLength: (line) => { + const FENCE_PATTERN = /^(?`{3,}|~{3,})/; + const match = line.match(FENCE_PATTERN); + return match ? match[0].length : null; + }, + }, + { + name: 'heading', + prefixLength: (line) => { + const match = line.match(HEADING_PATTERN); + return match ? match[0].length : null; + }, + }, + { + name: 'blockquote', + prefixLength: (line) => BLOCKQUOTE_PATTERN.test(line) ? 2 : null, + }, + { + name: 'list-item', + prefixLength: (line) => { + if (!UNORDERED_LIST_PATTERN.test(line)) { + return null; + } + return line.indexOf(' ') + 1; + }, + isList: true, + }, + { + name: 'ol-list-item', + prefixLength: (line) => { + if (!ORDERED_LIST_PATTERN.test(line)) { + return null; + } + return line.indexOf(' ') + 1; + }, + isList: true, + }, +]; + +// ─── Inline rules ───────────────────────────────────────────────────────────── + +// An inline rule describes how to detect and wrap a delimiter pair +// in a single line of text. Rules are applied left-to-right. +interface InlineRule { + // The CSS class applied to the wrapper span (e.g. 'md-bold'). + cls: string; + // The delimiter string on both sides (e.g. '**'). + delimiter: string; + // The regex to match a complete delimited run. Must have a + // named capture group 'content' for the text between delimiters. + pattern: RegExp; +} + +// Link is special: it has two delimiters and an href, so it gets +// its own handling in parseInline rather than going through InlineRule. +const LINK_PATTERN = /\[(?[^\]]+)\]\((?[^)]+)\)/g; + +// Inline rules in priority order (longer/higher-precedence delimiters first +// so *** is tried before ** which is tried before *). Derived from the +// existing tag definitions in defaultInlineTags wherever possible [C10]. +const INLINE_RULES: InlineRule[] = [ + { + cls: 'md-bold-italic', + delimiter: '***', + pattern: /\*\*\*(?.+?)\*\*\*/g, + }, + { + cls: 'md-bold', + delimiter: '**', + pattern: /\*\*(?.+?)\*\*/g, + }, + { + cls: 'md-italic', + delimiter: '*', + pattern: /\*(?.+?)\*/g, + }, + { + cls: 'md-strikethrough', + delimiter: '~~', + pattern: /~~(?.+?)~~/g, + }, + { + cls: 'md-code', + delimiter: '`', + pattern: /`(?[^`]+)`/g, + }, +]; + +// ─── 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]. + * + * const editor = new RibbitEditor({ editorId: 'my-element' }); + * editor.run(); + * editor.wysiwyg(); + */ +export class RibbitEditor extends Ribbit { + private vim?: VimHandler; + + // 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) { + 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(); + } + + // ── Event binding ────────────────────────────────────────────────────────── + + #bindEvents(): void { + let debounceTimer: number | undefined; + + this.element.addEventListener('input', () => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.#updateCurrentBlock(); + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(() => { + this.notifyChange(); + }, 300); + }); + + this.element.addEventListener('keydown', (event: KeyboardEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.#dispatchKeydown(event); + }); + + this.element.addEventListener('keyup', (event: KeyboardEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + if (event.key.startsWith('Arrow')) { + this.#updateEditingContext(); + } + }); + + document.addEventListener('selectionchange', () => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.#updateEditingContext(); + }); + + document.addEventListener('click', (event: MouseEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + if (!this.element.contains(event.target as Node)) { + this.#clearEditingContext(); + } + }); + } + + // ── Mode switching ───────────────────────────────────────────────────────── + + /** + * Switch to styled-source editing mode. Renders the current markdown + * as a styled DOM (one block div per line) and enables contentEditable. + * The DOM is never rebuilt on mode switch — only CSS changes. + * + * editor.wysiwyg(); + * // user now edits markdown directly with CSS rendering + */ + wysiwyg(): void { + if (this.getState() === this.states.WYSIWYG) { + return; + } + this.invalidateCache(); + this.vim?.detach(); + this.collaboration?.connect(); + this.element.innerHTML = ''; + this.element.appendChild(this.#markdownToStyledDOM(this.getMarkdown())); + 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') { + htmlMacro.contentEditable = 'false'; + } + } + this.setState(this.states.WYSIWYG); + } + + /** + * Convert the editor's current styled DOM back to markdown. + * Because delimiter characters live in text nodes inside .md-delim + * spans, element.textContent == the original markdown source. + * No conversion needed [see STYLED_SOURCE_DESIGN.md §getMarkdown()]. + * + * 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: element contains rendered HTML — fall back to + // the cached markdown that was used to render it. + if (this.cachedMarkdown !== null) { + return this.cachedMarkdown; + } + return this.element.textContent || ''; + } + + /** + * 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); + range.deleteContents(); + range.insertNode(node); + range.setStartAfter(node); + this.element.focus(); + selection.removeAllRanges(); + 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')) { + fragment.appendChild(this.#buildBlock(line)); + } + return fragment; + } + + /** + * 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'); + + if (line === '') { + block.className = `${BLOCK_CLASS_PREFIX}paragraph`; + block.appendChild(document.createElement('br')); + return block; + } + + for (const rule of BLOCK_RULES) { + const prefixLength = rule.prefixLength(line); + if (prefixLength === null) { + 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}`; + } else { + block.className = `${BLOCK_CLASS_PREFIX}${rule.name}`; + } + + const prefixSpan = document.createElement('span'); + prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS; + prefixSpan.textContent = line.slice(0, prefixLength); + block.appendChild(prefixSpan); + block.appendChild(this.#parseInline(line.slice(prefixLength))); + return block; + } + + // Fallback: plain paragraph + block.className = `${BLOCK_CLASS_PREFIX}paragraph`; + block.appendChild(this.#parseInline(line)); + return block; + } + + /** + * 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 { + // Stage 1: tokenise into raw-text segments and matched parts. + // We walk all rules left-to-right, splitting segments as we go. + // Each segment is either raw (unmatched) or a matched inline rule. + interface RawSegment { raw: true; text: string } + interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string } + interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string } + type Segment = RawSegment | RuleMatch | LinkMatch; + + let segments: Segment[] = [{ raw: true, text }]; + + for (const rule of INLINE_RULES) { + const nextSegments: Segment[] = []; + for (const segment of segments) { + if (!segment.raw) { + nextSegments.push(segment); + continue; + } + let lastIndex = 0; + let match: RegExpExecArray | null; + rule.pattern.lastIndex = 0; + while ((match = rule.pattern.exec(segment.text)) !== null) { + if (match.index > lastIndex) { + nextSegments.push({ raw: true, text: segment.text.slice(lastIndex, match.index) }); + } + nextSegments.push({ + raw: false, + rule, + content: match.groups!.content, + fullMatch: match[0], + }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < segment.text.length) { + nextSegments.push({ raw: true, text: segment.text.slice(lastIndex) }); + } + } + segments = nextSegments; + } + + // Handle links in a second pass over raw segments only [C14] + const withLinks: Segment[] = []; + for (const segment of segments) { + if (!segment.raw) { + withLinks.push(segment); + continue; + } + let lastIndex = 0; + let match: RegExpExecArray | null; + LINK_PATTERN.lastIndex = 0; + while ((match = LINK_PATTERN.exec(segment.text)) !== null) { + if (match.index > lastIndex) { + withLinks.push({ raw: true, text: segment.text.slice(lastIndex, match.index) }); + } + withLinks.push({ + raw: false, + isLink: true, + text: match.groups!.text, + href: match.groups!.href, + fullMatch: match[0], + }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < segment.text.length) { + withLinks.push({ raw: true, text: segment.text.slice(lastIndex) }); + } + } + + // Stage 2: build DOM nodes from the token list + const fragment = document.createDocumentFragment(); + for (const segment of withLinks) { + if (segment.raw) { + fragment.appendChild(document.createTextNode(segment.text)); + continue; + } + + const span = document.createElement('span'); + span.setAttribute(INLINE_SPAN_ATTR, '1'); + + if ('isLink' in segment) { + // Link: [text](href) + // All three parts go into .md-delim spans so textContent + // reproduces the full markdown [( href )] syntax + span.className = 'md-link'; + span.appendChild(this.#makeDelimSpan('[')); + const linkTextNode = document.createElement('span'); + linkTextNode.className = 'md-link-text'; + linkTextNode.textContent = segment.text; + span.appendChild(linkTextNode); + span.appendChild(this.#makeDelimSpan(`](${segment.href})`)); + } else { + // Standard delimiter pair: **content** + span.className = segment.rule.cls; + span.appendChild(this.#makeDelimSpan(segment.rule.delimiter)); + span.appendChild(document.createTextNode(segment.content)); + span.appendChild(this.#makeDelimSpan(segment.rule.delimiter)); + } + + fragment.appendChild(span); + } + + return fragment; + } + + /** Create a with the given text content. */ + #makeDelimSpan(text: string): HTMLSpanElement { + const span = document.createElement('span'); + span.className = DELIM_CLASS; + span.textContent = text; + 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; + } + + // Preserve the caret position across the rebuild + const caretOffset = this.#getCaretOffset(block); + + // Normalize   that browsers insert in contentEditable. + // Without this, NBSP characters in textContent break pattern + // matching for block classifiers like "## " or "> ". + const lineText = block.textContent!.replace(/\u00A0/g, ' '); + + const newBlock = this.#buildBlock(lineText); + block.className = newBlock.className; + block.innerHTML = ''; + while (newBlock.firstChild) { + block.appendChild(newBlock.firstChild); + } + + 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. This replaces the old + * dispatchKeydown which routed through the full tag system [C14]. + */ + #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(); + if (selection && selection.rangeCount > 0) { + const tagForBlock = this.converter.getBlockTags().find((tag) => { + if (typeof tag.selector !== 'string') { + return false; + } + return tag.selector.split(',').some( + (selector) => block.tagName === selector.trim() + ); + }); + if (tagForBlock?.handleKeydown) { + const handled = tagForBlock.handleKeydown(block, event, selection, this); + if (handled) { + event.preventDefault(); + return; + } + } + } + } + + if (event.key === 'Enter') { + event.preventDefault(); + this.#handleEnter(); + return; + } + + if (event.key === 'Backspace') { + if (this.#handleBackspace()) { + event.preventDefault(); + } + } + } + + /** + * 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, ' '); + const before = text.slice(0, offset); + const after = text.slice(offset); + + const firstBlock = this.#buildBlock(before); + const secondBlock = this.#buildBlock(after || ''); + + block.className = firstBlock.className; + block.innerHTML = ''; + while (firstBlock.firstChild) { + block.appendChild(firstBlock.firstChild); + } + + block.after(secondBlock); + this.#restoreCaret(secondBlock, 0); + } + + /** + * 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) { + return false; + } + + const offset = this.#getCaretOffset(block); + if (offset !== 0) { + return false; + } + + const previousBlock = block.previousElementSibling as HTMLElement | null; + if (!previousBlock) { + return false; + } + + const previousLength = previousBlock.textContent!.length; + const merged = previousBlock.textContent! + block.textContent!.replace(/\u00A0/g, ' '); + const mergedBlock = this.#buildBlock(merged); + + previousBlock.className = mergedBlock.className; + previousBlock.innerHTML = ''; + while (mergedBlock.firstChild) { + previousBlock.appendChild(mergedBlock.firstChild); + } + + block.remove(); + this.#restoreCaret(previousBlock, previousLength); + 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) { + return null; + } + let node: Node | null = selection.anchorNode; + while (node && node !== this.element) { + if (node.nodeType === 1 && node.parentNode === this.element) { + return node as HTMLElement; + } + node = node.parentNode; + } + 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) { + return 0; + } + const range = document.createRange(); + range.setStart(block, 0); + range.setEnd(selection.anchorNode!, selection.anchorOffset); + 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); + if (!placed) { + range.selectNodeContents(block); + range.collapse(false); + } + + selection.removeAllRanges(); + selection.addRange(range); + } + + /** + * 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. + */ + #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); + range.collapse(true); + return true; + } + // Mutate remaining via closure — TypeScript doesn't allow + // reassigning a parameter across recursive calls cleanly, + // so we use the return-value protocol: false = not placed yet, + // the caller subtracts and recurses. + 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; + } + consumed += textNode.length; + } else { + const childLength = (child.textContent || '').length; + if (remaining - consumed <= childLength) { + // Recurse into this subtree with adjusted remaining + const placed = this.#walkForCaret(child, range, remaining - consumed); + if (placed) { + return true; + } + } + consumed += childLength; + } + } + 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(); + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + let node: Node | null = selection.anchorNode; + while (node && node !== this.element) { + if (node.nodeType === 1) { + const element = node as HTMLElement; + if (element.hasAttribute(INLINE_SPAN_ATTR)) { + element.classList.add(EDITING_CONTEXT_CLASS); + this.activeFormattingSpan = element; + return; + } + } + node = node.parentNode; + } + } + + /** Remove EDITING_CONTEXT_CLASS from the previously active span. */ + #clearEditingContext(): void { + if (this.activeFormattingSpan) { + this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS); + this.activeFormattingSpan = null; + } + } + + // ── 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 + if (!block.textContent!.trim() && block.querySelector('br')) { + return ''; + } + // Macro islands store their source text in data-source + if (block.dataset.macro) { + return block.dataset.source || ''; + } + return block.textContent || ''; + } +} + +// Public API — matches the previous export shape so consumers don't break. +export { RibbitEditor as Editor }; +export { Ribbit as Viewer }; +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 }; /* * ribbit-editor.ts — WYSIWYG editing extension for Ribbit. */