One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
|
|
|
/**
|
|
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
|
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
|
|
* read) so they round-trip losslessly and can be unit-tested without Angular. The
|
|
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
|
|
*
|
|
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
|
|
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
|
|
* degrades to plain text rather than crashing.
|
|
*/
|
|
|
|
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
|
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
|
|
|
export function renderInto(
|
|
root: HTMLElement,
|
|
block: RichTextBlock,
|
|
labelFor: (key: string) => string,
|
|
autoFor?: (key: string) => boolean,
|
|
): void {
|
|
const doc = root.ownerDocument;
|
|
root.replaceChildren();
|
|
const paras = block.paragraphs;
|
|
let i = 0;
|
|
while (i < paras.length) {
|
|
const para = paras[i];
|
|
if (para.list) {
|
|
// Group consecutive lines of the same list kind into one <ul>/<ol>.
|
|
const listEl = doc.createElement(para.list === 'bullet' ? 'ul' : 'ol');
|
|
listEl.className = 'rte-list';
|
|
while (i < paras.length && paras[i].list === para.list) {
|
|
const li = doc.createElement('li');
|
|
fillLine(li, paras[i], labelFor, doc, autoFor);
|
|
listEl.appendChild(li);
|
|
i++;
|
|
}
|
|
root.appendChild(listEl);
|
|
} else {
|
|
const p = doc.createElement('p');
|
|
p.className = 'rte-para';
|
|
fillLine(p, para, labelFor, doc, autoFor);
|
|
root.appendChild(p);
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Fill one line element (<p> or <li>) with a paragraph's nodes; empty lines keep a
|
|
focusable <br>. Shared by paragraph and list-item rendering. */
|
|
function fillLine(
|
|
el: HTMLElement,
|
|
para: Paragraph,
|
|
labelFor: (key: string) => string,
|
|
doc: Document,
|
|
autoFor?: (key: string) => boolean,
|
|
): void {
|
|
if (para.nodes.length === 0) {
|
|
el.appendChild(doc.createElement('br'));
|
|
} else {
|
|
for (const node of para.nodes) el.appendChild(renderNode(node, labelFor, doc, autoFor));
|
|
}
|
|
}
|
|
|
|
/** Build one non-editable placeholder chip element (shared by initial render and
|
|
live caret insertion). `data-auto` distinguishes auto-resolvable vs manual fields
|
|
for styling; it is a render hint only and is ignored on read-back. */
|
|
export function createChip(doc: Document, key: string, label: string, auto = false): HTMLElement {
|
|
const span = doc.createElement('span');
|
|
span.dataset['phKey'] = key;
|
|
span.dataset['auto'] = String(auto);
|
|
span.setAttribute('contenteditable', 'false');
|
|
span.className = 'rte-chip';
|
|
span.textContent = label;
|
|
return span;
|
|
}
|
|
|
|
function renderNode(
|
|
node: RichTextNode,
|
|
labelFor: (key: string) => string,
|
|
doc: Document,
|
|
autoFor?: (key: string) => boolean,
|
|
): Node {
|
|
if (node.type === 'lineBreak') return doc.createElement('br');
|
|
if (node.type === 'placeholder')
|
|
return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
|
let el: Node = doc.createTextNode(node.text);
|
|
// Nest marks in a canonical order so read-back is deterministic.
|
|
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
|
const wrap = doc.createElement(MARK_TAG[m]);
|
|
wrap.appendChild(el);
|
|
el = wrap;
|
|
}
|
|
return el;
|
|
}
|
|
|
|
export function readBlock(root: HTMLElement): RichTextBlock {
|
|
const paragraphs: Paragraph[] = [];
|
|
const blockEls = Array.from(root.children).filter(
|
|
(c) => c.tagName === 'P' || c.tagName === 'DIV' || c.tagName === 'UL' || c.tagName === 'OL',
|
|
);
|
|
const containers = blockEls.length ? blockEls : [root];
|
|
for (const el of containers) {
|
|
// ponytail: only top-level lists are read as lists; a nested <ul> inside an <li>
|
|
// flattens into its item's text (fine for this POC's flat lists).
|
|
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
|
const list = el.tagName === 'UL' ? 'bullet' : 'number';
|
|
for (const li of Array.from(el.children).filter((c) => c.tagName === 'LI')) {
|
|
paragraphs.push({ ...readLine(li as HTMLElement), list });
|
|
}
|
|
} else {
|
|
paragraphs.push(readLine(el as HTMLElement));
|
|
}
|
|
}
|
|
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
|
}
|
|
|
|
/** Read one line element (<p>, <div>, or <li>) into a paragraph's nodes. */
|
|
function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
|
|
const kids = Array.from(el.childNodes);
|
|
const nodes: RichTextNode[] = [];
|
|
// A lone <br> is the empty-line filler, not a content line break.
|
|
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
|
for (const child of kids) collect(child, [], nodes);
|
|
}
|
|
return { nodes };
|
|
}
|
|
|
|
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent ?? '';
|
|
if (text !== '')
|
|
out.push(
|
|
marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text },
|
|
);
|
|
return;
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
const el = node as HTMLElement;
|
|
if (el.tagName === 'BR') {
|
|
out.push({ type: 'lineBreak' });
|
|
return;
|
|
}
|
|
const key = el.dataset?.['phKey'];
|
|
if (key != null) {
|
|
out.push({ type: 'placeholder', key });
|
|
return;
|
|
}
|
|
const m = markOf(el);
|
|
const next = m ? [...marks, m] : marks;
|
|
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
|
}
|
|
|
|
function isChip(node: Node | null | undefined): node is HTMLElement {
|
|
return (
|
|
!!node &&
|
|
node.nodeType === Node.ELEMENT_NODE &&
|
|
(node as HTMLElement).dataset?.['phKey'] != null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The placeholder chip immediately adjacent to a collapsed caret in the given direction
|
|
* (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false,
|
|
* which some browsers won't delete on Backspace; the editor uses this to remove them itself.
|
|
*/
|
|
export function adjacentChip(
|
|
container: Node,
|
|
offset: number,
|
|
direction: -1 | 1,
|
|
): HTMLElement | null {
|
|
let sibling: Node | null | undefined;
|
|
if (container.nodeType === Node.TEXT_NODE) {
|
|
// Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first).
|
|
if (direction === -1) {
|
|
if (offset > 0) return null;
|
|
sibling = container.previousSibling;
|
|
} else {
|
|
if (offset < (container.textContent?.length ?? 0)) return null;
|
|
sibling = container.nextSibling;
|
|
}
|
|
} else {
|
|
sibling = direction === -1 ? container.childNodes[offset - 1] : container.childNodes[offset];
|
|
}
|
|
return isChip(sibling) ? sibling : null;
|
|
}
|
|
|
|
function markOf(el: HTMLElement): Mark | null {
|
|
switch (el.tagName) {
|
|
case 'STRONG':
|
|
case 'B':
|
|
return 'bold';
|
|
case 'EM':
|
|
case 'I':
|
|
return 'italic';
|
|
case 'U':
|
|
return 'underline';
|
|
}
|
|
const s = el.style;
|
|
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
|
|
if (s.fontStyle === 'italic') return 'italic';
|
|
if (s.textDecoration.includes('underline')) return 'underline';
|
|
return null;
|
|
}
|
|
|
|
function canonical(marks: Mark[]): Mark[] {
|
|
return ORDER.filter((m) => marks.includes(m));
|
|
}
|