/** * Shared constants and helpers for the board file — a reserved design_file * whose HTML document holds absolute-positioned board elements. * * The board file is identified by the filename "__board__.html" and its id is * stored in designs.data.boardFileId. Board elements are direct children of * * each with an absolute position derived from their original BoardObjectEntry * geometry. * * This module is imported by the editor UI, actions, and migration code. * It must stay free of React, Nitro, and database imports. */ import type { BoardObjectEntry } from "./board-objects.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Reserved filename for the board overlay file. */ export const BOARD_FILENAME = "__board__.html"; const DEFAULT_SHAPE_FILL = "rgb(218 218 218)"; const DEFAULT_SHAPE_STROKE = "rgb(168 168 168)"; // Figma-parity default stroke for vector primitives (line/arrow/pen path). // Mirrors DEFAULT_LINE_STROKE / DEFAULT_LINE_STROKE_WIDTH_PX in // app/components/design/canvas-primitive-style.ts — duplicated as literal // values (not imported) so this module stays free of any dependency on the // React-adjacent app component tree, per this file's module doc above. // Keep these two values in sync if either canonical token ever changes. const DEFAULT_LINE_STROKE = "#000000"; const DEFAULT_LINE_STROKE_WIDTH_PX = 1; // --------------------------------------------------------------------------- // isBoardFile // --------------------------------------------------------------------------- /** * Returns true when `filename` is the reserved board file. * Comparison is case-sensitive to match the rest of the codebase. */ export function isBoardFile(filename: string): boolean { return filename === BOARD_FILENAME; } // --------------------------------------------------------------------------- // emptyBoardHtml // --------------------------------------------------------------------------- /** * The canonical empty-board document template. * * The uses: * - margin:0 — no browser default whitespace * - position:relative — so absolute children are positioned within the body * - background:transparent — the board layer sits behind screen iframes * - overflow:visible — board elements may extend beyond the logical surface */ export function emptyBoardHtml(): string { return ` `; } // --------------------------------------------------------------------------- // boardObjectEntryToHtmlFragment // --------------------------------------------------------------------------- /** * Convert a BoardObjectEntry to an absolute-positioned HTML fragment for * insertion into the board file's . * * Negative left/top coordinates are intentionally preserved — board objects * may live anywhere in the infinite canvas space, including at negative offsets * (e.g. to the left of or above the primary frame cluster). * * The fragment sets `data-agent-native-node-id` so the bridge engine can * select, style, and move elements exactly as it does for screen elements. */ export function boardObjectEntryToHtmlFragment( entry: BoardObjectEntry, ): string { const { id, kind, geometry, fill, stroke, strokeWidth, text, pathData, points, name, } = entry; const x = Math.round(geometry.x); const y = Math.round(geometry.y); const width = Math.max(1, Math.round(geometry.width)); const height = Math.max(1, Math.round(geometry.height)); const nodeId = id; const layerName = name ?? kindToLayerName(kind); // Auto-sized text grows to fit its content (matches the creation path in // DesignEditor.tsx canvasPrimitiveHtmlDocument, which omits width/height for // `kind === "text" && primitive.autoSize`). Persisting a fixed width/height // for these would fight the auto-size behavior on reload. const isAutoSizeText = kind === "text" && entry.autoSize === true; // Base inline style — negative left/top are kept as-is. const baseStyle = [ "position:absolute", `left:${x}px`, `top:${y}px`, ...(isAutoSizeText ? [] : [`width:${width}px`, `height:${height}px`]), ...(geometry.rotation ? [`transform:rotate(${geometry.rotation}deg)`] : []), ...(typeof geometry.z === "number" ? [`z-index:${geometry.z}`] : []), ].join(";"); const dataAttrs = `data-agent-native-node-id="${escapeAttr(nodeId)}"` + ` data-agent-native-layer-name="${escapeAttr(layerName)}"` + // Kind marker so the layers panel renders a shape/text/frame icon for the // primitive (a rectangle looks like a rectangle), matching in-screen drawn // primitives, instead of the generic code/element glyph. ` data-an-primitive="${escapeAttr(kind)}"`; // Path / line / arrow kinds use an inline SVG. if (kind === "path" || kind === "line" || kind === "arrow") { const pts = points?.length ? points : [ { x: 0, y: height / 2 }, { x: width, y: height / 2 }, ]; const originX = Math.min(...pts.map((p) => p.x)); const originY = Math.min(...pts.map((p) => p.y)); const d = pathData ?? pts .map( (p, i) => `${i === 0 ? "M" : "L"} ${Math.round(p.x - originX)} ${Math.round(p.y - originY)}`, ) .join(" "); const strokeColor = stroke ?? DEFAULT_LINE_STROKE; const sw = strokeWidth ?? DEFAULT_LINE_STROKE_WIDTH_PX; let markerDefs = ""; let markerEnd = ""; if (kind === "arrow") { const markerId = `${nodeId}-arrow`; markerDefs = ``; markerEnd = ` marker-end="url(#${escapeAttr(markerId)})"`; } // Pen-authored paths (pathData present) serialize anchors in absolute // canvas/geometry space, not relative to the fragment's own 0,0 origin // like the synthesized `pts`-based `d` above. Without a matching viewBox, // the SVG paints those absolute coordinates directly inside its own // top-left-at-0,0 box while the box itself is *also* offset to // geometry.x/y via baseStyle's left/top — doubling the displacement. // Give the SVG a viewBox anchored at the geometry origin so pathData // coordinates land exactly where they were authored. const viewBoxAttr = pathData ? ` viewBox="${x} ${y} ${width} ${height}"` : ""; return `${markerDefs}`; } // Ellipse kind uses a
with border-radius. if (kind === "ellipse") { const bgColor = fill ?? DEFAULT_SHAPE_FILL; const borderStyle = stroke ? `border:${strokeWidth ?? 1}px solid ${stroke};` : `border:1px solid ${DEFAULT_SHAPE_STROKE};`; const style = `${baseStyle};background:${bgColor};border-radius:50%;${borderStyle}`; return `
`; } // Text kind uses a
with text content. font-size/line-height defaults // match the creation path (DesignEditor.tsx canvasPrimitiveHtmlDocument: // element.style.fontSize = "16px"; element.style.lineHeight = "1.2";) so a // freshly persisted board text object looks identical to its draft preview. if (kind === "text") { const color = fill ?? "inherit"; const style = `${baseStyle};color:${color};white-space:pre-wrap;font-size:16px;line-height:1.2;`; return `
${text ? escapeHtml(text) : ""}
`; } // Frame / rectangle / polygon / star / default — basic colored
. const bgColor = fill ?? (kind === "frame" ? "transparent" : DEFAULT_SHAPE_FILL); const borderStyle = stroke ? `border:${strokeWidth ?? 1}px solid ${stroke};` : kind === "frame" ? "" : `border:1px solid ${DEFAULT_SHAPE_STROKE};`; const style = `${baseStyle};background:${bgColor};${borderStyle}`; if (kind === "frame") { return `
${text ? escapeHtml(text) : ""}
`; } return `
${text ? escapeHtml(text) : ""}
`; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- function kindToLayerName(kind: BoardObjectEntry["kind"]): string { switch (kind) { case "frame": return "Frame"; case "rectangle": return "Rectangle"; case "ellipse": return "Ellipse"; case "polygon": return "Polygon"; case "star": return "Star"; case "line": return "Line"; case "arrow": return "Arrow"; case "text": return "Text"; case "path": return "Path"; default: return "Shape"; } } // --------------------------------------------------------------------------- // backfillBoardPrimitiveMarkers // --------------------------------------------------------------------------- /** * Adds `data-an-primitive=""` to board primitive elements that are * missing the marker. * * ## Scope * * Only elements that look like top-level board primitives are touched: * - Must be a direct `` child (depth-1 element) * - Must carry `data-agent-native-node-id` (the bridge id stamp) * - Must NOT already have `data-an-primitive` * * ## Kind inference (conservative) * * | Condition | Inferred kind | * |--------------------------------------------------------|---------------| * | `` whose path carries `marker-end` | `"arrow"` | * | `` containing a `` | `"polygon"` | * | `` containing exactly one `` and no other shape | `"path"` | * | `` with no reliable vector signal | *(skip — left unmarked, still classifies as a generic shape via tag)* | * | Inline style contains `border-radius:50%` | `"ellipse"` | * | Inline style contains `background:transparent` or no background, but has `data-agent-native-layer-name` starting with "Frame" | `"frame"` | * | Element has non-empty text content and no background color in style | `"text"` | * | Otherwise | `"rectangle"` | * * The function is: * - **Pure** — returns a new string, never mutates. * - **Additive** — only inserts `data-an-primitive`; never alters geometry, * structure, or any other attributes. * - **Idempotent** — if the marker is already present on an element, that * element is skipped. * * The implementation uses string-level parsing to remain dependency-free * (no DOM parser, no JSDOM). It is intentionally conservative: when in doubt, * an element is left as-is rather than mis-classified. */ export function backfillBoardPrimitiveMarkers(html: string): string { // Quick exit: if every node-id-bearing element already has the marker we // have nothing to do. if (!html.includes("data-agent-native-node-id=")) return html; // We need to find direct children only. We walk the raw HTML string // looking for the opening of the element, then iterate sibling-level // opening tags until . const bodyStart = html.indexOf("", bodyStart); if (bodyTagEnd === -1) return html; const bodyClose = html.lastIndexOf(""); if (bodyClose === -1) return html; // The direct-child region. const before = html.slice(0, bodyTagEnd + 1); const children = html.slice(bodyTagEnd + 1, bodyClose); const after = html.slice(bodyClose); const patched = _patchDirectChildren(children); if (patched === children) return html; // nothing changed return before + patched + after; } /** * Walk the HTML fragment that represents the direct children of and * insert `data-an-primitive` on qualifying elements that lack it. * * We do NOT recurse into children — only sibling-level (depth-1) tags are * touched. */ function _patchDirectChildren(fragment: string): string { let result = ""; let pos = 0; while (pos < fragment.length) { // Find the next '<'. const tagStart = fragment.indexOf("<", pos); if (tagStart === -1) { result += fragment.slice(pos); break; } // Copy any text / whitespace before this tag verbatim. result += fragment.slice(pos, tagStart); pos = tagStart; // Peek: is this a comment, closing tag, or doctype? Copy verbatim. const rest = fragment.slice(pos); if ( rest.startsWith("|<\/?([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^"'<>])*)\/?>/g; /** Tags whose raw text content must not be tag-walked. */ const BOARD_RAW_TEXT_TAGS = new Set(["script", "style", "textarea"]); function parseInlineStylePx( style: string, prop: "left" | "top" | "width" | "height", ): number | null { const match = style.match( new RegExp( `(?:^|;)\\s*${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)px\\s*(?=;|$)`, "i", ), ); if (!match?.[1]) return null; const parsed = Number(match[1]); return Number.isFinite(parsed) ? parsed : null; } function replaceInlineStylePx( style: string, prop: "left" | "top", next: number, ): string { return style.replace( new RegExp(`((?:^|;)\\s*${prop}\\s*:\\s*)(-?\\d+(?:\\.\\d+)?)px`, "i"), `$1${next}px`, ); } function rebaseNestedBoardCoord(args: { value: number; ancestorOrigin: number; parentSize: number | null; childSize: number | null; }): number { const { value, ancestorOrigin, parentSize, childSize } = args; if (!isBoardSurfacePoisonedCoord(value)) return value; // Two distinct historic poison shapes, distinguishable by the sign of the // offset multiple: // - k >= 1 (positive): value = k*65536 + boardCoordinate — the child's // board/viewport-space position was written verbatim (container-drop // persist path). Parent-relative = board coordinate minus the parent's // accumulated board-space origin. // - k <= -1 (negative): value = parentRelative - 65536 — the bridge's // rect-space nest rebase pre-compensated the (old, blanket) content // translate. The stripped remainder already IS parent-relative; no // ancestor subtraction. (A genuine board coordinate can never reach // k <= -1 here: that would put content beyond the surface edge.) const k = Math.round(value / BOARD_SURFACE_CONTENT_OFFSET_PX); let rebased = stripBoardSurfaceOffsetFromCoord(value) - (k > 0 ? ancestorOrigin : 0); // Sanity clamp: exact recovery is possible for the common single-poison // case, but compounded corruption (the parent moved after the child's // position was written, multiple stacked offsets, user "fixes" made while // the content rendered off-world) can leave a rebased value that is still // far outside the parent. Pull those back into the parent's box so the // child is at least visible and re-editable — the alternative is an // element parked tens of thousands of px off-world. const sane = parentSize !== null ? rebased >= -parentSize && rebased <= 2 * parentSize : Math.abs(rebased) <= BOARD_NESTED_COORD_SANE_MAX_PX; if (!sane) { const max = parentSize !== null ? Math.max(0, parentSize - (childSize ?? 0)) : 0; rebased = Math.min(Math.max(0, rebased), max); } return Math.round(rebased); } /** * Repairs already-persisted board content whose NESTED children carry * left/top values poisoned by the board-surface offset (near-65536-multiple * values — see BOARD_SURFACE_CONTENT_OFFSET_PX). * * For each element that (a) carries `data-agent-native-node-id`, (b) has at * least one node-id-bearing ancestor inside `` (i.e. is a nested board * child, never a top-level one — top-level children legitimately use raw * board coordinates), and (c) has a poisoned inline left and/or top: * * 1. the nearest 65536-multiple is stripped (recovering the board-space * coordinate the value was written from), * 2. the accumulated origin of its node-id ancestor chain (using * already-normalized ancestor values, walking outer→inner) is subtracted * to produce a parent-relative coordinate, and * 3. values that are still implausible after rebasing are clamped into the * parent's box. * * Everything else — non-poisoned coordinates, top-level children, elements * without node ids, all other markup — passes through byte-identical. The * function is pure, string-level (no DOM), and idempotent: normalized output * contains no poisoned values, so a second pass returns `changed: false`. * * Applied on board-content load/adopt (DesignEditor) so designs corrupted by * the historic nest-on-drop bugs self-heal the next time an editor opens * them, and as a post-reparent safety net. * * Detectability (finding 4): this repair is a heuristic rewrite of * already-persisted content with no built-in trace — a bad heuristic could * silently mis-rebase real content and nobody would know. The return value * includes `fixedNodeCount` and a small `samples` list (each node's original * vs. rebased left/top) so callers can log what actually happened; this * function itself stays side-effect-free (no console usage) so it remains * safely callable from any context (including the post-reparent safety-net * call sites, not just the load effect) — logging is the caller's job. */ const NORMALIZE_POISONED_COORDS_SAMPLE_LIMIT = 5; export function normalizePoisonedBoardNestedCoords(html: string): { html: string; changed: boolean; fixedNodeCount: number; samples: Array<{ nodeId: string | null; before: { left: number | null; top: number | null }; after: { left: number | null; top: number | null }; }>; } { if (!html || !html.includes("data-agent-native-node-id")) { return { html, changed: false, fixedNodeCount: 0, samples: [] }; } // Cheap pre-scan: a poisoned coordinate needs at least 5 digits // (|value| >= 65536 - 16384 = 49152). if (!/(?:left|top)\s*:\s*-?\d{5,}/i.test(html)) { return { html, changed: false, fixedNodeCount: 0, samples: [] }; } const bodyStart = html.indexOf("", bodyStart); const walkStart = bodyTagEnd === -1 ? 0 : bodyTagEnd + 1; const bodyClose = html.lastIndexOf(""); const walkEnd = bodyClose === -1 ? html.length : bodyClose; const stack: BoardWalkFrame[] = []; let out = ""; let cursor = 0; let changed = false; let fixedNodeCount = 0; const samples: Array<{ nodeId: string | null; before: { left: number | null; top: number | null }; after: { left: number | null; top: number | null }; }> = []; BOARD_HTML_TAG_RE.lastIndex = walkStart; let match: RegExpExecArray | null; while ((match = BOARD_HTML_TAG_RE.exec(html)) !== null) { if (match.index >= walkEnd) break; const token = match[0]; const tagName = match[1]?.toLowerCase(); if (!tagName) continue; // comment if (token.startsWith(" frame.tagName).lastIndexOf(tagName); if (index >= 0) stack.splice(index); continue; } // Raw-text elements: skip their content wholesale so stray "<" inside // scripts/styles can't desync the walk. if (BOARD_RAW_TEXT_TAGS.has(tagName) && !token.endsWith("/>")) { const close = html.indexOf(` frame.isNodeIdElement); const isNestedBoardChild = isNodeIdElement && nodeIdAncestors.length > 0; const leftPoisoned = isNestedBoardChild && left !== null && isBoardSurfacePoisonedCoord(left); const topPoisoned = isNestedBoardChild && top !== null && isBoardSurfacePoisonedCoord(top); if ((leftPoisoned || topPoisoned) && styleMatch) { const parent = nodeIdAncestors[nodeIdAncestors.length - 1]; const originX = nodeIdAncestors.reduce( (sum, frame) => sum + (frame.left ?? 0), 0, ); const originY = nodeIdAncestors.reduce( (sum, frame) => sum + (frame.top ?? 0), 0, ); const beforeLeft = left; const beforeTop = top; let nextStyle = style; if (leftPoisoned && left !== null) { left = rebaseNestedBoardCoord({ value: left, ancestorOrigin: originX, parentSize: parent?.width ?? null, childSize: width, }); nextStyle = replaceInlineStylePx(nextStyle, "left", left); } if (topPoisoned && top !== null) { top = rebaseNestedBoardCoord({ value: top, ancestorOrigin: originY, parentSize: parent?.height ?? null, childSize: height, }); nextStyle = replaceInlineStylePx(nextStyle, "top", top); } if (nextStyle !== style) { fixedNodeCount += 1; if (samples.length < NORMALIZE_POISONED_COORDS_SAMPLE_LIMIT) { const nodeIdMatch = /\bdata-agent-native-node-id\s*=\s*("([^"]*)"|'([^']*)')/.exec( attrs, ); samples.push({ nodeId: nodeIdMatch?.[2] ?? nodeIdMatch?.[3] ?? null, before: { left: beforeLeft, top: beforeTop }, after: { left, top }, }); } const quote = styleMatch[2]?.startsWith("'") ? "'" : '"'; const rewrittenToken = token.slice(0, styleMatch.index ?? 0) + styleMatch[1] + quote + nextStyle + quote + token.slice((styleMatch.index ?? 0) + styleMatch[0].length); out += html.slice(cursor, match.index) + rewrittenToken; cursor = match.index + token.length; changed = true; } } const selfClosing = token.endsWith("/>") || VOID_ELEMENTS.has(tagName); if (!selfClosing) { stack.push({ tagName, isNodeIdElement, left, top, width, height }); } } if (!changed) return { html, changed: false, fixedNodeCount: 0, samples: [] }; return { html: out + html.slice(cursor), changed: true, fixedNodeCount, samples, }; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- function escapeAttr(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">"); }