@growgroup/visual-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,2262 @@
1
+ /**
2
+ * figma-paste.ts
3
+ *
4
+ * Utilities for parsing and converting Figma clipboard data.
5
+ * Uses fig-kiwi to decode Figma's binary kiwi format.
6
+ */
7
+
8
+ import { readHTMLMessage } from 'fig-kiwi';
9
+ import { generateElementId } from './dom-utils';
10
+ import { parseFigmaClipboardToJson, extractNodes, type FigmaNode } from './figma-kiwi-decoder';
11
+ import { cacheFigKiwiSchema } from './figma-export';
12
+
13
+ // Type definitions for Figma data structures
14
+ // Based on fig-kiwi schema-defs
15
+
16
+ interface GUID {
17
+ sessionID: number;
18
+ localID: number;
19
+ }
20
+
21
+ interface Vector {
22
+ x: number;
23
+ y: number;
24
+ }
25
+
26
+ interface Color {
27
+ r: number;
28
+ g: number;
29
+ b: number;
30
+ a: number;
31
+ }
32
+
33
+ interface Matrix {
34
+ m00: number;
35
+ m01: number;
36
+ m02: number;
37
+ m10: number;
38
+ m11: number;
39
+ m12: number;
40
+ }
41
+
42
+ interface Paint {
43
+ type?: string;
44
+ visible?: boolean;
45
+ opacity?: number;
46
+ color?: Color;
47
+ // Image fill properties
48
+ imageHash?: string;
49
+ scaleMode?: string; // 'FILL' | 'FIT' | 'CROP' | 'TILE'
50
+ imageTransform?: Matrix;
51
+ scalingFactor?: number;
52
+ rotation?: number;
53
+ filterColorAdjust?: {
54
+ contrast?: number;
55
+ exposure?: number;
56
+ highlights?: number;
57
+ saturation?: number;
58
+ shadows?: number;
59
+ temperature?: number;
60
+ tint?: number;
61
+ };
62
+ }
63
+
64
+ interface Effect {
65
+ type?: string;
66
+ visible?: boolean;
67
+ radius?: number;
68
+ color?: Color;
69
+ offset?: Vector;
70
+ }
71
+
72
+ interface FontName {
73
+ family: string;
74
+ style: string;
75
+ postscript: string;
76
+ }
77
+
78
+ interface TextData {
79
+ characters?: string;
80
+ }
81
+
82
+ interface ParentIndex {
83
+ guid?: GUID;
84
+ position?: string;
85
+ }
86
+
87
+ interface UnitValue {
88
+ value: number;
89
+ units: string; // 'PIXELS' | 'PERCENT' | 'AUTO'
90
+ }
91
+
92
+ interface NodeChange {
93
+ guid?: GUID;
94
+ parentIndex?: ParentIndex;
95
+ type?: string;
96
+ name?: string;
97
+ visible?: boolean;
98
+ locked?: boolean;
99
+ opacity?: number;
100
+ blendMode?: string;
101
+ size?: Vector;
102
+ transform?: Matrix;
103
+
104
+ // Fill & Stroke
105
+ fillPaints?: Paint[];
106
+ strokePaints?: Paint[];
107
+ strokeWeight?: number;
108
+ strokeAlign?: string; // 'INSIDE' | 'CENTER' | 'OUTSIDE'
109
+ strokeCap?: string; // 'NONE' | 'ROUND' | 'SQUARE'
110
+ strokeJoin?: string; // 'MITER' | 'BEVEL' | 'ROUND'
111
+ dashPattern?: number[];
112
+
113
+ // Individual border weights
114
+ borderTopWeight?: number;
115
+ borderRightWeight?: number;
116
+ borderBottomWeight?: number;
117
+ borderLeftWeight?: number;
118
+ borderStrokeWeightsIndependent?: boolean;
119
+
120
+ // Corner radius
121
+ cornerRadius?: number;
122
+ rectangleCornerRadii?: number[];
123
+ rectangleTopLeftCornerRadius?: number;
124
+ rectangleTopRightCornerRadius?: number;
125
+ rectangleBottomLeftCornerRadius?: number;
126
+ rectangleBottomRightCornerRadius?: number;
127
+
128
+ // Effects
129
+ effects?: Effect[];
130
+
131
+ // Text properties
132
+ fontSize?: number;
133
+ fontName?: FontName;
134
+ textData?: TextData;
135
+ textAlignHorizontal?: string; // 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
136
+ textAlignVertical?: string; // 'TOP' | 'CENTER' | 'BOTTOM'
137
+ lineHeight?: UnitValue;
138
+ letterSpacing?: UnitValue;
139
+ textCase?: string; // 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
140
+ textDecoration?: string; // 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
141
+ paragraphIndent?: number;
142
+ paragraphSpacing?: number;
143
+ textTruncation?: string; // 'DISABLED' | 'ENDING'
144
+ maxLines?: number;
145
+
146
+ // Auto-layout properties
147
+ stackMode?: string; // 'HORIZONTAL' | 'VERTICAL' | 'NONE'
148
+ stackSpacing?: number; // gap between items
149
+ stackPadding?: number; // uniform padding
150
+ stackHorizontalPadding?: number;
151
+ stackVerticalPadding?: number;
152
+ stackPaddingRight?: number;
153
+ stackPaddingBottom?: number;
154
+ stackPrimaryAlignItems?: string; // 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
155
+ stackCounterAlignItems?: string; // 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'
156
+ stackPrimarySizing?: string; // 'FIXED' | 'HUG' | 'FILL'
157
+ stackCounterSizing?: string; // 'FIXED' | 'HUG' | 'FILL'
158
+ stackChildPrimaryGrow?: number; // flex-grow
159
+ stackChildAlignSelf?: string; // 'AUTO' | 'STRETCH' | 'INHERIT'
160
+ stackPositioning?: string; // 'AUTO' | 'ABSOLUTE'
161
+ stackReverseZIndex?: boolean;
162
+ stackWrap?: string; // 'NO_WRAP' | 'WRAP'
163
+
164
+ // Constraints
165
+ horizontalConstraint?: string;
166
+ verticalConstraint?: string;
167
+
168
+ // Clipping
169
+ clipsContent?: boolean;
170
+ frameMaskDisabled?: boolean;
171
+
172
+ // Other
173
+ count?: number;
174
+ }
175
+
176
+ interface Message {
177
+ type?: string;
178
+ nodeChanges?: NodeChange[];
179
+ }
180
+
181
+ /**
182
+ * Figma clipboard metadata
183
+ */
184
+ export interface FigmaClipboardMeta {
185
+ fileKey: string;
186
+ pasteID: number;
187
+ dataType: string;
188
+ }
189
+
190
+ /**
191
+ * Result of parsing Figma clipboard data
192
+ */
193
+ export interface FigmaPasteResult {
194
+ success: boolean;
195
+ meta?: FigmaClipboardMeta;
196
+ nodes?: NodeChange[];
197
+ svg?: string;
198
+ error?: string;
199
+ }
200
+
201
+ /**
202
+ * Check if HTML content is from Figma clipboard
203
+ */
204
+ export function isFigmaContent(html: string): boolean {
205
+ return (
206
+ html.includes('data-metadata="') &&
207
+ html.includes('(figmeta)') &&
208
+ html.includes('data-buffer="') &&
209
+ html.includes('(figma)')
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Extract Figma metadata from clipboard HTML
215
+ */
216
+ export function extractFigmaMeta(html: string): FigmaClipboardMeta | null {
217
+ try {
218
+ // Extract base64 encoded metadata
219
+ const metaMatch = html.match(/\(figmeta\)([A-Za-z0-9+/=]+)\(\/figmeta\)/);
220
+ if (!metaMatch) return null;
221
+
222
+ const metaJson = atob(metaMatch[1]);
223
+ return JSON.parse(metaJson) as FigmaClipboardMeta;
224
+ } catch (e) {
225
+ console.error('[figma-paste] Failed to extract metadata:', e);
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Parse Figma clipboard HTML and extract scene data
232
+ *
233
+ * Strategy:
234
+ * 1. Try fig-kiwi first (faster, but may fail with newer Figma versions)
235
+ * 2. Fall back to custom decoder that handles extended types
236
+ */
237
+ export function parseFigmaClipboard(html: string): FigmaPasteResult {
238
+ try {
239
+ if (!isFigmaContent(html)) {
240
+ return { success: false, error: 'Not Figma content' };
241
+ }
242
+
243
+ const meta = extractFigmaMeta(html);
244
+ if (!meta) {
245
+ return { success: false, error: 'Failed to extract metadata' };
246
+ }
247
+
248
+ // Try fig-kiwi first
249
+ let parsed;
250
+ let useCustomDecoder = false;
251
+
252
+ try {
253
+ parsed = readHTMLMessage(html);
254
+ // Cache the schema for later use in export
255
+ if (parsed?.schema) {
256
+ cacheFigKiwiSchema(parsed.schema);
257
+ console.log('[figma-paste] Cached fig-kiwi schema for export');
258
+ }
259
+ } catch (decodeError) {
260
+ console.warn('[figma-paste] fig-kiwi decode failed, trying custom decoder:', decodeError);
261
+ useCustomDecoder = true;
262
+ }
263
+
264
+ let nodeChanges: NodeChange[] = [];
265
+
266
+ if (useCustomDecoder) {
267
+ // Use custom decoder with extended type support
268
+ try {
269
+ const customResult = parseFigmaClipboardToJson(html);
270
+ console.log('[figma-paste] Custom decoder result:', customResult.message);
271
+ console.log('[figma-paste] Schema has', customResult.schema.definitions.length, 'definitions');
272
+
273
+ // Extract nodes from custom decoder result
274
+ const customNodes = extractNodes(customResult.message);
275
+ console.log('[figma-paste] Extracted', customNodes.length, 'nodes from custom decoder');
276
+
277
+ // Convert FigmaNode to NodeChange format
278
+ nodeChanges = convertFigmaNodes(customNodes);
279
+ } catch (customError) {
280
+ console.error('[figma-paste] Custom decoder also failed:', customError);
281
+ return {
282
+ success: false,
283
+ error: 'DECODE_FAILED',
284
+ meta,
285
+ };
286
+ }
287
+ } else {
288
+ const message = parsed!.message as Message;
289
+
290
+ if (!message || message.type !== 'NODE_CHANGES') {
291
+ return { success: false, error: `Invalid message type: ${message?.type}`, meta };
292
+ }
293
+
294
+ nodeChanges = message.nodeChanges || [];
295
+ }
296
+
297
+ console.log('[figma-paste] Parsed nodes:', nodeChanges.length);
298
+
299
+ // Convert to SVG
300
+ const svg = convertNodesToSvg(nodeChanges);
301
+
302
+ return {
303
+ success: true,
304
+ meta,
305
+ nodes: nodeChanges,
306
+ svg,
307
+ };
308
+ } catch (e) {
309
+ console.error('[figma-paste] Failed to parse Figma clipboard:', e);
310
+ return {
311
+ success: false,
312
+ error: e instanceof Error ? e.message : 'Unknown error',
313
+ };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Convert FigmaNode from custom decoder to NodeChange format
319
+ * Copies all relevant properties for CSS conversion
320
+ */
321
+ function convertFigmaNodes(figmaNodes: FigmaNode[]): NodeChange[] {
322
+ return figmaNodes.map(node => {
323
+ // Copy all properties dynamically to ensure nothing is missed
324
+ const nodeChange: NodeChange = {
325
+ guid: node.guid as GUID | undefined,
326
+ parentIndex: node.parentIndex as ParentIndex | undefined,
327
+ type: node.type as string | undefined,
328
+ name: node.name,
329
+ visible: node.visible,
330
+ locked: node.locked as boolean | undefined,
331
+ opacity: node.opacity as number | undefined,
332
+ blendMode: node.blendMode as string | undefined,
333
+ size: node.size as Vector | undefined,
334
+ transform: node.transform as Matrix | undefined,
335
+
336
+ // Fill & Stroke
337
+ fillPaints: node.fillPaints as Paint[] | undefined,
338
+ strokePaints: node.strokePaints as Paint[] | undefined,
339
+ strokeWeight: node.strokeWeight as number | undefined,
340
+ strokeAlign: node.strokeAlign as string | undefined,
341
+ strokeCap: node.strokeCap as string | undefined,
342
+ strokeJoin: node.strokeJoin as string | undefined,
343
+ dashPattern: node.dashPattern as number[] | undefined,
344
+
345
+ // Individual border weights
346
+ borderTopWeight: node.borderTopWeight as number | undefined,
347
+ borderRightWeight: node.borderRightWeight as number | undefined,
348
+ borderBottomWeight: node.borderBottomWeight as number | undefined,
349
+ borderLeftWeight: node.borderLeftWeight as number | undefined,
350
+ borderStrokeWeightsIndependent: node.borderStrokeWeightsIndependent as boolean | undefined,
351
+
352
+ // Corner radius
353
+ cornerRadius: node.cornerRadius as number | undefined,
354
+ rectangleCornerRadii: node.rectangleCornerRadii as number[] | undefined,
355
+ rectangleTopLeftCornerRadius: node.rectangleTopLeftCornerRadius as number | undefined,
356
+ rectangleTopRightCornerRadius: node.rectangleTopRightCornerRadius as number | undefined,
357
+ rectangleBottomLeftCornerRadius: node.rectangleBottomLeftCornerRadius as number | undefined,
358
+ rectangleBottomRightCornerRadius: node.rectangleBottomRightCornerRadius as number | undefined,
359
+
360
+ // Effects
361
+ effects: node.effects as Effect[] | undefined,
362
+
363
+ // Text properties
364
+ fontSize: node.fontSize as number | undefined,
365
+ fontName: node.fontName as FontName | undefined,
366
+ textData: node.textData as TextData | undefined,
367
+ textAlignHorizontal: node.textAlignHorizontal as string | undefined,
368
+ textAlignVertical: node.textAlignVertical as string | undefined,
369
+ lineHeight: node.lineHeight as UnitValue | undefined,
370
+ letterSpacing: node.letterSpacing as UnitValue | undefined,
371
+ textCase: node.textCase as string | undefined,
372
+ textDecoration: node.textDecoration as string | undefined,
373
+ paragraphIndent: node.paragraphIndent as number | undefined,
374
+ paragraphSpacing: node.paragraphSpacing as number | undefined,
375
+ textTruncation: node.textTruncation as string | undefined,
376
+ maxLines: node.maxLines as number | undefined,
377
+
378
+ // Auto-layout properties
379
+ stackMode: node.stackMode as string | undefined,
380
+ stackSpacing: node.stackSpacing as number | undefined,
381
+ stackPadding: node.stackPadding as number | undefined,
382
+ stackHorizontalPadding: node.stackHorizontalPadding as number | undefined,
383
+ stackVerticalPadding: node.stackVerticalPadding as number | undefined,
384
+ stackPaddingRight: node.stackPaddingRight as number | undefined,
385
+ stackPaddingBottom: node.stackPaddingBottom as number | undefined,
386
+ stackPrimaryAlignItems: node.stackPrimaryAlignItems as string | undefined,
387
+ stackCounterAlignItems: node.stackCounterAlignItems as string | undefined,
388
+ stackPrimarySizing: node.stackPrimarySizing as string | undefined,
389
+ stackCounterSizing: node.stackCounterSizing as string | undefined,
390
+ stackChildPrimaryGrow: node.stackChildPrimaryGrow as number | undefined,
391
+ stackChildAlignSelf: node.stackChildAlignSelf as string | undefined,
392
+ stackPositioning: node.stackPositioning as string | undefined,
393
+ stackReverseZIndex: node.stackReverseZIndex as boolean | undefined,
394
+ stackWrap: node.stackWrap as string | undefined,
395
+
396
+ // Constraints
397
+ horizontalConstraint: node.horizontalConstraint as string | undefined,
398
+ verticalConstraint: node.verticalConstraint as string | undefined,
399
+
400
+ // Clipping
401
+ clipsContent: node.clipsContent as boolean | undefined,
402
+ frameMaskDisabled: node.frameMaskDisabled as boolean | undefined,
403
+
404
+ // Other
405
+ count: node.count as number | undefined,
406
+ };
407
+ return nodeChange;
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Convert Figma color to CSS rgba string
413
+ */
414
+ function colorToRgba(color: Color): string {
415
+ const r = Math.round(color.r * 255);
416
+ const g = Math.round(color.g * 255);
417
+ const b = Math.round(color.b * 255);
418
+ return `rgba(${r}, ${g}, ${b}, ${color.a})`;
419
+ }
420
+
421
+ /**
422
+ * Get fill color from paints array
423
+ */
424
+ function getFillColor(paints: Paint[] | undefined): string | null {
425
+ if (!paints || paints.length === 0) return null;
426
+
427
+ // Find first visible solid fill
428
+ for (const paint of paints) {
429
+ if (paint.visible === false) continue;
430
+ if (paint.type === 'SOLID' && paint.color) {
431
+ const opacity = paint.opacity !== undefined ? paint.opacity : 1;
432
+ return colorToRgba({ ...paint.color, a: paint.color.a * opacity });
433
+ }
434
+ // TODO: Support gradients
435
+ }
436
+
437
+ return null;
438
+ }
439
+
440
+ /**
441
+ * Build transform matrix string for SVG
442
+ */
443
+ function buildTransform(node: NodeChange): string {
444
+ if (!node.transform) return '';
445
+
446
+ const m = node.transform;
447
+ return `matrix(${m.m00}, ${m.m10}, ${m.m01}, ${m.m11}, ${m.m02}, ${m.m12})`;
448
+ }
449
+
450
+ /**
451
+ * Create shadow filter for SVG
452
+ */
453
+ function createShadowFilter(effect: Effect, filterId: string): string {
454
+ if (effect.type !== 'DROP_SHADOW' && effect.type !== 'INNER_SHADOW') {
455
+ return '';
456
+ }
457
+
458
+ const color = effect.color ? colorToRgba(effect.color) : 'rgba(0,0,0,0.25)';
459
+ const offsetX = effect.offset?.x || 0;
460
+ const offsetY = effect.offset?.y || 0;
461
+ const blur = effect.radius || 0;
462
+
463
+ return `
464
+ <filter id="${filterId}" x="-50%" y="-50%" width="200%" height="200%">
465
+ <feDropShadow dx="${offsetX}" dy="${offsetY}" stdDeviation="${blur / 2}" flood-color="${color}"/>
466
+ </filter>
467
+ `;
468
+ }
469
+
470
+ /**
471
+ * Convert a single node to SVG element string
472
+ */
473
+ function nodeToSvgElement(node: NodeChange, defs: string[]): string {
474
+ const type = node.type;
475
+ const name = node.name || 'element';
476
+
477
+ // Skip removed or invisible nodes
478
+ if (node.visible === false) return '';
479
+
480
+ // Common attributes
481
+ const fill = getFillColor(node.fillPaints);
482
+ const stroke = getFillColor(node.strokePaints);
483
+ const strokeWeight = node.strokeWeight || 0;
484
+ const transform = buildTransform(node);
485
+ const opacity = node.opacity !== undefined ? node.opacity : 1;
486
+
487
+ // Size from node
488
+ const width = node.size?.x || 100;
489
+ const height = node.size?.y || 100;
490
+
491
+ // Build common style attributes
492
+ const styleAttrs: string[] = [];
493
+ if (fill) styleAttrs.push(`fill="${fill}"`);
494
+ else styleAttrs.push('fill="none"');
495
+ if (stroke && strokeWeight > 0) {
496
+ styleAttrs.push(`stroke="${stroke}"`);
497
+ styleAttrs.push(`stroke-width="${strokeWeight}"`);
498
+ }
499
+ if (opacity < 1) styleAttrs.push(`opacity="${opacity}"`);
500
+
501
+ // Handle effects (shadows)
502
+ if (node.effects && node.effects.length > 0) {
503
+ for (let i = 0; i < node.effects.length; i++) {
504
+ const effect = node.effects[i];
505
+ if (effect.visible !== false && (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW')) {
506
+ const filterId = `shadow-${node.guid?.localID || Math.random().toString(36).slice(2)}`;
507
+ defs.push(createShadowFilter(effect, filterId));
508
+ styleAttrs.push(`filter="url(#${filterId})"`);
509
+ break; // Only apply first shadow for now
510
+ }
511
+ }
512
+ }
513
+
514
+ const styleStr = styleAttrs.join(' ');
515
+
516
+ // Wrap in group with transform
517
+ const wrapWithTransform = (content: string) => {
518
+ if (transform) {
519
+ return `<g transform="${transform}">${content}</g>`;
520
+ }
521
+ return content;
522
+ };
523
+
524
+ switch (type) {
525
+ case 'RECTANGLE':
526
+ case 'ROUNDED_RECTANGLE': {
527
+ const cornerRadius = node.rectangleCornerRadii?.[0] || node.cornerRadius || 0;
528
+ return wrapWithTransform(
529
+ `<rect x="0" y="0" width="${width}" height="${height}" rx="${cornerRadius}" ${styleStr} data-name="${escapeXml(name)}"/>`
530
+ );
531
+ }
532
+
533
+ case 'ELLIPSE': {
534
+ const cx = width / 2;
535
+ const cy = height / 2;
536
+ const rx = width / 2;
537
+ const ry = height / 2;
538
+ return wrapWithTransform(
539
+ `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" ${styleStr} data-name="${escapeXml(name)}"/>`
540
+ );
541
+ }
542
+
543
+ case 'LINE': {
544
+ return wrapWithTransform(
545
+ `<line x1="0" y1="0" x2="${width}" y2="${height}" ${styleStr} data-name="${escapeXml(name)}"/>`
546
+ );
547
+ }
548
+
549
+ case 'REGULAR_POLYGON':
550
+ case 'STAR': {
551
+ const points = generatePolygonPoints(width, height, type === 'STAR' ? 5 : (node.count || 3), type === 'STAR');
552
+ return wrapWithTransform(
553
+ `<polygon points="${points}" ${styleStr} data-name="${escapeXml(name)}"/>`
554
+ );
555
+ }
556
+
557
+ case 'VECTOR': {
558
+ // Vector paths require blob data parsing - use rectangle as placeholder
559
+ return wrapWithTransform(
560
+ `<rect x="0" y="0" width="${width}" height="${height}" ${styleStr} data-name="${escapeXml(name)}" data-type="vector"/>`
561
+ );
562
+ }
563
+
564
+ case 'TEXT': {
565
+ const fontSize = node.fontSize || 16;
566
+ const fontFamily = node.fontName?.family || 'sans-serif';
567
+ const textContent = node.textData?.characters || '';
568
+ const textFill = fill || '#000000';
569
+
570
+ return wrapWithTransform(
571
+ `<text x="0" y="${fontSize}" font-size="${fontSize}" font-family="${fontFamily}" fill="${textFill}" ${opacity < 1 ? `opacity="${opacity}"` : ''} data-name="${escapeXml(name)}">${escapeXml(textContent)}</text>`
572
+ );
573
+ }
574
+
575
+ case 'FRAME':
576
+ case 'GROUP':
577
+ case 'COMPONENT':
578
+ case 'INSTANCE':
579
+ case 'SECTION': {
580
+ // Container nodes - rendered as groups
581
+ // In a full implementation, children would be recursively processed
582
+ return wrapWithTransform(
583
+ `<rect x="0" y="0" width="${width}" height="${height}" ${styleStr} data-name="${escapeXml(name)}" data-type="${type?.toLowerCase()}"/>`
584
+ );
585
+ }
586
+
587
+ default:
588
+ // Fallback to rectangle for unknown types
589
+ console.log(`[figma-paste] Unknown node type: ${type}`);
590
+ return wrapWithTransform(
591
+ `<rect x="0" y="0" width="${width}" height="${height}" ${styleStr} data-name="${escapeXml(name)}" data-type="${type}"/>`
592
+ );
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Generate polygon/star points
598
+ */
599
+ function generatePolygonPoints(width: number, height: number, sides: number, isStar: boolean): string {
600
+ const points: string[] = [];
601
+ const cx = width / 2;
602
+ const cy = height / 2;
603
+ const radius = Math.min(width, height) / 2;
604
+ const innerRadius = isStar ? radius * 0.4 : radius;
605
+
606
+ const totalPoints = isStar ? sides * 2 : sides;
607
+ for (let i = 0; i < totalPoints; i++) {
608
+ const angle = (i * 2 * Math.PI) / totalPoints - Math.PI / 2;
609
+ const r = isStar && i % 2 === 1 ? innerRadius : radius;
610
+ const x = cx + r * Math.cos(angle);
611
+ const y = cy + r * Math.sin(angle);
612
+ points.push(`${x.toFixed(2)},${y.toFixed(2)}`);
613
+ }
614
+
615
+ return points.join(' ');
616
+ }
617
+
618
+ /**
619
+ * Escape XML special characters
620
+ */
621
+ function escapeXml(str: string): string {
622
+ return str
623
+ .replace(/&/g, '&amp;')
624
+ .replace(/</g, '&lt;')
625
+ .replace(/>/g, '&gt;')
626
+ .replace(/"/g, '&quot;')
627
+ .replace(/'/g, '&apos;');
628
+ }
629
+
630
+ /**
631
+ * Convert array of Figma nodes to SVG string
632
+ */
633
+ function convertNodesToSvg(nodes: NodeChange[]): string {
634
+ if (!nodes || nodes.length === 0) {
635
+ return '';
636
+ }
637
+
638
+ // Calculate bounding box from transforms
639
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
640
+
641
+ for (const node of nodes) {
642
+ if (node.visible === false) continue;
643
+
644
+ let x = 0, y = 0;
645
+ if (node.transform) {
646
+ x = node.transform.m02;
647
+ y = node.transform.m12;
648
+ }
649
+ const w = node.size?.x || 0;
650
+ const h = node.size?.y || 0;
651
+
652
+ if (w > 0 && h > 0) {
653
+ minX = Math.min(minX, x);
654
+ minY = Math.min(minY, y);
655
+ maxX = Math.max(maxX, x + w);
656
+ maxY = Math.max(maxY, y + h);
657
+ }
658
+ }
659
+
660
+ // Default bounds if no valid nodes
661
+ if (minX === Infinity) {
662
+ minX = 0;
663
+ minY = 0;
664
+ maxX = 100;
665
+ maxY = 100;
666
+ }
667
+
668
+ const width = Math.max(maxX - minX, 1);
669
+ const height = Math.max(maxY - minY, 1);
670
+
671
+ // Build SVG
672
+ const defs: string[] = [];
673
+ const elements: string[] = [];
674
+
675
+ for (const node of nodes) {
676
+ const svgElement = nodeToSvgElement(node, defs);
677
+ if (svgElement) {
678
+ elements.push(svgElement);
679
+ }
680
+ }
681
+
682
+ const defsStr = defs.length > 0 ? `<defs>${defs.join('\n')}</defs>` : '';
683
+
684
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${width} ${height}" width="${width}" height="${height}">
685
+ ${defsStr}
686
+ ${elements.join('\n')}
687
+ </svg>`;
688
+ }
689
+
690
+ /**
691
+ * Result type for processFigmaPaste
692
+ */
693
+ export interface FigmaPasteProcessResult {
694
+ elements: HTMLElement[];
695
+ shouldFallbackToImage: boolean;
696
+ /** Figma file key for fetching images */
697
+ fileKey?: string;
698
+ /** Image hashes that need to be fetched from Figma API */
699
+ imageHashes?: string[];
700
+ /** Node IDs (sessionID:localID) for fallback rendering when hashes can't be extracted */
701
+ imageNodeIds?: string[];
702
+ }
703
+
704
+ /**
705
+ * Convert Figma color to CSS color string
706
+ */
707
+ function figmaColorToCss(color: Color, opacity?: number): string {
708
+ const r = Math.round(color.r * 255);
709
+ const g = Math.round(color.g * 255);
710
+ const b = Math.round(color.b * 255);
711
+ const a = (color.a ?? 1) * (opacity ?? 1);
712
+ if (a === 1) {
713
+ return `rgb(${r}, ${g}, ${b})`;
714
+ }
715
+ return `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`;
716
+ }
717
+
718
+ /**
719
+ * Get CSS background from Figma paints
720
+ */
721
+ function getFillCss(paints: Paint[] | undefined): string | null {
722
+ if (!paints || paints.length === 0) return null;
723
+
724
+ for (const paint of paints) {
725
+ if (paint.visible === false) continue;
726
+ if (paint.type === 'SOLID' && paint.color) {
727
+ return figmaColorToCss(paint.color, paint.opacity);
728
+ }
729
+ // TODO: Support gradients (LINEAR_GRADIENT, RADIAL_GRADIENT)
730
+ }
731
+ return null;
732
+ }
733
+
734
+ /**
735
+ * Get CSS border from Figma stroke paints
736
+ */
737
+ function getStrokeCss(paints: Paint[] | undefined, weight: number | undefined): string | null {
738
+ if (!paints || paints.length === 0 || !weight || weight <= 0) return null;
739
+
740
+ for (const paint of paints) {
741
+ if (paint.visible === false) continue;
742
+ if (paint.type === 'SOLID' && paint.color) {
743
+ const color = figmaColorToCss(paint.color, paint.opacity);
744
+ return `${weight}px solid ${color}`;
745
+ }
746
+ }
747
+ return null;
748
+ }
749
+
750
+ /**
751
+ * Get CSS box-shadow from Figma effects
752
+ */
753
+ function getBoxShadowCss(effects: Effect[] | undefined): string | null {
754
+ if (!effects || effects.length === 0) return null;
755
+
756
+ const shadows: string[] = [];
757
+ for (const effect of effects) {
758
+ if (effect.visible === false) continue;
759
+ if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
760
+ const color = effect.color ? figmaColorToCss(effect.color) : 'rgba(0,0,0,0.25)';
761
+ const x = effect.offset?.x || 0;
762
+ const y = effect.offset?.y || 0;
763
+ const blur = effect.radius || 0;
764
+ const inset = effect.type === 'INNER_SHADOW' ? 'inset ' : '';
765
+ shadows.push(`${inset}${x}px ${y}px ${blur}px ${color}`);
766
+ }
767
+ }
768
+
769
+ return shadows.length > 0 ? shadows.join(', ') : null;
770
+ }
771
+
772
+ /**
773
+ * Get CSS font-weight from Figma font style name
774
+ */
775
+ function getFontWeight(fontStyle?: string): string {
776
+ if (!fontStyle) return '400';
777
+
778
+ const styleLower = fontStyle.toLowerCase();
779
+
780
+ // Common font weight mappings
781
+ if (styleLower.includes('thin') || styleLower.includes('hairline')) return '100';
782
+ if (styleLower.includes('extralight') || styleLower.includes('ultra light')) return '200';
783
+ if (styleLower.includes('light')) return '300';
784
+ if (styleLower.includes('regular') || styleLower.includes('normal') || styleLower.includes('book')) return '400';
785
+ if (styleLower.includes('medium')) return '500';
786
+ if (styleLower.includes('semibold') || styleLower.includes('semi bold') || styleLower.includes('demi')) return '600';
787
+ if (styleLower.includes('extrabold') || styleLower.includes('ultra bold')) return '800';
788
+ if (styleLower.includes('bold')) return '700';
789
+ if (styleLower.includes('black') || styleLower.includes('heavy')) return '900';
790
+
791
+ return '400';
792
+ }
793
+
794
+ /**
795
+ * Get CSS border-radius from Figma corner radius
796
+ */
797
+ function getBorderRadiusCss(cornerRadius?: number, cornerRadii?: number[]): string | null {
798
+ if (cornerRadii && cornerRadii.length === 4) {
799
+ // [topLeft, topRight, bottomRight, bottomLeft]
800
+ if (cornerRadii.every(r => r === cornerRadii[0])) {
801
+ return cornerRadii[0] > 0 ? `${cornerRadii[0]}px` : null;
802
+ }
803
+ return `${cornerRadii[0]}px ${cornerRadii[1]}px ${cornerRadii[2]}px ${cornerRadii[3]}px`;
804
+ }
805
+ if (cornerRadius && cornerRadius > 0) {
806
+ return `${cornerRadius}px`;
807
+ }
808
+ return null;
809
+ }
810
+
811
+ /**
812
+ * Node with hierarchy information
813
+ */
814
+ interface HierarchyNode {
815
+ node: NodeChange;
816
+ children: HierarchyNode[];
817
+ relativeX: number;
818
+ relativeY: number;
819
+ }
820
+
821
+ /**
822
+ * Extended hierarchy node with computed absolute bounds and GUID
823
+ */
824
+ interface HierarchyNodeWithBounds extends HierarchyNode {
825
+ width: number;
826
+ height: number;
827
+ guid?: { sessionID: number; localID: number };
828
+ parentGuid?: { sessionID: number; localID: number };
829
+ }
830
+
831
+ /**
832
+ * Helper to compare GUIDs
833
+ */
834
+ function guidEquals(a?: { sessionID: number; localID: number }, b?: { sessionID: number; localID: number }): boolean {
835
+ if (!a || !b) return false;
836
+ return a.sessionID === b.sessionID && a.localID === b.localID;
837
+ }
838
+
839
+ /**
840
+ * Build node hierarchy using parentIndex from Figma data
841
+ *
842
+ * Strategy:
843
+ * 1. First, try to use parentIndex field from Figma (most accurate)
844
+ * 2. Fall back to containment-based detection if parentIndex is not available
845
+ *
846
+ * Figma clipboard stores child positions relative to their parent.
847
+ */
848
+ function buildNodeHierarchy(nodes: NodeChange[]): HierarchyNode[] {
849
+ if (nodes.length === 0) return [];
850
+
851
+ // Check if nodes have parentIndex with valid guid
852
+ const hasParentIndex = nodes.some(n => n.parentIndex?.guid !== undefined);
853
+ console.log('[figma-paste] Has parentIndex.guid field:', hasParentIndex);
854
+
855
+ if (hasParentIndex) {
856
+ return buildHierarchyFromParentIndex(nodes);
857
+ } else {
858
+ return buildHierarchyFromContainment(nodes);
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Build hierarchy using Figma's parentIndex field (accurate method)
864
+ */
865
+ function buildHierarchyFromParentIndex(nodes: NodeChange[]): HierarchyNode[] {
866
+ console.log('[figma-paste] Building hierarchy from parentIndex for', nodes.length, 'nodes');
867
+
868
+ // Create hierarchy nodes and map by GUID
869
+ const nodeMap = new Map<string, HierarchyNodeWithBounds>();
870
+ const allNodes: HierarchyNodeWithBounds[] = [];
871
+
872
+ // First pass: create all nodes and build the map
873
+ for (const node of nodes) {
874
+ const guid = node.guid;
875
+ // parentIndex has nested structure: { guid: { sessionID, localID }, position: string }
876
+ const parentGuid = node.parentIndex?.guid;
877
+ const x = node.transform?.m02 ?? 0;
878
+ const y = node.transform?.m12 ?? 0;
879
+ const width = node.size?.x ?? 0;
880
+ const height = node.size?.y ?? 0;
881
+
882
+ const guidKey = guid ? `${guid.sessionID}:${guid.localID}` : 'no-guid';
883
+ const parentKey = parentGuid ? `${parentGuid.sessionID}:${parentGuid.localID}` : 'no-parent';
884
+
885
+ console.log(`[figma-paste] Node: ${node.type} "${node.name}" guid=${guidKey} parent=${parentKey} pos=(${x.toFixed(0)},${y.toFixed(0)}) size=${width.toFixed(0)}x${height.toFixed(0)}`);
886
+
887
+ const hNode: HierarchyNodeWithBounds = {
888
+ node,
889
+ children: [],
890
+ relativeX: x,
891
+ relativeY: y,
892
+ width,
893
+ height,
894
+ guid,
895
+ parentGuid,
896
+ };
897
+
898
+ allNodes.push(hNode);
899
+
900
+ if (guid) {
901
+ nodeMap.set(guidKey, hNode);
902
+ }
903
+ }
904
+
905
+ console.log('[figma-paste] Node map has', nodeMap.size, 'entries');
906
+
907
+ // Second pass: build parent-child relationships
908
+ const roots: HierarchyNodeWithBounds[] = [];
909
+ let childCount = 0;
910
+
911
+ for (const hNode of allNodes) {
912
+ const nodeName = `${hNode.node.type} "${hNode.node.name}"`;
913
+
914
+ if (hNode.parentGuid) {
915
+ const parentKey = `${hNode.parentGuid.sessionID}:${hNode.parentGuid.localID}`;
916
+ const parent = nodeMap.get(parentKey);
917
+
918
+ if (parent) {
919
+ console.log(`[figma-paste] ${nodeName} → child of ${parent.node.type} "${parent.node.name}"`);
920
+ parent.children.push(hNode);
921
+ childCount++;
922
+ } else {
923
+ // Parent not found in copied nodes - this is a root
924
+ console.log(`[figma-paste] ${nodeName} → ROOT (parent ${parentKey} not in selection)`);
925
+ hNode.relativeX = 0;
926
+ hNode.relativeY = 0;
927
+ roots.push(hNode);
928
+ }
929
+ } else {
930
+ // No parent - this is a root
931
+ console.log(`[figma-paste] ${nodeName} → ROOT (no parentIndex)`);
932
+ hNode.relativeX = 0;
933
+ hNode.relativeY = 0;
934
+ roots.push(hNode);
935
+ }
936
+ }
937
+
938
+ console.log(`[figma-paste] Result: ${roots.length} roots, ${childCount} children linked`);
939
+
940
+ // Debug: Print tree structure
941
+ printHierarchyTree(roots);
942
+
943
+ return roots;
944
+ }
945
+
946
+ /**
947
+ * Build hierarchy using containment-based detection (fallback method)
948
+ */
949
+ function buildHierarchyFromContainment(nodes: NodeChange[]): HierarchyNode[] {
950
+ console.log('[figma-paste] Building hierarchy from containment (fallback)');
951
+
952
+ // Sort by area descending - larger containers processed first
953
+ const sorted = [...nodes].sort((a, b) => {
954
+ const areaA = (a.size?.x ?? 0) * (a.size?.y ?? 0);
955
+ const areaB = (b.size?.x ?? 0) * (b.size?.y ?? 0);
956
+ return areaB - areaA;
957
+ });
958
+
959
+ // Track all processed nodes for parent-finding
960
+ const allProcessed: HierarchyNodeWithBounds[] = [];
961
+ const roots: HierarchyNodeWithBounds[] = [];
962
+
963
+ for (const node of sorted) {
964
+ const x = node.transform?.m02 ?? 0;
965
+ const y = node.transform?.m12 ?? 0;
966
+ const width = node.size?.x ?? 0;
967
+ const height = node.size?.y ?? 0;
968
+
969
+ // Create hierarchy node
970
+ const hNode: HierarchyNodeWithBounds = {
971
+ node,
972
+ children: [],
973
+ relativeX: x,
974
+ relativeY: y,
975
+ width,
976
+ height,
977
+ guid: node.guid,
978
+ };
979
+
980
+ // Try to find a parent among already-processed nodes
981
+ let bestParent: HierarchyNodeWithBounds | null = null;
982
+ const POSITION_TOLERANCE = 5;
983
+
984
+ for (const candidate of allProcessed) {
985
+ const fitsHorizontally = x >= -POSITION_TOLERANCE &&
986
+ x + width <= candidate.width + POSITION_TOLERANCE;
987
+ const fitsVertically = y >= -POSITION_TOLERANCE &&
988
+ y + height <= candidate.height + POSITION_TOLERANCE;
989
+
990
+ if (fitsHorizontally && fitsVertically) {
991
+ if (!bestParent ||
992
+ (candidate.width * candidate.height) < (bestParent.width * bestParent.height)) {
993
+ bestParent = candidate;
994
+ }
995
+ }
996
+ }
997
+
998
+ if (bestParent) {
999
+ bestParent.children.push(hNode);
1000
+ } else {
1001
+ hNode.relativeX = 0;
1002
+ hNode.relativeY = 0;
1003
+ roots.push(hNode);
1004
+ }
1005
+
1006
+ allProcessed.push(hNode);
1007
+ }
1008
+
1009
+ printHierarchyTree(roots);
1010
+ return roots;
1011
+ }
1012
+
1013
+ /**
1014
+ * Debug helper: Print hierarchy tree
1015
+ */
1016
+ function printHierarchyTree(roots: HierarchyNodeWithBounds[]) {
1017
+ console.log('[figma-paste] ========================================');
1018
+ console.log('[figma-paste] HIERARCHY STRUCTURE:', roots.length, 'root(s)');
1019
+ console.log('[figma-paste] ========================================');
1020
+
1021
+ function countNodes(node: HierarchyNodeWithBounds): number {
1022
+ return 1 + node.children.reduce((sum, c) => sum + countNodes(c as HierarchyNodeWithBounds), 0);
1023
+ }
1024
+
1025
+ function printTree(node: HierarchyNodeWithBounds, indent: string = '') {
1026
+ const hasImage = node.node.fillPaints?.some(p => (p as Record<string, unknown>).type === 'IMAGE');
1027
+ const imageMarker = hasImage ? ' 🖼️' : '';
1028
+ const sizeStr = node.width > 0 && node.height > 0
1029
+ ? `${node.width.toFixed(0)}x${node.height.toFixed(0)}`
1030
+ : 'no-size';
1031
+ console.log(`[figma-paste] ${indent}├─ ${node.node.type} "${node.node.name}"${imageMarker} [${sizeStr}] (${node.relativeX.toFixed(0)},${node.relativeY.toFixed(0)})`);
1032
+ for (const child of node.children) {
1033
+ printTree(child as HierarchyNodeWithBounds, indent + '│ ');
1034
+ }
1035
+ }
1036
+
1037
+ let totalNodes = 0;
1038
+ let nodesWithImages = 0;
1039
+
1040
+ for (const root of roots) {
1041
+ totalNodes += countNodes(root);
1042
+ printTree(root);
1043
+ console.log('[figma-paste] ----------------------------------------');
1044
+ }
1045
+
1046
+ // Count nodes with image fills
1047
+ function countImagesInTree(node: HierarchyNodeWithBounds): number {
1048
+ const hasImage = node.node.fillPaints?.some(p => (p as Record<string, unknown>).type === 'IMAGE') ? 1 : 0;
1049
+ return hasImage + node.children.reduce((sum, c) => sum + countImagesInTree(c as HierarchyNodeWithBounds), 0);
1050
+ }
1051
+
1052
+ for (const root of roots) {
1053
+ nodesWithImages += countImagesInTree(root);
1054
+ }
1055
+
1056
+ console.log(`[figma-paste] Summary: ${totalNodes} nodes, ${nodesWithImages} with image fills, ${roots.length} root(s)`);
1057
+ if (roots.length > 1) {
1058
+ console.warn('[figma-paste] ⚠️ Multiple roots detected - elements will stack vertically');
1059
+ }
1060
+ console.log('[figma-paste] ========================================');
1061
+ }
1062
+
1063
+ /**
1064
+ * Recursively convert hierarchy node to HTML element
1065
+ * Comprehensive conversion of all Figma properties to CSS
1066
+ *
1067
+ * Note: We handle invisible/zero-size nodes here (not in pre-filtering)
1068
+ * to ensure hierarchy relationships are maintained.
1069
+ */
1070
+ function hierarchyNodeToElement(
1071
+ hNode: HierarchyNode,
1072
+ doc: Document
1073
+ ): HTMLElement | null {
1074
+ const node = hNode.node;
1075
+
1076
+ // Skip invisible nodes - but still process children in case they're visible
1077
+ if (node.visible === false) {
1078
+ console.log(`[figma-paste] Skip invisible node: ${node.type} "${node.name}"`);
1079
+ // Still process children - they might be visible even if parent is marked invisible
1080
+ const visibleChildren: HTMLElement[] = [];
1081
+ for (const child of hNode.children) {
1082
+ const childEl = hierarchyNodeToElement(child, doc);
1083
+ if (childEl) {
1084
+ visibleChildren.push(childEl);
1085
+ }
1086
+ }
1087
+ // If we have visible children but invisible parent, return children directly
1088
+ // (They'll be added to the grandparent)
1089
+ return visibleChildren.length === 1 ? visibleChildren[0] : null;
1090
+ }
1091
+
1092
+ // Skip nodes without valid size (but not containers which may have zero size themselves)
1093
+ const isContainer = ['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'SECTION'].includes(node.type || '');
1094
+ if (!isContainer && (!node.size || node.size.x <= 0 || node.size.y <= 0)) {
1095
+ console.log(`[figma-paste] Skip zero-size node: ${node.type} "${node.name}"`);
1096
+ return null;
1097
+ }
1098
+
1099
+ // Create element
1100
+ const el = doc.createElement('div');
1101
+ el.setAttribute('data-editable', 'true');
1102
+ el.setAttribute('data-element-id', generateElementId('figma'));
1103
+ el.setAttribute('data-figma-type', node.type || 'unknown');
1104
+ if (node.name) {
1105
+ el.setAttribute('data-name', node.name);
1106
+ }
1107
+
1108
+ // Use pre-calculated relative position
1109
+ const relX = hNode.relativeX;
1110
+ const relY = hNode.relativeY;
1111
+
1112
+ // Base styles
1113
+ const styles: Record<string, string> = {
1114
+ 'box-sizing': 'border-box',
1115
+ 'position': 'absolute',
1116
+ 'left': `${relX}px`,
1117
+ 'top': `${relY}px`,
1118
+ };
1119
+
1120
+ // Size - add extra width for text to prevent last-character wrapping
1121
+ // Figma and browsers differ in text measurement; error scales with font size
1122
+ if (node.size) {
1123
+ let widthExtra = 0;
1124
+ if (node.type === 'TEXT' && node.fontSize) {
1125
+ widthExtra = Math.max(1, Math.ceil(node.fontSize * 0.25));
1126
+ }
1127
+ styles['width'] = `${node.size.x + widthExtra}px`;
1128
+ styles['height'] = `${node.size.y}px`;
1129
+ }
1130
+
1131
+ // Check for rotation/scale (non-identity transform)
1132
+ if (node.transform) {
1133
+ const { m00, m01, m10, m11 } = node.transform;
1134
+ const isIdentity = Math.abs(m00 - 1) < 0.001 && Math.abs(m11 - 1) < 0.001 &&
1135
+ Math.abs(m01) < 0.001 && Math.abs(m10) < 0.001;
1136
+ if (!isIdentity) {
1137
+ styles['transform'] = `matrix(${m00}, ${m10}, ${m01}, ${m11}, 0, 0)`;
1138
+ }
1139
+ }
1140
+
1141
+ // Opacity
1142
+ if (node.opacity !== undefined && node.opacity < 1) {
1143
+ styles['opacity'] = node.opacity.toString();
1144
+ }
1145
+
1146
+ // Blend mode
1147
+ if (node.blendMode && node.blendMode !== 'NORMAL' && node.blendMode !== 'PASS_THROUGH') {
1148
+ const blendModeMap: Record<string, string> = {
1149
+ 'MULTIPLY': 'multiply',
1150
+ 'SCREEN': 'screen',
1151
+ 'OVERLAY': 'overlay',
1152
+ 'DARKEN': 'darken',
1153
+ 'LIGHTEN': 'lighten',
1154
+ 'COLOR_DODGE': 'color-dodge',
1155
+ 'COLOR_BURN': 'color-burn',
1156
+ 'HARD_LIGHT': 'hard-light',
1157
+ 'SOFT_LIGHT': 'soft-light',
1158
+ 'DIFFERENCE': 'difference',
1159
+ 'EXCLUSION': 'exclusion',
1160
+ 'HUE': 'hue',
1161
+ 'SATURATION': 'saturation',
1162
+ 'COLOR': 'color',
1163
+ 'LUMINOSITY': 'luminosity',
1164
+ };
1165
+ if (blendModeMap[node.blendMode]) {
1166
+ styles['mix-blend-mode'] = blendModeMap[node.blendMode];
1167
+ }
1168
+ }
1169
+
1170
+ // Fill (background) - support gradients and images
1171
+ const backgroundResult = getBackgroundCss(node.fillPaints);
1172
+ if (backgroundResult.background) {
1173
+ if (backgroundResult.background.includes('gradient') || backgroundResult.hasImageFill) {
1174
+ styles['background'] = backgroundResult.background;
1175
+ } else {
1176
+ styles['background-color'] = backgroundResult.background;
1177
+ }
1178
+
1179
+ // Apply image-specific background properties
1180
+ if (backgroundResult.backgroundSize) {
1181
+ styles['background-size'] = backgroundResult.backgroundSize;
1182
+ }
1183
+ if (backgroundResult.backgroundPosition) {
1184
+ styles['background-position'] = backgroundResult.backgroundPosition;
1185
+ }
1186
+ if (backgroundResult.backgroundRepeat) {
1187
+ styles['background-repeat'] = backgroundResult.backgroundRepeat;
1188
+ }
1189
+
1190
+ // Mark element as having image fill for later replacement
1191
+ if (backgroundResult.hasImageFill) {
1192
+ el.setAttribute('data-needs-image', 'true');
1193
+ if (backgroundResult.imageHash) {
1194
+ el.setAttribute('data-figma-image-hash', backgroundResult.imageHash);
1195
+ }
1196
+ // Store node GUID for fallback rendering via Figma API
1197
+ const guid = (hNode as HierarchyNodeWithBounds).guid;
1198
+ if (guid) {
1199
+ el.setAttribute('data-figma-node-id', `${guid.sessionID}:${guid.localID}`);
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ // Stroke (border) - support individual borders and dash patterns
1205
+ applyBorderStyles(styles, node);
1206
+
1207
+ // Corner radius - use individual values if available
1208
+ const borderRadius = getDetailedBorderRadiusCss(node);
1209
+ if (borderRadius) {
1210
+ styles['border-radius'] = borderRadius;
1211
+ }
1212
+
1213
+ // Effects (shadows, blur)
1214
+ applyEffectStyles(styles, node.effects);
1215
+
1216
+ // Type-specific handling
1217
+ switch (node.type) {
1218
+ case 'ELLIPSE':
1219
+ styles['border-radius'] = '50%';
1220
+ break;
1221
+
1222
+ case 'TEXT': {
1223
+ applyTextStyles(styles, el, node);
1224
+ break;
1225
+ }
1226
+
1227
+ case 'LINE': {
1228
+ styles['height'] = '1px';
1229
+ const strokeColor = getFillCss(node.strokePaints);
1230
+ if (strokeColor) {
1231
+ styles['background-color'] = strokeColor;
1232
+ }
1233
+ break;
1234
+ }
1235
+
1236
+ case 'RECTANGLE':
1237
+ case 'ROUNDED_RECTANGLE': {
1238
+ // Check if this is an image placeholder and add visual indicator
1239
+ if (backgroundResult.hasImageFill) {
1240
+ // Add image icon indicator
1241
+ const imageIcon = doc.createElement('div');
1242
+ imageIcon.style.cssText = `
1243
+ position: absolute;
1244
+ top: 50%;
1245
+ left: 50%;
1246
+ transform: translate(-50%, -50%);
1247
+ display: flex;
1248
+ flex-direction: column;
1249
+ align-items: center;
1250
+ gap: 4px;
1251
+ color: #6b7280;
1252
+ font-size: 12px;
1253
+ font-family: sans-serif;
1254
+ pointer-events: none;
1255
+ `;
1256
+ // SVG image icon
1257
+ imageIcon.innerHTML = `
1258
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1259
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
1260
+ <circle cx="8.5" cy="8.5" r="1.5"/>
1261
+ <polyline points="21 15 16 10 5 21"/>
1262
+ </svg>
1263
+ <span style="font-size: 10px; opacity: 0.7;">画像を差し替え</span>
1264
+ `;
1265
+ el.appendChild(imageIcon);
1266
+ styles['position'] = 'relative';
1267
+ }
1268
+ break;
1269
+ }
1270
+
1271
+ case 'FRAME':
1272
+ case 'GROUP':
1273
+ case 'COMPONENT':
1274
+ case 'INSTANCE':
1275
+ case 'SECTION': {
1276
+ // Clipping
1277
+ if (node.clipsContent !== false && node.frameMaskDisabled !== true) {
1278
+ styles['overflow'] = 'hidden';
1279
+ }
1280
+
1281
+ // Auto-layout (Flexbox)
1282
+ applyAutoLayoutStyles(styles, node);
1283
+
1284
+ // For INSTANCE/COMPONENT with image fills, render as a single image instead of
1285
+ // trying to reconstruct internal structure. This prevents duplicate image placeholders.
1286
+ if ((node.type === 'INSTANCE' || node.type === 'COMPONENT') && backgroundResult.hasImageFill) {
1287
+ // Mark as needing instance render (not individual image fills)
1288
+ el.setAttribute('data-render-as-instance', 'true');
1289
+ // Clear children - we'll render the whole instance as an image
1290
+ // This prevents duplicate image placeholders from child elements
1291
+ }
1292
+
1293
+ // Check if this is an image placeholder and add visual indicator (for frames with image fills)
1294
+ if (backgroundResult.hasImageFill) {
1295
+ const imageIcon = doc.createElement('div');
1296
+ imageIcon.style.cssText = `
1297
+ position: absolute;
1298
+ top: 50%;
1299
+ left: 50%;
1300
+ transform: translate(-50%, -50%);
1301
+ display: flex;
1302
+ flex-direction: column;
1303
+ align-items: center;
1304
+ gap: 4px;
1305
+ color: #6b7280;
1306
+ font-size: 12px;
1307
+ font-family: sans-serif;
1308
+ pointer-events: none;
1309
+ z-index: 1;
1310
+ `;
1311
+ imageIcon.innerHTML = `
1312
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1313
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
1314
+ <circle cx="8.5" cy="8.5" r="1.5"/>
1315
+ <polyline points="21 15 16 10 5 21"/>
1316
+ </svg>
1317
+ <span style="font-size: 10px; opacity: 0.7;">画像を差し替え</span>
1318
+ `;
1319
+ el.appendChild(imageIcon);
1320
+ }
1321
+ break;
1322
+ }
1323
+ }
1324
+
1325
+ // Apply all styles
1326
+ for (const [prop, value] of Object.entries(styles)) {
1327
+ el.style.setProperty(prop, value);
1328
+ }
1329
+
1330
+ // Recursively add children
1331
+ // Exception: For INSTANCE/COMPONENT with image fills, skip children to avoid duplicates
1332
+ // (The whole instance will be rendered as a single image from Figma API)
1333
+ const shouldSkipChildren = el.hasAttribute('data-render-as-instance');
1334
+
1335
+ if (!shouldSkipChildren) {
1336
+ for (const child of hNode.children) {
1337
+ const childEl = hierarchyNodeToElement(child, doc);
1338
+ if (childEl) {
1339
+ el.appendChild(childEl);
1340
+ }
1341
+ }
1342
+ } else {
1343
+ console.log(`[figma-paste] Skipping children for ${node.type} "${node.name}" (will render as instance)`);
1344
+ }
1345
+
1346
+ return el;
1347
+ }
1348
+
1349
+ /**
1350
+ * Result of background CSS extraction
1351
+ */
1352
+ interface BackgroundCssResult {
1353
+ background: string | null;
1354
+ backgroundSize?: string;
1355
+ backgroundPosition?: string;
1356
+ backgroundRepeat?: string;
1357
+ hasImageFill?: boolean;
1358
+ imageHash?: string;
1359
+ }
1360
+
1361
+ /**
1362
+ * Get CSS background including gradient and image support
1363
+ */
1364
+ function getBackgroundCss(paints: Paint[] | undefined): BackgroundCssResult {
1365
+ if (!paints || paints.length === 0) return { background: null };
1366
+
1367
+ const backgrounds: string[] = [];
1368
+ let backgroundSize: string | undefined;
1369
+ let backgroundPosition: string | undefined;
1370
+ let backgroundRepeat: string | undefined;
1371
+ let hasImageFill = false;
1372
+ let imageHash: string | undefined;
1373
+
1374
+ for (const paint of paints) {
1375
+ if (paint.visible === false) continue;
1376
+
1377
+ if (paint.type === 'SOLID' && paint.color) {
1378
+ const opacity = paint.opacity !== undefined ? paint.opacity : 1;
1379
+ backgrounds.push(figmaColorToCss(paint.color, opacity));
1380
+ } else if (paint.type === 'GRADIENT_LINEAR' && (paint as GradientPaint).gradientStops) {
1381
+ const gradientCss = getLinearGradientCss(paint as GradientPaint);
1382
+ if (gradientCss) backgrounds.push(gradientCss);
1383
+ } else if (paint.type === 'GRADIENT_RADIAL' && (paint as GradientPaint).gradientStops) {
1384
+ const gradientCss = getRadialGradientCss(paint as GradientPaint);
1385
+ if (gradientCss) backgrounds.push(gradientCss);
1386
+ } else if (paint.type === 'IMAGE') {
1387
+ // Image fill - extract hash using deep inspection
1388
+ const anyPaint = paint as Record<string, unknown>;
1389
+ const detectedHash = extractImageHashFromPaint(anyPaint);
1390
+
1391
+ hasImageFill = true;
1392
+ imageHash = detectedHash || undefined;
1393
+
1394
+ // Placeholder gradient pattern to indicate missing image
1395
+ const placeholderGradient = 'repeating-linear-gradient(45deg, #e5e7eb 0px, #e5e7eb 10px, #f3f4f6 10px, #f3f4f6 20px)';
1396
+ backgrounds.push(placeholderGradient);
1397
+
1398
+ // Map Figma imageScaleMode to CSS background-size
1399
+ const scaleMode = (anyPaint.imageScaleMode as string) || paint.scaleMode;
1400
+ switch (scaleMode) {
1401
+ case 'FILL':
1402
+ backgroundSize = 'cover';
1403
+ backgroundPosition = 'center';
1404
+ break;
1405
+ case 'FIT':
1406
+ backgroundSize = 'contain';
1407
+ backgroundPosition = 'center';
1408
+ backgroundRepeat = 'no-repeat';
1409
+ break;
1410
+ case 'CROP':
1411
+ backgroundSize = 'cover';
1412
+ backgroundPosition = 'center';
1413
+ break;
1414
+ case 'TILE':
1415
+ backgroundSize = 'auto';
1416
+ backgroundRepeat = 'repeat';
1417
+ break;
1418
+ default:
1419
+ backgroundSize = 'cover';
1420
+ backgroundPosition = 'center';
1421
+ }
1422
+
1423
+ console.log(`[figma-paste] IMAGE fill detected: hash=${detectedHash?.substring(0, 16) || 'none'} scaleMode=${scaleMode}`);
1424
+ }
1425
+ }
1426
+
1427
+ if (backgrounds.length === 0) return { background: null };
1428
+
1429
+ return {
1430
+ background: backgrounds.length === 1 ? backgrounds[0] : backgrounds.join(', '),
1431
+ backgroundSize,
1432
+ backgroundPosition,
1433
+ backgroundRepeat,
1434
+ hasImageFill,
1435
+ imageHash,
1436
+ };
1437
+ }
1438
+
1439
+ /**
1440
+ * Extended Paint interface for gradients
1441
+ */
1442
+ interface GradientPaint extends Paint {
1443
+ gradientStops?: Array<{ color: Color; position: number }>;
1444
+ gradientTransform?: Matrix;
1445
+ }
1446
+
1447
+ /**
1448
+ * Convert linear gradient to CSS
1449
+ */
1450
+ function getLinearGradientCss(paint: GradientPaint): string | null {
1451
+ if (!paint.gradientStops || paint.gradientStops.length < 2) return null;
1452
+
1453
+ const stops = paint.gradientStops
1454
+ .map(stop => `${figmaColorToCss(stop.color)} ${(stop.position * 100).toFixed(1)}%`)
1455
+ .join(', ');
1456
+
1457
+ // Default angle, can be calculated from gradientTransform if needed
1458
+ return `linear-gradient(180deg, ${stops})`;
1459
+ }
1460
+
1461
+ /**
1462
+ * Convert radial gradient to CSS
1463
+ */
1464
+ function getRadialGradientCss(paint: GradientPaint): string | null {
1465
+ if (!paint.gradientStops || paint.gradientStops.length < 2) return null;
1466
+
1467
+ const stops = paint.gradientStops
1468
+ .map(stop => `${figmaColorToCss(stop.color)} ${(stop.position * 100).toFixed(1)}%`)
1469
+ .join(', ');
1470
+
1471
+ return `radial-gradient(ellipse at center, ${stops})`;
1472
+ }
1473
+
1474
+ /**
1475
+ * Apply border styles including individual borders and dash patterns
1476
+ */
1477
+ function applyBorderStyles(styles: Record<string, string>, node: NodeChange): void {
1478
+ const strokeColor = getFillCss(node.strokePaints);
1479
+ if (!strokeColor) return;
1480
+
1481
+ const weight = node.strokeWeight || 1;
1482
+
1483
+ // Check for dash pattern
1484
+ const borderStyle = (node.dashPattern && node.dashPattern.length > 0) ? 'dashed' : 'solid';
1485
+
1486
+ // Check for individual border weights
1487
+ if (node.borderStrokeWeightsIndependent) {
1488
+ const top = node.borderTopWeight ?? weight;
1489
+ const right = node.borderRightWeight ?? weight;
1490
+ const bottom = node.borderBottomWeight ?? weight;
1491
+ const left = node.borderLeftWeight ?? weight;
1492
+
1493
+ if (top > 0) styles['border-top'] = `${top}px ${borderStyle} ${strokeColor}`;
1494
+ if (right > 0) styles['border-right'] = `${right}px ${borderStyle} ${strokeColor}`;
1495
+ if (bottom > 0) styles['border-bottom'] = `${bottom}px ${borderStyle} ${strokeColor}`;
1496
+ if (left > 0) styles['border-left'] = `${left}px ${borderStyle} ${strokeColor}`;
1497
+ } else if (weight > 0) {
1498
+ styles['border'] = `${weight}px ${borderStyle} ${strokeColor}`;
1499
+ }
1500
+
1501
+ // Stroke alignment - adjust sizing for INSIDE/OUTSIDE
1502
+ // Note: CSS borders are always painted inside, so we handle OUTSIDE with box-shadow
1503
+ if (node.strokeAlign === 'OUTSIDE' && weight > 0) {
1504
+ // Use box-shadow for outside stroke
1505
+ const existingShadow = styles['box-shadow'];
1506
+ const outsideStroke = `0 0 0 ${weight}px ${strokeColor}`;
1507
+ styles['box-shadow'] = existingShadow ? `${existingShadow}, ${outsideStroke}` : outsideStroke;
1508
+ delete styles['border'];
1509
+ delete styles['border-top'];
1510
+ delete styles['border-right'];
1511
+ delete styles['border-bottom'];
1512
+ delete styles['border-left'];
1513
+ }
1514
+ }
1515
+
1516
+ /**
1517
+ * Get detailed border radius using individual corner values
1518
+ */
1519
+ function getDetailedBorderRadiusCss(node: NodeChange): string | null {
1520
+ // Check individual corner radii first
1521
+ const tl = node.rectangleTopLeftCornerRadius ?? node.cornerRadius ?? 0;
1522
+ const tr = node.rectangleTopRightCornerRadius ?? node.cornerRadius ?? 0;
1523
+ const br = node.rectangleBottomRightCornerRadius ?? node.cornerRadius ?? 0;
1524
+ const bl = node.rectangleBottomLeftCornerRadius ?? node.cornerRadius ?? 0;
1525
+
1526
+ if (tl === 0 && tr === 0 && br === 0 && bl === 0) return null;
1527
+
1528
+ // All same
1529
+ if (tl === tr && tr === br && br === bl) {
1530
+ return `${tl}px`;
1531
+ }
1532
+
1533
+ return `${tl}px ${tr}px ${br}px ${bl}px`;
1534
+ }
1535
+
1536
+ /**
1537
+ * Apply effect styles (shadows, blur)
1538
+ */
1539
+ function applyEffectStyles(styles: Record<string, string>, effects: Effect[] | undefined): void {
1540
+ if (!effects || effects.length === 0) return;
1541
+
1542
+ const shadows: string[] = [];
1543
+ let blur = 0;
1544
+ let backdropBlur = 0;
1545
+
1546
+ for (const effect of effects) {
1547
+ if (effect.visible === false) continue;
1548
+
1549
+ switch (effect.type) {
1550
+ case 'DROP_SHADOW':
1551
+ case 'INNER_SHADOW': {
1552
+ const color = effect.color ? figmaColorToCss(effect.color) : 'rgba(0,0,0,0.25)';
1553
+ const x = effect.offset?.x || 0;
1554
+ const y = effect.offset?.y || 0;
1555
+ const blurRadius = effect.radius || 0;
1556
+ const spread = (effect as ExtendedEffect).spread || 0;
1557
+ const inset = effect.type === 'INNER_SHADOW' ? 'inset ' : '';
1558
+ shadows.push(`${inset}${x}px ${y}px ${blurRadius}px ${spread}px ${color}`);
1559
+ break;
1560
+ }
1561
+ case 'LAYER_BLUR':
1562
+ blur = Math.max(blur, effect.radius || 0);
1563
+ break;
1564
+ case 'BACKGROUND_BLUR':
1565
+ backdropBlur = Math.max(backdropBlur, effect.radius || 0);
1566
+ break;
1567
+ }
1568
+ }
1569
+
1570
+ if (shadows.length > 0) {
1571
+ const existingShadow = styles['box-shadow'];
1572
+ styles['box-shadow'] = existingShadow
1573
+ ? `${existingShadow}, ${shadows.join(', ')}`
1574
+ : shadows.join(', ');
1575
+ }
1576
+
1577
+ if (blur > 0) {
1578
+ styles['filter'] = `blur(${blur}px)`;
1579
+ }
1580
+
1581
+ if (backdropBlur > 0) {
1582
+ styles['backdrop-filter'] = `blur(${backdropBlur}px)`;
1583
+ }
1584
+ }
1585
+
1586
+ interface ExtendedEffect extends Effect {
1587
+ spread?: number;
1588
+ }
1589
+
1590
+ /**
1591
+ * Apply text styles
1592
+ */
1593
+ function applyTextStyles(
1594
+ styles: Record<string, string>,
1595
+ el: HTMLElement,
1596
+ node: NodeChange
1597
+ ): void {
1598
+ const fontSize = node.fontSize || 16;
1599
+ const fontFamily = node.fontName?.family || 'sans-serif';
1600
+ const fontWeight = getFontWeight(node.fontName?.style);
1601
+ const fontStyle = getFontStyle(node.fontName?.style);
1602
+ const textContent = node.textData?.characters || '';
1603
+
1604
+ styles['font-size'] = `${fontSize}px`;
1605
+ styles['font-family'] = `"${fontFamily}", sans-serif`;
1606
+ if (fontWeight !== '400') {
1607
+ styles['font-weight'] = fontWeight;
1608
+ }
1609
+ if (fontStyle !== 'normal') {
1610
+ styles['font-style'] = fontStyle;
1611
+ }
1612
+ styles['white-space'] = 'pre-wrap';
1613
+ styles['word-break'] = 'break-word';
1614
+
1615
+ // Text alignment (horizontal)
1616
+ if (node.textAlignHorizontal) {
1617
+ const alignMap: Record<string, string> = {
1618
+ 'LEFT': 'left',
1619
+ 'CENTER': 'center',
1620
+ 'RIGHT': 'right',
1621
+ 'JUSTIFIED': 'justify',
1622
+ };
1623
+ styles['text-align'] = alignMap[node.textAlignHorizontal] || 'left';
1624
+ }
1625
+
1626
+ // Text alignment (vertical) - use flexbox
1627
+ if (node.textAlignVertical && node.textAlignVertical !== 'TOP') {
1628
+ styles['display'] = 'flex';
1629
+ styles['flex-direction'] = 'column';
1630
+ styles['justify-content'] = node.textAlignVertical === 'CENTER' ? 'center' : 'flex-end';
1631
+ }
1632
+
1633
+ // Line height
1634
+ if (node.lineHeight) {
1635
+ if (node.lineHeight.units === 'PIXELS') {
1636
+ styles['line-height'] = `${node.lineHeight.value}px`;
1637
+ } else if (node.lineHeight.units === 'PERCENT') {
1638
+ styles['line-height'] = `${node.lineHeight.value / 100}`;
1639
+ } else {
1640
+ styles['line-height'] = 'normal';
1641
+ }
1642
+ }
1643
+
1644
+ // Letter spacing
1645
+ if (node.letterSpacing) {
1646
+ if (node.letterSpacing.units === 'PIXELS') {
1647
+ styles['letter-spacing'] = `${node.letterSpacing.value}px`;
1648
+ } else if (node.letterSpacing.units === 'PERCENT') {
1649
+ styles['letter-spacing'] = `${(node.letterSpacing.value / 100) * fontSize}px`;
1650
+ }
1651
+ }
1652
+
1653
+ // Text case
1654
+ if (node.textCase && node.textCase !== 'ORIGINAL') {
1655
+ const caseMap: Record<string, string> = {
1656
+ 'UPPER': 'uppercase',
1657
+ 'LOWER': 'lowercase',
1658
+ 'TITLE': 'capitalize',
1659
+ 'SMALL_CAPS': 'small-caps',
1660
+ 'SMALL_CAPS_FORCED': 'small-caps',
1661
+ };
1662
+ if (caseMap[node.textCase]) {
1663
+ if (node.textCase.includes('SMALL_CAPS')) {
1664
+ styles['font-variant'] = 'small-caps';
1665
+ } else {
1666
+ styles['text-transform'] = caseMap[node.textCase];
1667
+ }
1668
+ }
1669
+ }
1670
+
1671
+ // Text decoration
1672
+ if (node.textDecoration && node.textDecoration !== 'NONE') {
1673
+ const decoMap: Record<string, string> = {
1674
+ 'UNDERLINE': 'underline',
1675
+ 'STRIKETHROUGH': 'line-through',
1676
+ };
1677
+ if (decoMap[node.textDecoration]) {
1678
+ styles['text-decoration'] = decoMap[node.textDecoration];
1679
+ }
1680
+ }
1681
+
1682
+ // Paragraph indent
1683
+ if (node.paragraphIndent && node.paragraphIndent > 0) {
1684
+ styles['text-indent'] = `${node.paragraphIndent}px`;
1685
+ }
1686
+
1687
+ // Text truncation
1688
+ if (node.textTruncation === 'ENDING') {
1689
+ styles['overflow'] = 'hidden';
1690
+ styles['text-overflow'] = 'ellipsis';
1691
+ if (node.maxLines && node.maxLines > 0) {
1692
+ styles['display'] = '-webkit-box';
1693
+ styles['-webkit-line-clamp'] = node.maxLines.toString();
1694
+ styles['-webkit-box-orient'] = 'vertical';
1695
+ } else {
1696
+ styles['white-space'] = 'nowrap';
1697
+ }
1698
+ }
1699
+
1700
+ // Text color from fill
1701
+ const fill = getFillCss(node.fillPaints);
1702
+ if (fill) {
1703
+ styles['color'] = fill;
1704
+ delete styles['background-color'];
1705
+ delete styles['background'];
1706
+ }
1707
+
1708
+ el.textContent = textContent;
1709
+ }
1710
+
1711
+ /**
1712
+ * Get font style (italic) from Figma font style name
1713
+ */
1714
+ function getFontStyle(fontStyle?: string): string {
1715
+ if (!fontStyle) return 'normal';
1716
+ const styleLower = fontStyle.toLowerCase();
1717
+ if (styleLower.includes('italic') || styleLower.includes('oblique')) {
1718
+ return 'italic';
1719
+ }
1720
+ return 'normal';
1721
+ }
1722
+
1723
+ /**
1724
+ * Apply auto-layout (flexbox) styles
1725
+ */
1726
+ function applyAutoLayoutStyles(styles: Record<string, string>, node: NodeChange): void {
1727
+ if (!node.stackMode || node.stackMode === 'NONE') return;
1728
+
1729
+ styles['display'] = 'flex';
1730
+ styles['flex-direction'] = node.stackMode === 'HORIZONTAL' ? 'row' : 'column';
1731
+
1732
+ // Flex wrap
1733
+ if (node.stackWrap === 'WRAP') {
1734
+ styles['flex-wrap'] = 'wrap';
1735
+ }
1736
+
1737
+ // Gap
1738
+ if (node.stackSpacing !== undefined && node.stackSpacing > 0) {
1739
+ styles['gap'] = `${node.stackSpacing}px`;
1740
+ }
1741
+
1742
+ // Padding - check individual paddings first, then uniform
1743
+ const paddingTop = node.stackVerticalPadding ?? node.stackPadding ?? 0;
1744
+ const paddingRight = node.stackPaddingRight ?? node.stackHorizontalPadding ?? node.stackPadding ?? 0;
1745
+ const paddingBottom = node.stackPaddingBottom ?? node.stackVerticalPadding ?? node.stackPadding ?? 0;
1746
+ const paddingLeft = node.stackHorizontalPadding ?? node.stackPadding ?? 0;
1747
+
1748
+ if (paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0) {
1749
+ styles['padding'] = `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px`;
1750
+ }
1751
+
1752
+ // Main axis alignment (justify-content)
1753
+ if (node.stackPrimaryAlignItems) {
1754
+ const justifyMap: Record<string, string> = {
1755
+ 'MIN': 'flex-start',
1756
+ 'CENTER': 'center',
1757
+ 'MAX': 'flex-end',
1758
+ 'SPACE_BETWEEN': 'space-between',
1759
+ };
1760
+ styles['justify-content'] = justifyMap[node.stackPrimaryAlignItems] || 'flex-start';
1761
+ }
1762
+
1763
+ // Cross axis alignment (align-items)
1764
+ if (node.stackCounterAlignItems) {
1765
+ const alignMap: Record<string, string> = {
1766
+ 'MIN': 'flex-start',
1767
+ 'CENTER': 'center',
1768
+ 'MAX': 'flex-end',
1769
+ 'BASELINE': 'baseline',
1770
+ 'STRETCH': 'stretch',
1771
+ };
1772
+ styles['align-items'] = alignMap[node.stackCounterAlignItems] || 'stretch';
1773
+ }
1774
+
1775
+ // Reverse order
1776
+ if (node.stackReverseZIndex) {
1777
+ styles['flex-direction'] = node.stackMode === 'HORIZONTAL' ? 'row-reverse' : 'column-reverse';
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Convert array of Figma nodes to HTML elements with hierarchy
1783
+ *
1784
+ * IMPORTANT: Pass ALL nodes (including ones that might be filtered) to ensure
1785
+ * proper parent-child relationships. The hierarchy will be built first, then
1786
+ * only valid root elements will be converted.
1787
+ */
1788
+ function convertNodesToDom(nodes: NodeChange[], doc: Document): HTMLElement[] {
1789
+ if (!nodes || nodes.length === 0) {
1790
+ return [];
1791
+ }
1792
+
1793
+ // Build hierarchy from ALL nodes - this ensures parent-child relationships
1794
+ // are properly established even if some parents might seem "invalid"
1795
+ const rootNodes = buildNodeHierarchy(nodes);
1796
+
1797
+ if (rootNodes.length === 0) {
1798
+ return [];
1799
+ }
1800
+
1801
+ // Convert each root to HTML element (children are nested inside)
1802
+ const elements: HTMLElement[] = [];
1803
+
1804
+ for (const root of rootNodes) {
1805
+ const el = hierarchyNodeToElement(root, doc);
1806
+ if (el) {
1807
+ // Root element needs position:relative for absolute children to work
1808
+ // Children use position:absolute for positioning within root
1809
+ el.style.position = 'relative';
1810
+ el.style.left = ''; // Remove left positioning for root
1811
+ el.style.top = ''; // Remove top positioning for root
1812
+ elements.push(el);
1813
+ }
1814
+ }
1815
+
1816
+ return elements;
1817
+ }
1818
+
1819
+ /**
1820
+ * Process Figma paste and return HTML elements
1821
+ *
1822
+ * @returns Object with elements array and shouldFallbackToImage flag
1823
+ */
1824
+ export function processFigmaPaste(html: string, doc: Document): FigmaPasteProcessResult {
1825
+ const result = parseFigmaClipboard(html);
1826
+
1827
+ // If decode failed (schema version mismatch), signal to fallback to image
1828
+ if (!result.success) {
1829
+ if (result.error === 'DECODE_FAILED') {
1830
+ console.log('[figma-paste] Decode failed, should fallback to image paste');
1831
+ return { elements: [], shouldFallbackToImage: true };
1832
+ }
1833
+ console.warn('[figma-paste] Failed to process Figma paste:', result.error);
1834
+ return { elements: [], shouldFallbackToImage: false };
1835
+ }
1836
+
1837
+ if (!result.nodes || result.nodes.length === 0) {
1838
+ console.warn('[figma-paste] No nodes found in Figma data');
1839
+ return { elements: [], shouldFallbackToImage: true };
1840
+ }
1841
+
1842
+ // Collect image hashes and node IDs from all nodes
1843
+ const imageInfo = collectImageInfo(result.nodes);
1844
+ if (imageInfo.hashes.length > 0) {
1845
+ console.log(`[figma-paste] Found ${imageInfo.hashes.length} image hashes:`, imageInfo.hashes);
1846
+ }
1847
+ if (imageInfo.nodeIds.length > 0) {
1848
+ console.log(`[figma-paste] Found ${imageInfo.nodeIds.length} image node IDs:`, imageInfo.nodeIds);
1849
+ }
1850
+
1851
+ // Filter out only document structure nodes - keep everything else for hierarchy building
1852
+ // IMPORTANT: We must NOT filter nodes before building hierarchy, or children become orphaned
1853
+ const SKIP_NODE_TYPES = ['DOCUMENT', 'CANVAS', 'PAGE'];
1854
+ console.log('[figma-paste] Total nodes from clipboard:', result.nodes.length);
1855
+
1856
+ // Only remove document structure nodes - keep all content nodes including containers
1857
+ // The hierarchy builder needs parent nodes to properly nest children
1858
+ const allContentNodes = result.nodes.filter(n => {
1859
+ if (n.type && SKIP_NODE_TYPES.includes(n.type)) {
1860
+ console.log(`[figma-paste] SKIP: ${n.type} "${n.name}" (structure node)`);
1861
+ return false;
1862
+ }
1863
+ return true;
1864
+ });
1865
+
1866
+ if (allContentNodes.length === 0) {
1867
+ console.warn('[figma-paste] No content nodes found');
1868
+ return { elements: [], shouldFallbackToImage: true };
1869
+ }
1870
+
1871
+ console.log('[figma-paste] Content nodes for hierarchy:', allContentNodes.length);
1872
+
1873
+ // Convert ALL nodes to DOM elements - hierarchy builder handles parent-child nesting
1874
+ // Filtering of invisible/zero-size nodes happens during element conversion, not before
1875
+ const elements = convertNodesToDom(allContentNodes, doc);
1876
+
1877
+ if (elements.length === 0) {
1878
+ console.warn('[figma-paste] No elements generated from Figma nodes');
1879
+ return { elements: [], shouldFallbackToImage: true };
1880
+ }
1881
+
1882
+ // Log generated elements
1883
+ for (const el of elements) {
1884
+ console.log('[figma-paste] Generated element:',
1885
+ el.style.width, 'x', el.style.height,
1886
+ 'with', el.children.length, 'nested children');
1887
+ }
1888
+
1889
+ return {
1890
+ elements,
1891
+ shouldFallbackToImage: false,
1892
+ fileKey: result.meta?.fileKey,
1893
+ imageHashes: imageInfo.hashes.length > 0 ? imageInfo.hashes : undefined,
1894
+ imageNodeIds: imageInfo.nodeIds.length > 0 ? imageInfo.nodeIds : undefined,
1895
+ };
1896
+ }
1897
+
1898
+ /**
1899
+ * Convert Uint8Array to hex string (for image hashes stored as bytes)
1900
+ */
1901
+ function bytesToHex(bytes: Uint8Array): string {
1902
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
1903
+ }
1904
+
1905
+ /**
1906
+ * Recursively search for a hash value (string or Uint8Array) in nested objects.
1907
+ * Figma's kiwi binary format decodes the `image` field as a nested struct,
1908
+ * so we need to search deeper than just one level.
1909
+ */
1910
+ function deepExtractHash(value: unknown, maxDepth: number): string | null {
1911
+ if (maxDepth <= 0) return null;
1912
+
1913
+ if (typeof value === 'string' && value.length > 0) {
1914
+ return value;
1915
+ }
1916
+ if (value instanceof Uint8Array && value.length > 0) {
1917
+ return bytesToHex(value);
1918
+ }
1919
+
1920
+ if (typeof value === 'object' && value !== null && !(value instanceof Uint8Array)) {
1921
+ const obj = value as Record<string, unknown>;
1922
+ // Priority keys first
1923
+ for (const key of ['hash', 'value', 'ref', 'data', 'bytes', 'imageHash', 'imageRef']) {
1924
+ if (obj[key] !== undefined) {
1925
+ const result = deepExtractHash(obj[key], maxDepth - 1);
1926
+ if (result) {
1927
+ console.log(`[figma-paste] Found hash via key: ${key}`);
1928
+ return result;
1929
+ }
1930
+ }
1931
+ }
1932
+ // Then all other keys
1933
+ for (const [k, v] of Object.entries(obj)) {
1934
+ if (['hash', 'value', 'ref', 'data', 'bytes', 'imageHash', 'imageRef'].includes(k)) continue;
1935
+ const result = deepExtractHash(v, maxDepth - 1);
1936
+ if (result) {
1937
+ console.log(`[figma-paste] Found hash via deep search at key: ${k}`);
1938
+ return result;
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ return null;
1944
+ }
1945
+
1946
+ /**
1947
+ * Extract image hash from Figma paint's `image` field.
1948
+ * The kiwi binary decoder produces nested objects for image references.
1949
+ * We recursively search for any Uint8Array or string value that could be the hash.
1950
+ */
1951
+ function extractImageHashFromPaint(paint: Record<string, unknown>): string | null {
1952
+ // Try known field names with recursive deep search
1953
+ for (const key of ['image', 'imageHash', 'imageRef', 'imageThumbnail', 'thumbHash']) {
1954
+ const field = paint[key];
1955
+ if (!field) continue;
1956
+
1957
+ const hash = deepExtractHash(field, 4); // search up to 4 levels deep
1958
+ if (hash) {
1959
+ console.log(`[figma-paste] Extracted hash from paint.${key}: ${hash.substring(0, 20)}...`);
1960
+ return hash;
1961
+ }
1962
+ }
1963
+
1964
+ return null;
1965
+ }
1966
+
1967
+ /**
1968
+ * Result of collecting image info from nodes
1969
+ */
1970
+ interface ImageCollectionResult {
1971
+ hashes: string[];
1972
+ nodeIds: string[];
1973
+ }
1974
+
1975
+ /**
1976
+ * Collect all unique imageHashes and node IDs from nodes with image fills.
1977
+ *
1978
+ * Strategy for INSTANCE/COMPONENT nodes:
1979
+ * - These are rendered as a single image (not reconstructed internally)
1980
+ * - We collect the INSTANCE/COMPONENT's node ID, not its children's
1981
+ * - This prevents duplicate image placeholders
1982
+ */
1983
+ function collectImageInfo(nodes: NodeChange[]): ImageCollectionResult {
1984
+ const hashes = new Set<string>();
1985
+ const nodeIds = new Set<string>();
1986
+ const instanceNodeIds = new Set<string>(); // INSTANCE/COMPONENT nodes that will be rendered as images
1987
+
1988
+ // First pass: identify INSTANCE/COMPONENT nodes with image fills
1989
+ for (const node of nodes) {
1990
+ if ((node.type === 'INSTANCE' || node.type === 'COMPONENT') && node.fillPaints) {
1991
+ const hasImageFill = node.fillPaints.some(p => (p as Record<string, unknown>).type === 'IMAGE');
1992
+ if (hasImageFill && node.guid) {
1993
+ const nodeId = `${node.guid.sessionID}:${node.guid.localID}`;
1994
+ instanceNodeIds.add(nodeId);
1995
+ nodeIds.add(nodeId);
1996
+ console.log(`[figma-paste] INSTANCE/COMPONENT "${node.name}" will be rendered as single image: ${nodeId}`);
1997
+ }
1998
+ }
1999
+ }
2000
+
2001
+ // Build set of nodes that are children of image-fill instances
2002
+ // These should NOT get separate image placeholders
2003
+ const childrenOfInstances = new Set<string>();
2004
+ for (const node of nodes) {
2005
+ if (node.parentIndex?.guid) {
2006
+ const parentId = `${node.parentIndex.guid.sessionID}:${node.parentIndex.guid.localID}`;
2007
+ if (instanceNodeIds.has(parentId)) {
2008
+ if (node.guid) {
2009
+ childrenOfInstances.add(`${node.guid.sessionID}:${node.guid.localID}`);
2010
+ }
2011
+ }
2012
+ }
2013
+ }
2014
+
2015
+ // Second pass: collect image info from remaining nodes
2016
+ for (const node of nodes) {
2017
+ const nodeId = node.guid ? `${node.guid.sessionID}:${node.guid.localID}` : '';
2018
+
2019
+ // Skip children of INSTANCE/COMPONENT nodes (they're rendered as part of the parent)
2020
+ if (childrenOfInstances.has(nodeId)) {
2021
+ console.log(`[figma-paste] Skipping child of instance: "${node.name}"`);
2022
+ continue;
2023
+ }
2024
+
2025
+ // Skip INSTANCE/COMPONENT nodes (already handled above)
2026
+ if (instanceNodeIds.has(nodeId)) {
2027
+ continue;
2028
+ }
2029
+
2030
+ if (node.fillPaints && Array.isArray(node.fillPaints)) {
2031
+ for (const paint of node.fillPaints) {
2032
+ const anyPaint = paint as Record<string, unknown>;
2033
+
2034
+ if (paint.type === 'IMAGE') {
2035
+ // Deep-inspect the `image` field
2036
+ const imageField = anyPaint.image;
2037
+ console.log(`[figma-paste] IMAGE paint on "${node.name}": image field type=${typeof imageField}, isUint8Array=${imageField instanceof Uint8Array}`);
2038
+
2039
+ if (imageField && typeof imageField === 'object') {
2040
+ if (imageField instanceof Uint8Array) {
2041
+ console.log(`[figma-paste] image = <Uint8Array(${imageField.length})> hex=${bytesToHex(imageField).substring(0, 40)}...`);
2042
+ } else {
2043
+ // Log nested object structure for debugging
2044
+ const imgObj = imageField as Record<string, unknown>;
2045
+ console.log(`[figma-paste] image object keys: [${Object.keys(imgObj).join(', ')}]`);
2046
+ logObjectDeep(imgObj, 'image', 2);
2047
+ }
2048
+ }
2049
+
2050
+ // Extract hash using recursive deep search
2051
+ const hash = extractImageHashFromPaint(anyPaint);
2052
+ if (hash) {
2053
+ console.log(`[figma-paste] => extracted hash: "${hash.substring(0, 40)}${hash.length > 40 ? '...' : ''}"`);
2054
+ hashes.add(hash);
2055
+ } else {
2056
+ console.log(`[figma-paste] => NO hash extracted from paint`);
2057
+ }
2058
+
2059
+ // Collect node GUID for fallback rendering
2060
+ if (node.guid) {
2061
+ nodeIds.add(nodeId);
2062
+ console.log(`[figma-paste] => node ID for rendering: ${nodeId}`);
2063
+ }
2064
+ }
2065
+ }
2066
+ }
2067
+ }
2068
+
2069
+ console.log(`[figma-paste] collectImageInfo: ${hashes.size} hashes, ${nodeIds.size} node IDs (${instanceNodeIds.size} instances)`);
2070
+ return {
2071
+ hashes: Array.from(hashes),
2072
+ nodeIds: Array.from(nodeIds),
2073
+ };
2074
+ }
2075
+
2076
+ /**
2077
+ * Debug helper: recursively log object structure
2078
+ */
2079
+ function logObjectDeep(obj: Record<string, unknown>, prefix: string, maxDepth: number): void {
2080
+ if (maxDepth <= 0) return;
2081
+ for (const [k, v] of Object.entries(obj)) {
2082
+ if (v instanceof Uint8Array) {
2083
+ console.log(`[figma-paste] ${prefix}.${k} = <Uint8Array(${v.length})> hex=${bytesToHex(v).substring(0, 40)}...`);
2084
+ } else if (typeof v === 'object' && v !== null) {
2085
+ const nested = v as Record<string, unknown>;
2086
+ console.log(`[figma-paste] ${prefix}.${k} = {${Object.keys(nested).join(', ')}}`);
2087
+ logObjectDeep(nested, `${prefix}.${k}`, maxDepth - 1);
2088
+ } else {
2089
+ console.log(`[figma-paste] ${prefix}.${k} = ${JSON.stringify(v)}`.substring(0, 120));
2090
+ }
2091
+ }
2092
+ }
2093
+
2094
+ /**
2095
+ * Fetch images from Figma API and apply to elements.
2096
+ * Uses dual strategy:
2097
+ * 1. Try image fills API with hash matching (efficient, one API call)
2098
+ * 2. Fall back to node rendering API if hash matching fails
2099
+ *
2100
+ * @param fileKey Figma file key from clipboard metadata
2101
+ * @param imageHashes Array of imageHash values to fetch
2102
+ * @param rootElement Root element containing elements with data-figma-image-hash
2103
+ * @param imageNodeIds Optional node IDs for fallback rendering
2104
+ * @returns Number of images successfully applied
2105
+ */
2106
+ export async function fetchAndApplyFigmaImages(
2107
+ fileKey: string,
2108
+ imageHashes: string[],
2109
+ rootElement: HTMLElement | Document,
2110
+ imageNodeIds?: string[]
2111
+ ): Promise<{ applied: number; errors: string[] }> {
2112
+ const errors: string[] = [];
2113
+
2114
+ if (!fileKey) {
2115
+ return { applied: 0, errors: ['No fileKey provided'] };
2116
+ }
2117
+
2118
+ const hasHashes = imageHashes && imageHashes.length > 0;
2119
+ const hasNodeIds = imageNodeIds && imageNodeIds.length > 0;
2120
+
2121
+ if (!hasHashes && !hasNodeIds) {
2122
+ return { applied: 0, errors: ['No imageHashes or nodeIds provided'] };
2123
+ }
2124
+
2125
+ let appliedCount = 0;
2126
+
2127
+ // Strategy 1: Try image fills API with hash matching
2128
+ if (hasHashes) {
2129
+ console.log(`[figma-paste] Strategy 1: Fetching ${imageHashes.length} images by hash...`);
2130
+ try {
2131
+ const response = await fetch('/api/figma/images', {
2132
+ method: 'POST',
2133
+ headers: { 'Content-Type': 'application/json' },
2134
+ body: JSON.stringify({ fileKey, imageHashes }),
2135
+ });
2136
+
2137
+ if (response.ok) {
2138
+ const data = await response.json();
2139
+ if (data.success && data.images) {
2140
+ const imageMap = data.images as Record<string, string>;
2141
+ console.log(`[figma-paste] Strategy 1: Got ${Object.keys(imageMap).length} image URLs`);
2142
+
2143
+ // Also log all available image refs for debugging
2144
+ if (data.allImageRefs) {
2145
+ console.log(`[figma-paste] All available image refs in file:`, data.allImageRefs);
2146
+ }
2147
+
2148
+ appliedCount += applyImagesByHash(rootElement, imageMap);
2149
+ }
2150
+ } else {
2151
+ const errorData = await response.json().catch(() => ({}));
2152
+ errors.push(`Image fills API error: ${errorData.error || response.status}`);
2153
+ }
2154
+ } catch (error) {
2155
+ errors.push(`Image fills API: ${error instanceof Error ? error.message : 'Unknown error'}`);
2156
+ }
2157
+ }
2158
+
2159
+ // Strategy 2: Fall back to node rendering if hash matching didn't cover all elements
2160
+ const remainingElements = Array.from(rootElement.querySelectorAll('[data-needs-image]'));
2161
+ if (remainingElements.length > 0 && hasNodeIds) {
2162
+ console.log(`[figma-paste] Strategy 2: Rendering ${remainingElements.length} nodes via API...`);
2163
+ try {
2164
+ // Collect node IDs from remaining elements
2165
+ const nodeIdsToRender: string[] = [];
2166
+ for (const el of remainingElements) {
2167
+ const nodeId = el.getAttribute('data-figma-node-id');
2168
+ if (nodeId) {
2169
+ nodeIdsToRender.push(nodeId);
2170
+ }
2171
+ }
2172
+
2173
+ if (nodeIdsToRender.length > 0) {
2174
+ const response = await fetch('/api/figma/images', {
2175
+ method: 'POST',
2176
+ headers: { 'Content-Type': 'application/json' },
2177
+ body: JSON.stringify({ fileKey, nodeIds: nodeIdsToRender, format: 'png', scale: 2 }),
2178
+ });
2179
+
2180
+ if (response.ok) {
2181
+ const data = await response.json();
2182
+ if (data.success && data.images) {
2183
+ const nodeImageMap = data.images as Record<string, string>;
2184
+ console.log(`[figma-paste] Strategy 2: Got ${Object.keys(nodeImageMap).length} rendered images`);
2185
+ appliedCount += applyImagesByNodeId(rootElement, nodeImageMap);
2186
+ }
2187
+ } else {
2188
+ const errorData = await response.json().catch(() => ({}));
2189
+ errors.push(`Node rendering API error: ${errorData.error || response.status}`);
2190
+ }
2191
+ }
2192
+ } catch (error) {
2193
+ errors.push(`Node rendering API: ${error instanceof Error ? error.message : 'Unknown error'}`);
2194
+ }
2195
+ }
2196
+
2197
+ console.log(`[figma-paste] Total applied: ${appliedCount} images`);
2198
+ return { applied: appliedCount, errors };
2199
+ }
2200
+
2201
+ /**
2202
+ * Apply images to elements by matching data-figma-image-hash attribute
2203
+ */
2204
+ function applyImagesByHash(rootElement: HTMLElement | Document, imageMap: Record<string, string>): number {
2205
+ const elements = Array.from(rootElement.querySelectorAll('[data-figma-image-hash]'));
2206
+ let applied = 0;
2207
+
2208
+ for (const el of elements) {
2209
+ const hash = el.getAttribute('data-figma-image-hash');
2210
+ if (hash && imageMap[hash]) {
2211
+ applyImageToElement(el as HTMLElement, imageMap[hash]);
2212
+ applied++;
2213
+ console.log(`[figma-paste] Applied image by hash: ${hash.substring(0, 16)}... → ${el.getAttribute('data-name') || 'unnamed'}`);
2214
+ }
2215
+ }
2216
+
2217
+ return applied;
2218
+ }
2219
+
2220
+ /**
2221
+ * Apply images to elements by matching data-figma-node-id attribute
2222
+ */
2223
+ function applyImagesByNodeId(rootElement: HTMLElement | Document, nodeImageMap: Record<string, string>): number {
2224
+ const elements = Array.from(rootElement.querySelectorAll('[data-figma-node-id]'));
2225
+ let applied = 0;
2226
+
2227
+ for (const el of elements) {
2228
+ const nodeId = el.getAttribute('data-figma-node-id');
2229
+ if (nodeId && nodeImageMap[nodeId]) {
2230
+ applyImageToElement(el as HTMLElement, nodeImageMap[nodeId]);
2231
+ applied++;
2232
+ console.log(`[figma-paste] Applied image by node render: ${nodeId} → ${el.getAttribute('data-name') || 'unnamed'}`);
2233
+ }
2234
+ }
2235
+
2236
+ return applied;
2237
+ }
2238
+
2239
+ /**
2240
+ * Apply an image URL to an element, replacing the placeholder
2241
+ */
2242
+ function applyImageToElement(htmlEl: HTMLElement, imageUrl: string): void {
2243
+ // Preserve existing background-size/position if set
2244
+ const existingSize = htmlEl.style.backgroundSize;
2245
+ const existingPos = htmlEl.style.backgroundPosition;
2246
+ const existingRepeat = htmlEl.style.backgroundRepeat;
2247
+
2248
+ htmlEl.style.background = `url(${imageUrl})`;
2249
+ htmlEl.style.backgroundSize = existingSize || 'cover';
2250
+ htmlEl.style.backgroundPosition = existingPos || 'center';
2251
+ htmlEl.style.backgroundRepeat = existingRepeat || 'no-repeat';
2252
+
2253
+ // Remove placeholder indicator
2254
+ htmlEl.removeAttribute('data-needs-image');
2255
+ htmlEl.setAttribute('data-figma-image-applied', 'true');
2256
+
2257
+ // Remove placeholder icon if exists
2258
+ const placeholderIcon = htmlEl.querySelector('[style*="pointer-events: none"]');
2259
+ if (placeholderIcon) {
2260
+ placeholderIcon.remove();
2261
+ }
2262
+ }