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.
- package/README.md +70 -0
- package/bin/cli.js +140 -0
- package/landing-pages/ai-saas-template/.orchids/orchids.json +8 -0
- package/landing-pages/ai-saas-template/README.md +36 -0
- package/landing-pages/ai-saas-template/bun.lock +2062 -0
- package/landing-pages/ai-saas-template/components.json +22 -0
- package/landing-pages/ai-saas-template/eslint.config.mjs +33 -0
- package/landing-pages/ai-saas-template/next.config.ts +24 -0
- package/landing-pages/ai-saas-template/package-lock.json +11708 -0
- package/landing-pages/ai-saas-template/package.json +114 -0
- package/landing-pages/ai-saas-template/postcss.config.mjs +7 -0
- package/landing-pages/ai-saas-template/public/file.svg +1 -0
- package/landing-pages/ai-saas-template/public/globe.svg +1 -0
- package/landing-pages/ai-saas-template/public/next.svg +1 -0
- package/landing-pages/ai-saas-template/public/vercel.svg +1 -0
- package/landing-pages/ai-saas-template/public/window.svg +1 -0
- package/landing-pages/ai-saas-template/src/app/favicon.ico +0 -0
- package/landing-pages/ai-saas-template/src/app/global-error.tsx +5 -0
- package/landing-pages/ai-saas-template/src/app/globals.css +172 -0
- package/landing-pages/ai-saas-template/src/app/layout.tsx +42 -0
- package/landing-pages/ai-saas-template/src/app/page.tsx +23 -0
- package/landing-pages/ai-saas-template/src/components/ErrorReporter.tsx +136 -0
- package/landing-pages/ai-saas-template/src/components/sections/cta.tsx +62 -0
- package/landing-pages/ai-saas-template/src/components/sections/features-grid.tsx +205 -0
- package/landing-pages/ai-saas-template/src/components/sections/footer.tsx +111 -0
- package/landing-pages/ai-saas-template/src/components/sections/hero.tsx +92 -0
- package/landing-pages/ai-saas-template/src/components/sections/logos.tsx +69 -0
- package/landing-pages/ai-saas-template/src/components/sections/navbar.tsx +83 -0
- package/landing-pages/ai-saas-template/src/components/sections/testimonials-header.tsx +41 -0
- package/landing-pages/ai-saas-template/src/components/sections/value-props.tsx +97 -0
- package/landing-pages/ai-saas-template/src/components/ui/accordion.tsx +66 -0
- package/landing-pages/ai-saas-template/src/components/ui/alert-dialog.tsx +157 -0
- package/landing-pages/ai-saas-template/src/components/ui/alert.tsx +66 -0
- package/landing-pages/ai-saas-template/src/components/ui/aspect-ratio.tsx +11 -0
- package/landing-pages/ai-saas-template/src/components/ui/avatar.tsx +53 -0
- package/landing-pages/ai-saas-template/src/components/ui/badge.tsx +46 -0
- package/landing-pages/ai-saas-template/src/components/ui/breadcrumb.tsx +109 -0
- package/landing-pages/ai-saas-template/src/components/ui/button-group.tsx +83 -0
- package/landing-pages/ai-saas-template/src/components/ui/button.tsx +59 -0
- package/landing-pages/ai-saas-template/src/components/ui/calendar.tsx +213 -0
- package/landing-pages/ai-saas-template/src/components/ui/card.tsx +92 -0
- package/landing-pages/ai-saas-template/src/components/ui/carousel.tsx +241 -0
- package/landing-pages/ai-saas-template/src/components/ui/chart.tsx +353 -0
- package/landing-pages/ai-saas-template/src/components/ui/checkbox.tsx +32 -0
- package/landing-pages/ai-saas-template/src/components/ui/collapsible.tsx +33 -0
- package/landing-pages/ai-saas-template/src/components/ui/command.tsx +184 -0
- package/landing-pages/ai-saas-template/src/components/ui/context-menu.tsx +252 -0
- package/landing-pages/ai-saas-template/src/components/ui/dialog.tsx +143 -0
- package/landing-pages/ai-saas-template/src/components/ui/drawer.tsx +135 -0
- package/landing-pages/ai-saas-template/src/components/ui/dropdown-menu.tsx +257 -0
- package/landing-pages/ai-saas-template/src/components/ui/empty.tsx +104 -0
- package/landing-pages/ai-saas-template/src/components/ui/field.tsx +248 -0
- package/landing-pages/ai-saas-template/src/components/ui/form.tsx +167 -0
- package/landing-pages/ai-saas-template/src/components/ui/hover-card.tsx +44 -0
- package/landing-pages/ai-saas-template/src/components/ui/input-group.tsx +170 -0
- package/landing-pages/ai-saas-template/src/components/ui/input-otp.tsx +77 -0
- package/landing-pages/ai-saas-template/src/components/ui/input.tsx +21 -0
- package/landing-pages/ai-saas-template/src/components/ui/item.tsx +193 -0
- package/landing-pages/ai-saas-template/src/components/ui/kbd.tsx +28 -0
- package/landing-pages/ai-saas-template/src/components/ui/label.tsx +24 -0
- package/landing-pages/ai-saas-template/src/components/ui/menubar.tsx +276 -0
- package/landing-pages/ai-saas-template/src/components/ui/navigation-menu.tsx +168 -0
- package/landing-pages/ai-saas-template/src/components/ui/pagination.tsx +127 -0
- package/landing-pages/ai-saas-template/src/components/ui/popover.tsx +48 -0
- package/landing-pages/ai-saas-template/src/components/ui/progress.tsx +31 -0
- package/landing-pages/ai-saas-template/src/components/ui/radio-group.tsx +45 -0
- package/landing-pages/ai-saas-template/src/components/ui/resizable.tsx +56 -0
- package/landing-pages/ai-saas-template/src/components/ui/scroll-area.tsx +58 -0
- package/landing-pages/ai-saas-template/src/components/ui/select.tsx +185 -0
- package/landing-pages/ai-saas-template/src/components/ui/separator.tsx +28 -0
- package/landing-pages/ai-saas-template/src/components/ui/sheet.tsx +139 -0
- package/landing-pages/ai-saas-template/src/components/ui/sidebar.tsx +726 -0
- package/landing-pages/ai-saas-template/src/components/ui/skeleton.tsx +13 -0
- package/landing-pages/ai-saas-template/src/components/ui/slider.tsx +63 -0
- package/landing-pages/ai-saas-template/src/components/ui/sonner.tsx +25 -0
- package/landing-pages/ai-saas-template/src/components/ui/spinner.tsx +16 -0
- package/landing-pages/ai-saas-template/src/components/ui/switch.tsx +31 -0
- package/landing-pages/ai-saas-template/src/components/ui/table.tsx +116 -0
- package/landing-pages/ai-saas-template/src/components/ui/tabs.tsx +66 -0
- package/landing-pages/ai-saas-template/src/components/ui/textarea.tsx +18 -0
- package/landing-pages/ai-saas-template/src/components/ui/toggle-group.tsx +73 -0
- package/landing-pages/ai-saas-template/src/components/ui/toggle.tsx +47 -0
- package/landing-pages/ai-saas-template/src/components/ui/tooltip.tsx +61 -0
- package/landing-pages/ai-saas-template/src/hooks/use-mobile.ts +19 -0
- package/landing-pages/ai-saas-template/src/lib/hooks/use-mobile.tsx +21 -0
- package/landing-pages/ai-saas-template/src/lib/utils.ts +6 -0
- package/landing-pages/ai-saas-template/src/visual-edits/VisualEditsMessenger.tsx +2159 -0
- package/landing-pages/ai-saas-template/src/visual-edits/component-tagger-loader.js +460 -0
- package/landing-pages/ai-saas-template/tsconfig.json +42 -0
- package/landing-pages/open-engineer-template/.orchids/orchids.json +8 -0
- package/landing-pages/open-engineer-template/README.md +36 -0
- package/landing-pages/open-engineer-template/bun.lock +2062 -0
- package/landing-pages/open-engineer-template/components.json +22 -0
- package/landing-pages/open-engineer-template/eslint.config.mjs +33 -0
- package/landing-pages/open-engineer-template/next.config.ts +24 -0
- package/landing-pages/open-engineer-template/package-lock.json +13669 -0
- package/landing-pages/open-engineer-template/package.json +114 -0
- package/landing-pages/open-engineer-template/postcss.config.mjs +7 -0
- package/landing-pages/open-engineer-template/public/file.svg +1 -0
- package/landing-pages/open-engineer-template/public/globe.svg +1 -0
- package/landing-pages/open-engineer-template/public/next.svg +1 -0
- package/landing-pages/open-engineer-template/public/vercel.svg +1 -0
- package/landing-pages/open-engineer-template/public/window.svg +1 -0
- package/landing-pages/open-engineer-template/src/app/favicon.ico +0 -0
- package/landing-pages/open-engineer-template/src/app/global-error.tsx +5 -0
- package/landing-pages/open-engineer-template/src/app/globals.css +189 -0
- package/landing-pages/open-engineer-template/src/app/layout.tsx +42 -0
- package/landing-pages/open-engineer-template/src/app/page.tsx +31 -0
- package/landing-pages/open-engineer-template/src/components/ErrorReporter.tsx +136 -0
- package/landing-pages/open-engineer-template/src/components/sections/cta-stats.tsx +71 -0
- package/landing-pages/open-engineer-template/src/components/sections/faq.tsx +188 -0
- package/landing-pages/open-engineer-template/src/components/sections/features-grid.tsx +193 -0
- package/landing-pages/open-engineer-template/src/components/sections/footer.tsx +137 -0
- package/landing-pages/open-engineer-template/src/components/sections/header.tsx +105 -0
- package/landing-pages/open-engineer-template/src/components/sections/hero.tsx +118 -0
- package/landing-pages/open-engineer-template/src/components/sections/how-it-works.tsx +123 -0
- package/landing-pages/open-engineer-template/src/components/sections/pricing.tsx +168 -0
- package/landing-pages/open-engineer-template/src/components/sections/testimonials-logos.tsx +88 -0
- package/landing-pages/open-engineer-template/src/components/sections/use-cases.tsx +141 -0
- package/landing-pages/open-engineer-template/src/components/sections/workflow-tabs.tsx +792 -0
- package/landing-pages/open-engineer-template/src/components/ui/accordion.tsx +66 -0
- package/landing-pages/open-engineer-template/src/components/ui/alert-dialog.tsx +157 -0
- package/landing-pages/open-engineer-template/src/components/ui/alert.tsx +66 -0
- package/landing-pages/open-engineer-template/src/components/ui/aspect-ratio.tsx +11 -0
- package/landing-pages/open-engineer-template/src/components/ui/avatar.tsx +53 -0
- package/landing-pages/open-engineer-template/src/components/ui/badge.tsx +46 -0
- package/landing-pages/open-engineer-template/src/components/ui/breadcrumb.tsx +109 -0
- package/landing-pages/open-engineer-template/src/components/ui/button-group.tsx +83 -0
- package/landing-pages/open-engineer-template/src/components/ui/button.tsx +59 -0
- package/landing-pages/open-engineer-template/src/components/ui/calendar.tsx +213 -0
- package/landing-pages/open-engineer-template/src/components/ui/card.tsx +92 -0
- package/landing-pages/open-engineer-template/src/components/ui/carousel.tsx +241 -0
- package/landing-pages/open-engineer-template/src/components/ui/chart.tsx +353 -0
- package/landing-pages/open-engineer-template/src/components/ui/checkbox.tsx +32 -0
- package/landing-pages/open-engineer-template/src/components/ui/collapsible.tsx +33 -0
- package/landing-pages/open-engineer-template/src/components/ui/command.tsx +184 -0
- package/landing-pages/open-engineer-template/src/components/ui/context-menu.tsx +252 -0
- package/landing-pages/open-engineer-template/src/components/ui/dialog.tsx +143 -0
- package/landing-pages/open-engineer-template/src/components/ui/drawer.tsx +135 -0
- package/landing-pages/open-engineer-template/src/components/ui/dropdown-menu.tsx +257 -0
- package/landing-pages/open-engineer-template/src/components/ui/empty.tsx +104 -0
- package/landing-pages/open-engineer-template/src/components/ui/field.tsx +248 -0
- package/landing-pages/open-engineer-template/src/components/ui/form.tsx +167 -0
- package/landing-pages/open-engineer-template/src/components/ui/hover-card.tsx +44 -0
- package/landing-pages/open-engineer-template/src/components/ui/input-group.tsx +170 -0
- package/landing-pages/open-engineer-template/src/components/ui/input-otp.tsx +77 -0
- package/landing-pages/open-engineer-template/src/components/ui/input.tsx +21 -0
- package/landing-pages/open-engineer-template/src/components/ui/item.tsx +193 -0
- package/landing-pages/open-engineer-template/src/components/ui/kbd.tsx +28 -0
- package/landing-pages/open-engineer-template/src/components/ui/label.tsx +24 -0
- package/landing-pages/open-engineer-template/src/components/ui/menubar.tsx +276 -0
- package/landing-pages/open-engineer-template/src/components/ui/navigation-menu.tsx +168 -0
- package/landing-pages/open-engineer-template/src/components/ui/pagination.tsx +127 -0
- package/landing-pages/open-engineer-template/src/components/ui/popover.tsx +48 -0
- package/landing-pages/open-engineer-template/src/components/ui/progress.tsx +31 -0
- package/landing-pages/open-engineer-template/src/components/ui/radio-group.tsx +45 -0
- package/landing-pages/open-engineer-template/src/components/ui/resizable.tsx +56 -0
- package/landing-pages/open-engineer-template/src/components/ui/scroll-area.tsx +58 -0
- package/landing-pages/open-engineer-template/src/components/ui/select.tsx +185 -0
- package/landing-pages/open-engineer-template/src/components/ui/separator.tsx +28 -0
- package/landing-pages/open-engineer-template/src/components/ui/sheet.tsx +139 -0
- package/landing-pages/open-engineer-template/src/components/ui/sidebar.tsx +726 -0
- package/landing-pages/open-engineer-template/src/components/ui/skeleton.tsx +13 -0
- package/landing-pages/open-engineer-template/src/components/ui/slider.tsx +63 -0
- package/landing-pages/open-engineer-template/src/components/ui/sonner.tsx +25 -0
- package/landing-pages/open-engineer-template/src/components/ui/spinner.tsx +16 -0
- package/landing-pages/open-engineer-template/src/components/ui/switch.tsx +31 -0
- package/landing-pages/open-engineer-template/src/components/ui/table.tsx +116 -0
- package/landing-pages/open-engineer-template/src/components/ui/tabs.tsx +66 -0
- package/landing-pages/open-engineer-template/src/components/ui/textarea.tsx +18 -0
- package/landing-pages/open-engineer-template/src/components/ui/toggle-group.tsx +73 -0
- package/landing-pages/open-engineer-template/src/components/ui/toggle.tsx +47 -0
- package/landing-pages/open-engineer-template/src/components/ui/tooltip.tsx +61 -0
- package/landing-pages/open-engineer-template/src/hooks/use-mobile.ts +19 -0
- package/landing-pages/open-engineer-template/src/lib/hooks/use-mobile.tsx +21 -0
- package/landing-pages/open-engineer-template/src/lib/utils.ts +6 -0
- package/landing-pages/open-engineer-template/src/visual-edits/VisualEditsMessenger.tsx +2159 -0
- package/landing-pages/open-engineer-template/src/visual-edits/component-tagger-loader.js +460 -0
- package/landing-pages/open-engineer-template/tsconfig.json +42 -0
- package/package.json +36 -0
- 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
|
+
}
|