1import type {
2 PickedElementPayload,
3 PickedElementStackFrame,
4 PreviewAnnotationPayload,
5} from "@t3tools/contracts";
6
7const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000;
8const ELEMENT_CONTEXT_STYLES_LIMIT = 4000;
9
10/**
11 * Stable, persistable element selection captured from the in-app preview
12 * browser. We deliberately keep the shape JSON-serializable so it can ride
13 * through `localStorage` persistence, draft restoration, and transcript
14 * snapshots without bespoke marshalling.
15 */
16export interface ElementContextSelection {
17 /** Page URL where the element was picked. */
18 pageUrl: string;
19 /** Best-effort `<title>`. */
20 pageTitle: string | null;
21 /** Lowercase tag, e.g. `"button"`. */
22 tagName: string;
23 /** CSS selector — may be null when react-grab can't compute one. */
24 selector: string | null;
25 /** Truncated outer-HTML preview. */
26 htmlPreview: string;
27 /** Nearest React component display name, or null. */
28 componentName: string | null;
29 /** Source frame (file + line) — null when unavailable. */
30 source: PickedElementStackFrame | null;
31 /** Author CSS (no UA defaults). May be empty. */
32 styles: string;
33}
34
35function truncateString(value: string, limit: number): string {
36 if (value.length <= limit) return value;
37 return `${value.slice(0, Math.max(0, limit - 1))}…`;
38}
39
40function normalizeText(value: string): string {
41 return value.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
42}
43
44/**
45 * Sanitize a payload coming back from the desktop bridge before it lands in
46 * the composer draft. Trims/clamps every string field so we never persist a
47 * 5MB outerHTML blob and silently break `localStorage`.
48 */
49export function normalizeElementContextSelection(
50 raw: PickedElementPayload,
51): ElementContextSelection | null {
52 const pageUrl = raw.pageUrl.trim();
53 const tagName = raw.tagName.trim().toLowerCase();
54 if (pageUrl.length === 0 || tagName.length === 0) {
55 return null;
56 }
57 const stackFrame = raw.source ?? raw.stack[0] ?? null;
58 return {
59 pageUrl,
60 pageTitle: raw.pageTitle?.trim() ?? null,
61 tagName,
62 selector: raw.selector?.trim() || null,
63 htmlPreview: truncateString(normalizeText(raw.htmlPreview), ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT),
64 componentName: raw.componentName?.trim() || null,
65 source: stackFrame
66 ? {
67 functionName: stackFrame.functionName?.trim() || null,
68 fileName: stackFrame.fileName?.trim() || null,
69 lineNumber: stackFrame.lineNumber ?? null,
70 columnNumber: stackFrame.columnNumber ?? null,
71 }
72 : null,
73 styles: truncateString(normalizeText(raw.styles), ELEMENT_CONTEXT_STYLES_LIMIT),
74 };
75}
76
77/** Converts a saved element pick into the annotation shape used by current drafts. */
78export function elementContextToPreviewAnnotation(
79 element: ElementContextSelection,
80 id: string,
81 pickedAt: string,
82): PreviewAnnotationPayload {
83 return {
84 id,
85 pageUrl: element.pageUrl,
86 pageTitle: element.pageTitle,
87 comment: "",
88 elements: [
89 {
90 id,
91 element: { ...element, stack: [], pickedAt },
92 rect: { x: 0, y: 0, width: 0, height: 0 },
93 },
94 ],
95 regions: [],
96 strokes: [],
97 styleChanges: [],
98 screenshot: null,
99 createdAt: pickedAt,
100 };
101}