/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into // letterhead/body/signature/footer makes "what does the letter look like" a five-file // question for no behavioural seam. Deliberate, not deferred. import { Component, DestroyRef, ElementRef, computed, effect, inject, input, linkedSignal, output, signal, viewChild, } from '@angular/core'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component'; import { formatDatumNl } from '@shared/kernel/datum'; import { Paragraph } from '@shared/kernel/rich-text'; import { Brief, LetterBlock } from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { Diagnostic } from '@brief/domain/placeholders'; import { BlockDiffKind } from '@brief/domain/brief-diff'; import { LetterLineComponent } from './letter-line.component'; /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[]; }; function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] { const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = []; for (const para of paras) { const kind = para.list ?? null; const last = out[out.length - 1]; if (kind && last && last.list === kind) last.items.push(para); else out.push({ list: kind, items: [para] }); } return out; } /** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */ const A4_HEIGHT_PX = (297 * 96) / 25.4; /** Organism: the letter as one surface — the org template's letterhead, signature and footer around the case-type template's sections. `editableRegions` picks who edits what: `'content'` hosts the editable letter-sections in place (drafter), `'none'` renders everything read-only (approver/locked, absorbs the old letter-preview), `'template'` reserves the org-identity regions for the admin editor. Letter typography/geometry come from the shared `public/letter.css` contract — the same file the backend preview renderer inlines. Each rendered line is its own `app-letter-line` (RD-26), replacing the outlet-template indirection that stood in for it. */ @Component({ selector: 'app-letter-canvas', imports: [ButtonComponent, LetterLineComponent], styles: [ ` :host { display: block; } .toolbar { display: flex; justify-content: space-between; align-items: center; gap: var(--rhc-space-max-sm); margin-block-end: var(--rhc-space-max-sm); } .zoom { display: flex; align-items: center; gap: var(--rhc-space-max-sm); } .zoom-pct { min-width: 3.5ch; text-align: center; color: var(--rhc-color-foreground-subtle); font-variant-numeric: tabular-nums; } /* Rejection-diff badge: a small pill above a changed/added block. */ .diff-block.diff-changed { border-inline-start: 3px solid var(--rhc-color-oranje-500); padding-inline-start: var(--rhc-space-max-sm); } .diff-badge { display: inline-block; margin-block-end: 1mm; padding: 0 1.5mm; border-radius: var(--rhc-border-radius-sm); font-size: 7.5pt; /* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */ color: var(--rhc-color-foreground-default); background: var(--rhc-color-oranje-500); } .diff-badge.added { /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */ color: var(--rhc-color-wit); background: var(--rhc-color-groen-700); } /* Portal-side chrome around the letter surface (not part of the contract file). */ .surface { background: var(--rhc-color-grijs-100); border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); border-radius: var(--rhc-border-radius-md); padding: var(--rhc-space-max-lg); overflow-x: auto; } .surface .letter { box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */ } /* Admin edit-in-place (editableRegions='template'): the org-identity fields become controls styled to sit in the letter, with a visible editable affordance. */ .tmpl-input, .tmpl-textarea { font: inherit; color: inherit; width: 100%; box-sizing: border-box; background: var(--rhc-color-geel-100); border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong); border-radius: var(--rhc-border-radius-sm); padding: 0.5mm 1mm; } .tmpl-textarea { resize: vertical; } .org-logo { max-height: 20mm; max-width: 60mm; margin-block-end: 3mm; } `, ], template: ` @if (editableRegions() !== 'template') {
− {{ zoomPct() }} + 100%
@if (editableRegions() === 'none') { @if (showSample()) { Testwaarden verbergen } @else { Voorbeeld met testwaarden } }
}
@if (logoUrl()) { } @if (editing()) { } @else {

{{ orgTemplate().orgName }}

{{ orgTemplate().returnAddress }}
}
{{ recipientText() }}
Ons kenmerk
{{ brief().briefId }}
Datum
{{ letterDate }}
@for (section of brief().sections; track section.sectionKey) {

{{ section.title }}

@for (block of section.blocks; track block.blockId) { @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
@if (diffKind) { @if (diffKind === 'added') { nieuw } @else { gewijzigd sinds afwijzing } } @for (seg of segmentsOf(block); track $index) { @if (seg.list === 'bullet') {
    @for (para of seg.items; track $index) {
  • }
} @else if (seg.list === 'number') {
    @for (para of seg.items; track $index) {
  1. }
} @else {

} }
}
}
@if (editing()) { } @else {

{{ orgTemplate().signatureClosing }}

{{ orgTemplate().signatureName }}

{{ orgTemplate().signatureRole }}

}
@for (top of pageBreaks(); track $index) { }
`, }) export class LetterCanvasComponent { brief = input.required(); orgTemplate = input.required(); /** Who edits what on the surface: read-only ('none', the drafter preview + approver view) or admin editor ('template'). Authoring moved to letter-editor. */ editableRegions = input<'template' | 'none'>('none'); diagnostics = input([]); /** Initial zoom; the in-canvas controls take over from here. */ zoom = input(1); /** Blocks changed/added/removed since the letter was rejected; badged when `showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer exist in the letter — the composer surfaces them as a count. */ blockDiffs = input>(new Map()); showDiff = input(false); /** The org logo's content URL (letterhead), or null when none is set. */ logoUrl = input(null); /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); /** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2): its message embeds a literal `\n`. As template text that `\n` becomes a source line break, a different string to Angular's extractor — so inlining it would change the extracted source text, unlike the other 19 labels this ticket inlines. */ recipientText = input( $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`, ); protected showSample = signal(false); protected letterDate = formatDatumNl(new Date()); /** Zoom seeded from the input; the +/−/reset controls drive it from there. */ protected zoomLevel = linkedSignal(() => this.zoom()); protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`); protected zoomBy(delta: number) { // clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks. this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10); } /** Admin edit-in-place: the org-identity regions render as controls. */ protected editing = computed(() => this.editableRegions() === 'template'); protected emitEdit(field: OrgTemplateTextField, event: Event) { this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value, }); } protected marginStyle = computed(() => { const m = this.orgTemplate().margins; return { '--letter-margin-top': `${m.topMm}mm`, '--letter-margin-right': `${m.rightMm}mm`, '--letter-margin-bottom': `${m.bottomMm}mm`, '--letter-margin-left': `${m.leftMm}mm`, }; }); // --- read-only rendering helper (migrated from the superseded letter-preview) --- protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs); // --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) --- private page = viewChild>('page'); protected pageBreaks = signal([]); constructor() { // ponytail: whole-surface height / A4-interval — ignores that a break never truly // falls mid-line; the caption says "±" and the server preview is authoritative. const observer = new ResizeObserver(([entry]) => { // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark. const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX); this.pageBreaks.set( Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX), ); }); effect((onCleanup) => { const el = this.page()?.nativeElement; if (!el) return; observer.observe(el); onCleanup(() => observer.unobserve(el)); }); inject(DestroyRef).onDestroy(() => observer.disconnect()); } }