snowbase-templates-installer 1.0.1

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 (181) hide show
  1. package/README.md +70 -0
  2. package/bin/cli.js +140 -0
  3. package/landing-pages/ai-saas-template/.orchids/orchids.json +8 -0
  4. package/landing-pages/ai-saas-template/README.md +36 -0
  5. package/landing-pages/ai-saas-template/bun.lock +2062 -0
  6. package/landing-pages/ai-saas-template/components.json +22 -0
  7. package/landing-pages/ai-saas-template/eslint.config.mjs +33 -0
  8. package/landing-pages/ai-saas-template/next.config.ts +24 -0
  9. package/landing-pages/ai-saas-template/package-lock.json +11708 -0
  10. package/landing-pages/ai-saas-template/package.json +114 -0
  11. package/landing-pages/ai-saas-template/postcss.config.mjs +7 -0
  12. package/landing-pages/ai-saas-template/public/file.svg +1 -0
  13. package/landing-pages/ai-saas-template/public/globe.svg +1 -0
  14. package/landing-pages/ai-saas-template/public/next.svg +1 -0
  15. package/landing-pages/ai-saas-template/public/vercel.svg +1 -0
  16. package/landing-pages/ai-saas-template/public/window.svg +1 -0
  17. package/landing-pages/ai-saas-template/src/app/favicon.ico +0 -0
  18. package/landing-pages/ai-saas-template/src/app/global-error.tsx +5 -0
  19. package/landing-pages/ai-saas-template/src/app/globals.css +172 -0
  20. package/landing-pages/ai-saas-template/src/app/layout.tsx +42 -0
  21. package/landing-pages/ai-saas-template/src/app/page.tsx +23 -0
  22. package/landing-pages/ai-saas-template/src/components/ErrorReporter.tsx +136 -0
  23. package/landing-pages/ai-saas-template/src/components/sections/cta.tsx +62 -0
  24. package/landing-pages/ai-saas-template/src/components/sections/features-grid.tsx +205 -0
  25. package/landing-pages/ai-saas-template/src/components/sections/footer.tsx +111 -0
  26. package/landing-pages/ai-saas-template/src/components/sections/hero.tsx +92 -0
  27. package/landing-pages/ai-saas-template/src/components/sections/logos.tsx +69 -0
  28. package/landing-pages/ai-saas-template/src/components/sections/navbar.tsx +83 -0
  29. package/landing-pages/ai-saas-template/src/components/sections/testimonials-header.tsx +41 -0
  30. package/landing-pages/ai-saas-template/src/components/sections/value-props.tsx +97 -0
  31. package/landing-pages/ai-saas-template/src/components/ui/accordion.tsx +66 -0
  32. package/landing-pages/ai-saas-template/src/components/ui/alert-dialog.tsx +157 -0
  33. package/landing-pages/ai-saas-template/src/components/ui/alert.tsx +66 -0
  34. package/landing-pages/ai-saas-template/src/components/ui/aspect-ratio.tsx +11 -0
  35. package/landing-pages/ai-saas-template/src/components/ui/avatar.tsx +53 -0
  36. package/landing-pages/ai-saas-template/src/components/ui/badge.tsx +46 -0
  37. package/landing-pages/ai-saas-template/src/components/ui/breadcrumb.tsx +109 -0
  38. package/landing-pages/ai-saas-template/src/components/ui/button-group.tsx +83 -0
  39. package/landing-pages/ai-saas-template/src/components/ui/button.tsx +59 -0
  40. package/landing-pages/ai-saas-template/src/components/ui/calendar.tsx +213 -0
  41. package/landing-pages/ai-saas-template/src/components/ui/card.tsx +92 -0
  42. package/landing-pages/ai-saas-template/src/components/ui/carousel.tsx +241 -0
  43. package/landing-pages/ai-saas-template/src/components/ui/chart.tsx +353 -0
  44. package/landing-pages/ai-saas-template/src/components/ui/checkbox.tsx +32 -0
  45. package/landing-pages/ai-saas-template/src/components/ui/collapsible.tsx +33 -0
  46. package/landing-pages/ai-saas-template/src/components/ui/command.tsx +184 -0
  47. package/landing-pages/ai-saas-template/src/components/ui/context-menu.tsx +252 -0
  48. package/landing-pages/ai-saas-template/src/components/ui/dialog.tsx +143 -0
  49. package/landing-pages/ai-saas-template/src/components/ui/drawer.tsx +135 -0
  50. package/landing-pages/ai-saas-template/src/components/ui/dropdown-menu.tsx +257 -0
  51. package/landing-pages/ai-saas-template/src/components/ui/empty.tsx +104 -0
  52. package/landing-pages/ai-saas-template/src/components/ui/field.tsx +248 -0
  53. package/landing-pages/ai-saas-template/src/components/ui/form.tsx +167 -0
  54. package/landing-pages/ai-saas-template/src/components/ui/hover-card.tsx +44 -0
  55. package/landing-pages/ai-saas-template/src/components/ui/input-group.tsx +170 -0
  56. package/landing-pages/ai-saas-template/src/components/ui/input-otp.tsx +77 -0
  57. package/landing-pages/ai-saas-template/src/components/ui/input.tsx +21 -0
  58. package/landing-pages/ai-saas-template/src/components/ui/item.tsx +193 -0
  59. package/landing-pages/ai-saas-template/src/components/ui/kbd.tsx +28 -0
  60. package/landing-pages/ai-saas-template/src/components/ui/label.tsx +24 -0
  61. package/landing-pages/ai-saas-template/src/components/ui/menubar.tsx +276 -0
  62. package/landing-pages/ai-saas-template/src/components/ui/navigation-menu.tsx +168 -0
  63. package/landing-pages/ai-saas-template/src/components/ui/pagination.tsx +127 -0
  64. package/landing-pages/ai-saas-template/src/components/ui/popover.tsx +48 -0
  65. package/landing-pages/ai-saas-template/src/components/ui/progress.tsx +31 -0
  66. package/landing-pages/ai-saas-template/src/components/ui/radio-group.tsx +45 -0
  67. package/landing-pages/ai-saas-template/src/components/ui/resizable.tsx +56 -0
  68. package/landing-pages/ai-saas-template/src/components/ui/scroll-area.tsx +58 -0
  69. package/landing-pages/ai-saas-template/src/components/ui/select.tsx +185 -0
  70. package/landing-pages/ai-saas-template/src/components/ui/separator.tsx +28 -0
  71. package/landing-pages/ai-saas-template/src/components/ui/sheet.tsx +139 -0
  72. package/landing-pages/ai-saas-template/src/components/ui/sidebar.tsx +726 -0
  73. package/landing-pages/ai-saas-template/src/components/ui/skeleton.tsx +13 -0
  74. package/landing-pages/ai-saas-template/src/components/ui/slider.tsx +63 -0
  75. package/landing-pages/ai-saas-template/src/components/ui/sonner.tsx +25 -0
  76. package/landing-pages/ai-saas-template/src/components/ui/spinner.tsx +16 -0
  77. package/landing-pages/ai-saas-template/src/components/ui/switch.tsx +31 -0
  78. package/landing-pages/ai-saas-template/src/components/ui/table.tsx +116 -0
  79. package/landing-pages/ai-saas-template/src/components/ui/tabs.tsx +66 -0
  80. package/landing-pages/ai-saas-template/src/components/ui/textarea.tsx +18 -0
  81. package/landing-pages/ai-saas-template/src/components/ui/toggle-group.tsx +73 -0
  82. package/landing-pages/ai-saas-template/src/components/ui/toggle.tsx +47 -0
  83. package/landing-pages/ai-saas-template/src/components/ui/tooltip.tsx +61 -0
  84. package/landing-pages/ai-saas-template/src/hooks/use-mobile.ts +19 -0
  85. package/landing-pages/ai-saas-template/src/lib/hooks/use-mobile.tsx +21 -0
  86. package/landing-pages/ai-saas-template/src/lib/utils.ts +6 -0
  87. package/landing-pages/ai-saas-template/src/visual-edits/VisualEditsMessenger.tsx +2159 -0
  88. package/landing-pages/ai-saas-template/src/visual-edits/component-tagger-loader.js +460 -0
  89. package/landing-pages/ai-saas-template/tsconfig.json +42 -0
  90. package/landing-pages/open-engineer-template/.orchids/orchids.json +8 -0
  91. package/landing-pages/open-engineer-template/README.md +36 -0
  92. package/landing-pages/open-engineer-template/bun.lock +2062 -0
  93. package/landing-pages/open-engineer-template/components.json +22 -0
  94. package/landing-pages/open-engineer-template/eslint.config.mjs +33 -0
  95. package/landing-pages/open-engineer-template/next.config.ts +24 -0
  96. package/landing-pages/open-engineer-template/package-lock.json +13669 -0
  97. package/landing-pages/open-engineer-template/package.json +114 -0
  98. package/landing-pages/open-engineer-template/postcss.config.mjs +7 -0
  99. package/landing-pages/open-engineer-template/public/file.svg +1 -0
  100. package/landing-pages/open-engineer-template/public/globe.svg +1 -0
  101. package/landing-pages/open-engineer-template/public/next.svg +1 -0
  102. package/landing-pages/open-engineer-template/public/vercel.svg +1 -0
  103. package/landing-pages/open-engineer-template/public/window.svg +1 -0
  104. package/landing-pages/open-engineer-template/src/app/favicon.ico +0 -0
  105. package/landing-pages/open-engineer-template/src/app/global-error.tsx +5 -0
  106. package/landing-pages/open-engineer-template/src/app/globals.css +189 -0
  107. package/landing-pages/open-engineer-template/src/app/layout.tsx +42 -0
  108. package/landing-pages/open-engineer-template/src/app/page.tsx +31 -0
  109. package/landing-pages/open-engineer-template/src/components/ErrorReporter.tsx +136 -0
  110. package/landing-pages/open-engineer-template/src/components/sections/cta-stats.tsx +71 -0
  111. package/landing-pages/open-engineer-template/src/components/sections/faq.tsx +188 -0
  112. package/landing-pages/open-engineer-template/src/components/sections/features-grid.tsx +193 -0
  113. package/landing-pages/open-engineer-template/src/components/sections/footer.tsx +137 -0
  114. package/landing-pages/open-engineer-template/src/components/sections/header.tsx +105 -0
  115. package/landing-pages/open-engineer-template/src/components/sections/hero.tsx +118 -0
  116. package/landing-pages/open-engineer-template/src/components/sections/how-it-works.tsx +123 -0
  117. package/landing-pages/open-engineer-template/src/components/sections/pricing.tsx +168 -0
  118. package/landing-pages/open-engineer-template/src/components/sections/testimonials-logos.tsx +88 -0
  119. package/landing-pages/open-engineer-template/src/components/sections/use-cases.tsx +141 -0
  120. package/landing-pages/open-engineer-template/src/components/sections/workflow-tabs.tsx +792 -0
  121. package/landing-pages/open-engineer-template/src/components/ui/accordion.tsx +66 -0
  122. package/landing-pages/open-engineer-template/src/components/ui/alert-dialog.tsx +157 -0
  123. package/landing-pages/open-engineer-template/src/components/ui/alert.tsx +66 -0
  124. package/landing-pages/open-engineer-template/src/components/ui/aspect-ratio.tsx +11 -0
  125. package/landing-pages/open-engineer-template/src/components/ui/avatar.tsx +53 -0
  126. package/landing-pages/open-engineer-template/src/components/ui/badge.tsx +46 -0
  127. package/landing-pages/open-engineer-template/src/components/ui/breadcrumb.tsx +109 -0
  128. package/landing-pages/open-engineer-template/src/components/ui/button-group.tsx +83 -0
  129. package/landing-pages/open-engineer-template/src/components/ui/button.tsx +59 -0
  130. package/landing-pages/open-engineer-template/src/components/ui/calendar.tsx +213 -0
  131. package/landing-pages/open-engineer-template/src/components/ui/card.tsx +92 -0
  132. package/landing-pages/open-engineer-template/src/components/ui/carousel.tsx +241 -0
  133. package/landing-pages/open-engineer-template/src/components/ui/chart.tsx +353 -0
  134. package/landing-pages/open-engineer-template/src/components/ui/checkbox.tsx +32 -0
  135. package/landing-pages/open-engineer-template/src/components/ui/collapsible.tsx +33 -0
  136. package/landing-pages/open-engineer-template/src/components/ui/command.tsx +184 -0
  137. package/landing-pages/open-engineer-template/src/components/ui/context-menu.tsx +252 -0
  138. package/landing-pages/open-engineer-template/src/components/ui/dialog.tsx +143 -0
  139. package/landing-pages/open-engineer-template/src/components/ui/drawer.tsx +135 -0
  140. package/landing-pages/open-engineer-template/src/components/ui/dropdown-menu.tsx +257 -0
  141. package/landing-pages/open-engineer-template/src/components/ui/empty.tsx +104 -0
  142. package/landing-pages/open-engineer-template/src/components/ui/field.tsx +248 -0
  143. package/landing-pages/open-engineer-template/src/components/ui/form.tsx +167 -0
  144. package/landing-pages/open-engineer-template/src/components/ui/hover-card.tsx +44 -0
  145. package/landing-pages/open-engineer-template/src/components/ui/input-group.tsx +170 -0
  146. package/landing-pages/open-engineer-template/src/components/ui/input-otp.tsx +77 -0
  147. package/landing-pages/open-engineer-template/src/components/ui/input.tsx +21 -0
  148. package/landing-pages/open-engineer-template/src/components/ui/item.tsx +193 -0
  149. package/landing-pages/open-engineer-template/src/components/ui/kbd.tsx +28 -0
  150. package/landing-pages/open-engineer-template/src/components/ui/label.tsx +24 -0
  151. package/landing-pages/open-engineer-template/src/components/ui/menubar.tsx +276 -0
  152. package/landing-pages/open-engineer-template/src/components/ui/navigation-menu.tsx +168 -0
  153. package/landing-pages/open-engineer-template/src/components/ui/pagination.tsx +127 -0
  154. package/landing-pages/open-engineer-template/src/components/ui/popover.tsx +48 -0
  155. package/landing-pages/open-engineer-template/src/components/ui/progress.tsx +31 -0
  156. package/landing-pages/open-engineer-template/src/components/ui/radio-group.tsx +45 -0
  157. package/landing-pages/open-engineer-template/src/components/ui/resizable.tsx +56 -0
  158. package/landing-pages/open-engineer-template/src/components/ui/scroll-area.tsx +58 -0
  159. package/landing-pages/open-engineer-template/src/components/ui/select.tsx +185 -0
  160. package/landing-pages/open-engineer-template/src/components/ui/separator.tsx +28 -0
  161. package/landing-pages/open-engineer-template/src/components/ui/sheet.tsx +139 -0
  162. package/landing-pages/open-engineer-template/src/components/ui/sidebar.tsx +726 -0
  163. package/landing-pages/open-engineer-template/src/components/ui/skeleton.tsx +13 -0
  164. package/landing-pages/open-engineer-template/src/components/ui/slider.tsx +63 -0
  165. package/landing-pages/open-engineer-template/src/components/ui/sonner.tsx +25 -0
  166. package/landing-pages/open-engineer-template/src/components/ui/spinner.tsx +16 -0
  167. package/landing-pages/open-engineer-template/src/components/ui/switch.tsx +31 -0
  168. package/landing-pages/open-engineer-template/src/components/ui/table.tsx +116 -0
  169. package/landing-pages/open-engineer-template/src/components/ui/tabs.tsx +66 -0
  170. package/landing-pages/open-engineer-template/src/components/ui/textarea.tsx +18 -0
  171. package/landing-pages/open-engineer-template/src/components/ui/toggle-group.tsx +73 -0
  172. package/landing-pages/open-engineer-template/src/components/ui/toggle.tsx +47 -0
  173. package/landing-pages/open-engineer-template/src/components/ui/tooltip.tsx +61 -0
  174. package/landing-pages/open-engineer-template/src/hooks/use-mobile.ts +19 -0
  175. package/landing-pages/open-engineer-template/src/lib/hooks/use-mobile.tsx +21 -0
  176. package/landing-pages/open-engineer-template/src/lib/utils.ts +6 -0
  177. package/landing-pages/open-engineer-template/src/visual-edits/VisualEditsMessenger.tsx +2159 -0
  178. package/landing-pages/open-engineer-template/src/visual-edits/component-tagger-loader.js +460 -0
  179. package/landing-pages/open-engineer-template/tsconfig.json +42 -0
  180. package/package.json +36 -0
  181. package/templates.json +22 -0
@@ -0,0 +1,2159 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ "use client";
3
+
4
+ import { useEffect, useState, useRef } from "react";
5
+
6
+ export const CHANNEL = "ORCHIDS_HOVER_v1" as const;
7
+ const VISUAL_EDIT_MODE_KEY = "orchids_visual_edit_mode" as const;
8
+ const FOCUSED_ELEMENT_KEY = "orchids_focused_element" as const;
9
+
10
+ // Deduplicate helper for high-frequency traffic (HIT / FOCUS_MOVED / SCROLL)
11
+ // -----------------------------------------------------------------------------
12
+ let _orchidsLastMsg = "";
13
+ const postMessageDedup = (data: any) => {
14
+ try {
15
+ const key = JSON.stringify(data);
16
+ if (key === _orchidsLastMsg) return; // identical – drop
17
+ _orchidsLastMsg = key;
18
+ } catch {
19
+ // if stringify fails, fall through
20
+ }
21
+ window.parent.postMessage(data, "*");
22
+ };
23
+
24
+ export type ParentToChild =
25
+ | { type: typeof CHANNEL; msg: "POINTER"; x: number; y: number }
26
+ | { type: typeof CHANNEL; msg: "VISUAL_EDIT_MODE"; active: boolean }
27
+ | { type: typeof CHANNEL; msg: "SCROLL"; dx: number; dy: number }
28
+ | { type: typeof CHANNEL; msg: "CLEAR_INLINE_STYLES"; elementId: string }
29
+ | {
30
+ type: typeof CHANNEL;
31
+ msg: "PREVIEW_FONT";
32
+ elementId: string;
33
+ fontFamily: string;
34
+ }
35
+ | {
36
+ type: typeof CHANNEL;
37
+ msg: "RESIZE_ELEMENT";
38
+ elementId: string;
39
+ width: number;
40
+ height: number;
41
+ }
42
+ | {
43
+ type: typeof CHANNEL;
44
+ msg: "SHOW_ELEMENT_HOVER";
45
+ elementId: string | null;
46
+ };
47
+
48
+ export type ChildToParent =
49
+ | {
50
+ type: typeof CHANNEL;
51
+ msg: "HIT";
52
+ id: string | null;
53
+ tag: string | null;
54
+ rect: { top: number; left: number; width: number; height: number } | null;
55
+ }
56
+ | {
57
+ type: typeof CHANNEL;
58
+ msg: "ELEMENT_CLICKED";
59
+ id: string | null;
60
+ tag: string | null;
61
+ rect: { top: number; left: number; width: number; height: number };
62
+ clickPosition: { x: number; y: number };
63
+ isEditable?: boolean;
64
+ currentStyles?: {
65
+ fontSize?: string;
66
+ color?: string;
67
+ fontWeight?: string;
68
+ fontStyle?: string;
69
+ textDecoration?: string;
70
+ textAlign?: string;
71
+ lineHeight?: string;
72
+ letterSpacing?: string;
73
+ paddingLeft?: string;
74
+ paddingRight?: string;
75
+ paddingTop?: string;
76
+ paddingBottom?: string;
77
+ marginLeft?: string;
78
+ marginRight?: string;
79
+ marginTop?: string;
80
+ marginBottom?: string;
81
+ backgroundColor?: string;
82
+ backgroundImage?: string;
83
+ borderRadius?: string;
84
+ fontFamily?: string;
85
+ opacity?: string;
86
+ display?: string;
87
+ flexDirection?: string;
88
+ alignItems?: string;
89
+ justifyContent?: string;
90
+ gap?: string;
91
+ };
92
+ className?: string;
93
+ src?: string;
94
+ }
95
+ | { type: typeof CHANNEL; msg: "SCROLL_STARTED" }
96
+ | { type: typeof CHANNEL; msg: "SCROLL_STOPPED" }
97
+ | {
98
+ type: typeof CHANNEL;
99
+ msg: "TEXT_CHANGED";
100
+ id: string;
101
+ oldText: string;
102
+ newText: string;
103
+ filePath: string;
104
+ line: number;
105
+ column: number;
106
+ }
107
+ | {
108
+ type: typeof CHANNEL;
109
+ msg: "STYLE_CHANGED";
110
+ id: string;
111
+ styles: Record<string, string>;
112
+ filePath: string;
113
+ line: number;
114
+ column: number;
115
+ }
116
+ | {
117
+ type: typeof CHANNEL;
118
+ msg: "STYLE_BLUR";
119
+ id: string;
120
+ styles: Record<string, string>;
121
+ filePath: string;
122
+ line: number;
123
+ column: number;
124
+ className: string;
125
+ }
126
+ | {
127
+ type: typeof CHANNEL;
128
+ msg: "IMAGE_BLUR";
129
+ id: string;
130
+ oldSrc: string;
131
+ newSrc: string;
132
+ filePath: string;
133
+ line: number;
134
+ column: number;
135
+ }
136
+ | {
137
+ type: typeof CHANNEL;
138
+ msg: "FOCUS_MOVED";
139
+ id: string;
140
+ rect: { top: number; left: number; width: number; height: number };
141
+ }
142
+ | { type: typeof CHANNEL; msg: "VISUAL_EDIT_MODE_ACK"; active: boolean }
143
+ | { type: typeof CHANNEL; msg: "VISUAL_EDIT_MODE_RESTORED"; active: boolean };
144
+
145
+ type Box = null | { top: number; left: number; width: number; height: number };
146
+
147
+ const BOX_PADDING = 4; // Pixels to expand the box on each side
148
+
149
+ // Helper to check if element can be made contentEditable
150
+ const isTextEditable = (element: HTMLElement): boolean => {
151
+ const tagName = element.tagName.toLowerCase();
152
+ // Elements that typically contain text and can be made contentEditable
153
+ const editableTags = [
154
+ "p",
155
+ "h1",
156
+ "h2",
157
+ "h3",
158
+ "h4",
159
+ "h5",
160
+ "h6",
161
+ "span",
162
+ "div",
163
+ "li",
164
+ "td",
165
+ "th",
166
+ "label",
167
+ "a",
168
+ "button",
169
+ ];
170
+
171
+ // Check if it's already contentEditable or an input/textarea
172
+ if (
173
+ element.contentEditable === "true" ||
174
+ tagName === "input" ||
175
+ tagName === "textarea"
176
+ ) {
177
+ return true;
178
+ }
179
+
180
+ // Allow editing if element contains text and is an editable tag
181
+ // Only allow editing if element has at most 1 child OR has direct text content
182
+ if (editableTags.includes(tagName) && element.textContent?.trim()) {
183
+ // Check if element has direct text nodes (not just text from children)
184
+ const hasDirectText = Array.from(element.childNodes).some(
185
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
186
+ );
187
+
188
+ // Allow editing if:
189
+ // 1. Element has no children (pure text element)
190
+ // 2. Element has 1 or fewer children AND has direct text content
191
+ if (
192
+ element.childElementCount === 0 ||
193
+ (element.childElementCount <= 1 && hasDirectText)
194
+ ) {
195
+ return true;
196
+ }
197
+ }
198
+
199
+ return false;
200
+ };
201
+
202
+ // Helper to extract only text nodes from an element (excluding child element text)
203
+ const extractDirectTextContent = (element: HTMLElement): string => {
204
+ let text = "";
205
+ for (const node of element.childNodes) {
206
+ if (node.nodeType === Node.TEXT_NODE) {
207
+ text += node.textContent || "";
208
+ }
209
+ }
210
+ return text;
211
+ };
212
+
213
+ // Helper to parse data-orchids-id to extract file path, line, and column
214
+ const parseOrchidsId = (
215
+ orchidsId: string
216
+ ): { filePath: string; line: number; column: number } | null => {
217
+ // Format: "filepath:line:column"
218
+ const parts = orchidsId.split(":");
219
+ if (parts.length < 3) return null;
220
+
221
+ // The file path might contain colons, so we need to handle that
222
+ const column = parseInt(parts.pop() || "0");
223
+ const line = parseInt(parts.pop() || "0");
224
+ const filePath = parts.join(":"); // Rejoin the remaining parts as the file path
225
+
226
+ if (isNaN(line) || isNaN(column)) return null;
227
+
228
+ return { filePath, line, column };
229
+ };
230
+
231
+ // Helper to get current styles of an element (including inline styles)
232
+ const getCurrentStyles = (
233
+ element: HTMLElement
234
+ ): {
235
+ fontSize?: string;
236
+ color?: string;
237
+ fontWeight?: string;
238
+ fontStyle?: string;
239
+ textDecoration?: string;
240
+ textAlign?: string;
241
+ lineHeight?: string;
242
+ letterSpacing?: string;
243
+ paddingLeft?: string;
244
+ paddingRight?: string;
245
+ paddingTop?: string;
246
+ paddingBottom?: string;
247
+ marginLeft?: string;
248
+ marginRight?: string;
249
+ marginTop?: string;
250
+ marginBottom?: string;
251
+ backgroundColor?: string;
252
+ backgroundImage?: string;
253
+ borderRadius?: string;
254
+ fontFamily?: string;
255
+ opacity?: string;
256
+ display?: string;
257
+ flexDirection?: string;
258
+ alignItems?: string;
259
+ justifyContent?: string;
260
+ gap?: string;
261
+ } => {
262
+ const computed = window.getComputedStyle(element);
263
+
264
+ // Helper to normalize values and provide defaults
265
+ const normalizeValue = (value: string, property: string): string => {
266
+ // Handle background color - if it's transparent or rgba(0,0,0,0), return "transparent"
267
+ if (property === "backgroundColor") {
268
+ if (
269
+ value === "rgba(0, 0, 0, 0)" ||
270
+ value === "rgb(0, 0, 0, 0)" ||
271
+ value === "transparent" ||
272
+ value === ""
273
+ ) {
274
+ return "transparent";
275
+ }
276
+ }
277
+
278
+ // Handle background image - if none, return "none"
279
+ if (property === "backgroundImage" && (value === "none" || value === "")) {
280
+ return "none";
281
+ }
282
+
283
+ // Handle text decoration - if none, return "none"
284
+ if (property === "textDecoration") {
285
+ // Some browsers return complex values like "none solid rgb(0, 0, 0)"
286
+ if (value.includes("none") || value === "") {
287
+ return "none";
288
+ }
289
+ }
290
+
291
+ // Handle font style - if normal, return "normal"
292
+ if (property === "fontStyle" && (value === "normal" || value === "")) {
293
+ return "normal";
294
+ }
295
+
296
+ // Handle font weight - normalize to standard values
297
+ if (property === "fontWeight") {
298
+ const weight = parseInt(value);
299
+ if (!isNaN(weight)) {
300
+ return String(weight);
301
+ }
302
+ return value || "400";
303
+ }
304
+
305
+ // Handle opacity - if 1, return "1"
306
+ if (property === "opacity" && (value === "1" || value === "")) {
307
+ return "1";
308
+ }
309
+
310
+ // Handle spacing values (padding/margin) - if 0px, return "0"
311
+ if (
312
+ (property.includes("padding") || property.includes("margin")) &&
313
+ (value === "0px" || value === "0")
314
+ ) {
315
+ return "0";
316
+ }
317
+
318
+ // Handle border radius - if 0px, return "0"
319
+ if (property === "borderRadius" && (value === "0px" || value === "0")) {
320
+ return "0";
321
+ }
322
+
323
+ // Handle letter spacing - if normal, return "normal"
324
+ if (
325
+ property === "letterSpacing" &&
326
+ (value === "normal" || value === "0px")
327
+ ) {
328
+ return "normal";
329
+ }
330
+
331
+ // Handle gap - if normal, return "normal"
332
+ if (property === "gap" && (value === "normal" || value === "0px")) {
333
+ return "normal";
334
+ }
335
+
336
+ return value;
337
+ };
338
+
339
+ return {
340
+ fontSize: normalizeValue(computed.fontSize, "fontSize"),
341
+ color: normalizeValue(computed.color, "color"),
342
+ fontWeight: normalizeValue(computed.fontWeight, "fontWeight"),
343
+ fontStyle: normalizeValue(computed.fontStyle, "fontStyle"),
344
+ textDecoration: normalizeValue(computed.textDecoration, "textDecoration"),
345
+ textAlign: normalizeValue(computed.textAlign, "textAlign"),
346
+ lineHeight: normalizeValue(computed.lineHeight, "lineHeight"),
347
+ letterSpacing: normalizeValue(computed.letterSpacing, "letterSpacing"),
348
+ paddingLeft: normalizeValue(computed.paddingLeft, "paddingLeft"),
349
+ paddingRight: normalizeValue(computed.paddingRight, "paddingRight"),
350
+ paddingTop: normalizeValue(computed.paddingTop, "paddingTop"),
351
+ paddingBottom: normalizeValue(computed.paddingBottom, "paddingBottom"),
352
+ marginLeft: normalizeValue(computed.marginLeft, "marginLeft"),
353
+ marginRight: normalizeValue(computed.marginRight, "marginRight"),
354
+ marginTop: normalizeValue(computed.marginTop, "marginTop"),
355
+ marginBottom: normalizeValue(computed.marginBottom, "marginBottom"),
356
+ backgroundColor: normalizeValue(
357
+ computed.backgroundColor,
358
+ "backgroundColor"
359
+ ),
360
+ backgroundImage: normalizeValue(
361
+ computed.backgroundImage,
362
+ "backgroundImage"
363
+ ),
364
+ borderRadius: normalizeValue(computed.borderRadius, "borderRadius"),
365
+ fontFamily: normalizeValue(computed.fontFamily, "fontFamily"),
366
+ opacity: normalizeValue(computed.opacity, "opacity"),
367
+ display: normalizeValue(computed.display, "display"),
368
+ flexDirection: normalizeValue(computed.flexDirection, "flexDirection"),
369
+ alignItems: normalizeValue(computed.alignItems, "alignItems"),
370
+ justifyContent: normalizeValue(computed.justifyContent, "justifyContent"),
371
+ gap: normalizeValue(computed.gap, "gap"),
372
+ };
373
+ };
374
+
375
+ // Normalize image src for comparison (handles Next.js optimization wrappers)
376
+ const normalizeImageSrc = (input: string): string => {
377
+ if (!input) return "";
378
+ try {
379
+ const url = new URL(input, window.location.origin);
380
+ // Handle Next.js <Image> optimization wrapper
381
+ if (url.pathname === "/_next/image") {
382
+ const real = url.searchParams.get("url");
383
+ if (real) return decodeURIComponent(real);
384
+ }
385
+ return url.href; // absolute form
386
+ } catch {
387
+ return input;
388
+ }
389
+ };
390
+
391
+ // Helper to wrap multiline text only when it contains line breaks
392
+ const wrapMultiline = (text: string): string => {
393
+ if (text.includes("\n")) {
394
+ const escaped = text.replace(/\n/g, "\\n");
395
+ // Wrap in {` ... `} so JSX will interpret it as a template literal
396
+ return `{\`${escaped}\`}`;
397
+ }
398
+ return text;
399
+ };
400
+
401
+ export default function HoverReceiver() {
402
+ const [hoverBox, setHoverBox] = useState<Box>(null);
403
+ const [hoverBoxes, setHoverBoxes] = useState<Box[]>([]);
404
+ const [focusBox, setFocusBox] = useState<Box>(null);
405
+ const [focusedElementId, setFocusedElementId] = useState<string | null>(null);
406
+ const [isVisualEditMode, setIsVisualEditMode] = useState(() => {
407
+ // Initialize from localStorage if available
408
+ if (typeof window !== "undefined") {
409
+ const stored = localStorage.getItem(VISUAL_EDIT_MODE_KEY);
410
+ return stored === "true";
411
+ }
412
+ return false;
413
+ });
414
+ const [isResizing, setIsResizing] = useState(false);
415
+ const [resizeHandle, setResizeHandle] = useState<string | null>(null);
416
+ const [resizeStart, setResizeStart] = useState<{
417
+ x: number;
418
+ y: number;
419
+ width: number;
420
+ height: number;
421
+ } | null>(null);
422
+ const [isScrolling, setIsScrolling] = useState(false);
423
+
424
+ // Tag labels for hover and focus overlays
425
+ const [hoverTag, setHoverTag] = useState<string | null>(null);
426
+ const [focusTag, setFocusTag] = useState<string | null>(null);
427
+ const isResizingRef = useRef(false);
428
+ const lastHitElementRef = useRef<HTMLElement | null>(null);
429
+ const lastHitIdRef = useRef<string | null>(null);
430
+ const focusedElementRef = useRef<HTMLElement | null>(null);
431
+ const isVisualEditModeRef = useRef(false);
432
+ const scrollTimeoutRef = useRef<number | null>(null);
433
+ const originalContentRef = useRef<string>("");
434
+ const originalSrcRef = useRef<string>(""); // track original img src
435
+ const focusedImageElementRef = useRef<HTMLImageElement | null>(null); // track the actual img element
436
+ const editingElementRef = useRef<HTMLElement | null>(null);
437
+ const wasEditableRef = useRef<boolean>(false);
438
+ const styleElementRef = useRef<HTMLStyleElement | null>(null);
439
+ const originalStylesRef = useRef<Record<string, string>>({});
440
+ const appliedStylesRef = useRef<Map<string, Record<string, string>>>(
441
+ new Map()
442
+ );
443
+ const hasStyleChangesRef = useRef<boolean>(false);
444
+ const lastClickTimeRef = useRef<number>(0);
445
+ const pendingCleanupRef = useRef<NodeJS.Timeout | null>(null);
446
+
447
+ // Cache of loaded fonts
448
+ const loadedFontFamilies = useRef<Set<string>>(new Set());
449
+ // Map of elementId that already has a persistent font set
450
+ const persistentFontMap = useRef<Map<string, string>>(new Map());
451
+ // Timeout refs for clearing persistent font map
452
+ const persistentFontTimeouts = useRef<Map<string, number>>(new Map());
453
+
454
+ // Keep ref in sync with state and persist to localStorage
455
+ useEffect(() => {
456
+ isVisualEditModeRef.current = isVisualEditMode;
457
+ // Persist to localStorage
458
+ if (typeof window !== "undefined") {
459
+ localStorage.setItem(VISUAL_EDIT_MODE_KEY, String(isVisualEditMode));
460
+ }
461
+ }, [isVisualEditMode]);
462
+
463
+ // On mount, notify parent if visual edit mode was restored from localStorage
464
+ useEffect(() => {
465
+ if (isVisualEditMode) {
466
+ // Send acknowledgement to parent that visual edit mode is active
467
+ // This will sync the parent's state with our restored state
468
+ window.parent.postMessage(
469
+ { type: CHANNEL, msg: "VISUAL_EDIT_MODE_ACK", active: true },
470
+ "*"
471
+ );
472
+
473
+ // Also send a special message to indicate this was restored from localStorage
474
+ window.parent.postMessage(
475
+ { type: CHANNEL, msg: "VISUAL_EDIT_MODE_RESTORED", active: true },
476
+ "*"
477
+ );
478
+
479
+ // Restore focused element after a short delay to ensure DOM is ready
480
+ setTimeout(() => {
481
+ if (typeof window !== "undefined") {
482
+ // Restore focused element
483
+ const focusedData = localStorage.getItem(FOCUSED_ELEMENT_KEY);
484
+ if (focusedData) {
485
+ try {
486
+ const { id } = JSON.parse(focusedData);
487
+ const element = document.querySelector(
488
+ `[data-orchids-id="${id}"]`
489
+ ) as HTMLElement;
490
+
491
+ if (element) {
492
+ // Simulate a click on the element to restore focus
493
+ const rect = element.getBoundingClientRect();
494
+ const clickEvent = new MouseEvent("click", {
495
+ clientX: rect.left + rect.width / 2,
496
+ clientY: rect.top + rect.height / 2,
497
+ bubbles: true,
498
+ cancelable: true,
499
+ });
500
+ element.dispatchEvent(clickEvent);
501
+ }
502
+ } catch {
503
+ // Ignore parsing errors
504
+ }
505
+ }
506
+ }
507
+ }, 500); // Wait 500ms for DOM to be fully ready
508
+ }
509
+ }, []); // Run only on mount
510
+
511
+ // Helper function to expand box dimensions
512
+ const expandBox = (rect: DOMRect): Box => ({
513
+ top: rect.top - BOX_PADDING,
514
+ left: rect.left - BOX_PADDING,
515
+ width: rect.width + BOX_PADDING * 2,
516
+ height: rect.height + BOX_PADDING * 2,
517
+ });
518
+
519
+ // Helper to update focus box position
520
+ const updateFocusBox = () => {
521
+ if (focusedElementRef.current) {
522
+ const r = focusedElementRef.current.getBoundingClientRect();
523
+ setFocusBox(expandBox(r));
524
+ }
525
+ };
526
+
527
+ // Add global styles for contentEditable elements
528
+ useEffect(() => {
529
+ if (isVisualEditMode && !styleElementRef.current) {
530
+ const style = document.createElement("style");
531
+ style.textContent = `
532
+ [contenteditable="true"]:focus {
533
+ outline: none !important;
534
+ box-shadow: none !important;
535
+ border-color: inherit !important;
536
+ }
537
+ [contenteditable="true"] {
538
+ cursor: text !important;
539
+ }
540
+ /* Prevent the default blue highlight on contenteditable */
541
+ [contenteditable="true"]::selection {
542
+ background-color: rgba(59, 130, 246, 0.3);
543
+ }
544
+ [contenteditable="true"]::-moz-selection {
545
+ background-color: rgba(59, 130, 246, 0.3);
546
+ }
547
+ /* Prevent child elements from being editable */
548
+ [contenteditable="true"] [contenteditable="false"] {
549
+ user-select: none !important;
550
+ -webkit-user-select: none !important;
551
+ -moz-user-select: none !important;
552
+ -ms-user-select: none !important;
553
+ opacity: 0.7 !important;
554
+ cursor: default !important;
555
+ }
556
+ /* Ensure protected elements can't be selected */
557
+ [data-orchids-protected="true"] {
558
+ user-select: none !important;
559
+ -webkit-user-select: none !important;
560
+ -moz-user-select: none !important;
561
+ -ms-user-select: none !important;
562
+ }
563
+ `;
564
+ document.head.appendChild(style);
565
+ styleElementRef.current = style;
566
+ } else if (!isVisualEditMode && styleElementRef.current) {
567
+ styleElementRef.current.remove();
568
+ styleElementRef.current = null;
569
+ }
570
+
571
+ return () => {
572
+ if (styleElementRef.current) {
573
+ styleElementRef.current.remove();
574
+ styleElementRef.current = null;
575
+ }
576
+ };
577
+ }, [isVisualEditMode]);
578
+
579
+ // Helper to make only text nodes editable and protect child elements
580
+ const protectChildElements = (element: HTMLElement) => {
581
+ // Make all child elements non-editable
582
+ const childElements = element.querySelectorAll("*");
583
+ childElements.forEach((child) => {
584
+ const childEl = child as HTMLElement;
585
+ childEl.contentEditable = "false";
586
+ // Add a data attribute to mark protected elements
587
+ childEl.setAttribute("data-orchids-protected", "true");
588
+ // Only prevent text selection within the child elements when parent is being edited
589
+ // But still allow pointer events for hovering and clicking
590
+ childEl.style.userSelect = "none";
591
+ childEl.style.webkitUserSelect = "none";
592
+ // Don't set pointerEvents to none - we want to allow hover and click
593
+ });
594
+ };
595
+
596
+ // Helper to restore child elements after editing
597
+ const restoreChildElements = (element: HTMLElement) => {
598
+ const protectedElements = element.querySelectorAll(
599
+ '[data-orchids-protected="true"]'
600
+ );
601
+ protectedElements.forEach((child) => {
602
+ const childEl = child as HTMLElement;
603
+ childEl.removeAttribute("contenteditable");
604
+ childEl.removeAttribute("data-orchids-protected");
605
+ // Restore original styles
606
+ childEl.style.userSelect = "";
607
+ childEl.style.webkitUserSelect = "";
608
+ });
609
+ };
610
+
611
+ // Handle text changes and send to parent
612
+ const handleTextChange = (element: HTMLElement) => {
613
+ // Double-check this is still the editing element to avoid stale closures
614
+ if (element !== editingElementRef.current) {
615
+ console.warn("Attempting to handle text change for non-editing element");
616
+ return;
617
+ }
618
+
619
+ // Get the orchids ID from the element to ensure we're working with the right one
620
+ const orchidsId = element.getAttribute("data-orchids-id");
621
+ if (!orchidsId) return;
622
+
623
+ // For elements with children, only extract direct text content
624
+ let newText: string;
625
+ let oldText: string;
626
+
627
+ if (element.childElementCount > 0) {
628
+ // Element has children - only track direct text nodes
629
+ newText = extractDirectTextContent(element);
630
+ // We need to compare against the original direct text content
631
+ // Since originalContentRef stores the full text, we need to handle this differently
632
+ oldText = originalContentRef.current;
633
+ } else {
634
+ // No children - use innerText to preserve line breaks
635
+ newText = element.innerText || element.textContent || "";
636
+ oldText = originalContentRef.current;
637
+ }
638
+
639
+ if (newText !== oldText) {
640
+ const parsed = parseOrchidsId(orchidsId);
641
+ if (!parsed) return;
642
+
643
+ // Send text change message to parent
644
+ const msg: ChildToParent = {
645
+ type: CHANNEL,
646
+ msg: "TEXT_CHANGED",
647
+ id: orchidsId,
648
+ oldText: wrapMultiline(oldText),
649
+ newText: wrapMultiline(newText),
650
+ filePath: parsed.filePath,
651
+ line: parsed.line,
652
+ column: parsed.column,
653
+ };
654
+
655
+ postMessageDedup(msg);
656
+
657
+ // Update the original content reference
658
+ originalContentRef.current = newText;
659
+ }
660
+ };
661
+
662
+ // Handle style changes and send to parent
663
+ const handleStyleChange = (
664
+ element: HTMLElement,
665
+ styles: Record<string, string>
666
+ ) => {
667
+ const orchidsId = element.getAttribute("data-orchids-id");
668
+ if (!orchidsId) return;
669
+
670
+ const parsed = parseOrchidsId(orchidsId);
671
+ if (!parsed) return;
672
+
673
+ // Find ALL elements with the same orchids ID
674
+ const allMatchingElements = document.querySelectorAll(
675
+ `[data-orchids-id="${orchidsId}"]`
676
+ ) as NodeListOf<HTMLElement>;
677
+
678
+ // Apply styles to ALL matching elements for visual feedback
679
+ allMatchingElements.forEach((el) => {
680
+ Object.entries(styles).forEach(([property, value]) => {
681
+ // Convert camelCase to kebab-case for CSS property names
682
+ const cssProp = property.replace(/([A-Z])/g, "-$1").toLowerCase();
683
+
684
+ // Handle special cases for default values
685
+ let finalValue = value;
686
+
687
+ // If backgroundColor is being set to transparent, use transparent keyword
688
+ if (
689
+ property === "backgroundColor" &&
690
+ (value === "transparent" ||
691
+ value === "rgba(0, 0, 0, 0)" ||
692
+ value === "rgb(0, 0, 0, 0)")
693
+ ) {
694
+ finalValue = "transparent";
695
+ }
696
+
697
+ // If removing styles (setting to default), remove the property
698
+ if (
699
+ (property === "backgroundColor" && finalValue === "transparent") ||
700
+ (property === "backgroundImage" && value === "none") ||
701
+ (property === "textDecoration" && value === "none") ||
702
+ (property === "fontStyle" && value === "normal") ||
703
+ (property === "opacity" && value === "1") ||
704
+ ((property.includes("padding") || property.includes("margin")) &&
705
+ value === "0") ||
706
+ (property === "borderRadius" && value === "0") ||
707
+ (property === "letterSpacing" && value === "normal") ||
708
+ (property === "gap" && value === "normal")
709
+ ) {
710
+ // Remove the property to let the stylesheet value show through
711
+ el.style.removeProperty(cssProp);
712
+ } else {
713
+ // Apply with !important so it overrides existing rules
714
+ el.style.setProperty(cssProp, finalValue, "important");
715
+ }
716
+ });
717
+ });
718
+
719
+ // Store the applied styles
720
+ const existingStyles = appliedStylesRef.current.get(orchidsId) || {};
721
+ appliedStylesRef.current.set(orchidsId, { ...existingStyles, ...styles });
722
+ hasStyleChangesRef.current = true;
723
+
724
+ // Update the focus box after style change
725
+ requestAnimationFrame(() => {
726
+ updateFocusBox();
727
+ });
728
+
729
+ // Don't send to parent yet - wait for blur
730
+ };
731
+
732
+ // Send style changes on blur
733
+ const handleStyleBlur = (element: HTMLElement) => {
734
+ if (!hasStyleChangesRef.current) return;
735
+
736
+ const orchidsId = element.getAttribute("data-orchids-id");
737
+ if (!orchidsId) return;
738
+
739
+ const parsed = parseOrchidsId(orchidsId);
740
+ if (!parsed) return;
741
+
742
+ const appliedStyles = appliedStylesRef.current.get(orchidsId);
743
+ if (!appliedStyles || Object.keys(appliedStyles).length === 0) return;
744
+
745
+ // Get className for breakpoint detection
746
+ const className = element.getAttribute("class") || "";
747
+
748
+ // Send style blur message to parent for Tailwind conversion
749
+ const msg: ChildToParent = {
750
+ type: CHANNEL,
751
+ msg: "STYLE_BLUR",
752
+ id: orchidsId,
753
+ styles: appliedStyles,
754
+ className,
755
+ filePath: parsed.filePath,
756
+ line: parsed.line,
757
+ column: parsed.column,
758
+ };
759
+
760
+ postMessageDedup(msg);
761
+
762
+ // Reset style changes flag
763
+ hasStyleChangesRef.current = false;
764
+ };
765
+
766
+ // Flush image src updates on blur/focus change
767
+ const flushImageSrcChange = () => {
768
+ // Use the stored image element reference if available
769
+ const imgElement = focusedImageElementRef.current;
770
+ if (!imgElement) return;
771
+
772
+ const orchidsId = imgElement.getAttribute("data-orchids-id");
773
+ if (!orchidsId) return;
774
+
775
+ const parsed = parseOrchidsId(orchidsId);
776
+ if (!parsed) return;
777
+
778
+ const newSrc = normalizeImageSrc(imgElement.src);
779
+ const oldSrc = normalizeImageSrc(originalSrcRef.current);
780
+
781
+ if (!newSrc || newSrc === oldSrc) return; // nothing changed
782
+
783
+ const msg: ChildToParent = {
784
+ type: CHANNEL,
785
+ msg: "IMAGE_BLUR",
786
+ id: orchidsId,
787
+ oldSrc,
788
+ newSrc,
789
+ filePath: parsed.filePath,
790
+ line: parsed.line,
791
+ column: parsed.column,
792
+ };
793
+
794
+ postMessageDedup(msg);
795
+
796
+ originalSrcRef.current = newSrc; // reset baseline
797
+ focusedImageElementRef.current = null; // clear reference
798
+ };
799
+
800
+ // Listen for style and image updates from parent
801
+ useEffect(() => {
802
+ function handleMessage(e: MessageEvent) {
803
+ if (e.data?.type === "ORCHIDS_STYLE_UPDATE") {
804
+ const { elementId, styles } = e.data;
805
+
806
+ // Find ALL elements with the same orchids ID
807
+ const allMatchingElements = document.querySelectorAll(
808
+ `[data-orchids-id="${elementId}"]`
809
+ ) as NodeListOf<HTMLElement>;
810
+
811
+ if (allMatchingElements.length > 0) {
812
+ // If fontFamily is present ensure stylesheet loaded first
813
+ const fam = styles.fontFamily || styles["fontFamily"];
814
+ if (fam) {
815
+ const familyKey = fam.replace(/['\s]+/g, "+");
816
+ if (!loadedFontFamilies.current.has(familyKey)) {
817
+ const link = document.createElement("link");
818
+ link.rel = "stylesheet";
819
+ link.href = `https://fonts.googleapis.com/css2?family=${familyKey}:wght@400&display=swap`;
820
+ document.head.appendChild(link);
821
+ loadedFontFamilies.current.add(familyKey);
822
+ }
823
+ }
824
+
825
+ // If fontFamily made persistent via style update, remember so previews don't override
826
+ if (fam) {
827
+ persistentFontMap.current.set(elementId, fam);
828
+
829
+ // Clear any existing timeout
830
+ const existingTimeout =
831
+ persistentFontTimeouts.current.get(elementId);
832
+ if (existingTimeout) {
833
+ clearTimeout(existingTimeout);
834
+ }
835
+
836
+ // Set timeout to clear persistent font after 2 seconds, allowing previews again
837
+ const timeoutId = window.setTimeout(() => {
838
+ persistentFontMap.current.delete(elementId);
839
+ persistentFontTimeouts.current.delete(elementId);
840
+ }, 2000);
841
+
842
+ persistentFontTimeouts.current.set(elementId, timeoutId);
843
+ }
844
+
845
+ // Apply styles to ALL matching elements
846
+ allMatchingElements.forEach((element) => {
847
+ // Only update handleStyleChange for the focused element to track changes
848
+ if (focusedElementRef.current === element) {
849
+ handleStyleChange(element, styles);
850
+ } else {
851
+ // For other elements, apply styles directly
852
+ Object.entries(styles).forEach(([property, value]) => {
853
+ const cssProp = property
854
+ .replace(/([A-Z])/g, "-$1")
855
+ .toLowerCase();
856
+
857
+ // Handle special cases for default values
858
+ let finalValue = String(value);
859
+
860
+ // If backgroundColor is being set to transparent, use transparent keyword
861
+ if (
862
+ property === "backgroundColor" &&
863
+ (value === "transparent" ||
864
+ value === "rgba(0, 0, 0, 0)" ||
865
+ value === "rgb(0, 0, 0, 0)")
866
+ ) {
867
+ finalValue = "transparent";
868
+ }
869
+
870
+ // If removing styles (setting to default), remove the property
871
+ if (
872
+ (property === "backgroundColor" &&
873
+ finalValue === "transparent") ||
874
+ (property === "backgroundImage" && value === "none") ||
875
+ (property === "textDecoration" && value === "none") ||
876
+ (property === "fontStyle" && value === "normal") ||
877
+ (property === "opacity" && value === "1") ||
878
+ ((property.includes("padding") ||
879
+ property.includes("margin")) &&
880
+ value === "0") ||
881
+ (property === "borderRadius" && value === "0") ||
882
+ (property === "letterSpacing" && value === "normal") ||
883
+ (property === "gap" && value === "normal")
884
+ ) {
885
+ // Remove the property to let the stylesheet value show through
886
+ element.style.removeProperty(cssProp);
887
+ } else {
888
+ element.style.setProperty(cssProp, finalValue, "important");
889
+ }
890
+ });
891
+ }
892
+ });
893
+ }
894
+ } else if (e.data?.type === "ORCHIDS_IMAGE_UPDATE") {
895
+ const { elementId, src, oldSrc } = e.data;
896
+ let element: HTMLImageElement | null = null;
897
+ const candidates = document.querySelectorAll(
898
+ `[data-orchids-id="${elementId}"]`
899
+ );
900
+ candidates.forEach((el) => {
901
+ if (el.tagName.toLowerCase() === "img") {
902
+ const img = el as HTMLImageElement;
903
+ const norm = normalizeImageSrc(img.src);
904
+ if (!element) element = img; // first fallback
905
+ if (oldSrc && normalizeImageSrc(oldSrc) === norm) {
906
+ element = img;
907
+ }
908
+ }
909
+ });
910
+
911
+ if (!element) return;
912
+
913
+ if ((element as HTMLElement).tagName.toLowerCase() === "img") {
914
+ const imgEl = element as HTMLImageElement;
915
+
916
+ {
917
+ /*
918
+ * Clear any existing responsive sources so the newly uploaded image
919
+ * always displays. Some frameworks (e.g. Next.js) add a `srcset`
920
+ * attribute which can override `src` in certain viewport/device
921
+ * scenarios, so we strip it out before setting the new source.
922
+ */
923
+ imgEl.removeAttribute("srcset");
924
+ imgEl.srcset = "";
925
+
926
+ imgEl.src = src;
927
+
928
+ // Update baseline src so flush doesn't treat this as pending change
929
+ originalSrcRef.current = normalizeImageSrc(src);
930
+ focusedImageElementRef.current = imgEl;
931
+
932
+ imgEl.onload = () => updateFocusBox();
933
+ }
934
+ }
935
+ } else if (e.data?.type === "RESIZE_ELEMENT") {
936
+ const { elementId, width, height } = e.data;
937
+ const element = document.querySelector(
938
+ `[data-orchids-id="${elementId}"]`
939
+ ) as HTMLElement;
940
+
941
+ if (element && focusedElementRef.current === element) {
942
+ // Apply temporary resize styles
943
+ element.style.setProperty("width", `${width}px`, "important");
944
+ element.style.setProperty("height", `${height}px`, "important");
945
+
946
+ // Update focus box
947
+ updateFocusBox();
948
+ }
949
+ }
950
+ }
951
+
952
+ window.addEventListener("message", handleMessage);
953
+ return () => window.removeEventListener("message", handleMessage);
954
+ }, []);
955
+
956
+ // Handle resize
957
+ const handleResizeStart = (e: React.MouseEvent, handle: string) => {
958
+ if (!focusedElementRef.current) return;
959
+
960
+ e.preventDefault();
961
+ e.stopPropagation();
962
+
963
+ const rect = focusedElementRef.current.getBoundingClientRect();
964
+
965
+ // Clear any hover overlay when starting resize
966
+ setHoverBox(null);
967
+ lastHitElementRef.current = null;
968
+
969
+ // Disable pointer events on body to prevent hover detection
970
+ document.body.style.pointerEvents = "none";
971
+ // Keep resize handles interactive
972
+ const resizeHandles = document.querySelectorAll(".resize-handle");
973
+ resizeHandles.forEach((handle) => {
974
+ (handle as HTMLElement).style.pointerEvents = "auto";
975
+ });
976
+
977
+ setIsResizing(true);
978
+ isResizingRef.current = true;
979
+ setResizeHandle(handle);
980
+ setResizeStart({
981
+ x: e.clientX,
982
+ y: e.clientY,
983
+ width: rect.width,
984
+ height: rect.height,
985
+ });
986
+ };
987
+
988
+ // Handle resize move
989
+ useEffect(() => {
990
+ if (
991
+ !isResizing ||
992
+ !resizeStart ||
993
+ !resizeHandle ||
994
+ !focusedElementRef.current
995
+ )
996
+ return;
997
+
998
+ const handleMouseMove = (e: MouseEvent) => {
999
+ const dx = e.clientX - resizeStart.x;
1000
+ const dy = e.clientY - resizeStart.y;
1001
+
1002
+ let newWidth = resizeStart.width;
1003
+ let newHeight = resizeStart.height;
1004
+
1005
+ // Calculate new dimensions based on handle
1006
+ if (resizeHandle.includes("e")) newWidth = resizeStart.width + dx;
1007
+ if (resizeHandle.includes("w")) newWidth = resizeStart.width - dx;
1008
+ if (resizeHandle.includes("s")) newHeight = resizeStart.height + dy;
1009
+ if (resizeHandle.includes("n")) newHeight = resizeStart.height - dy;
1010
+
1011
+ // Get parent container for constraints
1012
+ const parent = focusedElementRef.current?.parentElement;
1013
+ if (parent) {
1014
+ const parentRect = parent.getBoundingClientRect();
1015
+ const parentStyles = window.getComputedStyle(parent);
1016
+ const parentPaddingLeft = parseFloat(parentStyles.paddingLeft) || 0;
1017
+ const parentPaddingRight = parseFloat(parentStyles.paddingRight) || 0;
1018
+ const parentPaddingTop = parseFloat(parentStyles.paddingTop) || 0;
1019
+ const parentPaddingBottom = parseFloat(parentStyles.paddingBottom) || 0;
1020
+
1021
+ const maxWidth =
1022
+ parentRect.width - parentPaddingLeft - parentPaddingRight;
1023
+ const maxHeight =
1024
+ parentRect.height - parentPaddingTop - parentPaddingBottom;
1025
+
1026
+ /*
1027
+ * Soft-clamp strategy: we respect the parent’s max size until the
1028
+ * user’s cursor actually travels beyond that limit. As soon as the
1029
+ * drag distance would produce a dimension larger than the container
1030
+ * can accommodate we stop clamping and let the element follow the
1031
+ * cursor, effectively allowing it to “spill” out of its parent.
1032
+ */
1033
+ const exceedsWidth = newWidth > maxWidth;
1034
+ const exceedsHeight = newHeight > maxHeight;
1035
+
1036
+ newWidth = Math.max(
1037
+ 20,
1038
+ exceedsWidth ? newWidth : Math.min(newWidth, maxWidth)
1039
+ );
1040
+
1041
+ newHeight = Math.max(
1042
+ 20,
1043
+ exceedsHeight ? newHeight : Math.min(newHeight, maxHeight)
1044
+ );
1045
+ } else {
1046
+ // Fallback to minimum dimensions if no parent
1047
+ newWidth = Math.max(20, newWidth);
1048
+ newHeight = Math.max(20, newHeight);
1049
+ }
1050
+
1051
+ // Ensure hover box stays hidden during resize
1052
+ if (hoverBox) {
1053
+ setHoverBox(null);
1054
+ }
1055
+
1056
+ // Send resize message to parent
1057
+ if (focusedElementId) {
1058
+ window.parent.postMessage(
1059
+ {
1060
+ type: CHANNEL,
1061
+ msg: "RESIZE_ELEMENT",
1062
+ elementId: focusedElementId,
1063
+ width: Math.round(newWidth),
1064
+ height: Math.round(newHeight),
1065
+ },
1066
+ "*"
1067
+ );
1068
+ }
1069
+ };
1070
+
1071
+ const handleMouseUp = () => {
1072
+ if (focusedElementRef.current && focusedElementId) {
1073
+ const element = focusedElementRef.current;
1074
+ const computedStyle = window.getComputedStyle(element);
1075
+ const width = parseFloat(computedStyle.width) || element.offsetWidth;
1076
+ const height = parseFloat(computedStyle.height) || element.offsetHeight;
1077
+
1078
+ // Check if element has max-width/max-height constraints
1079
+ const maxWidth = computedStyle.maxWidth;
1080
+ const maxHeight = computedStyle.maxHeight;
1081
+ const hasMaxWidth =
1082
+ maxWidth && maxWidth !== "none" && maxWidth !== "initial";
1083
+ const hasMaxHeight =
1084
+ maxHeight && maxHeight !== "none" && maxHeight !== "initial";
1085
+
1086
+ // Try to use relative units when possible
1087
+ const parent = element.parentElement;
1088
+ let widthValue = `${Math.round(width)}px`;
1089
+ let heightValue = `${Math.round(height)}px`;
1090
+
1091
+ if (parent) {
1092
+ const parentRect = parent.getBoundingClientRect();
1093
+ const parentStyles = window.getComputedStyle(parent);
1094
+ const parentPaddingLeft = parseFloat(parentStyles.paddingLeft) || 0;
1095
+ const parentPaddingRight = parseFloat(parentStyles.paddingRight) || 0;
1096
+ const parentPaddingTop = parseFloat(parentStyles.paddingTop) || 0;
1097
+ const parentPaddingBottom =
1098
+ parseFloat(parentStyles.paddingBottom) || 0;
1099
+
1100
+ const parentInnerWidth =
1101
+ parentRect.width - parentPaddingLeft - parentPaddingRight;
1102
+ const parentInnerHeight =
1103
+ parentRect.height - parentPaddingTop - parentPaddingBottom;
1104
+
1105
+ // If the element takes up a significant portion of parent, use percentage
1106
+ const widthPercent = (width / parentInnerWidth) * 100;
1107
+ const heightPercent = (height / parentInnerHeight) * 100;
1108
+
1109
+ // Use percentage if it's a round number or close to common values
1110
+ if (
1111
+ Math.abs(widthPercent - Math.round(widthPercent)) < 0.1 ||
1112
+ [25, 33.333, 50, 66.667, 75, 100].some(
1113
+ (v) => Math.abs(widthPercent - v) < 0.5
1114
+ )
1115
+ ) {
1116
+ widthValue = `${Math.round(widthPercent * 10) / 10}%`;
1117
+ }
1118
+
1119
+ // For height, be more conservative with percentages (often px is preferred)
1120
+ if (
1121
+ Math.abs(heightPercent - Math.round(heightPercent)) < 0.1 &&
1122
+ [25, 50, 75, 100].includes(Math.round(heightPercent))
1123
+ ) {
1124
+ heightValue = `${Math.round(heightPercent)}%`;
1125
+ }
1126
+ }
1127
+
1128
+ // Build styles object
1129
+ const styles: Record<string, string> = {};
1130
+
1131
+ // Always set a fixed width and height to break out of responsive classes.
1132
+ styles.width = widthValue;
1133
+ styles.height = heightValue;
1134
+
1135
+ // If the element had a max-width constraint (e.g. from `max-w-full`),
1136
+ // we update it to the new width to ensure the resize is not capped.
1137
+ if (hasMaxWidth) {
1138
+ styles.maxWidth = widthValue;
1139
+ }
1140
+
1141
+ // Same for height.
1142
+ if (hasMaxHeight) {
1143
+ styles.maxHeight = heightValue;
1144
+ }
1145
+
1146
+ // Send final dimensions as style change
1147
+ const msg: ChildToParent = {
1148
+ type: CHANNEL,
1149
+ msg: "STYLE_BLUR",
1150
+ id: focusedElementId,
1151
+ styles,
1152
+ filePath: "",
1153
+ line: 0,
1154
+ column: 0,
1155
+ className: element.getAttribute("class") || "",
1156
+ };
1157
+
1158
+ // Extract file info from data-orchids-id
1159
+ const orchidsId = element.getAttribute("data-orchids-id");
1160
+ if (orchidsId) {
1161
+ const parsed = parseOrchidsId(orchidsId);
1162
+ if (parsed) {
1163
+ msg.filePath = parsed.filePath;
1164
+ msg.line = parsed.line;
1165
+ msg.column = parsed.column;
1166
+ }
1167
+ }
1168
+
1169
+ window.parent.postMessage(msg, "*");
1170
+ }
1171
+
1172
+ setIsResizing(false);
1173
+ isResizingRef.current = false;
1174
+ setResizeHandle(null);
1175
+ setResizeStart(null);
1176
+
1177
+ // Re-enable pointer events
1178
+ document.body.style.pointerEvents = "";
1179
+
1180
+ // Clear the last hit element to force re-detection after resize
1181
+ lastHitElementRef.current = null;
1182
+ };
1183
+
1184
+ document.addEventListener("mousemove", handleMouseMove);
1185
+ document.addEventListener("mouseup", handleMouseUp);
1186
+
1187
+ return () => {
1188
+ document.removeEventListener("mousemove", handleMouseMove);
1189
+ document.removeEventListener("mouseup", handleMouseUp);
1190
+ };
1191
+ }, [isResizing, resizeStart, resizeHandle, focusedElementId, hoverBox]); // Added focusedElementId and hoverBox as dependencies
1192
+
1193
+ // Cleanup function to restore element state
1194
+ const cleanupEditingElement = () => {
1195
+ if (editingElementRef.current) {
1196
+ const element = editingElementRef.current;
1197
+
1198
+ // Immediately clear the ref to prevent any further operations
1199
+ editingElementRef.current = null;
1200
+
1201
+ // Flush pending style edits first for the same reason described above
1202
+ handleStyleBlur(element);
1203
+
1204
+ // Now process text changes
1205
+ handleTextChange(element);
1206
+
1207
+ // Restore child elements if they were protected
1208
+ if (element.childElementCount > 0) {
1209
+ restoreChildElements(element);
1210
+ }
1211
+
1212
+ // Only set contentEditable to false if we made it true
1213
+ if (!wasEditableRef.current) {
1214
+ element.contentEditable = "false";
1215
+ }
1216
+
1217
+ // Don't restore original styles - keep the applied styles
1218
+ // Remove the outline suppression styles only
1219
+ const currentStyle = element.getAttribute("style") || "";
1220
+ const cleanedStyle = currentStyle
1221
+ .replace(/outline:\s*none\s*!important;?/gi, "")
1222
+ .replace(/box-shadow:\s*none\s*!important;?/gi, "")
1223
+ .trim()
1224
+ .replace(/;\s*;/g, ";")
1225
+ .replace(/^;|;$/g, "");
1226
+
1227
+ if (cleanedStyle) {
1228
+ element.setAttribute("style", cleanedStyle);
1229
+ } else {
1230
+ element.removeAttribute("style");
1231
+ }
1232
+
1233
+ element.blur();
1234
+
1235
+ // Remove event handlers
1236
+ const handlers = (element as any)._editHandlers;
1237
+ if (handlers) {
1238
+ element.removeEventListener("focus", handlers.focus);
1239
+ element.removeEventListener("blur", handlers.blur);
1240
+ element.removeEventListener("input", handlers.input);
1241
+ delete (element as any)._editHandlers;
1242
+ }
1243
+
1244
+ wasEditableRef.current = false;
1245
+ // Clear the original content reference
1246
+ originalContentRef.current = "";
1247
+ }
1248
+ };
1249
+
1250
+ // Prevent all navigation in visual edit mode
1251
+ useEffect(() => {
1252
+ if (!isVisualEditMode) return;
1253
+
1254
+ // Prevent link clicks
1255
+ const preventLinkClick = (e: Event) => {
1256
+ const target = e.target as HTMLElement;
1257
+ const link = target.closest("a");
1258
+ if (link && !link.isContentEditable) {
1259
+ e.preventDefault();
1260
+ e.stopPropagation();
1261
+ }
1262
+ };
1263
+
1264
+ // Prevent form submissions
1265
+ const preventFormSubmit = (e: Event) => {
1266
+ e.preventDefault();
1267
+ e.stopPropagation();
1268
+ };
1269
+
1270
+ // Add listeners in capture phase to catch events early
1271
+ document.addEventListener("click", preventLinkClick, true);
1272
+ document.addEventListener("submit", preventFormSubmit, true);
1273
+
1274
+ return () => {
1275
+ document.removeEventListener("click", preventLinkClick, true);
1276
+ document.removeEventListener("submit", preventFormSubmit, true);
1277
+ };
1278
+ }, [isVisualEditMode]);
1279
+
1280
+ // Clean up when exiting visual edit mode
1281
+ useEffect(() => {
1282
+ if (!isVisualEditMode) {
1283
+ cleanupEditingElement();
1284
+ // Clear applied styles tracking
1285
+ appliedStylesRef.current.clear();
1286
+ hasStyleChangesRef.current = false;
1287
+
1288
+ // Clear image element reference
1289
+ focusedImageElementRef.current = null;
1290
+ }
1291
+ }, [isVisualEditMode]);
1292
+
1293
+ // Update focus box position when scrolling or resizing
1294
+ useEffect(() => {
1295
+ if (focusedElementRef.current) {
1296
+ const handleUpdate = () => {
1297
+ updateFocusBox();
1298
+
1299
+ if (focusedElementRef.current && focusedElementId) {
1300
+ const fr = focusedElementRef.current.getBoundingClientRect();
1301
+ const fBox = expandBox(fr);
1302
+ if (fBox) {
1303
+ const focMsg: ChildToParent = {
1304
+ type: CHANNEL,
1305
+ msg: "FOCUS_MOVED",
1306
+ id: focusedElementId,
1307
+ rect: {
1308
+ top: fBox.top,
1309
+ left: fBox.left,
1310
+ width: fBox.width,
1311
+ height: fBox.height,
1312
+ },
1313
+ };
1314
+ postMessageDedup(focMsg);
1315
+ }
1316
+ }
1317
+ };
1318
+
1319
+ window.addEventListener("scroll", handleUpdate, true);
1320
+ window.addEventListener("resize", handleUpdate);
1321
+
1322
+ // Also observe the focused element for size changes
1323
+ const resizeObserver = new ResizeObserver(handleUpdate);
1324
+ resizeObserver.observe(focusedElementRef.current);
1325
+
1326
+ return () => {
1327
+ window.removeEventListener("scroll", handleUpdate, true);
1328
+ window.removeEventListener("resize", handleUpdate);
1329
+ resizeObserver.disconnect();
1330
+ };
1331
+ }
1332
+ }, [focusedElementId]);
1333
+
1334
+ useEffect(() => {
1335
+ // Handle pointer movement directly in the iframe
1336
+ function onPointerMove(e: PointerEvent) {
1337
+ if (isResizingRef.current) {
1338
+ return;
1339
+ }
1340
+ // Only track pointer when visual edit mode is active
1341
+ if (!isVisualEditModeRef.current) return;
1342
+
1343
+ // Don't show hover boxes while scrolling
1344
+ if (isScrolling) return;
1345
+
1346
+ // Hit-testing at pointer position
1347
+ const hit =
1348
+ document
1349
+ .elementFromPoint(e.clientX, e.clientY)
1350
+ ?.closest<HTMLElement>("[data-orchids-id]") ?? null;
1351
+
1352
+ if (hit !== lastHitElementRef.current) {
1353
+ lastHitElementRef.current = hit;
1354
+
1355
+ if (!hit) {
1356
+ setHoverBox(null);
1357
+ setHoverBoxes([]);
1358
+ setHoverTag(null);
1359
+ lastHitIdRef.current = null;
1360
+ // If we were previously focused on an image, ensure its src is flushed
1361
+ flushImageSrcChange();
1362
+
1363
+ const msg: ChildToParent = {
1364
+ type: CHANNEL,
1365
+ msg: "HIT",
1366
+ id: null,
1367
+ tag: null,
1368
+ rect: null,
1369
+ };
1370
+ postMessageDedup(msg);
1371
+ return;
1372
+ }
1373
+
1374
+ // Don't show hover box if this is the focused element
1375
+ const hitId = hit.getAttribute("data-orchids-id");
1376
+
1377
+ // Check if we're already showing boxes for this ID
1378
+ if (hitId === lastHitIdRef.current) {
1379
+ return;
1380
+ }
1381
+
1382
+ lastHitIdRef.current = hitId;
1383
+
1384
+ const tagName =
1385
+ hit.getAttribute("data-orchids-name") || hit.tagName.toLowerCase();
1386
+
1387
+ // Update hover boxes immediately for instant feedback
1388
+ // Find ALL elements with the same orchids ID
1389
+ const allMatchingElements = document.querySelectorAll(
1390
+ `[data-orchids-id="${hitId}"]`
1391
+ ) as NodeListOf<HTMLElement>;
1392
+
1393
+ // Create hover boxes for all matching elements except the focused one
1394
+ const boxes: Box[] = [];
1395
+ allMatchingElements.forEach((element) => {
1396
+ // Skip if this element is the focused one
1397
+ const elementId = element.getAttribute("data-orchids-id");
1398
+ if (elementId === focusedElementId) {
1399
+ return;
1400
+ }
1401
+
1402
+ const rect = element.getBoundingClientRect();
1403
+ boxes.push(expandBox(rect));
1404
+ });
1405
+
1406
+ // Set multiple hover boxes
1407
+ setHoverBoxes(boxes);
1408
+
1409
+ // Set single hover box for the primary element (for compatibility)
1410
+ // Only set if it's not the focused element
1411
+ if (hitId !== focusedElementId) {
1412
+ const r = hit.getBoundingClientRect();
1413
+ const expandedBox = expandBox(r);
1414
+ setHoverBox(expandedBox);
1415
+ } else {
1416
+ setHoverBox(null);
1417
+ }
1418
+
1419
+ setHoverTag(tagName);
1420
+
1421
+ const msg: ChildToParent = {
1422
+ type: CHANNEL,
1423
+ msg: "HIT",
1424
+ id: hitId,
1425
+ tag: tagName,
1426
+ rect:
1427
+ hitId !== focusedElementId
1428
+ ? expandBox(hit.getBoundingClientRect())
1429
+ : null,
1430
+ };
1431
+ postMessageDedup(msg);
1432
+ }
1433
+ }
1434
+
1435
+ // Handle pointer leaving the document
1436
+ function onPointerLeave() {
1437
+ if (!isVisualEditModeRef.current) return;
1438
+
1439
+ if (isResizingRef.current) return;
1440
+
1441
+ setHoverBox(null);
1442
+ setHoverBoxes([]);
1443
+ setHoverTag(null);
1444
+
1445
+ // Flush image src change when cursor leaves iframe altogether
1446
+ flushImageSrcChange();
1447
+
1448
+ lastHitElementRef.current = null;
1449
+ lastHitIdRef.current = null;
1450
+ const msg: ChildToParent = {
1451
+ type: CHANNEL,
1452
+ msg: "HIT",
1453
+ id: null,
1454
+ tag: null,
1455
+ rect: null,
1456
+ };
1457
+ postMessageDedup(msg);
1458
+ }
1459
+
1460
+ // Handle mousedown to prepare element for editing
1461
+ function onMouseDownCapture(e: MouseEvent) {
1462
+ if (isResizingRef.current) return;
1463
+ // Only handle if visual edit mode is active
1464
+ if (!isVisualEditModeRef.current) return;
1465
+
1466
+ const hit = (e.target as HTMLElement)?.closest<HTMLElement>(
1467
+ "[data-orchids-id]"
1468
+ );
1469
+
1470
+ if (hit && isTextEditable(hit)) {
1471
+ // Store whether it was already editable
1472
+ wasEditableRef.current = hit.contentEditable === "true";
1473
+
1474
+ // Make element editable BEFORE the click goes through
1475
+ if (!wasEditableRef.current) {
1476
+ // Apply inline styles to remove focus ring
1477
+ const currentStyle = hit.getAttribute("style") || "";
1478
+ hit.setAttribute(
1479
+ "style",
1480
+ `${currentStyle}; outline: none !important; box-shadow: none !important;`
1481
+ );
1482
+
1483
+ hit.contentEditable = "true";
1484
+
1485
+ // If the element has children, protect them from being edited
1486
+ if (hit.childElementCount > 0) {
1487
+ protectChildElements(hit);
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ // Handle clicks to focus elements
1494
+ function onClickCapture(e: MouseEvent) {
1495
+ if (isResizingRef.current) return;
1496
+ // Only handle if visual edit mode is active
1497
+ if (!isVisualEditModeRef.current) return;
1498
+
1499
+ // Debounce rapid clicks
1500
+ const now = Date.now();
1501
+ if (now - lastClickTimeRef.current < 100) {
1502
+ return; // Ignore clicks within 100ms of each other
1503
+ }
1504
+ lastClickTimeRef.current = now;
1505
+
1506
+ const target = e.target as HTMLElement;
1507
+ const hit = target.closest<HTMLElement>("[data-orchids-id]");
1508
+
1509
+ if (hit) {
1510
+ const tagName =
1511
+ hit.getAttribute("data-orchids-name") || hit.tagName.toLowerCase();
1512
+
1513
+ const hitId = hit.getAttribute("data-orchids-id");
1514
+ const isEditable = isTextEditable(hit);
1515
+
1516
+ // Always prevent default for non-text interactions
1517
+ const isLink = hit.tagName.toLowerCase() === "a" || !!hit.closest("a");
1518
+ const isButton =
1519
+ hit.tagName.toLowerCase() === "button" ||
1520
+ hit.getAttribute("role") === "button";
1521
+
1522
+ // Prevent navigation and button actions
1523
+ if (isLink || isButton || !isEditable) {
1524
+ e.preventDefault();
1525
+ e.stopPropagation();
1526
+ }
1527
+
1528
+ // Capture previously focused element before updating
1529
+ const prevFocused = focusedElementRef.current;
1530
+
1531
+ // Update focused element
1532
+ focusedElementRef.current = hit;
1533
+ setFocusedElementId(hitId);
1534
+ setFocusTag(tagName);
1535
+
1536
+ // Save focused element info to localStorage
1537
+ if (hitId && typeof window !== "undefined") {
1538
+ const focusedElementData = {
1539
+ id: hitId,
1540
+ tag: tagName,
1541
+ };
1542
+ localStorage.setItem(
1543
+ FOCUSED_ELEMENT_KEY,
1544
+ JSON.stringify(focusedElementData)
1545
+ );
1546
+ }
1547
+
1548
+ // Find ALL other elements with the same orchids ID and show hover boxes
1549
+ const allMatchingElements = document.querySelectorAll(
1550
+ `[data-orchids-id="${hitId}"]`
1551
+ ) as NodeListOf<HTMLElement>;
1552
+
1553
+ // Create hover boxes for all matching elements except the focused one
1554
+ const boxes: Box[] = [];
1555
+ allMatchingElements.forEach((element) => {
1556
+ // Skip the focused element itself
1557
+ if (element === hit) {
1558
+ return;
1559
+ }
1560
+
1561
+ const rect = element.getBoundingClientRect();
1562
+ boxes.push(expandBox(rect));
1563
+ });
1564
+
1565
+ // Set hover boxes for other matching elements
1566
+ setHoverBoxes(boxes);
1567
+ // Set the hover tag to show on other elements
1568
+ if (boxes.length > 0) {
1569
+ setHoverTag(tagName);
1570
+ }
1571
+
1572
+ // Track image element specifically
1573
+ if (hit.tagName.toLowerCase() === "img") {
1574
+ focusedImageElementRef.current = hit as HTMLImageElement;
1575
+ } else {
1576
+ focusedImageElementRef.current = null;
1577
+ }
1578
+
1579
+ // Store original styles for the focused element
1580
+ originalStylesRef.current = getCurrentStyles(hit);
1581
+
1582
+ // If this is an editable element, set it up
1583
+ if (isEditable) {
1584
+ // Cancel any pending cleanup
1585
+ if (pendingCleanupRef.current) {
1586
+ clearTimeout(pendingCleanupRef.current);
1587
+ pendingCleanupRef.current = null;
1588
+ }
1589
+
1590
+ // Clean up any previous editing element first
1591
+ if (editingElementRef.current && editingElementRef.current !== hit) {
1592
+ // Force blur on the previous element to trigger handlers
1593
+ editingElementRef.current.blur();
1594
+ cleanupEditingElement();
1595
+ }
1596
+
1597
+ // Only set up if this is a new element
1598
+ if (hit !== editingElementRef.current) {
1599
+ editingElementRef.current = hit;
1600
+ // Store original content - for elements with children, only store direct text
1601
+ if (hit.childElementCount > 0) {
1602
+ originalContentRef.current = extractDirectTextContent(hit);
1603
+ } else {
1604
+ originalContentRef.current =
1605
+ hit.innerText || hit.textContent || "";
1606
+ }
1607
+
1608
+ // Create handlers with current element reference
1609
+ const createHandlers = (element: HTMLElement) => {
1610
+ const handleFocus = () => {
1611
+ // Double-check this is still the editing element
1612
+ if (element !== editingElementRef.current) return;
1613
+
1614
+ // If the user applied inline style edits **before** entering text
1615
+ // editing mode, make sure we commit them right away so that the
1616
+ // subsequent text edits operate on the updated source.
1617
+ handleStyleBlur(element);
1618
+
1619
+ // Update original content - for elements with children, only store direct text
1620
+ if (element.childElementCount > 0) {
1621
+ originalContentRef.current =
1622
+ extractDirectTextContent(element);
1623
+ } else {
1624
+ originalContentRef.current =
1625
+ element.innerText || element.textContent || "";
1626
+ }
1627
+
1628
+ // Style blur above resets the flag – keep it in sync.
1629
+ hasStyleChangesRef.current = false;
1630
+ };
1631
+
1632
+ const handleBlur = () => {
1633
+ // Double-check this is still the editing element
1634
+ if (element !== editingElementRef.current) return;
1635
+
1636
+ // Flush style changes *before* text changes so that the style
1637
+ // update is committed with the original line/column offsets.
1638
+ // A subsequent text update may shift the source code which would
1639
+ // otherwise cause the later style edit to fail.
1640
+ handleStyleBlur(element);
1641
+ handleTextChange(element);
1642
+ };
1643
+
1644
+ const handleInput = () => {
1645
+ // Double-check this is still the editing element
1646
+ if (element !== editingElementRef.current) return;
1647
+ // Optionally handle real-time updates
1648
+ // handleTextChange(element);
1649
+ };
1650
+
1651
+ return { handleFocus, handleBlur, handleInput };
1652
+ };
1653
+
1654
+ const handlers = createHandlers(hit);
1655
+ hit.addEventListener("focus", handlers.handleFocus);
1656
+ hit.addEventListener("blur", handlers.handleBlur);
1657
+ hit.addEventListener("input", handlers.handleInput);
1658
+
1659
+ // Store handlers for cleanup
1660
+ (hit as any)._editHandlers = {
1661
+ focus: handlers.handleFocus,
1662
+ blur: handlers.handleBlur,
1663
+ input: handlers.handleInput,
1664
+ };
1665
+ }
1666
+ }
1667
+
1668
+ // Update focus box with expanded dimensions
1669
+ const r = hit.getBoundingClientRect();
1670
+ const expandedBox = expandBox(r);
1671
+ setFocusBox(expandedBox);
1672
+
1673
+ // Clear hover box since we're focusing
1674
+ setHoverBox(null);
1675
+
1676
+ // Get className for Tailwind extraction
1677
+ const className = hit.getAttribute("class") || "";
1678
+
1679
+ // Get src for images & track original
1680
+ const srcRaw =
1681
+ hit.tagName.toLowerCase() === "img"
1682
+ ? (hit as HTMLImageElement).src
1683
+ : undefined;
1684
+
1685
+ if (srcRaw) {
1686
+ originalSrcRef.current = normalizeImageSrc(srcRaw);
1687
+ }
1688
+
1689
+ // Get current styles immediately
1690
+ const computedStyles = getCurrentStyles(hit);
1691
+
1692
+ // Send click event to parent with coordinates and current styles
1693
+ const msg: ChildToParent = {
1694
+ type: CHANNEL,
1695
+ msg: "ELEMENT_CLICKED",
1696
+ id: hitId,
1697
+ tag: tagName,
1698
+ rect: expandedBox
1699
+ ? {
1700
+ top: expandedBox.top,
1701
+ left: expandedBox.left,
1702
+ width: expandedBox.width,
1703
+ height: expandedBox.height,
1704
+ }
1705
+ : {
1706
+ top: 0,
1707
+ left: 0,
1708
+ width: 0,
1709
+ height: 0,
1710
+ },
1711
+ clickPosition: {
1712
+ x: e.clientX,
1713
+ y: e.clientY,
1714
+ },
1715
+ isEditable,
1716
+ currentStyles: computedStyles,
1717
+ className,
1718
+ src: srcRaw,
1719
+ };
1720
+
1721
+ // Send message with all data at once
1722
+ postMessageDedup(msg);
1723
+
1724
+ // Move cleanup operations to after message is sent
1725
+ setTimeout(() => {
1726
+ // Before changing focus, flush pending image src change
1727
+ flushImageSrcChange();
1728
+
1729
+ // Flush style changes for the previously focused element (if any)
1730
+ if (prevFocused && prevFocused !== hit) {
1731
+ handleStyleBlur(prevFocused);
1732
+ }
1733
+
1734
+ // Clean up any previous editing element (if it's different)
1735
+ if (editingElementRef.current && editingElementRef.current !== hit) {
1736
+ cleanupEditingElement();
1737
+ }
1738
+ }, 0);
1739
+ } else {
1740
+ // Clicked on empty space or element without data-orchids-id
1741
+ // Clear focus and hover boxes
1742
+ if (focusedElementRef.current) {
1743
+ // Flush any pending changes
1744
+ flushImageSrcChange();
1745
+ handleStyleBlur(focusedElementRef.current);
1746
+ cleanupEditingElement();
1747
+
1748
+ // Clear all focus and hover states
1749
+ focusedElementRef.current = null;
1750
+ setFocusedElementId(null);
1751
+ setFocusTag(null);
1752
+ setFocusBox(null);
1753
+ setHoverBox(null);
1754
+ setHoverBoxes([]);
1755
+ setHoverTag(null);
1756
+
1757
+ // Clear focused element from localStorage
1758
+ if (typeof window !== "undefined") {
1759
+ localStorage.removeItem(FOCUSED_ELEMENT_KEY);
1760
+ }
1761
+
1762
+ // Notify parent that focus was cleared
1763
+ const msg: ChildToParent = {
1764
+ type: CHANNEL,
1765
+ msg: "ELEMENT_CLICKED",
1766
+ id: null,
1767
+ tag: null,
1768
+ rect: {
1769
+ top: 0,
1770
+ left: 0,
1771
+ width: 0,
1772
+ height: 0,
1773
+ },
1774
+ clickPosition: {
1775
+ x: e.clientX,
1776
+ y: e.clientY,
1777
+ },
1778
+ isEditable: false,
1779
+ currentStyles: {},
1780
+ className: "",
1781
+ };
1782
+ postMessageDedup(msg);
1783
+ }
1784
+ }
1785
+ }
1786
+
1787
+ // Handle messages from parent
1788
+ function onMsg(e: MessageEvent<ParentToChild>) {
1789
+ if (e.data?.type !== CHANNEL) return;
1790
+
1791
+ // Handle preview font request from parent
1792
+ if (e.data.msg === "PREVIEW_FONT" && "elementId" in e.data) {
1793
+ const { elementId, fontFamily } = e.data;
1794
+
1795
+ // Skip if font already persisted for this element to avoid race condition
1796
+ if (persistentFontMap.current.has(elementId)) {
1797
+ return;
1798
+ }
1799
+
1800
+ const element = document.querySelector(
1801
+ `[data-orchids-id="${elementId}"]`
1802
+ ) as HTMLElement | null;
1803
+ if (!element) return;
1804
+
1805
+ // Ensure font stylesheet is loaded
1806
+ const familyKey = fontFamily.replace(/\s+/g, "+");
1807
+ if (!loadedFontFamilies.current.has(familyKey)) {
1808
+ const link = document.createElement("link");
1809
+ link.rel = "stylesheet";
1810
+ link.href = `https://fonts.googleapis.com/css2?family=${familyKey}:wght@400&display=swap`;
1811
+ document.head.appendChild(link);
1812
+ loadedFontFamilies.current.add(familyKey);
1813
+ }
1814
+
1815
+ // Apply font family to element inline for preview
1816
+ element.style.fontFamily = `'${fontFamily}', sans-serif`;
1817
+ return;
1818
+ }
1819
+
1820
+ // Handle scroll messages from parent
1821
+ if (e.data.msg === "SCROLL" && "dx" in e.data && "dy" in e.data) {
1822
+ window.scrollBy(e.data.dx, e.data.dy);
1823
+ }
1824
+
1825
+ // Handle visual edit mode state changes
1826
+ if (e.data.msg === "VISUAL_EDIT_MODE" && "active" in e.data) {
1827
+ const newMode = e.data.active;
1828
+ setIsVisualEditMode(newMode);
1829
+
1830
+ // Clear localStorage if visual edit mode is being turned off
1831
+ if (!newMode && typeof window !== "undefined") {
1832
+ localStorage.removeItem(VISUAL_EDIT_MODE_KEY);
1833
+ localStorage.removeItem(FOCUSED_ELEMENT_KEY);
1834
+ }
1835
+
1836
+ // Send acknowledgement back to parent so it knows we received the mode change
1837
+ window.parent.postMessage(
1838
+ { type: CHANNEL, msg: "VISUAL_EDIT_MODE_ACK", active: newMode },
1839
+ "*"
1840
+ );
1841
+
1842
+ if (!newMode) {
1843
+ // already handled, flush too
1844
+ // Flush image src change for current focus
1845
+ flushImageSrcChange();
1846
+
1847
+ // Clean up any editing element
1848
+ cleanupEditingElement();
1849
+
1850
+ // Clear image element reference
1851
+ focusedImageElementRef.current = null;
1852
+
1853
+ // Clear everything when exiting visual edit mode
1854
+ setHoverBox(null);
1855
+ setHoverBoxes([]);
1856
+ setFocusBox(null);
1857
+ setFocusedElementId(null);
1858
+ lastHitElementRef.current = null;
1859
+ focusedElementRef.current = null;
1860
+ hasStyleChangesRef.current = false;
1861
+
1862
+ setHoverTag(null);
1863
+ setFocusTag(null);
1864
+
1865
+ // Notify parent that we've cleared the selection
1866
+ const msg: ChildToParent = {
1867
+ type: CHANNEL,
1868
+ msg: "HIT",
1869
+ id: null,
1870
+ tag: null,
1871
+ rect: null,
1872
+ };
1873
+ postMessageDedup(msg);
1874
+ }
1875
+ }
1876
+
1877
+ // Handle clear inline styles message
1878
+ if (e.data.msg === "CLEAR_INLINE_STYLES" && "elementId" in e.data) {
1879
+ // Find ALL elements with the same orchids ID
1880
+ const allMatchingElements = document.querySelectorAll(
1881
+ `[data-orchids-id="${e.data.elementId}"]`
1882
+ ) as NodeListOf<HTMLElement>;
1883
+
1884
+ allMatchingElements.forEach((element) => {
1885
+ // Clear only the inline styles we track (typography, spacing, and background)
1886
+ const stylesToClear = [
1887
+ "fontSize",
1888
+ "color",
1889
+ "fontWeight",
1890
+ "fontStyle",
1891
+ "textDecoration",
1892
+ "textAlign",
1893
+ "paddingLeft",
1894
+ "paddingRight",
1895
+ "paddingTop",
1896
+ "paddingBottom",
1897
+ "marginLeft",
1898
+ "marginRight",
1899
+ "marginTop",
1900
+ "marginBottom",
1901
+ "backgroundColor",
1902
+ "backgroundImage",
1903
+ ];
1904
+
1905
+ stylesToClear.forEach((prop) => {
1906
+ (element.style as any)[prop] = "";
1907
+ });
1908
+ });
1909
+
1910
+ // Clear from our tracking
1911
+ appliedStylesRef.current.delete(e.data.elementId);
1912
+ }
1913
+
1914
+ // Handle show element hover message
1915
+ if (e.data.msg === "SHOW_ELEMENT_HOVER" && "elementId" in e.data) {
1916
+ const { elementId } = e.data;
1917
+
1918
+ if (!elementId) {
1919
+ // Clear hover boxes if no element ID
1920
+ setHoverBoxes([]);
1921
+ setHoverTag(null);
1922
+ return;
1923
+ }
1924
+
1925
+ // Find ALL elements with the same orchids ID
1926
+ const allMatchingElements = document.querySelectorAll(
1927
+ `[data-orchids-id="${elementId}"]`
1928
+ ) as NodeListOf<HTMLElement>;
1929
+
1930
+ if (allMatchingElements.length > 0) {
1931
+ const boxes: Box[] = [];
1932
+ let tagName = "";
1933
+
1934
+ allMatchingElements.forEach((element) => {
1935
+ // Skip if this element is the focused one
1936
+ if (element === focusedElementRef.current) {
1937
+ return;
1938
+ }
1939
+
1940
+ const rect = element.getBoundingClientRect();
1941
+ boxes.push(expandBox(rect));
1942
+
1943
+ if (!tagName) {
1944
+ tagName =
1945
+ element.getAttribute("data-orchids-name") ||
1946
+ element.tagName.toLowerCase();
1947
+ }
1948
+ });
1949
+
1950
+ // Set hover boxes for all matching elements
1951
+ setHoverBoxes(boxes);
1952
+ setHoverTag(boxes.length > 0 ? tagName : null);
1953
+ }
1954
+ }
1955
+ }
1956
+
1957
+ // Handle scroll events to update hover box position
1958
+ function onScroll() {
1959
+ if (isResizingRef.current) return;
1960
+ // Only update hover box if visual edit mode is active
1961
+ if (!isVisualEditModeRef.current) return;
1962
+
1963
+ // Hide hover boxes while scrolling
1964
+ setIsScrolling(true);
1965
+ setHoverBox(null);
1966
+ setHoverBoxes([]);
1967
+
1968
+ // Notify parent that scrolling has started
1969
+ const scrollMsg: ChildToParent = {
1970
+ type: CHANNEL,
1971
+ msg: "SCROLL_STARTED",
1972
+ };
1973
+ postMessageDedup(scrollMsg);
1974
+
1975
+ // Reset the notification flag after scrolling stops
1976
+ if (scrollTimeoutRef.current) {
1977
+ clearTimeout(scrollTimeoutRef.current);
1978
+ }
1979
+ scrollTimeoutRef.current = window.setTimeout(() => {
1980
+ setIsScrolling(false);
1981
+ const scrollStopMsg: ChildToParent = {
1982
+ type: CHANNEL,
1983
+ msg: "SCROLL_STOPPED",
1984
+ };
1985
+ postMessageDedup(scrollStopMsg);
1986
+ }, 16); // One frame (16ms) for instant restoration
1987
+ }
1988
+
1989
+ // Add event listeners
1990
+ document.addEventListener("pointermove", onPointerMove, { passive: true });
1991
+ document.addEventListener("pointerleave", onPointerLeave);
1992
+ document.addEventListener("mousedown", onMouseDownCapture, {
1993
+ capture: true,
1994
+ });
1995
+ document.addEventListener("click", onClickCapture, { capture: true });
1996
+ window.addEventListener("message", onMsg);
1997
+ window.addEventListener("scroll", onScroll, true);
1998
+
1999
+ return () => {
2000
+ document.removeEventListener("pointermove", onPointerMove);
2001
+ document.removeEventListener("pointerleave", onPointerLeave);
2002
+ document.removeEventListener("mousedown", onMouseDownCapture, true);
2003
+ document.removeEventListener("click", onClickCapture, true);
2004
+ window.removeEventListener("message", onMsg);
2005
+ window.removeEventListener("scroll", onScroll, true);
2006
+ if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
2007
+ };
2008
+ }, [focusedElementId, isResizing]); // Added focusedElementId and isResizing as dependencies
2009
+
2010
+ return (
2011
+ <>
2012
+ {/* Hover box - shows on hover with blue overlay */}
2013
+ {isVisualEditMode && !isResizing && (
2014
+ <>
2015
+ {/* Render all hover boxes for elements with same ID */}
2016
+ {hoverBoxes
2017
+ .filter((box): box is NonNullable<Box> => box !== null)
2018
+ .map((box, index) => (
2019
+ <div key={index}>
2020
+ <div
2021
+ className="fixed pointer-events-none border-[0.5px] border-[#38bdf8] bg-blue-200/20 border-dashed rounded-sm"
2022
+ style={{
2023
+ zIndex: 100000,
2024
+ left: box.left,
2025
+ top: box.top,
2026
+ width: box.width,
2027
+ height: box.height,
2028
+ }}
2029
+ />
2030
+ {/* Tag label on each hover box */}
2031
+ {hoverTag && (
2032
+ <div
2033
+ className="fixed pointer-events-none text-[10px] text-white bg-[#38bdf8] px-1 py-0.5 rounded-sm"
2034
+ style={{
2035
+ zIndex: 100001,
2036
+ left: box.left,
2037
+ top: box.top - 20,
2038
+ }}
2039
+ >
2040
+ {hoverTag}
2041
+ </div>
2042
+ )}
2043
+ </div>
2044
+ ))}
2045
+ </>
2046
+ )}
2047
+
2048
+ {/* Focus box - shows on click with just border, no overlay */}
2049
+ {isVisualEditMode && focusBox && (
2050
+ <>
2051
+ {focusTag && (
2052
+ <div
2053
+ className="fixed text-[10px] font-semibold text-white bg-[#3b82f6] px-1 rounded-sm pointer-events-none select-none"
2054
+ style={{
2055
+ zIndex: 100003,
2056
+ left: focusBox.left - 4,
2057
+ top: focusBox.top - 16,
2058
+ }}
2059
+ >
2060
+ {focusTag}
2061
+ </div>
2062
+ )}
2063
+
2064
+ <div
2065
+ className="fixed pointer-events-none border-[1.5px] border-[#38bdf8] rounded-sm"
2066
+ style={{
2067
+ zIndex: 100001,
2068
+ left: focusBox.left,
2069
+ top: focusBox.top,
2070
+ width: focusBox.width,
2071
+ height: focusBox.height,
2072
+ }}
2073
+ />
2074
+
2075
+ {/* Resize handles */}
2076
+ {!isResizing && (
2077
+ <>
2078
+ {/* Corner handles */}
2079
+ <div
2080
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-nw-resize pointer-events-auto resize-handle"
2081
+ style={{
2082
+ zIndex: 100002,
2083
+ left: focusBox.left - 4,
2084
+ top: focusBox.top - 4,
2085
+ }}
2086
+ onMouseDown={(e) => handleResizeStart(e, "nw")}
2087
+ />
2088
+ <div
2089
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-ne-resize pointer-events-auto resize-handle"
2090
+ style={{
2091
+ zIndex: 100002,
2092
+ left: focusBox.left + focusBox.width - 4,
2093
+ top: focusBox.top - 4,
2094
+ }}
2095
+ onMouseDown={(e) => handleResizeStart(e, "ne")}
2096
+ />
2097
+ <div
2098
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-sw-resize pointer-events-auto resize-handle"
2099
+ style={{
2100
+ zIndex: 100002,
2101
+ left: focusBox.left - 4,
2102
+ top: focusBox.top + focusBox.height - 4,
2103
+ }}
2104
+ onMouseDown={(e) => handleResizeStart(e, "sw")}
2105
+ />
2106
+ <div
2107
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-se-resize pointer-events-auto resize-handle"
2108
+ style={{
2109
+ zIndex: 100002,
2110
+ left: focusBox.left + focusBox.width - 4,
2111
+ top: focusBox.top + focusBox.height - 4,
2112
+ }}
2113
+ onMouseDown={(e) => handleResizeStart(e, "se")}
2114
+ />
2115
+
2116
+ {/* Edge handles */}
2117
+ <div
2118
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-n-resize pointer-events-auto resize-handle"
2119
+ style={{
2120
+ zIndex: 100002,
2121
+ left: focusBox.left + focusBox.width / 2 - 4,
2122
+ top: focusBox.top - 4,
2123
+ }}
2124
+ onMouseDown={(e) => handleResizeStart(e, "n")}
2125
+ />
2126
+ <div
2127
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-s-resize pointer-events-auto resize-handle"
2128
+ style={{
2129
+ zIndex: 100002,
2130
+ left: focusBox.left + focusBox.width / 2 - 4,
2131
+ top: focusBox.top + focusBox.height - 4,
2132
+ }}
2133
+ onMouseDown={(e) => handleResizeStart(e, "s")}
2134
+ />
2135
+ <div
2136
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-w-resize pointer-events-auto resize-handle"
2137
+ style={{
2138
+ zIndex: 100002,
2139
+ left: focusBox.left - 4,
2140
+ top: focusBox.top + focusBox.height / 2 - 4,
2141
+ }}
2142
+ onMouseDown={(e) => handleResizeStart(e, "w")}
2143
+ />
2144
+ <div
2145
+ className="fixed w-2 h-2 bg-[#38bdf8] rounded-full cursor-e-resize pointer-events-auto resize-handle"
2146
+ style={{
2147
+ zIndex: 100002,
2148
+ left: focusBox.left + focusBox.width - 4,
2149
+ top: focusBox.top + focusBox.height / 2 - 4,
2150
+ }}
2151
+ onMouseDown={(e) => handleResizeStart(e, "e")}
2152
+ />
2153
+ </>
2154
+ )}
2155
+ </>
2156
+ )}
2157
+ </>
2158
+ );
2159
+ }