@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.
- package/LICENSE +21 -0
- package/NOTICE +52 -0
- package/README.md +143 -0
- package/dist/editor.css +2 -0
- package/package.json +94 -0
- package/src/components/DeckSlideRender.tsx +21 -0
- package/src/components/SaveNote.tsx +21 -0
- package/src/components/auth/AuthProvider.tsx +44 -0
- package/src/components/ui/accordion.tsx +57 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +200 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +201 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/scrubbable-label.tsx +95 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/viewer/useDeck.ts +58 -0
- package/src/editor/EditorContext.tsx +632 -0
- package/src/editor/EditorToolbar.tsx +446 -0
- package/src/editor/FrontendVisualEditor.tsx +2595 -0
- package/src/editor/autosave.ts +34 -0
- package/src/editor/components/AiPromptPopover.tsx +378 -0
- package/src/editor/components/BreakpointGuides.tsx +323 -0
- package/src/editor/components/BreakpointSelector.tsx +180 -0
- package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
- package/src/editor/components/CssEditorDialog.tsx +168 -0
- package/src/editor/components/EditorCanvas.tsx +987 -0
- package/src/editor/components/EditorContextMenu.tsx +515 -0
- package/src/editor/components/EditorFooter.tsx +81 -0
- package/src/editor/components/EditorHeader.tsx +524 -0
- package/src/editor/components/EditorLayerPanel.tsx +1197 -0
- package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
- package/src/editor/components/EditorSidebar.tsx +226 -0
- package/src/editor/components/FigmaColorPicker.tsx +1577 -0
- package/src/editor/components/HtmlEditorDialog.tsx +71 -0
- package/src/editor/components/HtmlImportDialog.tsx +346 -0
- package/src/editor/components/JsEditorDialog.tsx +175 -0
- package/src/editor/components/LayerTreeItem.tsx +494 -0
- package/src/editor/components/MasterComponentEditor.tsx +846 -0
- package/src/editor/components/MediaLibraryDialog.tsx +447 -0
- package/src/editor/components/PageSettingsDialog.tsx +776 -0
- package/src/editor/components/VariablePicker.tsx +355 -0
- package/src/editor/components/VariablesDialog.tsx +564 -0
- package/src/editor/components/VariantEditor.tsx +687 -0
- package/src/editor/components/VariantMatrix.tsx +507 -0
- package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
- package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
- package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
- package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
- package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
- package/src/editor/components/component-panel/VariantList.tsx +260 -0
- package/src/editor/components/component-panel/index.ts +12 -0
- package/src/editor/components/index.ts +48 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
- package/src/editor/components/multi-page/PageLabel.tsx +34 -0
- package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
- package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
- package/src/editor/components/multi-page/index.ts +7 -0
- package/src/editor/components/ppt/PptChrome.tsx +2508 -0
- package/src/editor/components/ppt/PptComments.tsx +589 -0
- package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
- package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
- package/src/editor/components/ppt/PptNotes.tsx +246 -0
- package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
- package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
- package/src/editor/components/property-panel/BorderSection.tsx +104 -0
- package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
- package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
- package/src/editor/components/property-panel/EffectSection.tsx +157 -0
- package/src/editor/components/property-panel/FillSection.tsx +69 -0
- package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
- package/src/editor/components/property-panel/ImageSection.tsx +206 -0
- package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
- package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
- package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
- package/src/editor/components/property-panel/LinkSection.tsx +103 -0
- package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
- package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
- package/src/editor/components/property-panel/TypographySection.tsx +230 -0
- package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
- package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
- package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
- package/src/editor/components/property-panel/index.ts +24 -0
- package/src/editor/components/property-panel/unit-utils.ts +261 -0
- package/src/editor/components/shell/EditorTopBar.tsx +392 -0
- package/src/editor/components/shell/LeftPanel.tsx +47 -0
- package/src/editor/components/shell/PagesPanel.tsx +118 -0
- package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
- package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
- package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
- package/src/editor/components/variables-panel/index.ts +7 -0
- package/src/editor/constants.ts +468 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
- package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
- package/src/editor/contexts/EditorRefsContext.tsx +204 -0
- package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
- package/src/editor/contexts/EditorToolContext.tsx +58 -0
- package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
- package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
- package/src/editor/contexts/EditorViewContext.tsx +101 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
- package/src/editor/contexts/index.ts +17 -0
- package/src/editor/editor-skin.css +122 -0
- package/src/editor/hooks/index.ts +38 -0
- package/src/editor/hooks/useAiReplace.ts +397 -0
- package/src/editor/hooks/useAltMeasure.ts +67 -0
- package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
- package/src/editor/hooks/useCanvasControls.ts +1222 -0
- package/src/editor/hooks/useComponentEditMode.ts +658 -0
- package/src/editor/hooks/useComponentInstances.ts +515 -0
- package/src/editor/hooks/useContextMenuHandler.ts +157 -0
- package/src/editor/hooks/useCoordinateTransform.ts +207 -0
- package/src/editor/hooks/useDragResize.ts +1317 -0
- package/src/editor/hooks/useDrawingMode.ts +472 -0
- package/src/editor/hooks/useEditorColors.ts +144 -0
- package/src/editor/hooks/useEditorMessages.ts +108 -0
- package/src/editor/hooks/useElementActions.ts +1754 -0
- package/src/editor/hooks/useElementSelection.ts +1573 -0
- package/src/editor/hooks/useFocusManagement.ts +249 -0
- package/src/editor/hooks/useGoogleFonts.ts +305 -0
- package/src/editor/hooks/useIframeInitializer.ts +584 -0
- package/src/editor/hooks/useIframeSetup.ts +360 -0
- package/src/editor/hooks/useImageUpload.ts +270 -0
- package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
- package/src/editor/hooks/useMarqueeSelection.ts +388 -0
- package/src/editor/hooks/useMediaLibrary.ts +129 -0
- package/src/editor/hooks/usePageSettingsManager.ts +739 -0
- package/src/editor/hooks/useResizablePanel.ts +163 -0
- package/src/editor/hooks/useRichPaste.ts +255 -0
- package/src/editor/hooks/useTouchGestures.ts +197 -0
- package/src/editor/index.ts +27 -0
- package/src/editor/types/page-settings.ts +513 -0
- package/src/editor/types.ts +382 -0
- package/src/editor/utils/align-elements.ts +169 -0
- package/src/editor/utils/api-json.ts +21 -0
- package/src/editor/utils/aspect-lock.ts +50 -0
- package/src/editor/utils/component-renderer.ts +702 -0
- package/src/editor/utils/component-sync.ts +845 -0
- package/src/editor/utils/crop-mode.ts +265 -0
- package/src/editor/utils/dom-utils.ts +2238 -0
- package/src/editor/utils/element-effects.ts +218 -0
- package/src/editor/utils/eyedropper.ts +35 -0
- package/src/editor/utils/figma-export.ts +560 -0
- package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
- package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
- package/src/editor/utils/figma-paste.ts +2262 -0
- package/src/editor/utils/flex-reorder.ts +231 -0
- package/src/editor/utils/flex-utils.ts +1230 -0
- package/src/editor/utils/geometry.ts +232 -0
- package/src/editor/utils/grid-layout.ts +210 -0
- package/src/editor/utils/html-utils.ts +470 -0
- package/src/editor/utils/index.ts +11 -0
- package/src/editor/utils/ink-style.ts +57 -0
- package/src/editor/utils/inline-format.ts +236 -0
- package/src/editor/utils/measure-distance.ts +120 -0
- package/src/editor/utils/override-apply.ts +526 -0
- package/src/editor/utils/override-detection.ts +613 -0
- package/src/editor/utils/paste-processors.ts +609 -0
- package/src/editor/utils/paste-sanitizer.ts +244 -0
- package/src/editor/utils/restore-flow.ts +188 -0
- package/src/editor/utils/shape-library.ts +171 -0
- package/src/editor/utils/slide-root.ts +29 -0
- package/src/editor/utils/smart-guides.ts +218 -0
- package/src/editor/utils/style-utils.ts +685 -0
- package/src/editor/utils/table-edit.ts +151 -0
- package/src/editor/utils/tailwind-mappings.ts +778 -0
- package/src/editor/utils/tailwind-utils.ts +804 -0
- package/src/editor/utils/text-highlight.ts +216 -0
- package/src/editor/utils/theme-to-variables.ts +341 -0
- package/src/editor/utils/variant-resolver.ts +456 -0
- package/src/editor/utils/viewport-utils.ts +295 -0
- package/src/hooks/useEditorHistory.ts +195 -0
- package/src/hooks/useEditorShortcuts.ts +260 -0
- package/src/index.ts +40 -0
- package/src/io.ts +134 -0
- package/src/lib/agent/slide-agent/types.ts +547 -0
- package/src/lib/agent/website-agent/types.ts +805 -0
- package/src/lib/api/auth-fetch.ts +58 -0
- package/src/lib/deck.ts +69 -0
- package/src/lib/export.ts +47 -0
- package/src/lib/firebase/config.ts +29 -0
- package/src/lib/firebase/css-variables.ts +378 -0
- package/src/lib/firebase/editor-components.ts +677 -0
- package/src/lib/firebase/storage.ts +469 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/editor.css +52 -0
- package/src/styles/skin.css +122 -0
- package/src/types/css-variables.ts +645 -0
- package/src/types/editor-components.ts +944 -0
- package/src/types/editor.ts +420 -0
- package/src/types/page-master.ts +197 -0
- package/src/types/page.ts +419 -0
- package/src/types/slide.ts +402 -0
- package/src/types/website-theme.ts +788 -0
- package/src/types/website.ts +374 -0
- package/src/vendor/firebase-firestore.ts +13 -0
- 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, '&')
|
|
624
|
+
.replace(/</g, '<')
|
|
625
|
+
.replace(/>/g, '>')
|
|
626
|
+
.replace(/"/g, '"')
|
|
627
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|