feat(brief): locked sections, list formatting, auto/manual placeholder chips

- brief.machine: reducer refuses edits to locked (predefined) sections as
  defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
  list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
  both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
  aligned with the editor's chip styling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:50:22 +02:00
parent 053160c5c9
commit 84c2d1b6a0
23 changed files with 955 additions and 431 deletions

View File

@@ -1,4 +1,4 @@
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
/**
* The quarantined boundary between the imperative `contenteditable` DOM and the
@@ -14,35 +14,71 @@ import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
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): void {
export function renderInto(
root: HTMLElement,
block: RichTextBlock,
labelFor: (key: string) => string,
autoFor?: (key: string) => boolean,
): void {
const doc = root.ownerDocument;
root.replaceChildren();
for (const para of block.paragraphs) {
const p = doc.createElement('p');
p.className = 'rte-para';
if (para.nodes.length === 0) {
p.appendChild(doc.createElement('br')); // keep the empty line focusable
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 {
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
const p = doc.createElement('p');
p.className = 'rte-para';
fillLine(p, para, labelFor, doc, autoFor);
root.appendChild(p);
i++;
}
root.appendChild(p);
}
}
/** 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). setAttribute reflects reliably in jsdom + browsers. */
export function createChip(doc: Document, key: string, label: string): HTMLElement {
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): Node {
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));
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))) {
@@ -54,21 +90,37 @@ function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc:
}
export function readBlock(root: HTMLElement): RichTextBlock {
const paragraphs: { nodes: RichTextNode[] }[] = [];
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
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) {
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);
// 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));
}
paragraphs.push({ nodes });
}
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 ?? '';
@@ -91,6 +143,32 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
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':