sonance-brand-mcp 1.3.111 → 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-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 +988 -57
- 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/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 +429 -362
- package/dist/assets/dev-tools/components/ApplyFirstPreview.tsx +10 -10
- package/dist/assets/dev-tools/components/ChatHistory.tsx +11 -7
- package/dist/assets/dev-tools/components/ChatInterface.tsx +61 -20
- package/dist/assets/dev-tools/components/ChatTabBar.tsx +1 -1
- package/dist/assets/dev-tools/components/DiffPreview.tsx +1 -1
- package/dist/assets/dev-tools/components/InlineDiffPreview.tsx +360 -36
- package/dist/assets/dev-tools/components/InspectorOverlay.tsx +9 -9
- package/dist/assets/dev-tools/components/PropertiesPanel.tsx +743 -93
- 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 +4 -64
- 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 +171 -65
- 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/panels/ComponentsPanel.tsx +160 -57
- package/dist/assets/dev-tools/panels/TextPanel.tsx +10 -10
- package/dist/assets/dev-tools/types.ts +42 -0
- package/dist/assets/globals.css +225 -9
- package/dist/assets/styles/brand-overrides.css +3 -2
- package/dist/assets/utils.ts +2 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { DetectedElement, ElementFilters, OriginalLogoState, OriginalTextState } from "../types";
|
|
5
|
+
import { getActiveModalContent } from "../utils";
|
|
6
|
+
import { detectComponents } from "./useComponentDetection";
|
|
7
|
+
import { detectImages, extractLogoName } from "./useImageDetection";
|
|
8
|
+
import { detectTextElements } from "./useTextDetection";
|
|
9
|
+
import {
|
|
10
|
+
migrateLegacyIds,
|
|
11
|
+
isMigrationComplete,
|
|
12
|
+
markMigrationComplete,
|
|
13
|
+
} from "./useContentHash";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scanner configuration
|
|
17
|
+
*/
|
|
18
|
+
export interface ElementScannerOptions {
|
|
19
|
+
/** Whether scanning is enabled */
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
/** Element type filters */
|
|
22
|
+
filters: ElementFilters;
|
|
23
|
+
/** Whether inspector highlighting is active (affects text detection) */
|
|
24
|
+
inspectorEnabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scanner result
|
|
29
|
+
*/
|
|
30
|
+
export interface ElementScannerResult {
|
|
31
|
+
/** All detected elements */
|
|
32
|
+
elements: DetectedElement[];
|
|
33
|
+
/** Original logo states (for reset functionality) */
|
|
34
|
+
originalLogoStates: Record<string, OriginalLogoState>;
|
|
35
|
+
/** Original text states (for reset functionality) */
|
|
36
|
+
originalTextStates: Record<string, OriginalTextState>;
|
|
37
|
+
/** Trigger a manual re-scan */
|
|
38
|
+
rescan: () => void;
|
|
39
|
+
/** Reset all original states */
|
|
40
|
+
resetStates: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Debounce delay for mutation observer (ms)
|
|
45
|
+
*/
|
|
46
|
+
const MUTATION_DEBOUNCE_MS = 100;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Throttle limit for scans per second
|
|
50
|
+
*/
|
|
51
|
+
const MAX_SCANS_PER_SECOND = 5;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Helper: Check if element is inside a scroll container and clip rect to visible bounds
|
|
55
|
+
*/
|
|
56
|
+
function getVisibleRect(el: Element, rect: DOMRect): DOMRect | null {
|
|
57
|
+
const scrollParent = el.closest("aside, nav, [data-sidebar]");
|
|
58
|
+
|
|
59
|
+
if (scrollParent) {
|
|
60
|
+
const parentRect = scrollParent.getBoundingClientRect();
|
|
61
|
+
|
|
62
|
+
// Check if element is fully outside the scroll container
|
|
63
|
+
if (
|
|
64
|
+
rect.bottom < parentRect.top ||
|
|
65
|
+
rect.top > parentRect.bottom ||
|
|
66
|
+
rect.right < parentRect.left ||
|
|
67
|
+
rect.left > parentRect.right
|
|
68
|
+
) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Clip the rect to parent's visible bounds
|
|
73
|
+
const clippedTop = Math.max(rect.top, parentRect.top);
|
|
74
|
+
const clippedBottom = Math.min(rect.bottom, parentRect.bottom);
|
|
75
|
+
const clippedLeft = Math.max(rect.left, parentRect.left);
|
|
76
|
+
const clippedRight = Math.min(rect.right, parentRect.right);
|
|
77
|
+
|
|
78
|
+
// If clipped area is too small, skip it
|
|
79
|
+
if (clippedBottom - clippedTop < 10 || clippedRight - clippedLeft < 10) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new DOMRect(
|
|
84
|
+
clippedLeft,
|
|
85
|
+
clippedTop,
|
|
86
|
+
clippedRight - clippedLeft,
|
|
87
|
+
clippedBottom - clippedTop
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return rect;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Helper: Check if element is in the active layer (not behind a modal)
|
|
96
|
+
*/
|
|
97
|
+
function createActiveLayerChecker(): (el: Element) => boolean {
|
|
98
|
+
const activeModalContent = getActiveModalContent();
|
|
99
|
+
|
|
100
|
+
return (el: Element): boolean => {
|
|
101
|
+
// Always exclude DevTools panel
|
|
102
|
+
if (el.closest("[data-sonance-devtools]")) return false;
|
|
103
|
+
|
|
104
|
+
// If a modal is active, only include elements inside the modal
|
|
105
|
+
if (activeModalContent) {
|
|
106
|
+
return activeModalContent.contains(el) || el === activeModalContent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Perform a one-time migration of legacy sequential IDs in localStorage
|
|
115
|
+
*/
|
|
116
|
+
function performLegacyMigration(): void {
|
|
117
|
+
if (isMigrationComplete()) return;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Migrate text overrides
|
|
121
|
+
const textOverridesRaw = localStorage.getItem("sonance-text-overrides");
|
|
122
|
+
if (textOverridesRaw) {
|
|
123
|
+
const textOverrides = JSON.parse(textOverridesRaw);
|
|
124
|
+
const { migrated, mappings } = migrateLegacyIds("text", textOverrides);
|
|
125
|
+
|
|
126
|
+
if (mappings.length > 0) {
|
|
127
|
+
console.log("[Sonance] Migrated text element IDs:", mappings);
|
|
128
|
+
localStorage.setItem("sonance-text-overrides", JSON.stringify(migrated));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Migrate logo configs
|
|
133
|
+
const logoConfigsRaw = localStorage.getItem("sonance-logo-configs");
|
|
134
|
+
if (logoConfigsRaw) {
|
|
135
|
+
const logoConfigs = JSON.parse(logoConfigsRaw);
|
|
136
|
+
const { migrated, mappings } = migrateLegacyIds("logo", logoConfigs);
|
|
137
|
+
|
|
138
|
+
if (mappings.length > 0) {
|
|
139
|
+
console.log("[Sonance] Migrated logo element IDs:", mappings);
|
|
140
|
+
localStorage.setItem("sonance-logo-configs", JSON.stringify(migrated));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
markMigrationComplete();
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.warn("[Sonance] Migration failed:", e);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Main element scanner hook
|
|
152
|
+
*
|
|
153
|
+
* Uses MutationObserver for efficient change detection instead of
|
|
154
|
+
* continuous requestAnimationFrame polling.
|
|
155
|
+
*/
|
|
156
|
+
export function useElementScanner(options: ElementScannerOptions): ElementScannerResult {
|
|
157
|
+
const { enabled, filters, inspectorEnabled = false } = options;
|
|
158
|
+
|
|
159
|
+
// State
|
|
160
|
+
const [elements, setElements] = useState<DetectedElement[]>([]);
|
|
161
|
+
|
|
162
|
+
// Refs for original states (avoid re-renders on state changes)
|
|
163
|
+
const originalLogoStatesRef = useRef<Record<string, OriginalLogoState>>({});
|
|
164
|
+
const originalTextStatesRef = useRef<Record<string, OriginalTextState>>({});
|
|
165
|
+
|
|
166
|
+
// Throttling refs
|
|
167
|
+
const lastScanTimeRef = useRef<number>(0);
|
|
168
|
+
const pendingScanRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
169
|
+
const rafIdRef = useRef<number | null>(null);
|
|
170
|
+
|
|
171
|
+
// Mounted ref for cleanup
|
|
172
|
+
const mountedRef = useRef(true);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Core scan function - detects all elements based on current filters
|
|
176
|
+
*/
|
|
177
|
+
const performScan = useCallback(() => {
|
|
178
|
+
if (!mountedRef.current) return;
|
|
179
|
+
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const timeSinceLastScan = now - lastScanTimeRef.current;
|
|
182
|
+
const minInterval = 1000 / MAX_SCANS_PER_SECOND;
|
|
183
|
+
|
|
184
|
+
// Throttle if scanning too frequently
|
|
185
|
+
if (timeSinceLastScan < minInterval) {
|
|
186
|
+
if (!pendingScanRef.current) {
|
|
187
|
+
pendingScanRef.current = setTimeout(() => {
|
|
188
|
+
pendingScanRef.current = null;
|
|
189
|
+
performScan();
|
|
190
|
+
}, minInterval - timeSinceLastScan);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
lastScanTimeRef.current = now;
|
|
196
|
+
|
|
197
|
+
const allElements: DetectedElement[] = [];
|
|
198
|
+
const isInActiveLayer = createActiveLayerChecker();
|
|
199
|
+
|
|
200
|
+
// Scan components
|
|
201
|
+
if (filters.components) {
|
|
202
|
+
const componentConfig = {
|
|
203
|
+
includeModals: true,
|
|
204
|
+
isInActiveLayer,
|
|
205
|
+
getVisibleRect,
|
|
206
|
+
};
|
|
207
|
+
const componentElements = detectComponents(componentConfig);
|
|
208
|
+
allElements.push(...componentElements);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Scan images/logos
|
|
212
|
+
if (filters.images) {
|
|
213
|
+
const imageConfig = {
|
|
214
|
+
isInActiveLayer,
|
|
215
|
+
existingOriginalStates: originalLogoStatesRef.current,
|
|
216
|
+
};
|
|
217
|
+
const { elements: imageElements, originalStates: newLogoStates } = detectImages(imageConfig);
|
|
218
|
+
allElements.push(...imageElements);
|
|
219
|
+
originalLogoStatesRef.current = newLogoStates;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Scan text elements (only when inspector is enabled)
|
|
223
|
+
if (filters.text && inspectorEnabled) {
|
|
224
|
+
const textConfig = {
|
|
225
|
+
isInActiveLayer,
|
|
226
|
+
existingOriginalStates: originalTextStatesRef.current,
|
|
227
|
+
};
|
|
228
|
+
const { elements: textElements, originalStates: newTextStates } = detectTextElements(textConfig);
|
|
229
|
+
allElements.push(...textElements);
|
|
230
|
+
originalTextStatesRef.current = newTextStates;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setElements(allElements);
|
|
234
|
+
}, [filters, inspectorEnabled]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Position update function - uses RAF for smooth position tracking
|
|
238
|
+
* Only updates rects, doesn't re-detect elements
|
|
239
|
+
*/
|
|
240
|
+
const updatePositions = useCallback(() => {
|
|
241
|
+
if (!mountedRef.current || !enabled) return;
|
|
242
|
+
|
|
243
|
+
setElements((prevElements) => {
|
|
244
|
+
if (prevElements.length === 0) return prevElements;
|
|
245
|
+
|
|
246
|
+
let hasChanges = false;
|
|
247
|
+
const updated = prevElements.map((el) => {
|
|
248
|
+
// Find the actual DOM element
|
|
249
|
+
let domEl: Element | null = null;
|
|
250
|
+
|
|
251
|
+
if (el.type === "logo" && el.logoId) {
|
|
252
|
+
domEl = document.querySelector(`[data-sonance-logo-id="${el.logoId}"]`);
|
|
253
|
+
} else if (el.type === "text" && el.textId) {
|
|
254
|
+
domEl = document.querySelector(`[data-sonance-text-id="${el.textId}"]`);
|
|
255
|
+
} else if (el.type === "component" && el.variantId) {
|
|
256
|
+
domEl = document.querySelector(`[data-sonance-variant="${el.variantId}"]`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!domEl) return el;
|
|
260
|
+
|
|
261
|
+
const newRect = domEl.getBoundingClientRect();
|
|
262
|
+
const visibleRect = getVisibleRect(domEl, newRect);
|
|
263
|
+
|
|
264
|
+
if (!visibleRect) {
|
|
265
|
+
// Element is no longer visible
|
|
266
|
+
hasChanges = true;
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check if position actually changed
|
|
271
|
+
if (
|
|
272
|
+
Math.abs(el.rect.left - visibleRect.left) > 1 ||
|
|
273
|
+
Math.abs(el.rect.top - visibleRect.top) > 1 ||
|
|
274
|
+
Math.abs(el.rect.width - visibleRect.width) > 1 ||
|
|
275
|
+
Math.abs(el.rect.height - visibleRect.height) > 1
|
|
276
|
+
) {
|
|
277
|
+
hasChanges = true;
|
|
278
|
+
return { ...el, rect: visibleRect };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return el;
|
|
282
|
+
}).filter((el): el is DetectedElement => el !== null);
|
|
283
|
+
|
|
284
|
+
return hasChanges ? updated : prevElements;
|
|
285
|
+
});
|
|
286
|
+
}, [enabled]);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Rescan trigger (exposed to parent)
|
|
290
|
+
*/
|
|
291
|
+
const rescan = useCallback(() => {
|
|
292
|
+
performScan();
|
|
293
|
+
}, [performScan]);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Reset all original states
|
|
297
|
+
*/
|
|
298
|
+
const resetStates = useCallback(() => {
|
|
299
|
+
originalLogoStatesRef.current = {};
|
|
300
|
+
originalTextStatesRef.current = {};
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
// Perform legacy ID migration on mount
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
performLegacyMigration();
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
// Main effect: Set up MutationObserver and position tracking
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
mountedRef.current = true;
|
|
311
|
+
|
|
312
|
+
if (!enabled) {
|
|
313
|
+
setElements([]);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Initial scan
|
|
318
|
+
performScan();
|
|
319
|
+
|
|
320
|
+
// Set up MutationObserver for DOM changes
|
|
321
|
+
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
322
|
+
|
|
323
|
+
const observer = new MutationObserver((mutations) => {
|
|
324
|
+
// Skip mutations inside DevTools
|
|
325
|
+
const hasRelevantMutation = mutations.some((mutation) => {
|
|
326
|
+
const target = mutation.target as Element;
|
|
327
|
+
return !target.closest?.("[data-sonance-devtools]");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (!hasRelevantMutation) return;
|
|
331
|
+
|
|
332
|
+
// Debounce to batch rapid changes
|
|
333
|
+
if (debounceTimeout) {
|
|
334
|
+
clearTimeout(debounceTimeout);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
debounceTimeout = setTimeout(() => {
|
|
338
|
+
debounceTimeout = null;
|
|
339
|
+
performScan();
|
|
340
|
+
}, MUTATION_DEBOUNCE_MS);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
observer.observe(document.body, {
|
|
344
|
+
childList: true,
|
|
345
|
+
subtree: true,
|
|
346
|
+
attributes: true,
|
|
347
|
+
attributeFilter: ["class", "style", "src", "data-state"],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Set up scroll/resize listeners for position updates
|
|
351
|
+
const handlePositionUpdate = () => {
|
|
352
|
+
if (rafIdRef.current) {
|
|
353
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
354
|
+
}
|
|
355
|
+
rafIdRef.current = requestAnimationFrame(updatePositions);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
window.addEventListener("scroll", handlePositionUpdate, true);
|
|
359
|
+
window.addEventListener("resize", handlePositionUpdate);
|
|
360
|
+
|
|
361
|
+
// Cleanup
|
|
362
|
+
return () => {
|
|
363
|
+
mountedRef.current = false;
|
|
364
|
+
observer.disconnect();
|
|
365
|
+
|
|
366
|
+
if (debounceTimeout) {
|
|
367
|
+
clearTimeout(debounceTimeout);
|
|
368
|
+
}
|
|
369
|
+
if (pendingScanRef.current) {
|
|
370
|
+
clearTimeout(pendingScanRef.current);
|
|
371
|
+
}
|
|
372
|
+
if (rafIdRef.current) {
|
|
373
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
window.removeEventListener("scroll", handlePositionUpdate, true);
|
|
377
|
+
window.removeEventListener("resize", handlePositionUpdate);
|
|
378
|
+
};
|
|
379
|
+
}, [enabled, performScan, updatePositions]);
|
|
380
|
+
|
|
381
|
+
// Re-scan when filters change
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (enabled) {
|
|
384
|
+
performScan();
|
|
385
|
+
}
|
|
386
|
+
}, [enabled, filters, inspectorEnabled, performScan]);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
elements,
|
|
390
|
+
originalLogoStates: originalLogoStatesRef.current,
|
|
391
|
+
originalTextStates: originalTextStatesRef.current,
|
|
392
|
+
rescan,
|
|
393
|
+
resetStates,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Re-export utilities for use in other components
|
|
398
|
+
export { extractLogoName };
|