/** Inline formatting range — tracks formatting on plain text. */ export interface FormatRange { start: number; end: number; type: "bold" | "italic" | "code" | "strikethrough" | "link"; url?: string; } /** Plain text + formatting ranges — no markdown syntax visible. */ export interface InlineModel { text: string; formats: FormatRange[]; } /** Parse inline markdown into plain text + formatting ranges. */ export function parseInlineModel(markdown: string): InlineModel { const formats: FormatRange[] = []; let text = ""; let i = 0; while (i < markdown.length) { // Inline code: `...` if (markdown[i] === "`") { const end = markdown.indexOf("`", i + 1); if (end !== -1) { const start = text.length; text += markdown.substring(i + 1, end); formats.push({ start, end: text.length, type: "code" }); i = end + 1; continue; } } // Image: ![alt](url) — skip, treat as text for now if (markdown[i] === "!" && markdown[i + 1] === "[") { const closeBracket = markdown.indexOf("](", i + 2); if (closeBracket !== -1) { const closeParen = markdown.indexOf(")", closeBracket + 2); if (closeParen !== -1) { const alt = markdown.substring(i + 2, closeBracket); text += alt; i = closeParen + 1; continue; } } } // Link: [text](url) if (markdown[i] === "[") { const closeBracket = markdown.indexOf("](", i + 1); if (closeBracket !== -1) { const closeParen = markdown.indexOf(")", closeBracket + 2); if (closeParen !== -1) { const start = text.length; const linkText = markdown.substring(i + 1, closeBracket); const url = markdown.substring(closeBracket + 2, closeParen); text += linkText; formats.push({ start, end: text.length, type: "link", url }); i = closeParen + 1; continue; } } } // Strikethrough: ~~...~~ if (markdown[i] === "~" && markdown[i + 1] === "~") { const end = markdown.indexOf("~~", i + 2); if (end !== -1) { const start = text.length; const inner = markdown.substring(i + 2, end); const innerModel = parseInlineModel(inner); const offset = text.length; text += innerModel.text; formats.push({ start, end: text.length, type: "strikethrough" }); for (const f of innerModel.formats) { formats.push({ ...f, start: f.start + offset, end: f.end + offset, }); } i = end + 2; continue; } } // Bold: **...** if (markdown[i] === "*" && markdown[i + 1] === "*") { const end = markdown.indexOf("**", i + 2); if (end !== -1) { const start = text.length; const inner = markdown.substring(i + 2, end); const innerModel = parseInlineModel(inner); const offset = text.length; text += innerModel.text; formats.push({ start, end: text.length, type: "bold" }); for (const f of innerModel.formats) { formats.push({ ...f, start: f.start + offset, end: f.end + offset, }); } i = end + 2; continue; } } // Italic: *...* if (markdown[i] === "*") { const end = findClosingStar(markdown, i + 1); if (end !== -1) { const start = text.length; const inner = markdown.substring(i + 1, end); const innerModel = parseInlineModel(inner); const offset = text.length; text += innerModel.text; formats.push({ start, end: text.length, type: "italic" }); for (const f of innerModel.formats) { formats.push({ ...f, start: f.start + offset, end: f.end + offset, }); } i = end + 1; continue; } } text += markdown[i]; i++; } return { text, formats }; } /** Find closing single * that isn't part of ** */ function findClosingStar(s: string, from: number): number { for (let i = from; i < s.length; i++) { if (s[i] === "*" && s[i + 1] !== "*") return i; if (s[i] === "*" && s[i + 1] === "*") i++; // skip ** } return -1; } /** Serialize InlineModel back to markdown. */ export function modelToMarkdown(model: InlineModel): string { if (model.formats.length === 0) return model.text; const events: { pos: number; order: number; insert: string }[] = []; // Sort: larger ranges first so they open first and close last const sorted = [...model.formats].sort( (a, b) => a.start - b.start || b.end - b.start - (a.end - a.start), ); for (const f of sorted) { switch (f.type) { case "bold": events.push({ pos: f.start, order: 0, insert: "**" }); events.push({ pos: f.end, order: 1, insert: "**" }); break; case "italic": events.push({ pos: f.start, order: 0, insert: "*" }); events.push({ pos: f.end, order: 1, insert: "*" }); break; case "strikethrough": events.push({ pos: f.start, order: 0, insert: "~~" }); events.push({ pos: f.end, order: 1, insert: "~~" }); break; case "code": events.push({ pos: f.start, order: 0, insert: "`" }); events.push({ pos: f.end, order: 1, insert: "`" }); break; case "link": events.push({ pos: f.start, order: 0, insert: "[" }); events.push({ pos: f.end, order: 1, insert: `](${f.url ?? ""})`, }); break; } } // Sort: by position, then closes before opens at same position events.sort((a, b) => a.pos - b.pos || b.order - a.order); let result = ""; let pos = 0; for (const ev of events) { if (ev.pos > pos) { result += model.text.substring(pos, ev.pos); pos = ev.pos; } result += ev.insert; } if (pos < model.text.length) { result += model.text.substring(pos); } return result; } /** * Adjust format ranges after text input. * Call this when textarea content changes. */ export function adjustFormats( formats: FormatRange[], changePos: number, delta: number, ): FormatRange[] { return formats .map((f) => { if (delta > 0) { return { ...f, start: f.start > changePos ? f.start + delta : f.start, end: f.end >= changePos ? f.end + delta : f.end, }; } const delCount = -delta; const delEnd = changePos + delCount; if (f.start >= delEnd) { return { ...f, start: f.start + delta, end: f.end + delta }; } if (f.start >= changePos) { return { ...f, start: changePos, end: f.end >= delEnd ? f.end + delta : changePos, }; } if (f.end >= delEnd) { return { ...f, end: f.end + delta }; } if (f.end > changePos) { return { ...f, end: changePos }; } return f; }) .filter((f) => f.end > f.start); } /** * Reconcile format ranges with a textarea edit. * * `adjustFormats` describes one signed delta, which cannot express an edit that * deletes and inserts at once — paste over a selection, select-all-then-type, * drag-and-drop, IME/autocorrect replacement. Recovering a single delta from * such an edit leaves the ranges on unrelated characters. * * The `input` event carries no description of what changed, so derive it: the * inserted text ends at the cursor, which anchors the tail; the head is the * common prefix of what precedes it. That yields a delete and an insert, both * of which `adjustFormats` already handles. */ export function applyTextEdit( formats: FormatRange[], prevText: string, nextText: string, cursor: number, ): FormatRange[] { const tailLen = nextText.length - cursor; let delEnd = prevText.length - tailLen; // The cursor only anchors the tail if the text after it really is unchanged. // Undo, or a drag-drop that moves the caret elsewhere, breaks that; fall back // to replacing everything rather than shifting ranges by a bogus amount. if (tailLen < 0 || delEnd < 0 || prevText.substring(delEnd) !== nextText.substring(cursor)) { delEnd = prevText.length; cursor = nextText.length; } let start = 0; while (start < delEnd && start < cursor && prevText[start] === nextText[start]) { start++; } const delLen = delEnd - start; const insLen = cursor - start; let result = formats; if (delLen > 0) result = adjustFormats(result, start, -delLen); if (insLen > 0) result = adjustFormats(result, start, insLen); return result; } /** Check which format types are active at a cursor position. */ export function activeFormatsAt(formats: FormatRange[], start: number, end: number): Set { const active = new Set(); for (const f of formats) { if (f.start <= start && f.end >= end) { active.add(f.type); } } return active; } /** Toggle a format on a selection range. Returns updated formats array. */ export function toggleFormat( formats: FormatRange[], selStart: number, selEnd: number, type: "bold" | "italic" | "code" | "strikethrough", ): FormatRange[] { const covering = formats.find((f) => f.type === type && f.start <= selStart && f.end >= selEnd); if (covering) { const result = formats.filter((f) => f !== covering); if (covering.start < selStart) { result.push({ ...covering, end: selStart }); } if (covering.end > selEnd) { result.push({ ...covering, start: selEnd }); } return result; } return [...formats, { start: selStart, end: selEnd, type }]; } // --- CSS Custom Highlight API + Selection API helpers --- /** Walk text nodes in a container, returning nodes with cumulative offsets. */ export function walkTextNodes(container: Node): { node: Text; start: number; end: number }[] { const result: { node: Text; start: number; end: number }[] = []; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); let offset = 0; let node: Text | null = walker.nextNode() as Text | null; while (node) { const len = node.textContent?.length ?? 0; result.push({ node, start: offset, end: offset + len }); offset += len; node = walker.nextNode() as Text | null; } return result; } /** Map a DOM range (or StaticRange) to plain text offsets using a text node map. */ export function rangeToOffsets( range: { startContainer: Node; startOffset: number; endContainer: Node; endOffset: number; }, textNodes: { node: Text; start: number; end: number }[], ): { start: number; end: number } | null { const startPos = resolvePosition(range.startContainer, range.startOffset, textNodes, true); const endPos = resolvePosition(range.endContainer, range.endOffset, textNodes, false); if (startPos === -1 || endPos === -1 || startPos === endPos) return null; return { start: Math.min(startPos, endPos), end: Math.max(startPos, endPos), }; } function resolvePosition( container: Node, offset: number, textNodes: { node: Text; start: number; end: number }[], isStart: boolean, ): number { if (container.nodeType === 3 /* TEXT_NODE */) { for (const tn of textNodes) { if (tn.node === container) return tn.start + offset; } return -1; } const children = container.childNodes; if (isStart) { const target = children[offset]; if (!target) return -1; for (const tn of textNodes) { if (target === tn.node || target.contains(tn.node)) return tn.start; } } else { const target = children[Math.min(offset, children.length) - 1]; if (!target) return -1; for (let i = textNodes.length - 1; i >= 0; i--) { const tn = textNodes[i]; if (target === tn.node || target.contains(tn.node)) return tn.end; } } return -1; } /** Create DOM Range objects from a FormatRange spanning text nodes. */ function createDomRanges( format: FormatRange, textNodes: { node: Text; start: number; end: number }[], ): Range[] { const ranges: Range[] = []; for (const tn of textNodes) { const overlapStart = Math.max(format.start, tn.start); const overlapEnd = Math.min(format.end, tn.end); if (overlapStart < overlapEnd) { const range = new Range(); range.setStart(tn.node, overlapStart - tn.start); range.setEnd(tn.node, overlapEnd - tn.start); ranges.push(range); } } return ranges; } const _CSS = globalThis.CSS; const _Highlight = globalThis.Highlight; /** Apply CSS Custom Highlights from FormatRange[] onto DOM text nodes. */ export function applyHighlights( formats: FormatRange[], textNodes: { node: Text; start: number; end: number }[], ): void { clearHighlights(); if (!_CSS?.highlights || !_Highlight) return; const byType = new Map(); for (const f of formats) { const ranges = createDomRanges(f, textNodes); if (ranges.length === 0) continue; const key = f.type; if (!byType.has(key)) byType.set(key, []); byType.get(key)?.push(...ranges); } for (const [type, ranges] of byType) { _CSS.highlights.set(`md-${type}`, new _Highlight(...ranges)); } } /** Clear all markdown format highlights. */ export function clearHighlights(): void { if (!_CSS?.highlights) return; _CSS.highlights.delete("md-bold"); _CSS.highlights.delete("md-italic"); _CSS.highlights.delete("md-strikethrough"); _CSS.highlights.delete("md-code"); _CSS.highlights.delete("md-link"); }