/** * @file annotations-model.ts — React-free annotation data model * @scope apps/studio/annotations-model.ts * @purpose Single source for the annotation Stroke types, palettes, pure * geometry helpers, and the SVG serialize/parse pair. Extracted * from annotations-layer.tsx (FigJam v3) so headless consumers — * bun tests, the `maude design annotate` write verb, future * tooling — can import the model without pulling React or any * canvas runtime. The layer re-exports everything from here, so * every existing `from './annotations-layer.tsx'` import keeps * working unchanged. * * HARD INVARIANT (Phase 24 canary): `strokeToSvgEl` emits every * post-Phase-5.1 attribute ONLY for non-default values, so legacy * `.annotations.svg` files round-trip byte-identically. The * FigJam-v3 additions (`data-group-ids`, `data-author`, * `data-start-bind`, `data-end-bind`) follow the same rule. * * Schema (back-compatible with Phase 5): * - pen → * - rect → * - ellipse → * - arrow → * - text → * - sticky / polygon / image / link — see the per-tool serializers below. */ import { ARROW_HEADS, type ArrowHead, type ArrowLineType, arrowPrimitives, type SvgPrimitive, } from './canvas-arrowheads.ts'; // ───────────────────────────────────────────────────────────────────────────── // Types export type WorldPoint = readonly [number, number]; /** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */ export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down'; /** Phase 24 — horizontal alignment for text + sticky bodies. */ export type TextAlign = 'left' | 'center' | 'right'; /** Annotation polish (item 4c) — list style for text + sticky bodies. */ export type ListType = 'bullet' | 'number'; /** * FigJam v3 — a bound arrow endpoint. `hostId` is the bound stroke's id; * `nx`/`ny` are the anchor normalized over the host's bbox ([0..1], snapped to * the {0, 0.5, 1} side/center magnets at bind time — FigJam connector magnets, * Excalidraw `fixedPoint` semantics). The endpoint's world position is always * DERIVED from the host (`anchorPoint` in annotations-bindings.ts); the stored * x1/y1/x2/y2 are kept in sync so legacy consumers keep working. */ export interface ArrowBind { hostId: string; nx: number; ny: number; /** * FigJam v3 — true when the user EXPLICITLY re-anchored this endpoint (drag * onto a specific magnet). Auto (absent) binds re-route as the shapes move: * `recomputeBoundArrows` re-picks the side facing the other end, so a * connector always leaves the box in a sensible direction. Serialized as a * trailing ` p` token. */ pinned?: boolean; } /** * FigJam v3 — fields shared by every stroke variant. * - `groupIds` — group membership, ordered DEEPEST → SHALLOWEST (the * Excalidraw flat tag-array model; the outermost group is the LAST * element). Absent / empty = ungrouped. Serialized as `data-group-ids`. * - `author` — provenance. `'ai'` when created through the * `maude design annotate` write surface. Absent = human-drawn. * - `authorName` / `authorId` — Phase 3 (whiteboard-improvements): who drew * a human-authored stroke, stamped from presence identity (`useCollab`'s * `myName`/`myConnId` — git `user.name`, else an `anonymous-*` fallback). * Absent on AI-authored strokes and on any stroke drawn before this * field existed (back-compat — no author badge, not an error). Never * trust a wire/stored COLOR for this identity — badges re-derive their * color from the name via `colorForName` so they match the author's live * presence hue. */ interface StrokeBase { id: string; groupIds?: string[]; author?: 'ai'; authorName?: string; authorId?: string; /** * FigJam v3 — rotation in degrees (clockwise) around the stroke's bbox * center. Absent / 0 = axis-aligned (back-compat). Honoured by the box- * shaped strokes + standalone text; pen ink and arrows ignore it (their * geometry IS their orientation) and anchored text inherits its host's. * Serialized as `data-rot` plus a presentational `transform="rotate(…)"`. */ rotation?: number; } export interface PenStroke extends StrokeBase { tool: 'pen'; color: string; width: number; points: WorldPoint[]; /** * Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase / * hit-test / translate logic; it just renders wide + translucent with * `mix-blend-mode:multiply` (overlaps darken) and carries a translucent * marker colour. Absent / false = a normal solid pen (back-compat). */ highlighter?: boolean; } export interface RectStroke extends StrokeBase { tool: 'rect'; color: string; width: number; x: number; y: number; w: number; h: number; fill?: string | null; /** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */ cornerRadius?: number; /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */ dashed?: boolean; } export interface EllipseStroke extends StrokeBase { tool: 'ellipse'; color: string; width: number; cx: number; cy: number; rx: number; ry: number; fill?: string | null; /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */ dashed?: boolean; } /** * Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox * (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG * points are derived from the bbox at serialize + render time. Brand-new on * disk (``), so no back-compat * constraint — only idempotent round-trip. */ export interface PolygonStroke extends StrokeBase { tool: 'polygon'; shape: PolygonShape; color: string; width: number; x: number; y: number; w: number; h: number; fill?: string | null; /** Dashed outline (stroke-dasharray). Absent / false = solid. */ dashed?: boolean; } export interface ArrowStroke extends StrokeBase { tool: 'arrow'; color: string; width: number; x1: number; y1: number; x2: number; y2: number; /** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */ startHead?: ArrowHead; /** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */ endHead?: ArrowHead; /** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */ dashed?: boolean; /** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */ lineType?: ArrowLineType; /** FigJam v3 — magnetic binding of the (x1,y1) start to a host stroke. */ startBind?: ArrowBind; /** FigJam v3 — magnetic binding of the (x2,y2) end to a host stroke. */ endBind?: ArrowBind; } export interface TextStroke extends StrokeBase { tool: 'text'; color: string; fontSize: number; text: string; /** * Host shape id for anchored text (double-click a rect/ellipse). Phase 21 * relaxed this to optional: standalone text (the `text` tool) carries no * anchor and renders at its own world `(x, y)` instead. */ anchorId?: string; /** Phase 21 — world coords for standalone (unanchored) text. */ x?: number; y?: number; /** Phase 24 — bold weight. Absent / false = normal (back-compat). */ bold?: boolean; /** Phase 24 — strikethrough. Absent / false = none (back-compat). */ strike?: boolean; /** Italic style (item 4b). Absent / false = upright (back-compat). */ italic?: boolean; /** Underline (item 4b). Combined with strike into one text-decoration. */ underline?: boolean; /** List style (item 4c). Markers are render-only — never stored in `text`. */ listType?: ListType; /** * Phase 24 — horizontal alignment. Absent default differs by kind: anchored * text = 'center' (legacy, byte-identical), standalone = 'left'. */ align?: TextAlign; } /** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */ export interface StickyStroke extends StrokeBase { tool: 'sticky'; color: string; x: number; y: number; w: number; h: number; text: string; fontSize: number; /** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */ cornerRadius?: number; /** Phase 24 — bold body weight. Absent / false = normal. */ bold?: boolean; /** Phase 24 — strikethrough body. Absent / false = none. */ strike?: boolean; /** Italic body (item 4b). Absent / false = upright. */ italic?: boolean; /** Underline body (item 4b). Combined with strike into one text-decoration. */ underline?: boolean; /** List style (item 4c). Markers are render-only — never stored in `text`. */ listType?: ListType; /** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */ align?: TextAlign; } /** * Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves * and resizes like any annotation. `href` is ALWAYS a relative * `assets/.` path (never a data: URL — keeps the persisted SVG under * its 1 MB cap and matches the sanitizer's `` href allowlist). The live * canvas may briefly render an optimistic `blob:` href before the upload swaps * it to the content-addressed path; only the `assets/…` form is ever persisted. */ export interface ImageStroke extends StrokeBase { tool: 'image'; x: number; y: number; w: number; h: number; href: string; /** * Alt text. Persisted in `data-alt` and emitted as `aria-label` on the * ``, so it travels with the exported / saved SVG (where AT reads it). * NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden` * (editor chrome — AT shouldn't be flooded by decorative strokes), so the live * in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''. */ alt?: string; } /** * Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO * server fetch and NO external favicon (the dev-server stays zero-egress — * DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text * when present, else the prettified URL; `domain` is `new URL(url).hostname`. * Persists as an allowlisted `` (rect + vector glyph + two `` runs) — * the click-to-open handler reads `data-url`, no `` is ever stored. */ export interface LinkStroke extends StrokeBase { tool: 'link'; x: number; y: number; w: number; h: number; url: string; title: string; domain: string; } /** * DDR-150 P4 — a video/audio file dropped on the canvas BODY (not the timeline) * as a reference chip. It's the "nahazet klipy → agent z toho udělá video" * artifact: a non-destructive, versioned pointer to an `assets/…` clip the agent * can enumerate off the saved `.annotations.svg` (via `data-src`), then assemble * into a comp. Distinct from a timeline drop (which INSERTS a ``) and * from an ImageStroke (a rendered picture). Renders as a card: a media glyph * (▶ video / ♪ audio) + the filename. `src` is ALWAYS a relative `assets/` * path (never seeked by the capture spine — it's a reference, excluded from * export by `?hide-chrome`). Persists as an allowlisted `` like LinkStroke — * `data-src`/`data-media-kind`/`data-title` are the round-trip source of truth. */ export interface MediaRefStroke extends StrokeBase { tool: 'mediaref'; x: number; y: number; w: number; h: number; /** Relative `assets/.` path — the agent reads this off the SVG. */ src: string; mediaKind: 'video' | 'audio'; /** Human-facing label (the dropped file's name). */ title: string; } /** * FigJam v3 — section: a labelled organizing container. Renders as a soft * rounded region with a name chip above the top-left corner; its INTERIOR is * click-through (only the border + chip select it) and dragging a section * carries every stroke whose center sits inside it. Persists as an inert * `` wrapping the region rect + chip text. */ export interface SectionStroke extends StrokeBase { tool: 'section'; x: number; y: number; w: number; h: number; label: string; /** Region tint (rendered at low opacity). */ color: string; } export type Stroke = | PenStroke | RectStroke | EllipseStroke | PolygonStroke | ArrowStroke | TextStroke | StickyStroke | ImageStroke | LinkStroke | MediaRefStroke | SectionStroke; /** * Wave G — stroke types that can host anchored text (a TextStroke whose * `anchorId` points at them). Every closed shape qualifies: rect, ellipse, * and the polygon primitives (diamond / triangle). The text renders centered * in the host's bbox and inherits its rotation. */ export type AnchorHost = RectStroke | EllipseStroke | PolygonStroke; /** * Wave H — the selection chrome's breathing room, in SCREEN px (divide by * zoom for world units). Single source for the halo rect, the resize-handle * positions, and the resize cursor pad-correction, so they always sit on the * same frame. */ export const HALO_PAD_PX = 6; // ───────────────────────────────────────────────────────────────────────────── // Wave H — shape-kind conversion (the ctx-toolbar's square/rounded/circle/ // diamond/triangle switcher). Mirrors the tool palette's ShapeKind vocabulary. export type ConvertibleShapeKind = | 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle' | 'triangle-down'; /** The kind a closed shape currently is, or null for non-shape strokes. */ export function shapeKindOf(s: Stroke): ConvertibleShapeKind | null { if (s.tool === 'rect') return (s.cornerRadius ?? 0) > 0 ? 'rounded' : 'square'; if (s.tool === 'ellipse') return 'circle'; if (s.tool === 'polygon') return s.shape; return null; } /** * Patch converting a closed shape to another kind, preserving its bbox, * styling (color/width/fill/dashed), rotation, groups — and its ID, so * anchored text and arrow bindings follow the conversion. Returns null for * non-shapes and identity conversions. Stale source-geometry fields are * explicitly cleared (they would not serialize, but a clean in-memory object * keeps copy/paste payloads and debugging honest). */ export function convertShapeKind(s: Stroke, kind: ConvertibleShapeKind): Partial | null { const from = shapeKindOf(s); if (from == null || from === kind) return null; const bb = strokeBBox(s); if (!bb) return null; const clear = { x: undefined, y: undefined, w: undefined, h: undefined, cx: undefined, cy: undefined, rx: undefined, ry: undefined, cornerRadius: undefined, shape: undefined, }; if (kind === 'square' || kind === 'rounded') { return { ...clear, tool: 'rect', x: bb.x, y: bb.y, w: bb.w, h: bb.h, cornerRadius: kind === 'rounded' ? 8 : 0, } as unknown as Partial; } if (kind === 'circle') { return { ...clear, tool: 'ellipse', cx: bb.x + bb.w / 2, cy: bb.y + bb.h / 2, rx: Math.max(1, bb.w / 2), ry: Math.max(1, bb.h / 2), } as unknown as Partial; } return { ...clear, tool: 'polygon', shape: kind, x: bb.x, y: bb.y, w: bb.w, h: bb.h, } as unknown as Partial; } // ───────────────────────────────────────────────────────────────────────────── // Palettes + defaults // Phase 21 colour system — a single coherent hue family used everywhere. // FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are // light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use // their own lightened paper set (STICKY_PALETTE). Exported so the draw-time // chrome AND the per-selection context toolbar share ONE palette instead of // drifting apart. export const STROKE_PALETTE = [ '#e5484d', // red (default — markup ink) '#f2762a', // orange '#e0a500', // amber '#30a46c', // green '#3b82f6', // blue '#8b5cf6', // purple '#e93d82', // pink '#7c7c7c', // gray '#1f1f1f', // ink ] as const; export type PaletteColor = (typeof STROKE_PALETTE)[number]; // Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for // EVERY ink tool (pen / shape / arrow / text). It's a palette member so the // draw chrome + per-selection toolbar highlight it as the active swatch; the // other hues stay one click away. (Stickies keep their warm-paper default — // DEFAULT_STICKY_COLOR — they're paper, not ink.) export const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8]; // Annotation polish — the LIVE default ink follows the canvas theme so a // freshly-armed pen/shape/arrow/text reads true on dark canvases (the // `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f` // ink slot; dark → a light ink that reads on dark. This is the live draw // default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip // determinism + back-compat), and stored strokes keep their literal hex // (FigJam parity — no retroactive recolour). const DEFAULT_INK_DARK = '#ededed'; export function resolveDefaultInk(theme: string): string { return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR; } // Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a // pale blue wash under a saturated stroke, exactly like FigJam shapes. export const FILL_PALETTE = [ '#fbe0e1', // red tint '#fce6d6', // orange tint '#fbeec2', // amber tint '#d9f1e2', // green tint '#e0ebfd', // blue tint '#ebe3fc', // purple tint '#fbdfeb', // pink tint '#ededed', // gray tint '#e7e7e7', // ink tint ] as const; // Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas. const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7' const NEUTRAL_FILL_DARK = '#2a2a2a'; /** * Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape * tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated * outline over a pale wash of the same hue); the ink slot / themed-dark ink / * any unknown hex maps to a neutral wash. "No fill" stays one click away (the * chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored * shapes keep their literal fill — only NEW shapes pick up this default. */ export function defaultFillFor(color: string, theme: string): string { const idx = STROKE_PALETTE.indexOf(color as PaletteColor); // Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral. if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx] as string; return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT; } export const STROKE_WIDTH_THIN = 3; export const STROKE_WIDTH_THICK = 6; export type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK; const FONT_SIZE_MEDIUM = 14; export const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM; // Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set // (Image #2): a warm paper yellow default, then white/grey + soft pastels. // Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE // so stickies read as "paper", not "ink". Slot 0 (yellow) is the default. // Existing stickies keep their stored hex; only NEW stickies pick up the new // default tint. export const STICKY_PALETTE = [ '#fce8a6', // muted yellow (default — warm paper) '#ffffff', // white '#e6e4e0', // light grey '#f7c5c0', // salmon '#f8d2a6', // peach '#bfe3c0', // mint '#a9dbdb', // aqua '#bcd2f0', // light blue '#cfc4ec', // lavender '#f3c4dd', // light pink ] as const; export const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0]; export const STICKY_CORNER_RADIUS = 8; // Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex // (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`. // Yellow is the default; green / pink / blue follow. Wholly separate from the // ink PALETTE — the highlighter draws a soft wash, not a saturated line. export const HIGHLIGHTER_PALETTE = [ '#ffe24d80', // yellow (default) '#7ce8a080', // green '#ff9ed180', // pink '#7ec5ff80', // blue ] as const; export const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0]; // Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick), // all wider than the pen. Default medium. export const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const; export const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1]; // Phase 24 — stickies are 1:1; the default tap size is a square. export const STICKY_DEFAULT_W = 200; export const STICKY_DEFAULT_H = 200; export const STICKY_MIN_SIZE = 40; /** * Ceiling for the grow-to-fit in `grownStickyBox` — ~370 lines at the default * type. `fontSize` comes from a synced stroke's `data-fs` and is only checked * for finiteness, so without this a peer could set `data-fs="200000"`, wait for * the local user to edit that note, and have THEIR client persist a card * millions of units tall — grow-only, undoable by hand alone. */ export const STICKY_MAX_GROWN_H = 8000; /** * issue-106 — the box a sticky needs so its committed text is not clipped, or * null when the text already fits. * * `.dc-sticky-body` is `overflow: hidden` on a card whose size was fixed at * creation, and nothing ever grew it: past a card's capacity every further line * was simply invisible, which is what made Shift+Enter look inert on a full * note. `measuredH` is the editor's own `scrollHeight` — same class, width and * font as the committed body, in world units — so the growth is exact rather * than estimated. * * GROWS ONLY, and never past `STICKY_MAX_GROWN_H`: a sticky the user * deliberately made roomy never shrinks back on a text edit. The box is normalized on the way out (a sticky dragged bottom-up * carries a negative w/h), which is idempotent — every renderer already reads * it through the same min/abs pair. */ export function grownStickyBox( s: Pick, measuredH: number ): { x: number; y: number; w: number; h: number } | null { if (!Number.isFinite(measuredH) || measuredH <= 0) return null; const next = Math.min(Math.ceil(measuredH), STICKY_MAX_GROWN_H); if (next <= Math.abs(s.h)) return null; return { x: Math.min(s.x, s.x + s.w), y: Math.min(s.y, s.y + s.h), w: Math.abs(s.w), h: next, }; } // Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the // tap point (FigJam parity: click commits, drag sizes). Square aspect. export const SHAPE_DEFAULT_SIZE = 120; // FigJam v3 — section container defaults. export const SECTION_DEFAULT_W = 480; export const SECTION_DEFAULT_H = 320; export const SECTION_MIN_SIZE = 64; export const DEFAULT_SECTION_COLOR = '#8b8b94'; export const SECTION_CORNER_RADIUS = 12; /** Label chip geometry (world units; the chip scales with the canvas). */ export const SECTION_LABEL_FONT = 12; export const SECTION_LABEL_H = 20; // Phase 23 — image + link media strokes. /** Below this side an image stroke is discarded as an accidental micro-drop. */ export const IMAGE_MIN_SIZE = 16; /** Longest side a freshly dropped/pasted image is scaled down to (world px). */ export const IMAGE_MAX_DROP_SIDE = 480; export const LINK_DEFAULT_W = 260; export const LINK_DEFAULT_H = 76; // Phase 4 (whiteboard-improvements) — a sticker dropped from the picker has no // natural size to probe (unlike createImageFromFile's Image.onload path — the // shell already resolved it to a project asset by the time the canvas hears // about it) — one fixed, friendly on-board size for every sticker. export const STICKER_DROP_SIZE = 160; // DDR-150 P4 — media-reference chip (dropped video/audio → assets/ pointer). export const MEDIAREF_DEFAULT_W = 280; export const MEDIAREF_DEFAULT_H = 76; /** Video chips are taller: 26px header + a 16:9 inline player area (dogfood #8). */ export const MEDIAREF_VIDEO_H = 190; export const LINK_CARD_FILL = '#ffffff'; export const LINK_CARD_STROKE = '#d4d4d8'; export const LINK_DOMAIN_FILL = '#71717a'; export const LINK_TITLE_FILL = '#18181b'; export const LINK_GLYPH_STROKE = '#52525b'; // Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the // serialized nested- glyph AND the StrokeNode render so re-serialize stays // byte-stable. The parser ignores the glyph entirely (it reads data-* + the // geometry), so render/serialize only need to agree visually. export const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'; export const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'; // DDR-150 P4 — media-reference chip glyphs (24×24 viewBox, filled). One source // for serialize + StrokeNode render, like the link glyph. Video = play triangle; // audio = a filled note. The parser ignores the glyph (reads data-* + geometry). export const MEDIAREF_VIDEO_GLYPH = 'M8 5v14l11-7z'; export const MEDIAREF_AUDIO_GLYPH = 'M9 18V6l10-2v11.5a2.5 2.5 0 1 1-2-2.45V7.3L11 8.6v6.9a2.5 2.5 0 1 1-2-2.45z'; /** Card text positions, derived purely from the bbox (idempotent round-trip). */ export function linkCardLayout(x: number, y: number, w: number, h: number) { const textX = x + 48; return { glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 }, textX, domain: { y: y + h / 2 - 14, fontSize: 11 }, title: { y: y + h / 2, fontSize: 13 }, textMaxChars: Math.max(8, Math.floor((w - 60) / 7)), }; } /** Clamp a link title to the card's character budget (pure → byte-stable). */ export function clampLinkTitle(title: string, maxChars: number): string { return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title; } // ───────────────────────────────────────────────────────────────────────────── // Pure helpers — exported for unit tests. export function rid(): string { return `s_${Math.random().toString(36).slice(2, 10)}`; } /** FigJam v3 — group ids mirror the stroke id scheme (`g_` prefix). */ export function gid(): string { return `g_${Math.random().toString(36).slice(2, 10)}`; } export function esc(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"').replace(/`. The legacy text/sticky * paths only put user text in element CONTENT (where a bare `>` is harmless), * so `esc()` never escaped it; but the media strokes carry user text (pasted * link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt * / href). A bare `>` there would prematurely close the tag and confuse the * `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media * attribute value; element CONTENT keeps plain `esc()`. */ export function escAttr(s: string): string { return esc(s).replace(/>/g, '>'); } export function penPathD(points: readonly WorldPoint[]): string { if (points.length === 0) return ''; const [first, ...rest] = points as readonly WorldPoint[]; if (!first) return ''; let d = `M${first[0]} ${first[1]}`; for (const p of rest) d += ` L${p[0]} ${p[1]}`; return d; } // ── Multi-line text (item 4a) ──────────────────────────────────────────────── // SVG ignores `\n`, so multi-line annotation text must render as one // per line. The serialized + live forms share this geometry; single- // line text keeps the legacy single-run form (no tspan) so the canary holds. /** Line-height multiplier for multi-line annotation text. */ export const TEXT_LINE_HEIGHT = 1.25; /** Split a text body into its display lines. */ export function splitTextLines(text: string): string[] { return text.split('\n'); } /** * Render-time list marker prefix for one line. Markers are PRESENTATION ONLY — * never stored in `text` (DDR) — so the stored string stays clean and * contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `. */ export function listPrefixedLine(line: string, index: number, list?: ListType): string { if (!list) return line; return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`; } /** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */ export function stripListPrefix(line: string, index: number, list?: ListType): string { if (!list) return line; const marker = list === 'bullet' ? '• ' : `${index + 1}. `; return line.startsWith(marker) ? line.slice(marker.length) : line; } /** Prefix every line of a body with its list marker (for the editor display). */ export function listPrefixedBody(text: string, list?: ListType): string { if (!list) return text; return splitTextLines(text) .map((line, i) => listPrefixedLine(line, i, list)) .join('\n'); } /** * Strip list markers off editor `innerText` on commit (item 4c). Generic — a * `•` bullet OR any leading `N. ` number is removed once per line, regardless of * the index the user actually typed, so re-numbering while editing round-trips * cleanly (the stored text stays marker-free; the read view re-derives markers). */ export function stripEditorMarkers(text: string, list?: ListType): string { if (!list) return text; const re = list === 'bullet' ? /^• / : /^\d+\.\s/; return splitTextLines(text) .map((line) => line.replace(re, '')) .join('\n'); } /** * Combined `text-decoration` SVG attribute for strike + underline (item 4b). * Strike-only stays `text-decoration="line-through"` (byte-identical to the * legacy Phase-24 form); both → `line-through underline`; neither → empty. */ export function textDecoAttr(strike?: boolean, underline?: boolean): string { const vals: string[] = []; if (strike) vals.push('line-through'); if (underline) vals.push('underline'); return vals.length ? ` text-decoration="${vals.join(' ')}"` : ''; } /** CSS `text-decoration` value for the live render (strike + underline). */ export function textDecoCss(strike?: boolean, underline?: boolean): string | undefined { const vals: string[] = []; if (strike) vals.push('line-through'); if (underline) vals.push('underline'); return vals.length ? vals.join(' ') : undefined; } /** * Inline text formatting carried by an editor through commit (item 4b/4d * unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the * stroke. `strike` rides along unchanged (no shortcut; toolbar-only). */ export interface EditorFmt { bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean; /** FigJam v3 — live-editable font size (edit-mode text toolbar). */ fontSize?: number; /** FigJam v3 — live-editable alignment (edit-mode text toolbar). */ align?: TextAlign; } /** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the * serialize-only-when-set invariant + byte-identical canary hold. */ export function normFmt(fmt?: EditorFmt): EditorFmt { const out: EditorFmt = { bold: fmt?.bold || undefined, italic: fmt?.italic || undefined, underline: fmt?.underline || undefined, strike: fmt?.strike || undefined, }; // FigJam v3 — carry the edit-mode toolbar's size/align through commit, but // ONLY when set: an absent key must not clobber the stroke's stored value // (the spread `{ ...stroke, ...normFmt(fmt) }` would write undefined). if (typeof fmt?.fontSize === 'number' && Number.isFinite(fmt.fontSize)) { out.fontSize = fmt.fontSize; } if (fmt?.align) out.align = fmt.align; return out; } /** True when a stroke's existing formatting already matches `fmt` (so a pure * identity edit can short-circuit without a redundant undo record). */ export function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean { if (!fmt) return true; if (typeof fmt.fontSize === 'number' && s.fontSize !== fmt.fontSize) return false; if (fmt.align && s.align !== fmt.align) return false; return ( !!s.bold === !!fmt.bold && !!s.italic === !!fmt.italic && !!s.underline === !!fmt.underline && !!s.strike === !!fmt.strike ); } /** * Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored * (hanging) or is lifted half the block height when vertically centred * (anchored-in-host); every later line advances one line-height. */ export function textLineDy( i: number, fontSize: number, lineCount: number, centered: boolean ): number { const lh = fontSize * TEXT_LINE_HEIGHT; if (i > 0) return lh; return centered ? (-(lineCount - 1) / 2) * lh : 0; } /** * Inner content for a serialized `` stroke: the legacy single esc'd run * when there's no newline (byte-identical, canary-safe), else one `` per * line. `x` is set on each tspan for standalone text (resets the line origin); * anchored text omits it (the persisted form carries no absolute position — * geometry is resolved against the host at render time). A `list` prefix * (bullet / number) is prepended per line at render time only (DDR — never * stored in `text`). */ function textInnerSvg( text: string, fontSize: number, centered: boolean, x: number | undefined, list?: ListType ): string { if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list)); const lines = splitTextLines(text); const xAttr = x != null ? ` x="${x}"` : ''; return lines .map( (line, i) => `${esc( listPrefixedLine(line, i, list) )}` ) .join(''); } /** * Phase 24 — polygon vertices derived from the bbox. `diamond` = the four * edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every * shape's vertices span the FULL bbox, so a parse-back via the points' min/max * recovers x/y/w/h exactly (idempotent round-trip). */ export function polygonVertices( shape: PolygonShape, x: number, y: number, w: number, h: number ): Array<[number, number]> { if (shape === 'diamond') { return [ [x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2], ]; } if (shape === 'triangle') { return [ [x + w / 2, y], [x + w, y + h], [x, y + h], ]; } // triangle-down — apex at the bottom. return [ [x, y], [x + w, y], [x + w / 2, y + h], ]; } /** Vertices as an SVG `points` string. */ export function polygonPoints( shape: PolygonShape, x: number, y: number, w: number, h: number ): string { return polygonVertices(shape, x, y, w, h) .map(([px, py]) => `${px},${py}`) .join(' '); } /** * Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r` * and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The * radius is clamped to half the smaller side so it never self-overlaps. Used by * `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`) * stays a plain `` (DDR — zero canary / sanitizer / parse impact). */ export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string { const rr = Math.max(0, Math.min(r, w / 2, h / 2)); return [ `M${x + rr} ${y}`, `L${x + w - rr} ${y}`, `Q${x + w} ${y} ${x + w} ${y + rr}`, `L${x + w} ${y + h}`, // sharp bottom-right `L${x + rr} ${y + h}`, `Q${x} ${y + h} ${x} ${y + h - rr}`, `L${x} ${y + rr}`, `Q${x} ${y} ${x + rr} ${y}`, 'Z', ].join(' '); } /** Even-odd ray-cast point-in-polygon test. */ function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean { let inside = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const a = pts[i]; const b = pts[j]; if (!a || !b) continue; const [xi, yi] = a; const [xj, yj] = b; if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; } /** Parse a polygon `points` string back into its bounding box. */ function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null { let xMin = Number.POSITIVE_INFINITY; let yMin = Number.POSITIVE_INFINITY; let xMax = Number.NEGATIVE_INFINITY; let yMax = Number.NEGATIVE_INFINITY; for (const pair of points.trim().split(/\s+/)) { const [px, py] = pair.split(',').map((n) => Number.parseFloat(n)); // Skip non-finite points (NaN AND Infinity from a poisoned `1e999`) so a // bad vertex can't push the bbox to an Infinity extent — Wave H F4. if (px == null || py == null || !Number.isFinite(px) || !Number.isFinite(py)) continue; if (px < xMin) xMin = px; if (px > xMax) xMax = px; if (py < yMin) yMin = py; if (py > yMax) yMax = py; } if (!Number.isFinite(xMin)) return null; return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin }; } // ───────────────────────────────────────────────────────────────────────────── // Serialize /** * FigJam v3 — cross-tool root attributes (group membership / AI provenance / * arrow binds), serialized ONLY for non-default values so every legacy stroke * stays byte-identical (the canary). Injected into the stroke's root tag. */ function rootExtraAttrs(s: Stroke): string { let extra = ''; if (s.tool === 'arrow') { if (s.startBind) { const b = s.startBind; extra += ` data-start-bind="${esc(b.hostId)} ${b.nx} ${b.ny}${b.pinned ? ' p' : ''}"`; } if (s.endBind) { const b = s.endBind; extra += ` data-end-bind="${esc(b.hostId)} ${b.nx} ${b.ny}${b.pinned ? ' p' : ''}"`; } } if (s.groupIds && s.groupIds.length > 0) { extra += ` data-group-ids="${esc(s.groupIds.join(' '))}"`; } if (s.author === 'ai') extra += ' data-author="ai"'; // Phase 3 (whiteboard-improvements) — human author identity, independent of // `data-author="ai"` (never both: a stroke is either agent- or human-drawn). // escAttr (not esc) — these are attribute values, not element content; see // the Phase 23 escAttr() docblock above for why a bare `>` here is unsafe. if (s.authorName) extra += ` data-author-name="${escAttr(s.authorName)}"`; if (s.authorId) extra += ` data-author-id="${escAttr(s.authorId)}"`; // FigJam v3 — rotation: `data-rot` is the round-trip source of truth; the // transform is presentational so the persisted SVG renders rotated in any // viewer. The pivot derives from stored geometry → idempotent re-serialize. const rot = strokeRotation(s); if (rot !== 0) { const c = strokeCenter(s); if (c) extra += ` data-rot="${rot}" transform="rotate(${rot} ${c[0]} ${c[1]})"`; } return extra; } /** * Insert extra attributes at the end of the root tag's attribute list. Safe * because no legit attribute VALUE contains a literal `>` (media attrs are * escAttr'd; ids/colors/enums can't carry one), so the first `>` is always the * end of the opening tag. */ function injectRootAttrs(el: string, extra: string): string { if (!extra) return el; const gt = el.indexOf('>'); if (gt < 0) return el; const insertAt = el[gt - 1] === '/' ? gt - 1 : gt; return el.slice(0, insertAt) + extra + el.slice(insertAt); } export function strokeToSvgEl(s: Stroke): string { return injectRootAttrs(strokeToSvgElBase(s), rootExtraAttrs(s)); } function strokeToSvgElBase(s: Stroke): string { if (s.tool === 'text') { // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form; // standalone text (no anchorId) writes its own world x/y and omits // data-anchor-id (so the parser routes it back to the standalone branch). // bold/italic/strike/underline/align/list serialize ONLY for non-default // values, so a legacy text node stays byte-identical (every added fragment // is empty). Multi-line text emits one per line (item 4a); a // single-line unstyled run stays the legacy single esc'd text. const weight = s.bold ? ' font-weight="700"' : ''; const italic = s.italic ? ' font-style="italic"' : ''; const deco = textDecoAttr(s.strike, s.underline); // esc() the enum attrs too — a no-op for the constrained values, but it // keeps the "every value reaching serialize is escaped" invariant uniform // with the arrow-head attrs (Wave H defender suggestion, DDR-067). const listAttr = s.listType ? ` data-list="${esc(s.listType)}"` : ''; if (s.anchorId != null && s.anchorId !== '') { const align = s.align ?? 'center'; // anchored default = centre (legacy) const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle'; const alignAttr = align !== 'center' ? ` data-align="${esc(align)}"` : ''; return `${textInnerSvg( s.text, s.fontSize, true, undefined, s.listType )}`; } const tx = s.x ?? 0; const ty = s.y ?? 0; const align = s.align ?? 'left'; // standalone default = left const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle'; const alignAttr = align !== 'left' ? ` data-align="${esc(align)}"` : ''; return `${textInnerSvg( s.text, s.fontSize, false, tx, s.listType )}`; } if (s.tool === 'sticky') { // Phase 21 — sticky body lives in an allowlisted child so it // survives sanitizeAnnotationSvg (which strips , DDR-060 // F1). The live canvas re-renders this stroke with a foreignObject so the // text word-wraps; the persisted is the inert, sanitizer-safe form. const r = s.cornerRadius ?? STICKY_CORNER_RADIUS; const w = Math.max(0, s.w); const h = Math.max(0, s.h); // bold/italic/strike/underline/align/list on the data-attrs, emitted // ONLY for non-default values (sticky default align = left) so Phase-21 // stickies serialize byte-identically. The body stays raw text — // list markers are render-only (item 4c), never persisted. const align = s.align ?? 'left'; const styleAttrs = (s.bold ? ' data-bold="1"' : '') + (s.italic ? ' data-italic="1"' : '') + (s.strike ? ' data-strike="1"' : '') + (s.underline ? ' data-underline="1"' : '') + (align !== 'left' ? ` data-align="${esc(align)}"` : '') + (s.listType ? ` data-list="${esc(s.listType)}"` : ''); return `${esc(s.text)}`; } if (s.tool === 'image') { // Phase 23 — `href` is ALWAYS a relative assets/. path (asserted // on create + re-validated by the sanitizer's href allowlist). Alt // text persists in `data-alt` + is emitted as `aria-label` for the exported // SVG (the live annotation root is aria-hidden — see ImageStroke.alt). const nx = Math.min(s.x, s.x + s.w); const ny = Math.min(s.y, s.y + s.h); const nw = Math.abs(s.w); const nh = Math.abs(s.h); const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : ''; return ``; } if (s.tool === 'link') { // Phase 23 — client-only preview chip. data-url/title/domain are the // round-trip source of truth; the inner rect/glyph/text are the inert, // sanitizer-safe visual (no persisted — click-to-open reads // data-url client-side and validates http(s) before window.open). const nx = Math.min(s.x, s.x + s.w); const ny = Math.min(s.y, s.y + s.h); const nw = Math.abs(s.w); const nh = Math.abs(s.h); const lay = linkCardLayout(nx, ny, nw, nh); const shownTitle = clampLinkTitle(s.title, lay.textMaxChars); return ( `` + `` + `` + `${esc( s.domain )}` + `${esc( shownTitle )}` + `` ); } if (s.tool === 'mediaref') { // DDR-150 P4 — reference chip for a dropped clip. Like the link card: the // data-* are the round-trip source of truth (the agent reads data-src to // enumerate refs); the inner rect/glyph/text are the inert, sanitizer-safe // visual. NEVER a