@growgroup/visual-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,1230 @@
1
+ /**
2
+ * Flexbox drag-to-reorder utilities
3
+ * Concrete5-style element reordering in auto-layout containers
4
+ */
5
+
6
+ /**
7
+ * Information about a flex container
8
+ */
9
+ export interface FlexContainerInfo {
10
+ isFlexContainer: boolean;
11
+ container: HTMLElement;
12
+ direction: 'row' | 'column' | 'row-reverse' | 'column-reverse';
13
+ isReverse: boolean;
14
+ children: HTMLElement[];
15
+ childIndex: number;
16
+ gap: number;
17
+ }
18
+
19
+ /**
20
+ * Get information about the flex container of an element
21
+ */
22
+ export function getFlexContainerInfo(element: HTMLElement): FlexContainerInfo | null {
23
+ const parent = element.parentElement;
24
+ if (!parent) return null;
25
+
26
+ const style = window.getComputedStyle(parent);
27
+ const display = style.display;
28
+
29
+ if (display !== 'flex' && display !== 'inline-flex') {
30
+ return null;
31
+ }
32
+
33
+ const direction = (style.flexDirection || 'row') as FlexContainerInfo['direction'];
34
+ const isReverse = direction.includes('reverse');
35
+
36
+ // Get child elements excluding UI elements
37
+ const children = Array.from(parent.children).filter(child => {
38
+ const el = child as HTMLElement;
39
+ return !el.classList.contains('selection-box') &&
40
+ !el.classList.contains('resize-handle') &&
41
+ !el.classList.contains('flex-drop-indicator') &&
42
+ el.tagName !== 'SCRIPT' &&
43
+ el.tagName !== 'STYLE';
44
+ }) as HTMLElement[];
45
+
46
+ const childIndex = children.indexOf(element);
47
+ const gap = parseFloat(style.gap) || 0;
48
+
49
+ return {
50
+ isFlexContainer: true,
51
+ container: parent,
52
+ direction,
53
+ isReverse,
54
+ children,
55
+ childIndex,
56
+ gap,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Check if drag should trigger flex reordering
62
+ */
63
+ export function canReorderInFlex(element: HTMLElement): boolean {
64
+ const info = getFlexContainerInfo(element);
65
+ return info !== null && info.childIndex !== -1;
66
+ }
67
+
68
+ /**
69
+ * Calculate the target drop index based on cursor position
70
+ */
71
+ export function calculateDropIndex(
72
+ parent: HTMLElement,
73
+ cursorX: number,
74
+ cursorY: number,
75
+ iframeWindow: Window
76
+ ): number {
77
+ const style = iframeWindow.getComputedStyle(parent);
78
+ const direction = style.flexDirection || 'row';
79
+ const isColumn = direction === 'column' || direction === 'column-reverse';
80
+ const isReverse = direction.includes('reverse');
81
+
82
+ // Get child elements excluding UI elements
83
+ const children = Array.from(parent.children).filter(child => {
84
+ const el = child as HTMLElement;
85
+ return !el.classList.contains('selection-box') &&
86
+ !el.classList.contains('resize-handle') &&
87
+ !el.classList.contains('flex-drop-indicator') &&
88
+ el.tagName !== 'SCRIPT' &&
89
+ el.tagName !== 'STYLE';
90
+ }) as HTMLElement[];
91
+
92
+ if (children.length === 0) return 0;
93
+
94
+ for (let i = 0; i < children.length; i++) {
95
+ const child = children[i];
96
+ const rect = child.getBoundingClientRect();
97
+
98
+ if (isColumn) {
99
+ // For column layouts, check vertical position
100
+ const childMidY = rect.top + rect.height / 2;
101
+ if (cursorY < childMidY) {
102
+ return isReverse ? children.length - i : i;
103
+ }
104
+ } else {
105
+ // For row layouts, check horizontal position
106
+ const childMidX = rect.left + rect.width / 2;
107
+ if (cursorX < childMidX) {
108
+ return isReverse ? children.length - i : i;
109
+ }
110
+ }
111
+ }
112
+
113
+ // Cursor is after all children
114
+ return isReverse ? 0 : children.length;
115
+ }
116
+
117
+ /**
118
+ * Show the drop indicator at the target position
119
+ */
120
+ export function showFlexDropIndicator(
121
+ iframeDoc: Document,
122
+ parent: HTMLElement,
123
+ targetIndex: number,
124
+ currentDragElement?: HTMLElement
125
+ ): void {
126
+ // Remove existing indicator
127
+ hideFlexDropIndicator(iframeDoc);
128
+
129
+ const style = iframeDoc.defaultView?.getComputedStyle(parent);
130
+ if (!style) return;
131
+
132
+ // Use the same layout detection logic for consistency
133
+ const { isColumn } = isColumnLayout(parent, iframeDoc);
134
+ const gap = parseFloat(style.gap) || 0;
135
+
136
+ // Get child elements excluding UI elements and the element being dragged
137
+ const children = Array.from(parent.children).filter(child => {
138
+ const el = child as HTMLElement;
139
+ return !el.classList.contains('selection-box') &&
140
+ !el.classList.contains('resize-handle') &&
141
+ !el.classList.contains('flex-drop-indicator') &&
142
+ el.tagName !== 'SCRIPT' &&
143
+ el.tagName !== 'STYLE' &&
144
+ el !== currentDragElement;
145
+ }) as HTMLElement[];
146
+
147
+ const indicator = iframeDoc.createElement('div');
148
+ indicator.className = 'flex-drop-indicator';
149
+ indicator.style.cssText = `
150
+ position: absolute;
151
+ pointer-events: none;
152
+ z-index: 10000;
153
+ background: #6366F1;
154
+ border-radius: 1.5px;
155
+ box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
156
+ opacity: 0.9;
157
+ `;
158
+
159
+ // Add keyframes for nesting animation if not already present
160
+ if (!iframeDoc.getElementById('drop-indicator-styles')) {
161
+ const styleSheet = iframeDoc.createElement('style');
162
+ styleSheet.id = 'drop-indicator-styles';
163
+ styleSheet.textContent = `
164
+ @keyframes nestingPulse {
165
+ 0%, 100% { opacity: 0.9; }
166
+ 50% { opacity: 0.7; }
167
+ }
168
+ `;
169
+ iframeDoc.head.appendChild(styleSheet);
170
+ }
171
+
172
+ const parentRect = parent.getBoundingClientRect();
173
+ const scrollLeft = iframeDoc.defaultView?.scrollX || 0;
174
+ const scrollTop = iframeDoc.defaultView?.scrollY || 0;
175
+
176
+ if (children.length === 0 || targetIndex === 0) {
177
+ // Insert at beginning or empty container
178
+ if (isColumn) {
179
+ indicator.style.left = `${parentRect.left + scrollLeft}px`;
180
+ indicator.style.top = `${parentRect.top + scrollTop}px`;
181
+ indicator.style.width = `${parentRect.width}px`;
182
+ indicator.style.height = '3px';
183
+ } else {
184
+ indicator.style.left = `${parentRect.left + scrollLeft}px`;
185
+ indicator.style.top = `${parentRect.top + scrollTop}px`;
186
+ indicator.style.width = '3px';
187
+ indicator.style.height = `${parentRect.height}px`;
188
+ }
189
+ } else if (targetIndex >= children.length) {
190
+ // Insert at end
191
+ const lastChild = children[children.length - 1];
192
+ const lastRect = lastChild.getBoundingClientRect();
193
+
194
+ if (isColumn) {
195
+ indicator.style.left = `${parentRect.left + scrollLeft}px`;
196
+ indicator.style.top = `${lastRect.bottom + gap / 2 + scrollTop}px`;
197
+ indicator.style.width = `${parentRect.width}px`;
198
+ indicator.style.height = '3px';
199
+ } else {
200
+ indicator.style.left = `${lastRect.right + gap / 2 + scrollLeft}px`;
201
+ indicator.style.top = `${parentRect.top + scrollTop}px`;
202
+ indicator.style.width = '3px';
203
+ indicator.style.height = `${parentRect.height}px`;
204
+ }
205
+ } else {
206
+ // Insert between elements
207
+ const targetChild = children[targetIndex];
208
+ const targetRect = targetChild.getBoundingClientRect();
209
+
210
+ if (isColumn) {
211
+ indicator.style.left = `${parentRect.left + scrollLeft}px`;
212
+ indicator.style.top = `${targetRect.top - gap / 2 + scrollTop}px`;
213
+ indicator.style.width = `${parentRect.width}px`;
214
+ indicator.style.height = '3px';
215
+ } else {
216
+ indicator.style.left = `${targetRect.left - gap / 2 + scrollLeft}px`;
217
+ indicator.style.top = `${parentRect.top + scrollTop}px`;
218
+ indicator.style.width = '3px';
219
+ indicator.style.height = `${parentRect.height}px`;
220
+ }
221
+ }
222
+
223
+ iframeDoc.body.appendChild(indicator);
224
+ }
225
+
226
+ /**
227
+ * Hide and remove the drop indicator
228
+ */
229
+ export function hideFlexDropIndicator(iframeDoc: Document): void {
230
+ const indicators = iframeDoc.querySelectorAll('.flex-drop-indicator');
231
+ indicators.forEach(indicator => indicator.remove());
232
+ }
233
+
234
+ /**
235
+ * Perform the actual DOM reordering
236
+ */
237
+ export function reorderFlexChild(
238
+ parent: HTMLElement,
239
+ element: HTMLElement,
240
+ targetIndex: number
241
+ ): boolean {
242
+ // Safety check: element must be a child of parent
243
+ if (element.parentElement !== parent) {
244
+ console.warn('[flex-utils] reorderFlexChild: element is not a child of the specified parent');
245
+ return false;
246
+ }
247
+
248
+ // Get child elements excluding UI elements
249
+ const children = Array.from(parent.children).filter(child => {
250
+ const el = child as HTMLElement;
251
+ return !el.classList.contains('selection-box') &&
252
+ !el.classList.contains('resize-handle') &&
253
+ !el.classList.contains('flex-drop-indicator') &&
254
+ !el.classList.contains('drag-ghost') &&
255
+ !el.classList.contains('nesting-drop-indicator') &&
256
+ el.tagName !== 'SCRIPT' &&
257
+ el.tagName !== 'STYLE';
258
+ }) as HTMLElement[];
259
+
260
+ const currentIndex = children.indexOf(element);
261
+
262
+ if (currentIndex === -1) {
263
+ console.warn('[flex-utils] reorderFlexChild: element not found in valid children');
264
+ return false;
265
+ }
266
+
267
+ if (currentIndex === targetIndex) {
268
+ return false; // No change needed - element is already at target position
269
+ }
270
+
271
+ // Clamp target index to valid range
272
+ const clampedTargetIndex = Math.max(0, Math.min(targetIndex, children.length - 1));
273
+
274
+ if (currentIndex === clampedTargetIndex) {
275
+ return false; // No change needed after clamping
276
+ }
277
+
278
+ if (clampedTargetIndex >= children.length - 1 && currentIndex < children.length - 1) {
279
+ // Move to end (but not if already at end)
280
+ parent.appendChild(element);
281
+ } else {
282
+ // Insert at target position
283
+ const targetElement = children[clampedTargetIndex];
284
+
285
+ // Safety check: don't insert element before/after itself
286
+ if (targetElement === element) {
287
+ return false;
288
+ }
289
+
290
+ if (currentIndex < clampedTargetIndex) {
291
+ // Moving forward: insert after target
292
+ if (targetElement.nextSibling !== element) {
293
+ targetElement.insertAdjacentElement('afterend', element);
294
+ } else {
295
+ return false; // Already in correct position
296
+ }
297
+ } else {
298
+ // Moving backward: insert before target
299
+ if (parent.children[clampedTargetIndex] !== element) {
300
+ parent.insertBefore(element, targetElement);
301
+ } else {
302
+ return false; // Already in correct position
303
+ }
304
+ }
305
+ }
306
+
307
+ return true;
308
+ }
309
+
310
+ // ============================================================
311
+ // Enhanced Auto-Layout Drag Utilities (Ghost Clone Approach)
312
+ // ============================================================
313
+
314
+ /**
315
+ * Get valid children of a container (excluding UI elements and placeholders)
316
+ */
317
+ export function getValidChildren(parent: HTMLElement): HTMLElement[] {
318
+ return Array.from(parent.children).filter(child => {
319
+ const el = child as HTMLElement;
320
+ return !el.classList.contains('selection-box') &&
321
+ !el.classList.contains('resize-handle') &&
322
+ !el.classList.contains('flex-drop-indicator') &&
323
+ !el.classList.contains('drag-ghost') &&
324
+ !el.classList.contains('auto-layout-drop-indicator') &&
325
+ !el.classList.contains('drag-placeholder') &&
326
+ !el.classList.contains('drop-placeholder') &&
327
+ !el.dataset.dragUi &&
328
+ el.tagName !== 'SCRIPT' &&
329
+ el.tagName !== 'STYLE';
330
+ }) as HTMLElement[];
331
+ }
332
+
333
+ /**
334
+ * Drag state for auto-layout
335
+ */
336
+ export interface AutoLayoutDragState {
337
+ element: HTMLElement;
338
+ ghost: HTMLElement;
339
+ originalRect: DOMRect;
340
+ originalParent: HTMLElement;
341
+ originalIndex: number;
342
+ currentDropIndex: number;
343
+ /** Canvas zoom scale (1 = 100%) */
344
+ scale: number;
345
+ }
346
+
347
+ /**
348
+ * Create a clean clone of element for drag ghost
349
+ */
350
+ function createCleanGhost(element: HTMLElement, iframeDoc: Document): HTMLElement {
351
+ // Clone the element
352
+ const ghost = element.cloneNode(true) as HTMLElement;
353
+
354
+ // Remove all UI-related elements from the clone
355
+ const uiSelectors = [
356
+ '.selection-box',
357
+ '.resize-handle',
358
+ '.flex-drop-indicator',
359
+ '.drag-ghost',
360
+ '.auto-layout-drop-indicator',
361
+ '[data-drag-ui]',
362
+ ];
363
+ uiSelectors.forEach(selector => {
364
+ ghost.querySelectorAll(selector).forEach(el => el.remove());
365
+ });
366
+
367
+ // Remove any data attributes that might cause issues
368
+ ghost.removeAttribute('data-selected');
369
+ ghost.classList.remove('selected');
370
+
371
+ return ghost;
372
+ }
373
+
374
+ /**
375
+ * Start auto-layout drag - creates ghost clone and dims original element
376
+ * @param element The element being dragged
377
+ * @param iframeDoc The iframe document
378
+ * @param scale Canvas zoom scale (1 = 100%, 1.5 = 150%, etc.)
379
+ */
380
+ export function startAutoLayoutDrag(
381
+ element: HTMLElement,
382
+ iframeDoc: Document,
383
+ scale: number = 1
384
+ ): AutoLayoutDragState {
385
+ const originalRect = element.getBoundingClientRect();
386
+ const originalParent = element.parentElement!;
387
+
388
+ // Get original index among siblings
389
+ const siblings = getValidChildren(originalParent);
390
+ const originalIndex = siblings.indexOf(element);
391
+
392
+ // Create a clean ghost clone (preserves original classes)
393
+ const ghost = createCleanGhost(element, iframeDoc);
394
+ // Add drag-ghost class while preserving original classes for styling
395
+ ghost.classList.add('drag-ghost');
396
+
397
+ // When canvas is zoomed, getBoundingClientRect returns screen coords (post-scale).
398
+ // We need to set ghost to unscaled size and apply scale transform so internal
399
+ // content (fonts, etc.) scales correctly to match the zoomed canvas appearance.
400
+ const unscaledWidth = originalRect.width / scale;
401
+ const unscaledHeight = originalRect.height / scale;
402
+
403
+ // Apply positioning/layering styles with zoom compensation
404
+ // Use transform-origin: 0 0 so scaling happens from top-left corner (matches position)
405
+ ghost.style.position = 'fixed';
406
+ ghost.style.left = `${originalRect.left}px`;
407
+ ghost.style.top = `${originalRect.top}px`;
408
+ ghost.style.width = `${unscaledWidth}px`;
409
+ ghost.style.height = `${unscaledHeight}px`;
410
+ ghost.style.pointerEvents = 'none';
411
+ ghost.style.zIndex = '100000';
412
+ ghost.style.opacity = '0.9';
413
+ ghost.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)';
414
+ ghost.style.transformOrigin = '0 0';
415
+ ghost.style.transform = `scale(${scale})`;
416
+ ghost.style.margin = '0';
417
+
418
+ iframeDoc.body.appendChild(ghost);
419
+
420
+ // Subtle pickup animation (ghost only) - maintain scale while animating
421
+ requestAnimationFrame(() => {
422
+ ghost.style.transition = 'transform 0.15s ease-out, box-shadow 0.15s ease-out';
423
+ ghost.style.transform = `scale(${scale * 1.01})`;
424
+ ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)';
425
+ });
426
+
427
+ // Dim the original element (minimal styles - NO transform/transition to avoid affecting absolute mode)
428
+ element.style.opacity = '0.4';
429
+ element.dataset.dragging = 'true';
430
+
431
+ return {
432
+ element,
433
+ ghost,
434
+ originalRect,
435
+ originalParent,
436
+ originalIndex,
437
+ currentDropIndex: originalIndex,
438
+ scale,
439
+ };
440
+ }
441
+
442
+ // Store previous position (kept for potential future use)
443
+ let prevDragX = 0;
444
+ let prevDragY = 0;
445
+
446
+ /**
447
+ * Update ghost position to follow cursor (subtle movement, no rotation)
448
+ */
449
+ export function updateDragPosition(
450
+ state: AutoLayoutDragState,
451
+ cursorX: number,
452
+ cursorY: number
453
+ ): void {
454
+ const { ghost, originalRect, scale } = state;
455
+
456
+ // The ghost's visual size on screen is originalRect.width/height (after scaling)
457
+ // Center the ghost on cursor using screen dimensions
458
+ const targetLeft = cursorX - originalRect.width / 2;
459
+ const targetTop = cursorY - originalRect.height / 2;
460
+
461
+ ghost.style.left = `${targetLeft}px`;
462
+ ghost.style.top = `${targetTop}px`;
463
+ // Keep the subtle scale applied with zoom compensation
464
+ ghost.style.transform = `scale(${scale * 1.01})`;
465
+
466
+ // Store current position for next frame (kept for potential future use)
467
+ prevDragX = cursorX;
468
+ prevDragY = cursorY;
469
+ }
470
+
471
+ /**
472
+ * End auto-layout drag - move element to new position with animation
473
+ */
474
+ export function endAutoLayoutDrag(
475
+ state: AutoLayoutDragState,
476
+ dropInfo: DropTargetInfo | null,
477
+ iframeDoc: Document
478
+ ): boolean {
479
+ const { element, ghost, originalParent, originalIndex, scale } = state;
480
+
481
+ // Debug logging
482
+ console.log('[endAutoLayoutDrag] Starting:', {
483
+ elementTag: element?.tagName,
484
+ elementId: element?.id,
485
+ elementClass: element?.className,
486
+ hasParent: !!element?.parentElement,
487
+ parentTag: element?.parentElement?.tagName,
488
+ parentId: element?.parentElement?.id,
489
+ originalParentTag: originalParent?.tagName,
490
+ originalParentId: originalParent?.id,
491
+ originalIndex,
492
+ dropInfo: dropInfo ? {
493
+ containerTag: dropInfo.container?.tagName,
494
+ containerId: dropInfo.container?.id,
495
+ position: dropInfo.position,
496
+ index: dropInfo.index,
497
+ } : null,
498
+ });
499
+
500
+ // Remove drop indicators
501
+ hideFlexDropIndicator(iframeDoc);
502
+ hideNestingIndicator(iframeDoc);
503
+
504
+ // Reset position tracking
505
+ prevDragX = 0;
506
+ prevDragY = 0;
507
+
508
+ // Subtle fade animation for ghost - maintain zoom scale
509
+ ghost.style.transition = 'transform 0.15s ease-out, opacity 0.15s ease-out';
510
+ ghost.style.transform = `scale(${scale})`;
511
+ ghost.style.opacity = '0.5';
512
+
513
+ // Restore original element immediately (NO transition/transform - keep it simple to avoid affecting absolute mode)
514
+ element.style.opacity = '';
515
+ delete element.dataset.dragging;
516
+
517
+ let moved = false;
518
+
519
+ // Get the current parent of the element (should match originalParent in most cases)
520
+ const currentParent = element.parentElement;
521
+
522
+ console.log('[endAutoLayoutDrag] Before DOM operation:', {
523
+ elementInDOM: element.isConnected,
524
+ currentParentTag: currentParent?.tagName,
525
+ currentParentId: currentParent?.id,
526
+ });
527
+
528
+ // Get artboard for validation
529
+ const artboard = iframeDoc.querySelector('#artboard, .slide-artboard, #slide-artboard, [data-artboard]') as HTMLElement;
530
+
531
+ // Determine if dropInfo points to the same parent as the original
532
+ // Use multiple comparison methods for robustness
533
+ const isSameParent = dropInfo && dropInfo.container && (
534
+ dropInfo.container === originalParent ||
535
+ dropInfo.container === currentParent ||
536
+ (dropInfo.container && originalParent && dropInfo.container.isSameNode(originalParent)) ||
537
+ // Also compare by ID if both have IDs
538
+ (dropInfo.container.id && originalParent?.id && dropInfo.container.id === originalParent.id)
539
+ );
540
+
541
+ // Check if drop container is valid (inside artboard or is artboard)
542
+ const isValidDropLocation = dropInfo && dropInfo.container && (
543
+ dropInfo.container === artboard ||
544
+ (artboard && artboard.contains(dropInfo.container)) ||
545
+ dropInfo.container.id === 'artboard'
546
+ );
547
+
548
+ console.log('[endAutoLayoutDrag] isSameParent:', isSameParent, 'isValidDropLocation:', isValidDropLocation);
549
+
550
+ if (!isValidDropLocation && dropInfo) {
551
+ // Drop location is outside artboard - keep element in original position
552
+ console.warn('[endAutoLayoutDrag] Invalid drop location (outside artboard), keeping element in place');
553
+ // Don't move, element stays where it is
554
+ } else if (dropInfo && isSameParent && originalParent) {
555
+ // Same parent - reorder within the same container
556
+ const targetIndex = dropInfo.index;
557
+ console.log('[endAutoLayoutDrag] Same parent reorder:', { targetIndex, originalIndex });
558
+ if (targetIndex !== originalIndex) {
559
+ moved = reorderFlexChild(originalParent, element, targetIndex);
560
+ console.log('[endAutoLayoutDrag] reorderFlexChild result:', moved);
561
+ }
562
+ // If targetIndex === originalIndex, no move needed (element stays in place)
563
+ } else if (dropInfo && dropInfo.container && isValidDropLocation) {
564
+ // Different parent but valid location - move to new container
565
+ // Safety check: don't move into self or descendants
566
+ console.log('[endAutoLayoutDrag] Different parent move:', {
567
+ containerTag: dropInfo.container.tagName,
568
+ containerId: dropInfo.container.id,
569
+ isElementSelf: dropInfo.container === element,
570
+ isDescendant: element.contains(dropInfo.container),
571
+ });
572
+ if (dropInfo.container !== element && !element.contains(dropInfo.container)) {
573
+ moved = moveElementToContainer(element, dropInfo);
574
+ console.log('[endAutoLayoutDrag] moveElementToContainer result:', moved);
575
+ } else {
576
+ console.warn('[flex-utils] Prevented moving element into itself or descendant');
577
+ }
578
+ } else {
579
+ console.log('[endAutoLayoutDrag] No dropInfo or container, element stays in place');
580
+ }
581
+
582
+ console.log('[endAutoLayoutDrag] After DOM operation:', {
583
+ elementInDOM: element.isConnected,
584
+ hasParent: !!element.parentElement,
585
+ parentTag: element.parentElement?.tagName,
586
+ parentId: element.parentElement?.id,
587
+ });
588
+
589
+ // Safety check: Ensure element still has a valid parent
590
+ // If element was orphaned somehow, re-attach it to the original parent or artboard
591
+ if (!element.parentElement) {
592
+ console.warn('[flex-utils] Element was orphaned during drag, re-attaching to original parent');
593
+ if (originalParent && originalParent.isConnected) {
594
+ originalParent.appendChild(element);
595
+ moved = true; // Consider this a move since we're re-attaching
596
+ } else if (currentParent && currentParent.isConnected) {
597
+ currentParent.appendChild(element);
598
+ moved = true;
599
+ } else {
600
+ // Fallback to artboard
601
+ const artboard = iframeDoc.querySelector('#artboard, .slide-artboard, #slide-artboard, [data-artboard]') as HTMLElement;
602
+ if (artboard) {
603
+ artboard.appendChild(element);
604
+ moved = true;
605
+ } else {
606
+ console.error('[flex-utils] Could not find a valid parent for orphaned element');
607
+ }
608
+ }
609
+ }
610
+
611
+ // Remove ghost after animation completes
612
+ setTimeout(() => {
613
+ ghost.style.opacity = '0';
614
+ ghost.style.transform = 'scale(0.95)';
615
+ setTimeout(() => ghost.remove(), 150);
616
+ }, 50);
617
+
618
+ return moved;
619
+ }
620
+
621
+ /**
622
+ * Cancel auto-layout drag - restore everything with animation
623
+ */
624
+ export function cancelAutoLayoutDrag(
625
+ state: AutoLayoutDragState,
626
+ iframeDoc: Document
627
+ ): void {
628
+ const { element, ghost, originalRect, scale } = state;
629
+
630
+ // Reset position tracking
631
+ prevDragX = 0;
632
+ prevDragY = 0;
633
+
634
+ // Remove drop indicators
635
+ hideFlexDropIndicator(iframeDoc);
636
+ hideNestingIndicator(iframeDoc);
637
+
638
+ // Animate ghost back to original position with subtle transition - maintain zoom scale
639
+ ghost.style.transition = 'all 0.2s ease-out';
640
+ ghost.style.left = `${originalRect.left}px`;
641
+ ghost.style.top = `${originalRect.top}px`;
642
+ ghost.style.transform = `scale(${scale})`;
643
+ ghost.style.opacity = '0.5';
644
+
645
+ // Restore original element immediately (NO transition/transform)
646
+ element.style.opacity = '';
647
+ delete element.dataset.dragging;
648
+
649
+ // Remove ghost after animation
650
+ setTimeout(() => {
651
+ ghost.style.opacity = '0';
652
+ setTimeout(() => ghost.remove(), 150);
653
+ }, 200);
654
+ }
655
+
656
+ /**
657
+ * Check if element is inside a drag ghost
658
+ */
659
+ function isInsideDragGhost(element: HTMLElement): boolean {
660
+ let ancestor = element.parentElement;
661
+ while (ancestor) {
662
+ if (ancestor.classList.contains('drag-ghost')) {
663
+ return true;
664
+ }
665
+ ancestor = ancestor.parentElement;
666
+ }
667
+ return false;
668
+ }
669
+
670
+ /**
671
+ * Check if an element is a valid drop container
672
+ * Supports flex, grid, and block-level containers
673
+ */
674
+ export function isValidDropContainer(element: HTMLElement, iframeDoc?: Document): boolean {
675
+ const tagName = element.tagName.toLowerCase();
676
+
677
+ // Skip if it's a UI element
678
+ if (element.classList.contains('selection-box') ||
679
+ element.classList.contains('resize-handle') ||
680
+ element.classList.contains('drag-ghost') ||
681
+ element.classList.contains('flex-drop-indicator') ||
682
+ element.classList.contains('nesting-drop-indicator') ||
683
+ element.dataset.dragUi) {
684
+ return false;
685
+ }
686
+
687
+ // Skip if element is inside a drag ghost
688
+ if (isInsideDragGhost(element)) {
689
+ return false;
690
+ }
691
+
692
+ // Skip non-element tags
693
+ if (tagName === 'script' || tagName === 'style' || tagName === 'link' ||
694
+ tagName === 'br' || tagName === 'hr' || tagName === 'img' ||
695
+ tagName === 'input' || tagName === 'textarea' || tagName === 'select' ||
696
+ tagName === 'svg' || tagName === 'canvas' || tagName === 'video' || tagName === 'audio') {
697
+ return false;
698
+ }
699
+
700
+ // Always allow these container tags
701
+ const containerTags = [
702
+ 'div', 'section', 'article', 'main', 'aside', 'header', 'footer',
703
+ 'nav', 'ul', 'ol', 'li', 'figure', 'figcaption', 'form', 'fieldset',
704
+ 'table', 'tbody', 'thead', 'tfoot', 'tr', 'td', 'th',
705
+ 'details', 'summary', 'dialog', 'address', 'blockquote', 'pre', 'code'
706
+ ];
707
+
708
+ if (containerTags.includes(tagName)) {
709
+ return true;
710
+ }
711
+
712
+ // Check if element is a layout container (flex, grid, or block with children)
713
+ const win = element.ownerDocument?.defaultView || iframeDoc?.defaultView;
714
+ if (win) {
715
+ const style = win.getComputedStyle(element);
716
+ const display = style.display;
717
+
718
+ // Flex and grid containers are always valid drop targets
719
+ if (display === 'flex' || display === 'inline-flex' ||
720
+ display === 'grid' || display === 'inline-grid') {
721
+ return true;
722
+ }
723
+
724
+ // Block-level elements that can contain children
725
+ if (display === 'block' || display === 'inline-block' || display === 'flow-root') {
726
+ return true;
727
+ }
728
+ }
729
+
730
+ return false;
731
+ }
732
+
733
+ /**
734
+ * Drop target information
735
+ */
736
+ export interface DropTargetInfo {
737
+ container: HTMLElement;
738
+ position: 'before' | 'after' | 'inside';
739
+ index: number;
740
+ referenceElement: HTMLElement | null;
741
+ /** True if this is a nesting operation (moving into a container) */
742
+ isNesting?: boolean;
743
+ }
744
+
745
+ /**
746
+ * Find the drop target at cursor position
747
+ * Prioritizes sibling reordering over nesting
748
+ */
749
+ export function findDropTarget(
750
+ iframeDoc: Document,
751
+ cursorX: number,
752
+ cursorY: number,
753
+ draggedElement: HTMLElement,
754
+ _unused?: HTMLElement // kept for backward compatibility
755
+ ): DropTargetInfo | null {
756
+ const elementsAtPoint = iframeDoc.elementsFromPoint(cursorX, cursorY);
757
+ const draggedParent = draggedElement.parentElement;
758
+
759
+ // First pass: Look for sibling reordering in the same parent (highest priority)
760
+ for (const el of elementsAtPoint) {
761
+ const element = el as HTMLElement;
762
+
763
+ // Skip invalid elements
764
+ if (isSkippableElement(element, draggedElement)) {
765
+ continue;
766
+ }
767
+
768
+ const parent = element.parentElement;
769
+
770
+ // Prioritize same-parent reordering
771
+ if (parent === draggedParent && parent) {
772
+ const result = calculateSiblingDropPosition(
773
+ element,
774
+ parent,
775
+ cursorX,
776
+ cursorY,
777
+ draggedElement,
778
+ iframeDoc
779
+ );
780
+ if (result) {
781
+ return result;
782
+ }
783
+ }
784
+ }
785
+
786
+ // Second pass: Look for any valid drop target
787
+ for (const el of elementsAtPoint) {
788
+ const element = el as HTMLElement;
789
+
790
+ // Skip invalid elements
791
+ if (isSkippableElement(element, draggedElement)) {
792
+ continue;
793
+ }
794
+
795
+ const parent = element.parentElement;
796
+ if (!parent || !isValidDropContainer(parent, iframeDoc)) {
797
+ continue;
798
+ }
799
+
800
+ // Get valid children (excluding dragged element)
801
+ const children = getValidChildren(parent).filter(c => c !== draggedElement);
802
+ const elementIndex = children.indexOf(element);
803
+
804
+ if (elementIndex === -1) {
805
+ // Element is not a direct child - check for nesting into empty container
806
+ // Only allow nesting if cursor is very centered in the element
807
+ if (isValidDropContainer(element, iframeDoc) && shouldAllowNesting(element, cursorX, cursorY, draggedElement)) {
808
+ const childrenOfElement = getValidChildren(element).filter(c => c !== draggedElement);
809
+ if (childrenOfElement.length === 0) {
810
+ return {
811
+ container: element,
812
+ position: 'inside',
813
+ index: 0,
814
+ referenceElement: null,
815
+ isNesting: true, // Mark as nesting operation
816
+ } as DropTargetInfo;
817
+ }
818
+ }
819
+ continue;
820
+ }
821
+
822
+ // Calculate sibling drop position
823
+ const result = calculateSiblingDropPosition(
824
+ element,
825
+ parent,
826
+ cursorX,
827
+ cursorY,
828
+ draggedElement,
829
+ iframeDoc
830
+ );
831
+ if (result) {
832
+ return result;
833
+ }
834
+ }
835
+
836
+ // Ultimate fallback: keep element in its original parent with its original position
837
+ // This is now the first fallback to preserve the element's position when dropped outside valid targets
838
+ const originalParent = draggedElement.parentElement;
839
+ if (originalParent) {
840
+ const allChildren = Array.from(originalParent.children);
841
+ const currentIndex = allChildren.indexOf(draggedElement);
842
+ // Return the original parent with the current index to preserve position
843
+ return {
844
+ container: originalParent,
845
+ position: 'inside',
846
+ index: currentIndex >= 0 ? currentIndex : 0,
847
+ referenceElement: null,
848
+ };
849
+ }
850
+
851
+ // Secondary fallback to artboard (only if element has no parent)
852
+ const artboard = iframeDoc.querySelector('#artboard, .slide-artboard, #slide-artboard, [data-artboard]') as HTMLElement;
853
+ if (artboard && artboard !== draggedElement && !draggedElement.contains(artboard)) {
854
+ const children = getValidChildren(artboard).filter(c => c !== draggedElement);
855
+ return {
856
+ container: artboard,
857
+ position: 'inside',
858
+ index: children.length,
859
+ referenceElement: null,
860
+ };
861
+ }
862
+
863
+ return null;
864
+ }
865
+
866
+ /**
867
+ * Check if element should be skipped during drop target search
868
+ */
869
+ function isSkippableElement(element: HTMLElement, draggedElement: HTMLElement): boolean {
870
+ // Skip dragged element and its descendants
871
+ if (element === draggedElement || draggedElement.contains(element)) {
872
+ return true;
873
+ }
874
+
875
+ // Skip UI elements
876
+ if (element.classList.contains('selection-box') ||
877
+ element.classList.contains('resize-handle') ||
878
+ element.classList.contains('flex-drop-indicator') ||
879
+ element.classList.contains('drag-ghost') ||
880
+ element.classList.contains('nesting-drop-indicator') ||
881
+ element.classList.contains('auto-layout-drop-indicator') ||
882
+ element.dataset.dragging === 'true' ||
883
+ element.dataset.dragUi === 'true') {
884
+ return true;
885
+ }
886
+
887
+ // Skip elements inside drag ghost (ghost clone elements)
888
+ // The ghost has class 'drag-ghost', but its children don't
889
+ // So we need to check if any ancestor has the drag-ghost class
890
+ let ancestor = element.parentElement;
891
+ while (ancestor) {
892
+ if (ancestor.classList.contains('drag-ghost')) {
893
+ return true;
894
+ }
895
+ ancestor = ancestor.parentElement;
896
+ }
897
+
898
+ return false;
899
+ }
900
+
901
+ /**
902
+ * Check if nesting should be allowed based on cursor position
903
+ * Only allows nesting when cursor is very centered in the element
904
+ */
905
+ function shouldAllowNesting(
906
+ element: HTMLElement,
907
+ cursorX: number,
908
+ cursorY: number,
909
+ draggedElement: HTMLElement
910
+ ): boolean {
911
+ const rect = element.getBoundingClientRect();
912
+
913
+ // Require cursor to be at least 40% into the element from all edges
914
+ const minInset = 0.4;
915
+ const insetX = rect.width * minInset;
916
+ const insetY = rect.height * minInset;
917
+
918
+ const isWellInside =
919
+ cursorX > rect.left + insetX &&
920
+ cursorX < rect.right - insetX &&
921
+ cursorY > rect.top + insetY &&
922
+ cursorY < rect.bottom - insetY;
923
+
924
+ // Also check minimum size - don't nest into small elements
925
+ const minSize = 80;
926
+ const isLargeEnough = rect.width >= minSize && rect.height >= minSize;
927
+
928
+ // Don't nest into elements that are similar size to dragged element
929
+ const draggedRect = draggedElement.getBoundingClientRect();
930
+ const sizeDifferenceThreshold = 1.5;
931
+ const isSignificantlyLarger =
932
+ rect.width > draggedRect.width * sizeDifferenceThreshold ||
933
+ rect.height > draggedRect.height * sizeDifferenceThreshold;
934
+
935
+ return isWellInside && isLargeEnough && isSignificantlyLarger;
936
+ }
937
+
938
+ /**
939
+ * Determine if a container's layout direction is column-like
940
+ * Uses multiple detection methods for robustness:
941
+ * 1. Check flex-direction for flex containers
942
+ * 2. Check grid-auto-flow for grid containers
943
+ * 3. Check actual child positions as fallback
944
+ */
945
+ function isColumnLayout(parent: HTMLElement, iframeDoc: Document): { isColumn: boolean; isReverse: boolean } {
946
+ // Try to get computed style - use parent's ownerDocument for reliability
947
+ const win = parent.ownerDocument?.defaultView || iframeDoc.defaultView;
948
+ const style = win?.getComputedStyle(parent);
949
+
950
+ if (style) {
951
+ const display = style.display;
952
+
953
+ // Flex container
954
+ if (display === 'flex' || display === 'inline-flex') {
955
+ const flexDirection = style.flexDirection || 'row';
956
+ const isColumn = flexDirection === 'column' || flexDirection === 'column-reverse';
957
+ const isReverse = flexDirection.includes('reverse');
958
+ return { isColumn, isReverse };
959
+ }
960
+
961
+ // Grid container
962
+ if (display === 'grid' || display === 'inline-grid') {
963
+ // Check grid-auto-flow to determine primary direction
964
+ const gridAutoFlow = style.gridAutoFlow || 'row';
965
+ // Also check grid-template-columns/rows to infer layout
966
+ const templateColumns = style.gridTemplateColumns || '';
967
+ const templateRows = style.gridTemplateRows || '';
968
+
969
+ // If only one column is defined, it's effectively a column layout
970
+ const columnCount = templateColumns.split(/\s+/).filter(c => c && c !== 'none').length;
971
+ const rowCount = templateRows.split(/\s+/).filter(r => r && r !== 'none').length;
972
+
973
+ // grid-auto-flow: column means items flow in columns first
974
+ if (gridAutoFlow.includes('column')) {
975
+ return { isColumn: false, isReverse: false };
976
+ }
977
+
978
+ // If there's only 1 column defined, items stack vertically
979
+ if (columnCount === 1 || (columnCount === 0 && rowCount > 1)) {
980
+ return { isColumn: true, isReverse: false };
981
+ }
982
+
983
+ // Default for grid: items flow horizontally first (row)
984
+ return { isColumn: false, isReverse: false };
985
+ }
986
+ }
987
+
988
+ // For non-flex/grid containers (block, inline-block, etc.) or when style is unavailable,
989
+ // detect layout direction from actual child positions
990
+ const children = getValidChildren(parent);
991
+ if (children.length >= 2) {
992
+ const first = children[0].getBoundingClientRect();
993
+ const second = children[1].getBoundingClientRect();
994
+
995
+ // If children have similar X positions but different Y, it's column layout
996
+ // If children have similar Y positions but different X, it's row layout
997
+ const xDiff = Math.abs(first.left - second.left);
998
+ const yDiff = Math.abs(first.top - second.top);
999
+
1000
+ // Use a threshold to determine which axis has more variance
1001
+ if (yDiff > xDiff + 10) {
1002
+ // Children are stacked vertically → column layout
1003
+ return { isColumn: true, isReverse: first.top > second.top };
1004
+ } else if (xDiff > yDiff + 10) {
1005
+ // Children are side by side → row layout
1006
+ return { isColumn: false, isReverse: first.left > second.left };
1007
+ }
1008
+ }
1009
+
1010
+ // Default: block elements stack vertically
1011
+ return { isColumn: true, isReverse: false };
1012
+ }
1013
+
1014
+ /**
1015
+ * Calculate drop position for sibling reordering
1016
+ */
1017
+ function calculateSiblingDropPosition(
1018
+ element: HTMLElement,
1019
+ parent: HTMLElement,
1020
+ cursorX: number,
1021
+ cursorY: number,
1022
+ draggedElement: HTMLElement,
1023
+ iframeDoc: Document
1024
+ ): DropTargetInfo | null {
1025
+ const children = getValidChildren(parent).filter(c => c !== draggedElement);
1026
+ const elementIndex = children.indexOf(element);
1027
+
1028
+ if (elementIndex === -1) {
1029
+ return null;
1030
+ }
1031
+
1032
+ const rect = element.getBoundingClientRect();
1033
+ const { isColumn, isReverse } = isColumnLayout(parent, iframeDoc);
1034
+
1035
+ // Use edge detection - check if cursor is in the first or second half
1036
+ let position: 'before' | 'after';
1037
+
1038
+ if (isColumn) {
1039
+ const midY = rect.top + rect.height / 2;
1040
+ position = cursorY < midY ? 'before' : 'after';
1041
+ } else {
1042
+ const midX = rect.left + rect.width / 2;
1043
+ position = cursorX < midX ? 'before' : 'after';
1044
+ }
1045
+
1046
+ // Adjust for reverse direction
1047
+ if (isReverse) {
1048
+ position = position === 'before' ? 'after' : 'before';
1049
+ }
1050
+
1051
+ // Calculate the actual drop index
1052
+ let index = elementIndex;
1053
+ if (position === 'after') {
1054
+ index = elementIndex + 1;
1055
+ }
1056
+
1057
+ return {
1058
+ container: parent,
1059
+ position,
1060
+ index,
1061
+ referenceElement: element,
1062
+ };
1063
+ }
1064
+
1065
+ /**
1066
+ * Show drop indicator at target position
1067
+ * Shows a line for sibling reordering, or a border for nesting
1068
+ */
1069
+ export function showDropIndicator(
1070
+ iframeDoc: Document,
1071
+ dropInfo: DropTargetInfo,
1072
+ draggedElement: HTMLElement
1073
+ ): void {
1074
+ // If nesting, show a container highlight instead of a line
1075
+ if (dropInfo.isNesting || dropInfo.position === 'inside') {
1076
+ hideFlexDropIndicator(iframeDoc);
1077
+ showNestingIndicator(iframeDoc, dropInfo.container);
1078
+ } else {
1079
+ hideNestingIndicator(iframeDoc);
1080
+ showFlexDropIndicator(iframeDoc, dropInfo.container, dropInfo.index, draggedElement);
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Show nesting indicator (highlight the container)
1086
+ */
1087
+ function showNestingIndicator(iframeDoc: Document, container: HTMLElement): void {
1088
+ hideNestingIndicator(iframeDoc);
1089
+
1090
+ const rect = container.getBoundingClientRect();
1091
+ const scrollLeft = iframeDoc.defaultView?.scrollX || 0;
1092
+ const scrollTop = iframeDoc.defaultView?.scrollY || 0;
1093
+
1094
+ const indicator = iframeDoc.createElement('div');
1095
+ indicator.className = 'nesting-drop-indicator';
1096
+ indicator.dataset.dragUi = 'true';
1097
+ indicator.style.cssText = `
1098
+ position: absolute;
1099
+ left: ${rect.left + scrollLeft}px;
1100
+ top: ${rect.top + scrollTop}px;
1101
+ width: ${rect.width}px;
1102
+ height: ${rect.height}px;
1103
+ pointer-events: none;
1104
+ z-index: 9999;
1105
+ border: 2px dashed #10B981;
1106
+ border-radius: 8px;
1107
+ background: rgba(16, 185, 129, 0.05);
1108
+ box-sizing: border-box;
1109
+ animation: nestingPulse 2s ease-in-out infinite;
1110
+ `;
1111
+
1112
+ // Ensure animation styles exist
1113
+ if (!iframeDoc.getElementById('drop-indicator-styles')) {
1114
+ const styleSheet = iframeDoc.createElement('style');
1115
+ styleSheet.id = 'drop-indicator-styles';
1116
+ styleSheet.textContent = `
1117
+ @keyframes nestingPulse {
1118
+ 0%, 100% { opacity: 0.9; }
1119
+ 50% { opacity: 0.7; }
1120
+ }
1121
+ `;
1122
+ iframeDoc.head.appendChild(styleSheet);
1123
+ }
1124
+
1125
+ iframeDoc.body.appendChild(indicator);
1126
+ }
1127
+
1128
+ /**
1129
+ * Hide nesting indicator
1130
+ */
1131
+ function hideNestingIndicator(iframeDoc: Document): void {
1132
+ const indicators = iframeDoc.querySelectorAll('.nesting-drop-indicator');
1133
+ indicators.forEach(indicator => indicator.remove());
1134
+ }
1135
+
1136
+ /**
1137
+ * Move element to a different container
1138
+ */
1139
+ function moveElementToContainer(
1140
+ element: HTMLElement,
1141
+ dropInfo: DropTargetInfo
1142
+ ): boolean {
1143
+ const { container, position, index, referenceElement } = dropInfo;
1144
+
1145
+ console.log('[moveElementToContainer] Called:', {
1146
+ elementTag: element.tagName,
1147
+ elementId: element.id,
1148
+ containerTag: container?.tagName,
1149
+ containerId: container?.id,
1150
+ position,
1151
+ index,
1152
+ referenceElementTag: referenceElement?.tagName,
1153
+ });
1154
+
1155
+ // Safety check: don't move element into itself
1156
+ if (container === element || element.contains(container)) {
1157
+ console.warn('[flex-utils] Cannot move element into itself or its descendant');
1158
+ return false;
1159
+ }
1160
+
1161
+ // Check if element is already at the correct position
1162
+ if (element.parentElement === container) {
1163
+ const children = getValidChildren(container).filter(c => c !== element);
1164
+ const currentIndex = Array.from(container.children).indexOf(element);
1165
+
1166
+ // If the element is already in the correct position, skip DOM operation
1167
+ if (position === 'inside' && (index >= children.length || currentIndex === index)) {
1168
+ return false;
1169
+ }
1170
+ }
1171
+
1172
+ if (position === 'inside' || !referenceElement) {
1173
+ // Insert at the end or beginning of empty container
1174
+ // Exclude the dragged element from children count
1175
+ const children = getValidChildren(container).filter(c => c !== element);
1176
+ if (index >= children.length) {
1177
+ container.appendChild(element);
1178
+ } else if (children[index] && children[index] !== element) {
1179
+ container.insertBefore(element, children[index]);
1180
+ } else {
1181
+ container.appendChild(element);
1182
+ }
1183
+ } else if (position === 'before') {
1184
+ // Don't insert before itself
1185
+ if (referenceElement !== element) {
1186
+ container.insertBefore(element, referenceElement);
1187
+ }
1188
+ } else {
1189
+ // position === 'after'
1190
+ // Don't insert after itself
1191
+ if (referenceElement !== element) {
1192
+ if (referenceElement.nextSibling && referenceElement.nextSibling !== element) {
1193
+ container.insertBefore(element, referenceElement.nextSibling);
1194
+ } else if (!referenceElement.nextSibling) {
1195
+ container.appendChild(element);
1196
+ }
1197
+ // If nextSibling === element, element is already in the correct position
1198
+ }
1199
+ }
1200
+
1201
+ return true;
1202
+ }
1203
+
1204
+ // ============================================================
1205
+ // Legacy/Compatibility Functions
1206
+ // ============================================================
1207
+
1208
+ export function showDropPlaceholder(): void {
1209
+ // No longer used - we use indicator line instead
1210
+ }
1211
+
1212
+ export function removeDropPlaceholder(iframeDoc: Document): void {
1213
+ const placeholders = iframeDoc.querySelectorAll('.drop-placeholder, .drag-placeholder');
1214
+ placeholders.forEach(p => p.remove());
1215
+ }
1216
+
1217
+ export function createDragGhost(): HTMLElement | null { return null; }
1218
+ export function updateDragGhostPosition(): void {}
1219
+ export function removeDragGhost(iframeDoc: Document): void {
1220
+ const ghosts = iframeDoc.querySelectorAll('.drag-ghost');
1221
+ ghosts.forEach(ghost => ghost.remove());
1222
+ }
1223
+ export function showAutoLayoutDropIndicator(): void {}
1224
+ export function hideAutoLayoutDropIndicator(iframeDoc: Document): void {
1225
+ const indicators = iframeDoc.querySelectorAll('.auto-layout-drop-indicator');
1226
+ indicators.forEach(indicator => indicator.remove());
1227
+ }
1228
+ export function moveElementToParent(element: HTMLElement, dropInfo: DropTargetInfo): boolean {
1229
+ return moveElementToContainer(element, dropInfo);
1230
+ }