feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Mark, 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): 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
|
||||
} else {
|
||||
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
||||
}
|
||||
root.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const span = doc.createElement('span');
|
||||
span.dataset['phKey'] = key;
|
||||
span.setAttribute('contenteditable', 'false');
|
||||
span.className = 'rte-chip';
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
||||
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: { nodes: RichTextNode[] }[] = [];
|
||||
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
||||
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);
|
||||
}
|
||||
paragraphs.push({ nodes });
|
||||
}
|
||||
return { paragraphs: paragraphs.length ? paragraphs : [{ 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 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));
|
||||
}
|
||||
Reference in New Issue
Block a user