| 1 | import type { |
| 2 | PickedElementPayload, |
| 3 | PickedElementStackFrame, |
| 4 | PreviewAnnotationPayload, |
| 5 | } from "@t3tools/contracts"; |
| 6 | |
| 7 | const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000; |
| 8 | const 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 | */ |
| 16 | export 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 | |
| 35 | function 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 | |
| 40 | function 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 | */ |
| 49 | export 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. */ |
| 78 | export 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 | } |