/**
* 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 ``;
}
// 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 `
`;
}
// ---------------------------------------------------------------------------
// 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 |
* |--------------------------------------------------------|---------------|
* | `