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.
@@ -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
+ `;