sonance-brand-mcp 1.3.110 → 1.3.112
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/dist/assets/api/sonance-ai-edit/route.ts +30 -7
- package/dist/assets/api/sonance-save-image/route.ts +625 -0
- package/dist/assets/api/sonance-vision-apply/image-styling-detection.ts +1360 -0
- package/dist/assets/api/sonance-vision-apply/route.ts +1020 -64
- package/dist/assets/api/sonance-vision-apply/styling-detection.ts +730 -0
- package/dist/assets/api/sonance-vision-apply/theme-discovery.ts +1 -1
- package/dist/assets/api/sonance-vision-edit/route.ts +33 -8
- package/dist/assets/brand-system.ts +13 -12
- package/dist/assets/components/accordion.tsx +15 -7
- package/dist/assets/components/alert-dialog.tsx +35 -10
- package/dist/assets/components/alert.tsx +11 -10
- package/dist/assets/components/avatar.tsx +4 -4
- package/dist/assets/components/badge.tsx +16 -12
- package/dist/assets/components/button.stories.tsx +3 -3
- package/dist/assets/components/button.tsx +50 -31
- package/dist/assets/components/calendar.tsx +12 -8
- package/dist/assets/components/card.tsx +35 -29
- package/dist/assets/components/checkbox.tsx +9 -8
- package/dist/assets/components/code.tsx +19 -11
- package/dist/assets/components/command.tsx +32 -13
- package/dist/assets/components/context-menu.tsx +37 -16
- package/dist/assets/components/dialog.tsx +8 -5
- package/dist/assets/components/divider.tsx +15 -5
- package/dist/assets/components/drawer.tsx +4 -3
- package/dist/assets/components/dropdown-menu.tsx +15 -13
- package/dist/assets/components/hover-card.tsx +4 -1
- package/dist/assets/components/image.tsx +1 -1
- package/dist/assets/components/input.tsx +29 -14
- package/dist/assets/components/kbd.stories.tsx +3 -3
- package/dist/assets/components/kbd.tsx +29 -13
- package/dist/assets/components/listbox.tsx +8 -8
- package/dist/assets/components/menubar.tsx +50 -23
- package/dist/assets/components/navbar.stories.tsx +140 -13
- package/dist/assets/components/navbar.tsx +22 -5
- package/dist/assets/components/navigation-menu.tsx +28 -6
- package/dist/assets/components/pagination.tsx +10 -10
- package/dist/assets/components/popover.tsx +10 -8
- package/dist/assets/components/progress.tsx +6 -4
- package/dist/assets/components/radio-group.tsx +5 -5
- package/dist/assets/components/select.tsx +49 -29
- package/dist/assets/components/separator.tsx +3 -3
- package/dist/assets/components/sheet.tsx +4 -4
- package/dist/assets/components/sidebar.tsx +10 -10
- package/dist/assets/components/skeleton.tsx +13 -5
- package/dist/assets/components/slider.tsx +12 -10
- package/dist/assets/components/switch.tsx +4 -4
- package/dist/assets/components/table.tsx +5 -5
- package/dist/assets/components/tabs.tsx +8 -8
- package/dist/assets/components/textarea.tsx +11 -9
- package/dist/assets/components/toast.tsx +7 -7
- package/dist/assets/components/toggle.tsx +27 -7
- package/dist/assets/components/tooltip.tsx +10 -8
- package/dist/assets/components/user.tsx +8 -6
- package/dist/assets/dev-tools/SonanceDevTools.tsx +851 -708
- package/dist/assets/dev-tools/components/ApplyFirstPreview.tsx +10 -10
- package/dist/assets/dev-tools/components/ChatHistory.tsx +145 -0
- package/dist/assets/dev-tools/components/ChatInterface.tsx +444 -295
- package/dist/assets/dev-tools/components/ChatTabBar.tsx +82 -0
- package/dist/assets/dev-tools/components/DiffPreview.tsx +1 -1
- package/dist/assets/dev-tools/components/InlineDiffPreview.tsx +528 -0
- package/dist/assets/dev-tools/components/InspectorOverlay.tsx +21 -18
- package/dist/assets/dev-tools/components/PropertiesPanel.tsx +1345 -0
- package/dist/assets/dev-tools/components/ScreenshotAnnotator.tsx +1 -1
- package/dist/assets/dev-tools/components/SectionHighlight.tsx +1 -1
- package/dist/assets/dev-tools/components/VisionDiffPreview.tsx +7 -7
- package/dist/assets/dev-tools/components/VisionModeBorder.tsx +12 -63
- package/dist/assets/dev-tools/constants.ts +38 -6
- package/dist/assets/dev-tools/hooks/index.ts +69 -0
- package/dist/assets/dev-tools/hooks/useComponentDetection.ts +132 -0
- package/dist/assets/dev-tools/hooks/useComputedStyles.ts +471 -0
- package/dist/assets/dev-tools/hooks/useContentHash.ts +212 -0
- package/dist/assets/dev-tools/hooks/useElementScanner.ts +398 -0
- package/dist/assets/dev-tools/hooks/useImageDetection.ts +162 -0
- package/dist/assets/dev-tools/hooks/useTextDetection.ts +217 -0
- package/dist/assets/dev-tools/index.ts +3 -0
- package/dist/assets/dev-tools/panels/AnalysisPanel.tsx +32 -32
- package/dist/assets/dev-tools/panels/ComponentsPanel.tsx +384 -131
- package/dist/assets/dev-tools/panels/TextPanel.tsx +10 -10
- package/dist/assets/dev-tools/types.ts +93 -2
- package/dist/assets/globals.css +225 -9
- package/dist/assets/styles/brand-overrides.css +3 -2
- package/dist/assets/utils.ts +2 -1
- package/dist/index.js +22 -3
- package/package.json +2 -1
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import type { VisionFocusedElement } from "../types";
|
|
5
|
+
|
|
6
|
+
export interface ComputedGeometry {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
rotation: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ComputedTypography {
|
|
15
|
+
fontFamily: string;
|
|
16
|
+
fontSize: string;
|
|
17
|
+
fontWeight: string;
|
|
18
|
+
lineHeight: string;
|
|
19
|
+
letterSpacing: string;
|
|
20
|
+
textAlign: string;
|
|
21
|
+
color: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ComputedFill {
|
|
25
|
+
type: "solid" | "gradient" | "image";
|
|
26
|
+
color?: string;
|
|
27
|
+
opacity: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ComputedStroke {
|
|
31
|
+
color: string;
|
|
32
|
+
width: string;
|
|
33
|
+
style: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ComputedEffect {
|
|
37
|
+
type: "shadow" | "blur";
|
|
38
|
+
value: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ComputedStyles {
|
|
42
|
+
// Element Info
|
|
43
|
+
tagName: string;
|
|
44
|
+
className: string;
|
|
45
|
+
id: string;
|
|
46
|
+
|
|
47
|
+
// Geometry (Layout)
|
|
48
|
+
geometry: ComputedGeometry;
|
|
49
|
+
|
|
50
|
+
// Appearance
|
|
51
|
+
opacity: number;
|
|
52
|
+
borderRadius: string;
|
|
53
|
+
overflow: string;
|
|
54
|
+
|
|
55
|
+
// Typography (for text elements)
|
|
56
|
+
typography: ComputedTypography | null;
|
|
57
|
+
hasText: boolean;
|
|
58
|
+
textContent: string;
|
|
59
|
+
|
|
60
|
+
// Fills (backgrounds)
|
|
61
|
+
fills: ComputedFill[];
|
|
62
|
+
|
|
63
|
+
// Strokes (borders)
|
|
64
|
+
strokes: ComputedStroke[];
|
|
65
|
+
|
|
66
|
+
// Effects (shadows, filters)
|
|
67
|
+
effects: ComputedEffect[];
|
|
68
|
+
|
|
69
|
+
// Display & Layout
|
|
70
|
+
display: string;
|
|
71
|
+
flexDirection: string;
|
|
72
|
+
alignItems: string;
|
|
73
|
+
justifyContent: string;
|
|
74
|
+
gap: string;
|
|
75
|
+
padding: string;
|
|
76
|
+
margin: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseColor(color: string): { color: string; opacity: number } {
|
|
80
|
+
// Handle rgba
|
|
81
|
+
const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
82
|
+
if (rgbaMatch) {
|
|
83
|
+
const [, r, g, b, a] = rgbaMatch;
|
|
84
|
+
const hex = `#${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`;
|
|
85
|
+
return { color: hex.toUpperCase(), opacity: a ? parseFloat(a) : 1 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle hex
|
|
89
|
+
if (color.startsWith('#')) {
|
|
90
|
+
return { color: color.toUpperCase(), opacity: 1 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle transparent
|
|
94
|
+
if (color === 'transparent') {
|
|
95
|
+
return { color: 'transparent', opacity: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { color, opacity: 1 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractFills(styles: CSSStyleDeclaration): ComputedFill[] {
|
|
102
|
+
const fills: ComputedFill[] = [];
|
|
103
|
+
|
|
104
|
+
const bg = styles.backgroundColor;
|
|
105
|
+
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') {
|
|
106
|
+
const { color, opacity } = parseColor(bg);
|
|
107
|
+
fills.push({ type: 'solid', color, opacity });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const bgImage = styles.backgroundImage;
|
|
111
|
+
if (bgImage && bgImage !== 'none') {
|
|
112
|
+
if (bgImage.includes('gradient')) {
|
|
113
|
+
fills.push({ type: 'gradient', color: bgImage, opacity: 1 });
|
|
114
|
+
} else if (bgImage.includes('url')) {
|
|
115
|
+
fills.push({ type: 'image', opacity: 1 });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return fills;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractStrokes(styles: CSSStyleDeclaration): ComputedStroke[] {
|
|
123
|
+
const strokes: ComputedStroke[] = [];
|
|
124
|
+
|
|
125
|
+
const borderWidth = styles.borderWidth;
|
|
126
|
+
const borderColor = styles.borderColor;
|
|
127
|
+
const borderStyle = styles.borderStyle;
|
|
128
|
+
|
|
129
|
+
if (borderWidth && borderWidth !== '0px' && borderStyle !== 'none') {
|
|
130
|
+
const { color } = parseColor(borderColor);
|
|
131
|
+
strokes.push({
|
|
132
|
+
color,
|
|
133
|
+
width: borderWidth,
|
|
134
|
+
style: borderStyle,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return strokes;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractEffects(styles: CSSStyleDeclaration): ComputedEffect[] {
|
|
142
|
+
const effects: ComputedEffect[] = [];
|
|
143
|
+
|
|
144
|
+
const boxShadow = styles.boxShadow;
|
|
145
|
+
if (boxShadow && boxShadow !== 'none') {
|
|
146
|
+
effects.push({ type: 'shadow', value: boxShadow });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const filter = styles.filter;
|
|
150
|
+
if (filter && filter !== 'none') {
|
|
151
|
+
effects.push({ type: 'blur', value: filter });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return effects;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isTextElement(element: HTMLElement): boolean {
|
|
158
|
+
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'A', 'LABEL', 'LI', 'TD', 'TH', 'BUTTON'];
|
|
159
|
+
if (textTags.includes(element.tagName)) return true;
|
|
160
|
+
|
|
161
|
+
// Check if element has direct text content
|
|
162
|
+
const hasDirectText = Array.from(element.childNodes).some(
|
|
163
|
+
node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return hasDirectText;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function useComputedStyles(elementId: string | null, variantId?: string | null): ComputedStyles | null {
|
|
170
|
+
const [styles, setStyles] = useState<ComputedStyles | null>(null);
|
|
171
|
+
|
|
172
|
+
const extractStyles = useCallback(() => {
|
|
173
|
+
if (!elementId) {
|
|
174
|
+
setStyles(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Try to find element by ID first, then by data attributes
|
|
179
|
+
let element: HTMLElement | null = document.getElementById(elementId);
|
|
180
|
+
|
|
181
|
+
if (!element) {
|
|
182
|
+
// Try finding by component type and variant
|
|
183
|
+
const selector = variantId
|
|
184
|
+
? `[data-component-type="${elementId}"][data-variant-id="${variantId}"]`
|
|
185
|
+
: `[data-component-type="${elementId}"]`;
|
|
186
|
+
element = document.querySelector(selector);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!element) {
|
|
190
|
+
// Try finding any element with matching text content or class
|
|
191
|
+
const allElements = document.querySelectorAll('button, a, [role="button"], input, select, textarea, h1, h2, h3, h4, h5, h6, p, span');
|
|
192
|
+
for (const el of allElements) {
|
|
193
|
+
if (el.id === elementId || el.className.includes(elementId)) {
|
|
194
|
+
element = el as HTMLElement;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!element) {
|
|
201
|
+
setStyles(null);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const computed = window.getComputedStyle(element);
|
|
206
|
+
const rect = element.getBoundingClientRect();
|
|
207
|
+
|
|
208
|
+
// Extract rotation from transform matrix
|
|
209
|
+
let rotation = 0;
|
|
210
|
+
const transform = computed.transform;
|
|
211
|
+
if (transform && transform !== 'none') {
|
|
212
|
+
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
|
|
213
|
+
if (matrixMatch) {
|
|
214
|
+
const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));
|
|
215
|
+
rotation = Math.round(Math.atan2(values[1], values[0]) * (180 / Math.PI));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const hasText = isTextElement(element);
|
|
220
|
+
const textContent = element.textContent?.trim() || '';
|
|
221
|
+
|
|
222
|
+
const computedStyles: ComputedStyles = {
|
|
223
|
+
tagName: element.tagName.toLowerCase(),
|
|
224
|
+
className: element.className?.toString() || '',
|
|
225
|
+
id: element.id || '',
|
|
226
|
+
|
|
227
|
+
geometry: {
|
|
228
|
+
x: Math.round(rect.left),
|
|
229
|
+
y: Math.round(rect.top),
|
|
230
|
+
width: Math.round(rect.width),
|
|
231
|
+
height: Math.round(rect.height),
|
|
232
|
+
rotation,
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
opacity: parseFloat(computed.opacity) * 100,
|
|
236
|
+
borderRadius: computed.borderRadius,
|
|
237
|
+
overflow: computed.overflow,
|
|
238
|
+
|
|
239
|
+
typography: hasText ? {
|
|
240
|
+
fontFamily: computed.fontFamily.split(',')[0].replace(/['"]/g, ''),
|
|
241
|
+
fontSize: computed.fontSize,
|
|
242
|
+
fontWeight: computed.fontWeight,
|
|
243
|
+
lineHeight: computed.lineHeight,
|
|
244
|
+
letterSpacing: computed.letterSpacing,
|
|
245
|
+
textAlign: computed.textAlign,
|
|
246
|
+
color: parseColor(computed.color).color,
|
|
247
|
+
} : null,
|
|
248
|
+
hasText,
|
|
249
|
+
textContent,
|
|
250
|
+
|
|
251
|
+
fills: extractFills(computed),
|
|
252
|
+
strokes: extractStrokes(computed),
|
|
253
|
+
effects: extractEffects(computed),
|
|
254
|
+
|
|
255
|
+
display: computed.display,
|
|
256
|
+
flexDirection: computed.flexDirection,
|
|
257
|
+
alignItems: computed.alignItems,
|
|
258
|
+
justifyContent: computed.justifyContent,
|
|
259
|
+
gap: computed.gap,
|
|
260
|
+
padding: computed.padding,
|
|
261
|
+
margin: computed.margin,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
setStyles(computedStyles);
|
|
265
|
+
}, [elementId, variantId]);
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
extractStyles();
|
|
269
|
+
|
|
270
|
+
// Re-extract on resize/scroll
|
|
271
|
+
const handleUpdate = () => extractStyles();
|
|
272
|
+
window.addEventListener('resize', handleUpdate);
|
|
273
|
+
window.addEventListener('scroll', handleUpdate, true);
|
|
274
|
+
|
|
275
|
+
return () => {
|
|
276
|
+
window.removeEventListener('resize', handleUpdate);
|
|
277
|
+
window.removeEventListener('scroll', handleUpdate, true);
|
|
278
|
+
};
|
|
279
|
+
}, [extractStyles]);
|
|
280
|
+
|
|
281
|
+
return styles;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Multi-strategy element finding for robustness after HMR
|
|
286
|
+
* Tries multiple methods to find the element, falling back through them
|
|
287
|
+
*
|
|
288
|
+
* Priority order:
|
|
289
|
+
* 1. Element ID with styled child detection (most reliable for nested styled elements)
|
|
290
|
+
* 2. Text content with span priority (finds styled inline elements)
|
|
291
|
+
* 3. Coordinates (fallback for when other strategies fail)
|
|
292
|
+
*
|
|
293
|
+
* Note: Coordinates are stored as screen coordinates at click time and become
|
|
294
|
+
* invalid after scroll/layout changes, so they're used as a fallback, not primary.
|
|
295
|
+
*/
|
|
296
|
+
function findElementByMultipleStrategies(focusedElement: VisionFocusedElement): HTMLElement | null {
|
|
297
|
+
// Strategy 1: Find by element ID with styled child detection
|
|
298
|
+
// This handles cases where the detected element is a container (like <p>)
|
|
299
|
+
// but the actual styled element is a child (like <span style="color:...">)
|
|
300
|
+
if (focusedElement.elementId) {
|
|
301
|
+
const byId = document.getElementById(focusedElement.elementId);
|
|
302
|
+
if (byId && !byId.closest('[data-sonance-devtools="true"]')) {
|
|
303
|
+
// First, check if there's a styled child element with inline color
|
|
304
|
+
const styledChild = byId.querySelector('[style*="color"]') as HTMLElement;
|
|
305
|
+
if (styledChild) {
|
|
306
|
+
return styledChild;
|
|
307
|
+
}
|
|
308
|
+
// No styled child, return the element itself
|
|
309
|
+
return byId;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Strategy 2: Find by text content, prioritizing span elements (which often have styling)
|
|
314
|
+
if (focusedElement.textContent) {
|
|
315
|
+
const textToFind = focusedElement.textContent.trim();
|
|
316
|
+
if (textToFind.length > 0) {
|
|
317
|
+
// Search specifically for span first (inline styled elements), then other text elements
|
|
318
|
+
const spanCandidates = document.querySelectorAll('span');
|
|
319
|
+
for (const el of spanCandidates) {
|
|
320
|
+
if (el.closest('[data-sonance-devtools="true"]')) continue;
|
|
321
|
+
const elText = el.textContent?.trim() || '';
|
|
322
|
+
const compareLength = Math.min(30, textToFind.length);
|
|
323
|
+
if (elText.startsWith(textToFind.substring(0, compareLength))) {
|
|
324
|
+
return el as HTMLElement;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fallback to other text elements
|
|
329
|
+
const candidates = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, label, button, a, td, th, li, div');
|
|
330
|
+
for (const el of candidates) {
|
|
331
|
+
if (el.closest('[data-sonance-devtools="true"]')) continue;
|
|
332
|
+
const elText = el.textContent?.trim() || '';
|
|
333
|
+
const compareLength = Math.min(30, textToFind.length);
|
|
334
|
+
if (elText.startsWith(textToFind.substring(0, compareLength))) {
|
|
335
|
+
// Check if this element has a styled span child
|
|
336
|
+
const styledSpan = el.querySelector('span[style*="color"]') as HTMLElement;
|
|
337
|
+
if (styledSpan) {
|
|
338
|
+
return styledSpan;
|
|
339
|
+
}
|
|
340
|
+
return el as HTMLElement;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Strategy 3: Find by coordinates (fallback - coordinates may be stale after scroll)
|
|
347
|
+
const { coordinates } = focusedElement;
|
|
348
|
+
if (coordinates) {
|
|
349
|
+
const centerX = coordinates.x + coordinates.width / 2;
|
|
350
|
+
const centerY = coordinates.y + coordinates.height / 2;
|
|
351
|
+
const byCoords = document.elementFromPoint(centerX, centerY) as HTMLElement;
|
|
352
|
+
if (byCoords && !byCoords.closest('[data-sonance-devtools="true"]')) {
|
|
353
|
+
return byCoords;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Enhanced version that works with full VisionFocusedElement for robust element finding
|
|
361
|
+
export function useElementFromCoordinates(focusedElement: VisionFocusedElement | null): ComputedStyles | null {
|
|
362
|
+
const [styles, setStyles] = useState<ComputedStyles | null>(null);
|
|
363
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
364
|
+
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!focusedElement) {
|
|
367
|
+
setStyles(null);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Use multi-strategy element finding for robustness after HMR
|
|
372
|
+
const element = findElementByMultipleStrategies(focusedElement);
|
|
373
|
+
|
|
374
|
+
if (!element) {
|
|
375
|
+
setStyles(null);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const extractAndSetStyles = () => {
|
|
380
|
+
const computed = window.getComputedStyle(element);
|
|
381
|
+
const rect = element.getBoundingClientRect();
|
|
382
|
+
|
|
383
|
+
let rotation = 0;
|
|
384
|
+
const transform = computed.transform;
|
|
385
|
+
if (transform && transform !== 'none') {
|
|
386
|
+
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
|
|
387
|
+
if (matrixMatch) {
|
|
388
|
+
const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));
|
|
389
|
+
rotation = Math.round(Math.atan2(values[1], values[0]) * (180 / Math.PI));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const hasText = isTextElement(element);
|
|
394
|
+
const textContent = element.textContent?.trim() || '';
|
|
395
|
+
|
|
396
|
+
setStyles({
|
|
397
|
+
tagName: element.tagName.toLowerCase(),
|
|
398
|
+
className: element.className?.toString() || '',
|
|
399
|
+
id: element.id || '',
|
|
400
|
+
|
|
401
|
+
geometry: {
|
|
402
|
+
x: Math.round(rect.left),
|
|
403
|
+
y: Math.round(rect.top),
|
|
404
|
+
width: Math.round(rect.width),
|
|
405
|
+
height: Math.round(rect.height),
|
|
406
|
+
rotation,
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
opacity: parseFloat(computed.opacity) * 100,
|
|
410
|
+
borderRadius: computed.borderRadius,
|
|
411
|
+
overflow: computed.overflow,
|
|
412
|
+
|
|
413
|
+
typography: hasText ? {
|
|
414
|
+
fontFamily: computed.fontFamily.split(',')[0].replace(/['"]/g, ''),
|
|
415
|
+
fontSize: computed.fontSize,
|
|
416
|
+
fontWeight: computed.fontWeight,
|
|
417
|
+
lineHeight: computed.lineHeight,
|
|
418
|
+
letterSpacing: computed.letterSpacing,
|
|
419
|
+
textAlign: computed.textAlign,
|
|
420
|
+
color: parseColor(computed.color).color,
|
|
421
|
+
} : null,
|
|
422
|
+
hasText,
|
|
423
|
+
textContent,
|
|
424
|
+
|
|
425
|
+
fills: extractFills(computed),
|
|
426
|
+
strokes: extractStrokes(computed),
|
|
427
|
+
effects: extractEffects(computed),
|
|
428
|
+
|
|
429
|
+
display: computed.display,
|
|
430
|
+
flexDirection: computed.flexDirection,
|
|
431
|
+
alignItems: computed.alignItems,
|
|
432
|
+
justifyContent: computed.justifyContent,
|
|
433
|
+
gap: computed.gap,
|
|
434
|
+
padding: computed.padding,
|
|
435
|
+
margin: computed.margin,
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Initial extraction
|
|
440
|
+
extractAndSetStyles();
|
|
441
|
+
|
|
442
|
+
// Watch for DOM changes (HMR updates) using MutationObserver
|
|
443
|
+
const observer = new MutationObserver((mutations) => {
|
|
444
|
+
// Check if any mutation affects our element's content
|
|
445
|
+
for (const mutation of mutations) {
|
|
446
|
+
if (mutation.type === 'characterData' ||
|
|
447
|
+
mutation.type === 'childList' ||
|
|
448
|
+
(mutation.type === 'attributes' && mutation.target === element)) {
|
|
449
|
+
// Trigger a refresh
|
|
450
|
+
setRefreshCounter(c => c + 1);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Observe the element and its subtree for changes
|
|
457
|
+
observer.observe(element, {
|
|
458
|
+
characterData: true,
|
|
459
|
+
childList: true,
|
|
460
|
+
subtree: true,
|
|
461
|
+
attributes: true,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return () => {
|
|
465
|
+
observer.disconnect();
|
|
466
|
+
};
|
|
467
|
+
}, [focusedElement, refreshCounter]);
|
|
468
|
+
|
|
469
|
+
return styles;
|
|
470
|
+
}
|
|
471
|
+
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Content-based hashing utilities for generating stable element IDs
|
|
5
|
+
*
|
|
6
|
+
* Instead of sequential IDs (text-0, logo-1) that change on re-render,
|
|
7
|
+
* we generate IDs based on element content that persist across page loads.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fast hash function (djb2 algorithm)
|
|
12
|
+
* Produces a short, consistent hash string from any input
|
|
13
|
+
*/
|
|
14
|
+
export function hashString(str: string): string {
|
|
15
|
+
let hash = 5381;
|
|
16
|
+
for (let i = 0; i < str.length; i++) {
|
|
17
|
+
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
|
18
|
+
}
|
|
19
|
+
// Convert to hex and take last 8 chars for brevity
|
|
20
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a stable DOM path for an element (up to 3 levels)
|
|
25
|
+
* This helps differentiate elements with similar content in different locations
|
|
26
|
+
*/
|
|
27
|
+
function getDOMPath(element: Element, maxDepth: number = 3): string {
|
|
28
|
+
const path: string[] = [];
|
|
29
|
+
let current: Element | null = element;
|
|
30
|
+
let depth = 0;
|
|
31
|
+
|
|
32
|
+
while (current && current !== document.body && depth < maxDepth) {
|
|
33
|
+
const tag = current.tagName.toLowerCase();
|
|
34
|
+
const parent: Element | null = current.parentElement;
|
|
35
|
+
|
|
36
|
+
if (parent) {
|
|
37
|
+
// Get index among siblings of same type
|
|
38
|
+
const siblings = Array.from(parent.children).filter(
|
|
39
|
+
(child) => child.tagName === current!.tagName
|
|
40
|
+
);
|
|
41
|
+
const index = siblings.indexOf(current);
|
|
42
|
+
path.unshift(`${tag}[${index}]`);
|
|
43
|
+
} else {
|
|
44
|
+
path.unshift(tag);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
current = parent;
|
|
48
|
+
depth++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return path.join(">");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate a stable ID for text elements
|
|
56
|
+
* Based on: tagName + truncated textContent + DOM path
|
|
57
|
+
*/
|
|
58
|
+
export function generateTextElementId(element: Element): string {
|
|
59
|
+
const tagName = element.tagName.toLowerCase();
|
|
60
|
+
const textContent = (element.textContent || "").trim().substring(0, 50);
|
|
61
|
+
const domPath = getDOMPath(element);
|
|
62
|
+
|
|
63
|
+
const signature = `text:${tagName}:${textContent}:${domPath}`;
|
|
64
|
+
return `text-${hashString(signature)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate a stable ID for image elements
|
|
69
|
+
* Based on: src (normalized) + alt + natural dimensions
|
|
70
|
+
*/
|
|
71
|
+
export function generateImageElementId(img: HTMLImageElement): string {
|
|
72
|
+
// Normalize src by removing query params and getting filename
|
|
73
|
+
const src = img.src || img.getAttribute("src") || "";
|
|
74
|
+
const srcPath = src.split("?")[0].split("/").pop() || src;
|
|
75
|
+
const alt = img.alt || "";
|
|
76
|
+
const dimensions = `${img.naturalWidth || 0}x${img.naturalHeight || 0}`;
|
|
77
|
+
|
|
78
|
+
const signature = `img:${srcPath}:${alt}:${dimensions}`;
|
|
79
|
+
return `logo-${hashString(signature)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate a stable ID for component elements
|
|
84
|
+
* Based on: component name + variant styles + DOM path
|
|
85
|
+
*/
|
|
86
|
+
export function generateComponentId(element: Element): string {
|
|
87
|
+
const name = element.getAttribute("data-sonance-name") || element.tagName.toLowerCase();
|
|
88
|
+
const className = element.className?.toString() || "";
|
|
89
|
+
const domPath = getDOMPath(element);
|
|
90
|
+
|
|
91
|
+
const signature = `comp:${name}:${className}:${domPath}`;
|
|
92
|
+
return `comp-${hashString(signature)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a variant ID based on computed styles
|
|
97
|
+
* This groups visually identical components together
|
|
98
|
+
*/
|
|
99
|
+
export function generateVariantId(element: Element): string {
|
|
100
|
+
const className = element.className?.toString() || "";
|
|
101
|
+
const computed = window.getComputedStyle(element);
|
|
102
|
+
|
|
103
|
+
const styleSignature = [
|
|
104
|
+
className,
|
|
105
|
+
computed.backgroundColor,
|
|
106
|
+
computed.borderColor,
|
|
107
|
+
computed.borderRadius,
|
|
108
|
+
computed.color,
|
|
109
|
+
].join("|");
|
|
110
|
+
|
|
111
|
+
return hashString(styleSignature);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Migration utilities for backwards compatibility with old sequential IDs
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
interface LegacyIdMapping {
|
|
119
|
+
oldId: string;
|
|
120
|
+
newId: string;
|
|
121
|
+
selector: string;
|
|
122
|
+
textContent?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Migrate old sequential IDs to new content-based IDs
|
|
127
|
+
* Returns a mapping that can be used to update localStorage entries
|
|
128
|
+
*/
|
|
129
|
+
export function migrateLegacyIds(
|
|
130
|
+
type: "text" | "logo",
|
|
131
|
+
oldOverrides: Record<string, unknown>
|
|
132
|
+
): { migrated: Record<string, unknown>; mappings: LegacyIdMapping[] } {
|
|
133
|
+
const migrated: Record<string, unknown> = {};
|
|
134
|
+
const mappings: LegacyIdMapping[] = [];
|
|
135
|
+
|
|
136
|
+
// Pattern for old sequential IDs
|
|
137
|
+
const legacyPattern = type === "text" ? /^text-\d+$/ : /^logo-\d+$/;
|
|
138
|
+
|
|
139
|
+
for (const [oldId, value] of Object.entries(oldOverrides)) {
|
|
140
|
+
if (legacyPattern.test(oldId)) {
|
|
141
|
+
// Try to find the element and generate new ID
|
|
142
|
+
const selector = `[data-sonance-${type}-id="${oldId}"]`;
|
|
143
|
+
const element = document.querySelector(selector);
|
|
144
|
+
|
|
145
|
+
if (element) {
|
|
146
|
+
const newId = type === "text"
|
|
147
|
+
? generateTextElementId(element)
|
|
148
|
+
: generateImageElementId(element as HTMLImageElement);
|
|
149
|
+
|
|
150
|
+
migrated[newId] = value;
|
|
151
|
+
mappings.push({
|
|
152
|
+
oldId,
|
|
153
|
+
newId,
|
|
154
|
+
selector,
|
|
155
|
+
textContent: element.textContent?.substring(0, 50),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Update the element's data attribute to use new ID
|
|
159
|
+
element.setAttribute(`data-sonance-${type}-id`, newId);
|
|
160
|
+
} else {
|
|
161
|
+
// Element not found - keep old ID for now
|
|
162
|
+
migrated[oldId] = value;
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// Already using new format or unknown format - keep as is
|
|
166
|
+
migrated[oldId] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { migrated, mappings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if an ID is in the legacy sequential format
|
|
175
|
+
*/
|
|
176
|
+
export function isLegacyId(id: string): boolean {
|
|
177
|
+
return /^(text|logo)-\d+$/.test(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Storage key constants for migration tracking
|
|
182
|
+
*/
|
|
183
|
+
export const MIGRATION_STORAGE_KEY = "sonance-id-migration-complete";
|
|
184
|
+
export const MIGRATION_VERSION = 1;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if migration has already been performed
|
|
188
|
+
*/
|
|
189
|
+
export function isMigrationComplete(): boolean {
|
|
190
|
+
try {
|
|
191
|
+
const stored = localStorage.getItem(MIGRATION_STORAGE_KEY);
|
|
192
|
+
if (!stored) return false;
|
|
193
|
+
const { version } = JSON.parse(stored);
|
|
194
|
+
return version >= MIGRATION_VERSION;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mark migration as complete
|
|
202
|
+
*/
|
|
203
|
+
export function markMigrationComplete(): void {
|
|
204
|
+
try {
|
|
205
|
+
localStorage.setItem(
|
|
206
|
+
MIGRATION_STORAGE_KEY,
|
|
207
|
+
JSON.stringify({ version: MIGRATION_VERSION, timestamp: Date.now() })
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
// Ignore storage errors
|
|
211
|
+
}
|
|
212
|
+
}
|