react-spot 0.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 +115 -0
- package/package.json +42 -0
- package/src/core/chain-transformer.ts +89 -0
- package/src/core/fiber-utils.ts +684 -0
- package/src/core/source-location-resolver.test.ts +415 -0
- package/src/core/source-location-resolver.ts +801 -0
- package/src/core/types.ts +79 -0
- package/src/index.ts +26 -0
- package/src/react/ReactSpot.tsx +1058 -0
- package/src/react/components/ui/index.ts +1 -0
- package/src/react/components/ui/popover.tsx +24 -0
- package/src/transformers/formatted-message.ts +159 -0
- package/src/transformers/transformer-rule.ts +386 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
import JsonView from '@uiw/react-json-view';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
ChainTransformContext,
|
|
6
|
+
ChainTransformer,
|
|
7
|
+
TransformedEntry,
|
|
8
|
+
} from '../core/chain-transformer';
|
|
9
|
+
import { applyTransformer } from '../core/chain-transformer';
|
|
10
|
+
import { buildFiberChain, buildFiberReturnChain, getComponentName, getStackFrame, isHostFiberEntry } from '../core/fiber-utils';
|
|
11
|
+
import { configureSourceRoot, resolveLocation } from '../core/source-location-resolver';
|
|
12
|
+
import type { ClickToNodeInfo, ComponentHandle, NavigationEvent } from '../core/types';
|
|
13
|
+
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
|
|
14
|
+
|
|
15
|
+
/* ── Inline SVG icons (replaces lucide-react to avoid 43 MB dependency) ── */
|
|
16
|
+
|
|
17
|
+
function BracesIcon({ size = 24, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
|
|
18
|
+
return (
|
|
19
|
+
<svg
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
width={size}
|
|
22
|
+
height={size}
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
fill="none"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
strokeWidth={strokeWidth}
|
|
27
|
+
strokeLinecap="round"
|
|
28
|
+
strokeLinejoin="round"
|
|
29
|
+
>
|
|
30
|
+
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" />
|
|
31
|
+
<path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" />
|
|
32
|
+
</svg>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ExternalLinkIcon({ size = 24, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
width={size}
|
|
41
|
+
height={size}
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
strokeWidth={strokeWidth}
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
strokeLinejoin="round"
|
|
48
|
+
>
|
|
49
|
+
<path d="M15 3h6v6" />
|
|
50
|
+
<path d="M10 14 21 3" />
|
|
51
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
ChainTransformContext,
|
|
58
|
+
ChainTransformer,
|
|
59
|
+
TransformedEntry,
|
|
60
|
+
ClickToNodeInfo,
|
|
61
|
+
ComponentHandle,
|
|
62
|
+
NavigationEvent,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface ReactSpotProps {
|
|
66
|
+
/**
|
|
67
|
+
* Called when the user triggers a navigation (Alt+Click or selecting a
|
|
68
|
+
* component from the chain popover). When provided the default
|
|
69
|
+
* `window.open("cursor://…")` call is skipped; the consumer decides
|
|
70
|
+
* what to do with the resolved location.
|
|
71
|
+
*/
|
|
72
|
+
onNavigate?: (event: NavigationEvent) => void;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absolute filesystem path to the project root. Used to convert
|
|
76
|
+
* URL-relative paths (like `/src/components/Foo.tsx`) into absolute
|
|
77
|
+
* paths the editor can open (like `/Users/me/project/src/components/Foo.tsx`).
|
|
78
|
+
*
|
|
79
|
+
* Can also be set globally via `window.__SHOW_COMPONENT_SOURCE_ROOT__`.
|
|
80
|
+
*/
|
|
81
|
+
sourceRoot?: string;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* URL scheme used for editor navigation (the part before `://`).
|
|
85
|
+
*
|
|
86
|
+
* Common values: `"cursor"`, `"vscode"`, `"vscode-insiders"`, `"windsurf"`.
|
|
87
|
+
*
|
|
88
|
+
* @default "cursor"
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // Open files in VS Code instead of Cursor
|
|
92
|
+
* <ReactSpot editorScheme="vscode" />
|
|
93
|
+
*/
|
|
94
|
+
editorScheme?: string;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Customise which component is navigated to on Alt + Right-Click.
|
|
98
|
+
*
|
|
99
|
+
* Receives the full component chain (closest-to-DOM-first) as an array
|
|
100
|
+
* of {@link ComponentHandle} objects. Each handle exposes the component
|
|
101
|
+
* name and props immediately, plus a lazy `resolveSource()` that only
|
|
102
|
+
* performs source-map resolution when called.
|
|
103
|
+
*
|
|
104
|
+
* Return a chain index to navigate to, or `null` / `undefined` to use
|
|
105
|
+
* the default behaviour (index 0 — the closest component).
|
|
106
|
+
*
|
|
107
|
+
* May return synchronously (when only names/props are needed) or
|
|
108
|
+
* asynchronously (when source resolution is required).
|
|
109
|
+
*/
|
|
110
|
+
getClickTarget?: (
|
|
111
|
+
chain: ComponentHandle[]
|
|
112
|
+
) => number | null | undefined | Promise<number | null | undefined>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* When `true`, logs a detailed debug trace for every source-map
|
|
116
|
+
* resolution step, the resolved result, and the final editor URL to
|
|
117
|
+
* the browser console.
|
|
118
|
+
*
|
|
119
|
+
* Useful for diagnosing why a click isn't opening the right file.
|
|
120
|
+
*
|
|
121
|
+
* @default false
|
|
122
|
+
*/
|
|
123
|
+
debug?: boolean;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Transform the component chain before it is displayed in the popover.
|
|
127
|
+
*
|
|
128
|
+
* A {@link ChainTransformer} receives the raw fiber chain and returns a
|
|
129
|
+
* new chain of {@link TransformedEntry} objects. This allows collapsing
|
|
130
|
+
* entries (e.g. `span → FormattedMessage` → `"message text"`), relabelling
|
|
131
|
+
* components, and overriding navigation targets.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```tsx
|
|
135
|
+
* import { createFormattedMessageTransformer } from 'react-spot';
|
|
136
|
+
*
|
|
137
|
+
* <ReactSpot
|
|
138
|
+
* chainTransformer={createFormattedMessageTransformer()}
|
|
139
|
+
* />
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
chainTransformer?: ChainTransformer;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Opens a file in the editor via a custom protocol (e.g. cursor://file/{path}:{L}:{C}).
|
|
147
|
+
* When `onNavigate` is provided, the callback receives the resolved location
|
|
148
|
+
* instead of triggering the protocol handler.
|
|
149
|
+
*/
|
|
150
|
+
function openInEditor(
|
|
151
|
+
source: string,
|
|
152
|
+
line: number,
|
|
153
|
+
column: number,
|
|
154
|
+
onNavigate?: ReactSpotProps['onNavigate'],
|
|
155
|
+
componentName?: string,
|
|
156
|
+
editorScheme = 'cursor',
|
|
157
|
+
debug?: boolean
|
|
158
|
+
): void {
|
|
159
|
+
let cleanPath = source.replace(/^file:\/\//, '');
|
|
160
|
+
cleanPath = decodeURIComponent(cleanPath);
|
|
161
|
+
// Ensure the path starts with / so the protocol URL is well-formed
|
|
162
|
+
// (e.g. cursor://file/… not cursor://filesrc/…)
|
|
163
|
+
if (!cleanPath.startsWith('/')) {
|
|
164
|
+
cleanPath = `/${cleanPath}`;
|
|
165
|
+
}
|
|
166
|
+
// Encode each path segment so special characters (parentheses, brackets,
|
|
167
|
+
// spaces, #, etc.) produce a well-formed protocol URL while preserving
|
|
168
|
+
// the '/' separators.
|
|
169
|
+
const encodedPath = cleanPath
|
|
170
|
+
.split('/')
|
|
171
|
+
.map((segment) => encodeURIComponent(segment))
|
|
172
|
+
.join('/');
|
|
173
|
+
const url = `${editorScheme}://file${encodedPath}:${line}:${column + 1}`;
|
|
174
|
+
|
|
175
|
+
if (debug) {
|
|
176
|
+
console.log('[show-component] openInEditor:', {
|
|
177
|
+
source: cleanPath,
|
|
178
|
+
line,
|
|
179
|
+
column,
|
|
180
|
+
componentName,
|
|
181
|
+
url,
|
|
182
|
+
mode: onNavigate ? 'onNavigate callback' : 'location.href',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (onNavigate) {
|
|
187
|
+
onNavigate({ source: cleanPath, line, column, url, componentName });
|
|
188
|
+
} else {
|
|
189
|
+
// location.href (not window.open) is needed for custom protocol URLs —
|
|
190
|
+
// some browsers won't trigger the OS handler otherwise.
|
|
191
|
+
window.location.href = url;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolves the source location for a single component and opens the editor.
|
|
197
|
+
* Delegates to the resolver's own two-level cache.
|
|
198
|
+
*/
|
|
199
|
+
async function resolveAndNavigate(
|
|
200
|
+
component: ClickToNodeInfo,
|
|
201
|
+
onNavigate?: ReactSpotProps['onNavigate'],
|
|
202
|
+
editorScheme?: string,
|
|
203
|
+
debug?: boolean
|
|
204
|
+
): Promise<boolean> {
|
|
205
|
+
if (!component.stackFrame) return false;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const resolved = await resolveLocation(component.stackFrame, debug);
|
|
209
|
+
if (resolved) {
|
|
210
|
+
openInEditor(
|
|
211
|
+
resolved.source,
|
|
212
|
+
resolved.line,
|
|
213
|
+
resolved.column,
|
|
214
|
+
onNavigate,
|
|
215
|
+
component.componentName,
|
|
216
|
+
editorScheme,
|
|
217
|
+
debug
|
|
218
|
+
);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function ReactSpot({
|
|
228
|
+
onNavigate,
|
|
229
|
+
sourceRoot,
|
|
230
|
+
editorScheme,
|
|
231
|
+
getClickTarget,
|
|
232
|
+
debug,
|
|
233
|
+
chainTransformer,
|
|
234
|
+
}: ReactSpotProps = {}) {
|
|
235
|
+
// Keep stable refs so event handlers registered once (in useEffect [])
|
|
236
|
+
// always see the latest callbacks without re-registering listeners.
|
|
237
|
+
const onNavigateRef = useRef(onNavigate);
|
|
238
|
+
onNavigateRef.current = onNavigate;
|
|
239
|
+
|
|
240
|
+
const editorSchemeRef = useRef(editorScheme);
|
|
241
|
+
editorSchemeRef.current = editorScheme;
|
|
242
|
+
|
|
243
|
+
const getClickTargetRef = useRef(getClickTarget);
|
|
244
|
+
getClickTargetRef.current = getClickTarget;
|
|
245
|
+
|
|
246
|
+
const debugRef = useRef(debug);
|
|
247
|
+
debugRef.current = debug;
|
|
248
|
+
|
|
249
|
+
const chainTransformerRef = useRef(chainTransformer);
|
|
250
|
+
chainTransformerRef.current = chainTransformer;
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
configureSourceRoot(sourceRoot);
|
|
254
|
+
}, [sourceRoot]);
|
|
255
|
+
|
|
256
|
+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
257
|
+
const [displayChain, setDisplayChain] = useState<TransformedEntry[]>([]);
|
|
258
|
+
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
|
|
259
|
+
interface PropsPopup {
|
|
260
|
+
id: string;
|
|
261
|
+
entry: TransformedEntry;
|
|
262
|
+
position: { x: number; y: number };
|
|
263
|
+
size: { width: number; height: number };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const [propsPopups, setPropsPopups] = useState<PropsPopup[]>([]);
|
|
267
|
+
const [draggingPopup, setDraggingPopup] = useState<{
|
|
268
|
+
id: string;
|
|
269
|
+
offset: { x: number; y: number };
|
|
270
|
+
} | null>(null);
|
|
271
|
+
const [resizingPopup, setResizingPopup] = useState<{
|
|
272
|
+
id: string;
|
|
273
|
+
startX: number;
|
|
274
|
+
startY: number;
|
|
275
|
+
startW: number;
|
|
276
|
+
startH: number;
|
|
277
|
+
startPosX: number;
|
|
278
|
+
startPosY: number;
|
|
279
|
+
direction: string;
|
|
280
|
+
} | null>(null);
|
|
281
|
+
|
|
282
|
+
// 检查模式:按住 Option 键时高亮悬停元素并显示组件链路面包屑
|
|
283
|
+
const [inspectMode, setInspectMode] = useState(false);
|
|
284
|
+
const [hoverInfo, setHoverInfo] = useState<{
|
|
285
|
+
rect: DOMRect;
|
|
286
|
+
breadcrumb: { name: string; isComponent: boolean }[];
|
|
287
|
+
parentRects: DOMRect[];
|
|
288
|
+
} | null>(null);
|
|
289
|
+
|
|
290
|
+
const buildTransformContext = useCallback(
|
|
291
|
+
(): ChainTransformContext => ({
|
|
292
|
+
resolveLocation: (sf, dbg) => resolveLocation(sf, dbg ?? debugRef.current),
|
|
293
|
+
getComponentName,
|
|
294
|
+
getStackFrame,
|
|
295
|
+
}),
|
|
296
|
+
[]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const navigateFromEntry = useCallback(async (entry: TransformedEntry): Promise<boolean> => {
|
|
300
|
+
if (entry.resolveLocation) {
|
|
301
|
+
const loc = await entry.resolveLocation();
|
|
302
|
+
if (loc) {
|
|
303
|
+
openInEditor(
|
|
304
|
+
loc.source,
|
|
305
|
+
loc.line,
|
|
306
|
+
loc.column,
|
|
307
|
+
onNavigateRef.current,
|
|
308
|
+
entry.label,
|
|
309
|
+
editorSchemeRef.current,
|
|
310
|
+
debugRef.current
|
|
311
|
+
);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
return resolveAndNavigate(
|
|
317
|
+
entry.sourceEntry,
|
|
318
|
+
onNavigateRef.current,
|
|
319
|
+
editorSchemeRef.current,
|
|
320
|
+
debugRef.current
|
|
321
|
+
);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
const handleComponentClick = async (index: number) => {
|
|
325
|
+
setIsPopoverOpen(false);
|
|
326
|
+
await navigateFromEntry(displayChain[index]);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const handleNavigateFromPopup = async (entry: TransformedEntry) => {
|
|
330
|
+
await navigateFromEntry(entry);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const handlePropsClick = (entry: TransformedEntry) => {
|
|
334
|
+
const popupId = `props-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
335
|
+
|
|
336
|
+
const popupWidth = 400;
|
|
337
|
+
const popupHeight = 300;
|
|
338
|
+
const cascadeOffset = 40;
|
|
339
|
+
|
|
340
|
+
let baseX = 200 + propsPopups.length * cascadeOffset;
|
|
341
|
+
let baseY = 200 + propsPopups.length * cascadeOffset;
|
|
342
|
+
|
|
343
|
+
const viewportWidth = window.innerWidth;
|
|
344
|
+
const viewportHeight = window.innerHeight;
|
|
345
|
+
|
|
346
|
+
// Wrap to next column when cascading would go off-screen
|
|
347
|
+
if (baseX + popupWidth > viewportWidth - 20) {
|
|
348
|
+
const column = Math.floor(propsPopups.length / 5);
|
|
349
|
+
const row = propsPopups.length % 5;
|
|
350
|
+
baseX = 50 + column * 200;
|
|
351
|
+
baseY = 100 + row * cascadeOffset;
|
|
352
|
+
}
|
|
353
|
+
if (baseY + popupHeight > viewportHeight - 20) {
|
|
354
|
+
baseY = 100;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const newPopup: PropsPopup = {
|
|
358
|
+
id: popupId,
|
|
359
|
+
entry,
|
|
360
|
+
position: { x: baseX, y: baseY },
|
|
361
|
+
size: { width: popupWidth, height: popupHeight },
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
setPropsPopups((prev) => [...prev, newPopup]);
|
|
365
|
+
setIsPopoverOpen(false);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Handle dragging of props popups
|
|
369
|
+
const handleMouseDown = (popupId: string) => (e: React.MouseEvent) => {
|
|
370
|
+
if ((e.target as HTMLElement).classList.contains('drag-handle')) {
|
|
371
|
+
const popup = propsPopups.find((p) => p.id === popupId);
|
|
372
|
+
if (popup) {
|
|
373
|
+
setDraggingPopup({
|
|
374
|
+
id: popupId,
|
|
375
|
+
offset: {
|
|
376
|
+
x: e.clientX - popup.position.x,
|
|
377
|
+
y: e.clientY - popup.position.y,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const handleMouseMove = useCallback(
|
|
386
|
+
(e: MouseEvent) => {
|
|
387
|
+
if (draggingPopup) {
|
|
388
|
+
setPropsPopups((prev) =>
|
|
389
|
+
prev.map((popup) =>
|
|
390
|
+
popup.id === draggingPopup.id
|
|
391
|
+
? {
|
|
392
|
+
...popup,
|
|
393
|
+
position: {
|
|
394
|
+
x: e.clientX - draggingPopup.offset.x,
|
|
395
|
+
y: e.clientY - draggingPopup.offset.y,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
: popup
|
|
399
|
+
)
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (resizingPopup) {
|
|
403
|
+
const MIN_W = 200;
|
|
404
|
+
const MIN_H = 120;
|
|
405
|
+
const dx = e.clientX - resizingPopup.startX;
|
|
406
|
+
const dy = e.clientY - resizingPopup.startY;
|
|
407
|
+
const dir = resizingPopup.direction;
|
|
408
|
+
let newW = resizingPopup.startW;
|
|
409
|
+
let newH = resizingPopup.startH;
|
|
410
|
+
let newX = resizingPopup.startPosX;
|
|
411
|
+
let newY = resizingPopup.startPosY;
|
|
412
|
+
|
|
413
|
+
if (dir.includes('e')) newW = Math.max(MIN_W, resizingPopup.startW + dx);
|
|
414
|
+
if (dir.includes('s')) newH = Math.max(MIN_H, resizingPopup.startH + dy);
|
|
415
|
+
if (dir.includes('w')) {
|
|
416
|
+
const proposed = resizingPopup.startW - dx;
|
|
417
|
+
if (proposed >= MIN_W) {
|
|
418
|
+
newW = proposed;
|
|
419
|
+
newX = resizingPopup.startPosX + dx;
|
|
420
|
+
} else {
|
|
421
|
+
newW = MIN_W;
|
|
422
|
+
newX = resizingPopup.startPosX + (resizingPopup.startW - MIN_W);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (dir.includes('n')) {
|
|
426
|
+
const proposed = resizingPopup.startH - dy;
|
|
427
|
+
if (proposed >= MIN_H) {
|
|
428
|
+
newH = proposed;
|
|
429
|
+
newY = resizingPopup.startPosY + dy;
|
|
430
|
+
} else {
|
|
431
|
+
newH = MIN_H;
|
|
432
|
+
newY = resizingPopup.startPosY + (resizingPopup.startH - MIN_H);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
setPropsPopups((prev) =>
|
|
437
|
+
prev.map((popup) =>
|
|
438
|
+
popup.id === resizingPopup.id
|
|
439
|
+
? { ...popup, position: { x: newX, y: newY }, size: { width: newW, height: newH } }
|
|
440
|
+
: popup
|
|
441
|
+
)
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
[draggingPopup, resizingPopup]
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const handleMouseUp = useCallback(() => {
|
|
449
|
+
setDraggingPopup(null);
|
|
450
|
+
setResizingPopup(null);
|
|
451
|
+
}, []);
|
|
452
|
+
|
|
453
|
+
const closePopup = (popupId: string) => {
|
|
454
|
+
setPropsPopups((prev) => prev.filter((p) => p.id !== popupId));
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const startResize =
|
|
458
|
+
(popupId: string, direction: string, popup: PropsPopup) => (e: React.MouseEvent) => {
|
|
459
|
+
e.preventDefault();
|
|
460
|
+
e.stopPropagation();
|
|
461
|
+
setResizingPopup({
|
|
462
|
+
id: popupId,
|
|
463
|
+
direction,
|
|
464
|
+
startX: e.clientX,
|
|
465
|
+
startY: e.clientY,
|
|
466
|
+
startW: popup.size.width,
|
|
467
|
+
startH: popup.size.height,
|
|
468
|
+
startPosX: popup.position.x,
|
|
469
|
+
startPosY: popup.position.y,
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// ── 检查模式:Option 键按下/释放 ───────────────────────────────────────────
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
476
|
+
if (e.key === 'Alt') {
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
setInspectMode(true);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
482
|
+
if (e.key === 'Alt') {
|
|
483
|
+
setInspectMode(false);
|
|
484
|
+
setHoverInfo(null);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
// 窗口失焦时退出检查模式,防止 Alt 键状态残留
|
|
488
|
+
const onBlur = () => {
|
|
489
|
+
setInspectMode(false);
|
|
490
|
+
setHoverInfo(null);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
494
|
+
window.addEventListener('keyup', onKeyUp, true);
|
|
495
|
+
window.addEventListener('blur', onBlur);
|
|
496
|
+
return () => {
|
|
497
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
498
|
+
window.removeEventListener('keyup', onKeyUp, true);
|
|
499
|
+
window.removeEventListener('blur', onBlur);
|
|
500
|
+
};
|
|
501
|
+
}, []);
|
|
502
|
+
|
|
503
|
+
// ── 检查模式:悬停追踪 + Option+左键直接跳转 ─────────────────────────────────
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (!inspectMode) return;
|
|
506
|
+
|
|
507
|
+
document.body.style.cursor = 'crosshair';
|
|
508
|
+
document.body.style.userSelect = 'none';
|
|
509
|
+
|
|
510
|
+
// 闭包变量:保存当前悬停元素的 fiber 链路,供左键点击时使用,
|
|
511
|
+
// 避免 React state 的异步更新导致点击时读到过期数据
|
|
512
|
+
let currentChain: ClickToNodeInfo[] = [];
|
|
513
|
+
|
|
514
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
515
|
+
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
|
516
|
+
if (!el || el.closest('[data-react-spot-overlay]')) return;
|
|
517
|
+
|
|
518
|
+
const rect = el.getBoundingClientRect();
|
|
519
|
+
currentChain = buildFiberChain(el);
|
|
520
|
+
|
|
521
|
+
// 面包屑只展示用户组件,不显示 span/div 等原生 DOM
|
|
522
|
+
const breadcrumb = currentChain
|
|
523
|
+
.filter((c) => c.componentName !== 'Component (No Type)')
|
|
524
|
+
.filter((c) => !isHostFiberEntry(c))
|
|
525
|
+
.map((c) => ({
|
|
526
|
+
name: c.componentName,
|
|
527
|
+
isComponent: true,
|
|
528
|
+
}))
|
|
529
|
+
.reverse();
|
|
530
|
+
|
|
531
|
+
// 收集父级 DOM 元素的定位矩形用于多层高亮
|
|
532
|
+
const parentRects: DOMRect[] = [];
|
|
533
|
+
let parent = el.parentElement;
|
|
534
|
+
while (parent && parentRects.length < 4) {
|
|
535
|
+
if (parent !== document.body && parent !== document.documentElement) {
|
|
536
|
+
const pr = parent.getBoundingClientRect();
|
|
537
|
+
if (pr.width > rect.width + 4 || pr.height > rect.height + 4) {
|
|
538
|
+
parentRects.push(pr);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
parent = parent.parentElement;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
setHoverInfo({ rect, breadcrumb, parentRects });
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Option + 左键 → 跳转到最近的用户组件源码
|
|
548
|
+
const onClick = (e: MouseEvent) => {
|
|
549
|
+
if (e.button !== 0 || !e.altKey) return;
|
|
550
|
+
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
e.stopPropagation();
|
|
553
|
+
setInspectMode(false);
|
|
554
|
+
setHoverInfo(null);
|
|
555
|
+
|
|
556
|
+
if (currentChain.length === 0) return;
|
|
557
|
+
|
|
558
|
+
// 优先用叶节点栈帧(含原生 DOM),实现 JSX 标签级定位
|
|
559
|
+
const target = currentChain.find((c) => c.stackFrame) ?? currentChain[0];
|
|
560
|
+
|
|
561
|
+
if (getClickTargetRef.current) {
|
|
562
|
+
const dbg = debugRef.current;
|
|
563
|
+
const handles: ComponentHandle[] = currentChain.map((c, i) => ({
|
|
564
|
+
componentName: c.componentName,
|
|
565
|
+
props: c.props,
|
|
566
|
+
index: i,
|
|
567
|
+
resolveSource: () =>
|
|
568
|
+
c.stackFrame
|
|
569
|
+
? resolveLocation(c.stackFrame, dbg).then((r) =>
|
|
570
|
+
r ? { source: r.source, line: r.line, column: r.column } : null
|
|
571
|
+
)
|
|
572
|
+
: Promise.resolve(null),
|
|
573
|
+
}));
|
|
574
|
+
Promise.resolve(getClickTargetRef.current(handles)).then((targetIndex) => {
|
|
575
|
+
const idx = targetIndex ?? 0;
|
|
576
|
+
if (idx >= 0 && idx < currentChain.length) {
|
|
577
|
+
resolveAndNavigate(
|
|
578
|
+
currentChain[idx],
|
|
579
|
+
onNavigateRef.current,
|
|
580
|
+
editorSchemeRef.current,
|
|
581
|
+
debugRef.current
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
} else if (chainTransformerRef.current) {
|
|
586
|
+
const ctx = buildTransformContext();
|
|
587
|
+
const transformed = applyTransformer(currentChain, chainTransformerRef.current, ctx);
|
|
588
|
+
if (transformed.length > 0) navigateFromEntry(transformed[0]);
|
|
589
|
+
} else {
|
|
590
|
+
resolveAndNavigate(
|
|
591
|
+
target,
|
|
592
|
+
onNavigateRef.current,
|
|
593
|
+
editorSchemeRef.current,
|
|
594
|
+
debugRef.current
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
document.addEventListener('mousemove', onMouseMove, true);
|
|
600
|
+
document.addEventListener('click', onClick, true);
|
|
601
|
+
return () => {
|
|
602
|
+
document.removeEventListener('mousemove', onMouseMove, true);
|
|
603
|
+
document.removeEventListener('click', onClick, true);
|
|
604
|
+
document.body.style.cursor = '';
|
|
605
|
+
document.body.style.userSelect = '';
|
|
606
|
+
};
|
|
607
|
+
}, [inspectMode, buildTransformContext, navigateFromEntry]);
|
|
608
|
+
|
|
609
|
+
// ── Option + 右键 → 弹出组件链路菜单 ─────────────────────────────────────
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
const handleContextMenu = (event: MouseEvent) => {
|
|
612
|
+
if (!event.altKey) return;
|
|
613
|
+
|
|
614
|
+
event.preventDefault();
|
|
615
|
+
event.stopPropagation();
|
|
616
|
+
|
|
617
|
+
// 关闭检查模式高亮,让弹出菜单获得焦点
|
|
618
|
+
setInspectMode(false);
|
|
619
|
+
setHoverInfo(null);
|
|
620
|
+
|
|
621
|
+
// 右键菜单只展示组件层级,原生 DOM 保留在完整 chain 中供跳转
|
|
622
|
+
const fullChain = buildFiberReturnChain(event.target as HTMLElement).filter(
|
|
623
|
+
(c) => !isHostFiberEntry(c)
|
|
624
|
+
);
|
|
625
|
+
if (fullChain.length === 0) return;
|
|
626
|
+
|
|
627
|
+
const ctx = buildTransformContext();
|
|
628
|
+
const transformed = applyTransformer(fullChain, chainTransformerRef.current, ctx);
|
|
629
|
+
setDisplayChain(transformed);
|
|
630
|
+
setPopoverPosition({ x: event.clientX, y: event.clientY });
|
|
631
|
+
setIsPopoverOpen(true);
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// 阻止 Alt+右键时浏览器默认的 mousedown 行为(如文本选中)
|
|
635
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
636
|
+
if (event.button === 2 && event.altKey) {
|
|
637
|
+
event.preventDefault();
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
document.addEventListener('mousedown', handleMouseDown, true);
|
|
642
|
+
document.addEventListener('contextmenu', handleContextMenu, true);
|
|
643
|
+
return () => {
|
|
644
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
645
|
+
document.removeEventListener('contextmenu', handleContextMenu, true);
|
|
646
|
+
};
|
|
647
|
+
}, [buildTransformContext]);
|
|
648
|
+
|
|
649
|
+
useEffect(() => {
|
|
650
|
+
const active = draggingPopup || resizingPopup;
|
|
651
|
+
if (active) {
|
|
652
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
653
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
654
|
+
const resizeCursors: Record<string, string> = {
|
|
655
|
+
n: 'ns-resize',
|
|
656
|
+
s: 'ns-resize',
|
|
657
|
+
e: 'ew-resize',
|
|
658
|
+
w: 'ew-resize',
|
|
659
|
+
ne: 'nesw-resize',
|
|
660
|
+
sw: 'nesw-resize',
|
|
661
|
+
nw: 'nwse-resize',
|
|
662
|
+
se: 'nwse-resize',
|
|
663
|
+
};
|
|
664
|
+
document.body.style.cursor = resizingPopup
|
|
665
|
+
? resizeCursors[resizingPopup.direction] || 'nwse-resize'
|
|
666
|
+
: 'grabbing';
|
|
667
|
+
document.body.style.userSelect = 'none';
|
|
668
|
+
} else {
|
|
669
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
670
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
671
|
+
document.body.style.cursor = '';
|
|
672
|
+
document.body.style.userSelect = '';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return () => {
|
|
676
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
677
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
678
|
+
document.body.style.cursor = '';
|
|
679
|
+
document.body.style.userSelect = '';
|
|
680
|
+
};
|
|
681
|
+
}, [draggingPopup, resizingPopup, handleMouseMove, handleMouseUp]);
|
|
682
|
+
|
|
683
|
+
return (
|
|
684
|
+
<>
|
|
685
|
+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS string, no user input */}
|
|
686
|
+
<style dangerouslySetInnerHTML={{ __html: SC_STYLES }} />
|
|
687
|
+
|
|
688
|
+
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
689
|
+
<PopoverTrigger asChild>
|
|
690
|
+
<div
|
|
691
|
+
style={{
|
|
692
|
+
position: 'fixed',
|
|
693
|
+
left: popoverPosition.x,
|
|
694
|
+
top: popoverPosition.y,
|
|
695
|
+
width: 1,
|
|
696
|
+
height: 1,
|
|
697
|
+
pointerEvents: 'none',
|
|
698
|
+
zIndex: 2147483647,
|
|
699
|
+
}}
|
|
700
|
+
/>
|
|
701
|
+
</PopoverTrigger>
|
|
702
|
+
<PopoverContent
|
|
703
|
+
align="start"
|
|
704
|
+
style={{
|
|
705
|
+
width: '20rem',
|
|
706
|
+
padding: 0,
|
|
707
|
+
backgroundColor: '#fff',
|
|
708
|
+
border: '1px solid #e5e7eb',
|
|
709
|
+
borderRadius: 8,
|
|
710
|
+
boxShadow: '0 10px 25px -5px rgba(0,0,0,.15), 0 4px 10px -4px rgba(0,0,0,.08)',
|
|
711
|
+
color: '#1f2937',
|
|
712
|
+
zIndex: 2147483647,
|
|
713
|
+
}}
|
|
714
|
+
>
|
|
715
|
+
<div style={{ padding: '8px 6px' }}>
|
|
716
|
+
{/* 反转:根组件在上、被点击元素在下,缩进体现层级深度 */}
|
|
717
|
+
{[...displayChain].reverse().map((entry, visualIndex) => {
|
|
718
|
+
const realIndex = displayChain.length - 1 - visualIndex;
|
|
719
|
+
const entryProps = entry.props ?? entry.sourceEntry.props;
|
|
720
|
+
const hasProps = entryProps && Object.keys(entryProps).some((k) => k !== 'children');
|
|
721
|
+
const isLeaf = visualIndex === displayChain.length - 1;
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div key={`${entry.label}-${realIndex}`} className="sc-chain-row">
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
className={`sc-chain-item ${isLeaf ? 'sc-chain-item-active' : ''}`}
|
|
728
|
+
style={{ paddingLeft: `${8 + visualIndex * 12}px` }}
|
|
729
|
+
onClick={() => handleComponentClick(realIndex)}
|
|
730
|
+
>
|
|
731
|
+
<span className="sc-chain-indent" aria-hidden="true">
|
|
732
|
+
{visualIndex > 0 ? '└ ' : ''}
|
|
733
|
+
</span>
|
|
734
|
+
{entry.label}
|
|
735
|
+
</button>
|
|
736
|
+
{hasProps && (
|
|
737
|
+
<button
|
|
738
|
+
type="button"
|
|
739
|
+
className="sc-icon-btn"
|
|
740
|
+
onClick={() => handlePropsClick(entry)}
|
|
741
|
+
title="Inspect props"
|
|
742
|
+
>
|
|
743
|
+
<BracesIcon size={14} strokeWidth={2} />
|
|
744
|
+
</button>
|
|
745
|
+
)}
|
|
746
|
+
</div>
|
|
747
|
+
);
|
|
748
|
+
})}
|
|
749
|
+
</div>
|
|
750
|
+
</PopoverContent>
|
|
751
|
+
</Popover>
|
|
752
|
+
|
|
753
|
+
{propsPopups.map((popup) => (
|
|
754
|
+
<div
|
|
755
|
+
key={popup.id}
|
|
756
|
+
style={{
|
|
757
|
+
position: 'fixed',
|
|
758
|
+
left: popup.position.x,
|
|
759
|
+
top: popup.position.y,
|
|
760
|
+
width: popup.size.width,
|
|
761
|
+
height: popup.size.height,
|
|
762
|
+
zIndex: 2147483647,
|
|
763
|
+
background: '#fff',
|
|
764
|
+
border: '1px solid #d1d5db',
|
|
765
|
+
borderRadius: 8,
|
|
766
|
+
boxShadow: '0 10px 25px -5px rgba(0,0,0,.15), 0 4px 10px -4px rgba(0,0,0,.08)',
|
|
767
|
+
overflow: 'hidden',
|
|
768
|
+
display: 'flex',
|
|
769
|
+
flexDirection: 'column',
|
|
770
|
+
color: '#1f2937',
|
|
771
|
+
}}
|
|
772
|
+
onMouseDown={handleMouseDown(popup.id)}
|
|
773
|
+
>
|
|
774
|
+
<div
|
|
775
|
+
className="drag-handle"
|
|
776
|
+
style={{
|
|
777
|
+
display: 'flex',
|
|
778
|
+
alignItems: 'center',
|
|
779
|
+
justifyContent: 'space-between',
|
|
780
|
+
padding: '6px 10px',
|
|
781
|
+
background: '#f3f4f6',
|
|
782
|
+
borderBottom: '1px solid #e5e7eb',
|
|
783
|
+
cursor: 'move',
|
|
784
|
+
userSelect: 'none',
|
|
785
|
+
flexShrink: 0,
|
|
786
|
+
}}
|
|
787
|
+
>
|
|
788
|
+
<span style={{ fontWeight: 600, fontSize: 13 }}>{popup.entry.label}</span>
|
|
789
|
+
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
|
790
|
+
<button
|
|
791
|
+
type="button"
|
|
792
|
+
className="sc-icon-btn"
|
|
793
|
+
onClick={() => handleNavigateFromPopup(popup.entry)}
|
|
794
|
+
title="Go to source"
|
|
795
|
+
>
|
|
796
|
+
<ExternalLinkIcon size={13} strokeWidth={2} />
|
|
797
|
+
</button>
|
|
798
|
+
<button
|
|
799
|
+
type="button"
|
|
800
|
+
className="sc-icon-btn"
|
|
801
|
+
onClick={() => closePopup(popup.id)}
|
|
802
|
+
title="Close"
|
|
803
|
+
>
|
|
804
|
+
<svg
|
|
805
|
+
aria-hidden="true"
|
|
806
|
+
width="13"
|
|
807
|
+
height="13"
|
|
808
|
+
viewBox="0 0 24 24"
|
|
809
|
+
fill="none"
|
|
810
|
+
stroke="currentColor"
|
|
811
|
+
strokeWidth="2.5"
|
|
812
|
+
strokeLinecap="round"
|
|
813
|
+
strokeLinejoin="round"
|
|
814
|
+
>
|
|
815
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
816
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
817
|
+
</svg>
|
|
818
|
+
</button>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
|
|
822
|
+
<div
|
|
823
|
+
style={{
|
|
824
|
+
flex: 1,
|
|
825
|
+
overflow: 'auto',
|
|
826
|
+
padding: 10,
|
|
827
|
+
overscrollBehavior: 'contain',
|
|
828
|
+
}}
|
|
829
|
+
onWheel={(e) => {
|
|
830
|
+
const el = e.currentTarget;
|
|
831
|
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
832
|
+
if (
|
|
833
|
+
(e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight) ||
|
|
834
|
+
(e.deltaY < 0 && scrollTop <= 0)
|
|
835
|
+
) {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
e.stopPropagation();
|
|
838
|
+
}
|
|
839
|
+
}}
|
|
840
|
+
>
|
|
841
|
+
{(() => {
|
|
842
|
+
const popupProps = popup.entry.props ?? popup.entry.sourceEntry.props;
|
|
843
|
+
return popupProps ? (
|
|
844
|
+
<JsonView
|
|
845
|
+
value={popupProps}
|
|
846
|
+
style={{
|
|
847
|
+
fontSize: '12px',
|
|
848
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
849
|
+
}}
|
|
850
|
+
collapsed={1}
|
|
851
|
+
displayDataTypes={false}
|
|
852
|
+
displayObjectSize={false}
|
|
853
|
+
shortenTextAfterLength={Math.max(20, Math.floor((popup.size.width - 60) / 7.2))}
|
|
854
|
+
/>
|
|
855
|
+
) : (
|
|
856
|
+
<div style={{ color: '#9ca3af', fontSize: 13 }}>No props available</div>
|
|
857
|
+
);
|
|
858
|
+
})()}
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
{(['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const).map((dir) => (
|
|
862
|
+
<div
|
|
863
|
+
key={dir}
|
|
864
|
+
className={`sc-resize-edge sc-resize-${dir}`}
|
|
865
|
+
onMouseDown={startResize(popup.id, dir, popup)}
|
|
866
|
+
/>
|
|
867
|
+
))}
|
|
868
|
+
</div>
|
|
869
|
+
))}
|
|
870
|
+
|
|
871
|
+
{/* 检查模式覆盖层:高亮悬停元素 + 父级边框 + 组件链路面包屑 */}
|
|
872
|
+
{inspectMode && !isPopoverOpen && hoverInfo && (
|
|
873
|
+
<div data-react-spot-overlay="" className="sc-inspect-overlay">
|
|
874
|
+
{hoverInfo.parentRects.map((pr, i) => (
|
|
875
|
+
<div
|
|
876
|
+
key={`p${i}`}
|
|
877
|
+
className="sc-highlight-parent"
|
|
878
|
+
style={{
|
|
879
|
+
left: pr.left,
|
|
880
|
+
top: pr.top,
|
|
881
|
+
width: pr.width,
|
|
882
|
+
height: pr.height,
|
|
883
|
+
opacity: 0.6 - i * 0.12,
|
|
884
|
+
}}
|
|
885
|
+
/>
|
|
886
|
+
))}
|
|
887
|
+
|
|
888
|
+
<div
|
|
889
|
+
className="sc-highlight"
|
|
890
|
+
style={{
|
|
891
|
+
left: hoverInfo.rect.left,
|
|
892
|
+
top: hoverInfo.rect.top,
|
|
893
|
+
width: hoverInfo.rect.width,
|
|
894
|
+
height: hoverInfo.rect.height,
|
|
895
|
+
}}
|
|
896
|
+
/>
|
|
897
|
+
|
|
898
|
+
<div
|
|
899
|
+
className="sc-breadcrumb"
|
|
900
|
+
style={{
|
|
901
|
+
left: Math.max(4, hoverInfo.rect.left),
|
|
902
|
+
top: hoverInfo.rect.top > 28 ? hoverInfo.rect.top - 24 : hoverInfo.rect.bottom + 4,
|
|
903
|
+
}}
|
|
904
|
+
>
|
|
905
|
+
{hoverInfo.breadcrumb.slice(-5).map((item, i) => (
|
|
906
|
+
<span key={`b${i}`}>
|
|
907
|
+
{i > 0 && (
|
|
908
|
+
<svg className="sc-breadcrumb-sep" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
909
|
+
<polyline points="9 6 15 12 9 18" />
|
|
910
|
+
</svg>
|
|
911
|
+
)}
|
|
912
|
+
<span className={item.isComponent ? 'sc-breadcrumb-cmp' : 'sc-breadcrumb-el'}>
|
|
913
|
+
{item.name}
|
|
914
|
+
</span>
|
|
915
|
+
</span>
|
|
916
|
+
))}
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
)}
|
|
920
|
+
</>
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Scoped CSS injected via <style> — keeps the component self-contained
|
|
925
|
+
// without requiring Tailwind CSS variables in the consumer's app.
|
|
926
|
+
const SC_STYLES = `
|
|
927
|
+
.sc-chain-row {
|
|
928
|
+
display: flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
gap: 2px;
|
|
931
|
+
}
|
|
932
|
+
.sc-chain-item {
|
|
933
|
+
flex: 1;
|
|
934
|
+
display: block;
|
|
935
|
+
padding: 5px 8px;
|
|
936
|
+
border: none;
|
|
937
|
+
background: transparent;
|
|
938
|
+
border-radius: 6px;
|
|
939
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
940
|
+
font-size: 12px;
|
|
941
|
+
font-weight: 500;
|
|
942
|
+
color: #1f2937;
|
|
943
|
+
text-align: left;
|
|
944
|
+
cursor: pointer;
|
|
945
|
+
transition: background-color 0.1s;
|
|
946
|
+
line-height: 1.4;
|
|
947
|
+
}
|
|
948
|
+
.sc-chain-item:hover {
|
|
949
|
+
background-color: #f3f4f6;
|
|
950
|
+
}
|
|
951
|
+
.sc-chain-item-active {
|
|
952
|
+
color: #2563eb;
|
|
953
|
+
font-weight: 600;
|
|
954
|
+
}
|
|
955
|
+
.sc-chain-indent {
|
|
956
|
+
color: #d1d5db;
|
|
957
|
+
font-weight: 400;
|
|
958
|
+
}
|
|
959
|
+
.sc-icon-btn {
|
|
960
|
+
display: inline-flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
justify-content: center;
|
|
963
|
+
width: 26px;
|
|
964
|
+
height: 26px;
|
|
965
|
+
border: none;
|
|
966
|
+
background: transparent;
|
|
967
|
+
border-radius: 5px;
|
|
968
|
+
color: #6b7280;
|
|
969
|
+
cursor: pointer;
|
|
970
|
+
flex-shrink: 0;
|
|
971
|
+
transition: background-color 0.1s, color 0.1s;
|
|
972
|
+
}
|
|
973
|
+
.sc-icon-btn:hover {
|
|
974
|
+
background-color: #e5e7eb;
|
|
975
|
+
color: #1f2937;
|
|
976
|
+
}
|
|
977
|
+
/* Resize handles — invisible hit zones */
|
|
978
|
+
.sc-resize-edge { position: absolute; z-index: 1; }
|
|
979
|
+
.sc-resize-n { top: 0; left: 8px; right: 8px; height: 5px; cursor: ns-resize; }
|
|
980
|
+
.sc-resize-s { bottom: 0; left: 8px; right: 8px; height: 5px; cursor: ns-resize; }
|
|
981
|
+
.sc-resize-e { top: 8px; right: 0; bottom: 8px; width: 5px; cursor: ew-resize; }
|
|
982
|
+
.sc-resize-w { top: 8px; left: 0; bottom: 8px; width: 5px; cursor: ew-resize; }
|
|
983
|
+
.sc-resize-ne { top: 0; right: 0; width: 10px; height: 10px; cursor: nesw-resize; }
|
|
984
|
+
.sc-resize-nw { top: 0; left: 0; width: 10px; height: 10px; cursor: nwse-resize; }
|
|
985
|
+
.sc-resize-se { bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; }
|
|
986
|
+
.sc-resize-sw { bottom: 0; left: 0; width: 10px; height: 10px; cursor: nesw-resize; }
|
|
987
|
+
.sc-resize-se::after {
|
|
988
|
+
content: '';
|
|
989
|
+
position: absolute;
|
|
990
|
+
bottom: 2px;
|
|
991
|
+
right: 2px;
|
|
992
|
+
width: 8px;
|
|
993
|
+
height: 8px;
|
|
994
|
+
background:
|
|
995
|
+
linear-gradient(135deg, transparent 50%, #94a3b8 50%, #94a3b8 55%, transparent 55%,
|
|
996
|
+
transparent 65%, #94a3b8 65%, #94a3b8 70%, transparent 70%,
|
|
997
|
+
transparent 80%, #94a3b8 80%, #94a3b8 85%, transparent 85%);
|
|
998
|
+
opacity: 0.4;
|
|
999
|
+
transition: opacity 0.15s;
|
|
1000
|
+
pointer-events: none;
|
|
1001
|
+
}
|
|
1002
|
+
.sc-resize-se:hover::after {
|
|
1003
|
+
opacity: 0.8;
|
|
1004
|
+
}
|
|
1005
|
+
/* ── Inspect mode overlay ── */
|
|
1006
|
+
.sc-inspect-overlay {
|
|
1007
|
+
position: fixed;
|
|
1008
|
+
inset: 0;
|
|
1009
|
+
z-index: 2147483646;
|
|
1010
|
+
pointer-events: none;
|
|
1011
|
+
}
|
|
1012
|
+
.sc-highlight {
|
|
1013
|
+
position: fixed;
|
|
1014
|
+
border: 1.5px dashed #60a5fa;
|
|
1015
|
+
background: rgba(96, 165, 250, 0.06);
|
|
1016
|
+
pointer-events: none;
|
|
1017
|
+
border-radius: 2px;
|
|
1018
|
+
transition: left 0.04s, top 0.04s, width 0.04s, height 0.04s;
|
|
1019
|
+
}
|
|
1020
|
+
.sc-highlight-parent {
|
|
1021
|
+
position: fixed;
|
|
1022
|
+
border: 1px dashed rgba(96, 165, 250, 0.45);
|
|
1023
|
+
pointer-events: none;
|
|
1024
|
+
border-radius: 2px;
|
|
1025
|
+
transition: left 0.04s, top 0.04s, width 0.04s, height 0.04s;
|
|
1026
|
+
}
|
|
1027
|
+
.sc-breadcrumb {
|
|
1028
|
+
position: fixed;
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
flex-wrap: nowrap;
|
|
1032
|
+
max-width: 80vw;
|
|
1033
|
+
padding: 2px 8px;
|
|
1034
|
+
background: rgba(15, 23, 42, 0.88);
|
|
1035
|
+
border-radius: 4px;
|
|
1036
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
1037
|
+
font-size: 11px;
|
|
1038
|
+
line-height: 18px;
|
|
1039
|
+
white-space: nowrap;
|
|
1040
|
+
pointer-events: none;
|
|
1041
|
+
backdrop-filter: blur(6px);
|
|
1042
|
+
z-index: 2147483647;
|
|
1043
|
+
}
|
|
1044
|
+
.sc-breadcrumb-sep {
|
|
1045
|
+
color:rgb(64, 113, 182);
|
|
1046
|
+
display: inline-block;
|
|
1047
|
+
vertical-align: middle;
|
|
1048
|
+
margin: 0 1px;
|
|
1049
|
+
flex-shrink: 0;
|
|
1050
|
+
}
|
|
1051
|
+
.sc-breadcrumb-cmp {
|
|
1052
|
+
color: #93c5fd;
|
|
1053
|
+
font-weight: 600;
|
|
1054
|
+
}
|
|
1055
|
+
.sc-breadcrumb-el {
|
|
1056
|
+
color: #94a3b8;
|
|
1057
|
+
}
|
|
1058
|
+
`;
|