react-boxmodel-inspector 1.0.0

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,743 @@
1
+ /**
2
+ * BoxModel.jsx
3
+ *
4
+ * Usage:
5
+ * import BoxModelInspector from './BoxModel'
6
+ *
7
+ * function App() {
8
+ * return (
9
+ * <BoxModelInspector>
10
+ * <YourApp />
11
+ * </BoxModelInspector>
12
+ * )
13
+ * }
14
+ *
15
+ * • Press ` (backtick) to toggle inspector mode
16
+ * • Long-press any element to inspect it
17
+ * • Press ESC to close
18
+ */
19
+
20
+ import { useRef, useEffect, useState } from "react";
21
+ import { createPortal } from "react-dom";
22
+
23
+ /* ─── palette ─────────────────────────────────────────────────────────────── */
24
+ const C = {
25
+ margin: { fill: "rgba(232,168,56,0.09)", stroke: "rgba(232,168,56,0.75)", text: "#e8a838" },
26
+ padding: { fill: "rgba(93,168,93,0.09)", stroke: "rgba(93,168,93,0.75)", text: "#5da85d" },
27
+ content: { fill: "rgba(167,139,250,0.12)", stroke: "rgba(167,139,250,0.75)", text: "#a78bfa" },
28
+ };
29
+
30
+ /* ─── edge label ──────────────────────────────────────────────────────────── */
31
+ function EL({ v, style, color }) {
32
+ if (!v) return null;
33
+ return (
34
+ <div style={{
35
+ position: "absolute",
36
+ fontSize: 9, fontFamily: "monospace", fontWeight: 700,
37
+ color, background: "rgba(0,0,0,0.65)",
38
+ border: `1px solid ${color}44`, borderRadius: 3,
39
+ padding: "0 3px", lineHeight: "15px",
40
+ pointerEvents: "none",
41
+ transform: "translate(-50%,-50%)",
42
+ whiteSpace: "nowrap", zIndex: 100001,
43
+ ...style,
44
+ }}>
45
+ {v}px
46
+ </div>
47
+ );
48
+ }
49
+
50
+ /* ─── box overlay ─────────────────────────────────────────────────────────── */
51
+ function BoxOverlay({ box, rect, tagName }) {
52
+ const { width: w, height: h, margin: m, padding: p, border: b } = box;
53
+ const f = n => n % 1 === 0 ? String(n) : n.toFixed(1);
54
+ const cW = Math.max(0, w - p.left - p.right - b.left - b.right);
55
+ const cH = Math.max(0, h - p.top - p.bottom - b.top - b.bottom);
56
+
57
+ const containerStyle = {
58
+ position: "fixed",
59
+ left: rect.left - m.left,
60
+ top: rect.top - m.top,
61
+ width: w + m.left + m.right,
62
+ height: h + m.top + m.bottom,
63
+ pointerEvents: "none",
64
+ zIndex: 100000,
65
+ };
66
+
67
+ return (
68
+ <div style={containerStyle}>
69
+ {/* Tag label */}
70
+ <div style={{
71
+ position: "absolute",
72
+ top: -18, left: 0,
73
+ fontSize: 10, fontWeight: 700, fontFamily: "monospace",
74
+ letterSpacing: "0.06em", color: "#fff",
75
+ background: "#4a90d9", borderRadius: "4px 4px 4px 0",
76
+ padding: "1px 7px", lineHeight: "17px",
77
+ zIndex: 100003, pointerEvents: "none",
78
+ }}>
79
+ {tagName}
80
+ </div>
81
+
82
+ {/* margin */}
83
+ <div style={{
84
+ position: "absolute",
85
+ top: 0, left: 0,
86
+ width: "100%", height: "100%",
87
+ background: C.margin.fill, border: `1.5px dashed ${C.margin.stroke}`,
88
+ boxSizing: "border-box",
89
+ }}>
90
+ <EL v={f(m.top)} color={C.margin.text} style={{ top: m.top / 2, left: "50%" }} />
91
+ <EL v={f(m.bottom)} color={C.margin.text} style={{ top: m.top + h + m.bottom / 2, left: "50%" }} />
92
+ <EL v={f(m.left)} color={C.margin.text} style={{ top: "50%", left: m.left / 2 }} />
93
+ <EL v={f(m.right)} color={C.margin.text} style={{ top: "50%", left: m.left + w + m.right / 2 }} />
94
+ </div>
95
+
96
+ {/* padding */}
97
+ <div style={{
98
+ position: "absolute",
99
+ top: m.top, left: m.left,
100
+ width: w, height: h,
101
+ background: C.padding.fill, border: `1.5px dashed ${C.padding.stroke}`,
102
+ boxSizing: "border-box",
103
+ }}>
104
+ <EL v={f(p.top)} color={C.padding.text} style={{ top: p.top / 2, left: "50%" }} />
105
+ <EL v={f(p.bottom)} color={C.padding.text} style={{ top: h - p.bottom / 2, left: "50%" }} />
106
+ <EL v={f(p.left)} color={C.padding.text} style={{ top: "50%", left: p.left / 2 }} />
107
+ <EL v={f(p.right)} color={C.padding.text} style={{ top: "50%", left: w - p.right / 2 }} />
108
+ </div>
109
+
110
+ {/* content */}
111
+ <div style={{
112
+ position: "absolute",
113
+ top: m.top + p.top + b.top,
114
+ left: m.left + p.left + b.left,
115
+ width: cW, height: cH,
116
+ background: C.content.fill, border: `1.5px solid ${C.content.stroke}`,
117
+ boxSizing: "border-box",
118
+ }}>
119
+ <div style={{
120
+ position: "absolute", bottom: 2, right: 3,
121
+ fontSize: 9, fontFamily: "monospace", fontWeight: 700,
122
+ color: C.content.text,
123
+ }}>
124
+ {Math.round(cW)}×{Math.round(cH)}
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /* ─── CSS property groups ─────────────────────────────────────────────────── */
132
+ const EXTRA_GROUPS = [
133
+ {
134
+ key: "typography",
135
+ label: "Typography",
136
+ color: "#f472b6",
137
+ props: [
138
+ "fontFamily", "fontSize", "fontWeight", "fontStyle",
139
+ "lineHeight", "letterSpacing", "textAlign", "textDecoration",
140
+ "textTransform", "whiteSpace", "wordBreak",
141
+ ],
142
+ },
143
+ {
144
+ key: "color",
145
+ label: "Color & Background",
146
+ color: "#fb923c",
147
+ props: [
148
+ "color", "backgroundColor", "backgroundImage",
149
+ "opacity", "visibility",
150
+ ],
151
+ },
152
+ {
153
+ key: "layout",
154
+ label: "Layout",
155
+ color: "#34d399",
156
+ props: [
157
+ "display", "position", "top", "right", "bottom", "left",
158
+ "float", "clear", "zIndex", "overflow", "overflowX", "overflowY",
159
+ ],
160
+ },
161
+ {
162
+ key: "flex",
163
+ label: "Flex / Grid",
164
+ color: "#60a5fa",
165
+ props: [
166
+ "flexDirection", "flexWrap", "justifyContent", "alignItems",
167
+ "alignContent", "flex", "flexGrow", "flexShrink", "flexBasis",
168
+ "gap", "gridTemplateColumns", "gridTemplateRows", "gridArea",
169
+ ],
170
+ },
171
+ {
172
+ key: "box",
173
+ label: "Box",
174
+ color: "#a78bfa",
175
+ props: [
176
+ "width", "height", "minWidth", "maxWidth", "minHeight", "maxHeight",
177
+ "boxSizing", "boxShadow", "borderRadius", "outline", "cursor",
178
+ ],
179
+ },
180
+ {
181
+ key: "transform",
182
+ label: "Transform & Animation",
183
+ color: "#e879f9",
184
+ props: [
185
+ "transform", "transition", "animation",
186
+ "willChange", "pointerEvents",
187
+ ],
188
+ },
189
+ ];
190
+
191
+ function camelToKebab(str) {
192
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
193
+ }
194
+
195
+ function PropRow({ name, value }) {
196
+ if (!value || value === "none" || value === "normal" || value === "auto" || value === "0px") return null;
197
+ return (
198
+ <div style={{
199
+ display: "flex", gap: 6, alignItems: "baseline",
200
+ padding: "2px 0",
201
+ borderBottom: "1px solid rgba(255,255,255,0.04)",
202
+ }}>
203
+ <span style={{
204
+ fontSize: 10, color: "rgba(255,255,255,0.35)",
205
+ fontFamily: "monospace", flexShrink: 0, minWidth: 140,
206
+ }}>
207
+ {camelToKebab(name)}
208
+ </span>
209
+ <span style={{
210
+ fontSize: 10, color: "#e2e8f0",
211
+ fontFamily: "monospace",
212
+ wordBreak: "break-all",
213
+ maxWidth: 180,
214
+ overflow: "hidden",
215
+ textOverflow: "ellipsis",
216
+ whiteSpace: "nowrap",
217
+ }}
218
+ title={value}
219
+ >
220
+ {value}
221
+ </span>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function ExtraSection({ computed }) {
227
+ const [openGroups, setOpenGroups] = useState({});
228
+ const toggle = (key) => setOpenGroups(prev => ({ ...prev, [key]: !prev[key] }));
229
+
230
+ return (
231
+ <div style={{ marginTop: 8, borderTop: "1px solid rgba(255,255,255,0.07)", paddingTop: 8 }}>
232
+ {EXTRA_GROUPS.map(({ key, label, color, props }) => {
233
+ const entries = props
234
+ .map(p => ({ name: p, value: computed[p] }))
235
+ .filter(({ value }) => value && value !== "none" && value !== "normal" && value !== "auto" && value !== "0px");
236
+
237
+ if (entries.length === 0) return null;
238
+
239
+ const open = openGroups[key];
240
+ return (
241
+ <div key={key} style={{ marginBottom: 4 }}>
242
+ <div
243
+ onClick={() => toggle(key)}
244
+ style={{
245
+ display: "flex", alignItems: "center", gap: 6,
246
+ cursor: "pointer", padding: "3px 0",
247
+ }}
248
+ >
249
+ <span style={{
250
+ width: 7, height: 7, borderRadius: "50%",
251
+ background: color, flexShrink: 0,
252
+ boxShadow: `0 0 4px ${color}88`,
253
+ }} />
254
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.55)", flex: 1 }}>
255
+ {label}
256
+ </span>
257
+ <span style={{
258
+ fontSize: 10,
259
+ color: "rgba(255,255,255,0.25)",
260
+ marginRight: 2,
261
+ transition: "transform 0.15s",
262
+ display: "inline-block",
263
+ transform: open ? "rotate(180deg)" : "rotate(0deg)",
264
+ }}>
265
+
266
+ </span>
267
+ </div>
268
+
269
+ {open && (
270
+ <div style={{ paddingLeft: 13, paddingTop: 3 }}>
271
+ {entries.map(({ name, value }) => (
272
+ <PropRow key={name} name={name} value={value} />
273
+ ))}
274
+ </div>
275
+ )}
276
+ </div>
277
+ );
278
+ })}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ /* ─── draggable panel ─────────────────────────────────────────────────────── */
284
+ function Panel({ box, tagName, initialPos, onClose }) {
285
+ const [pos, setPos] = useState(initialPos);
286
+ const [expanded, setExp] = useState(false);
287
+ const dragging = useRef(false);
288
+ const offset = useRef({ x: 0, y: 0 });
289
+
290
+ const onMouseDown = (e) => {
291
+ if (e.target.closest("[data-nodrag]")) return;
292
+ dragging.current = true;
293
+ offset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y };
294
+ e.preventDefault();
295
+ };
296
+
297
+ useEffect(() => {
298
+ const move = (e) => {
299
+ if (!dragging.current) return;
300
+ setPos({ x: e.clientX - offset.current.x, y: e.clientY - offset.current.y });
301
+ };
302
+ const up = () => { dragging.current = false; };
303
+ window.addEventListener("mousemove", move);
304
+ window.addEventListener("mouseup", up);
305
+ return () => {
306
+ window.removeEventListener("mousemove", move);
307
+ window.removeEventListener("mouseup", up);
308
+ };
309
+ }, []);
310
+
311
+ if (!box) return null;
312
+
313
+ const { margin: m, padding: p, border: b, width, height, computed } = box;
314
+ const f = n => n % 1 === 0 ? String(n) : n.toFixed(1);
315
+ const cW = Math.round(Math.max(0, width - p.left - p.right - b.left - b.right));
316
+ const cH = Math.round(Math.max(0, height - p.top - p.bottom - b.top - b.bottom));
317
+
318
+ const rows = [
319
+ { key: "margin", color: C.margin.text, label: "margin", vals: [m.top, m.right, m.bottom, m.left] },
320
+ { key: "border", color: "#4a90d9", label: "border", vals: [b.top, b.right, b.bottom, b.left] },
321
+ { key: "padding", color: C.padding.text, label: "padding", vals: [p.top, p.right, p.bottom, p.left] },
322
+ ];
323
+
324
+ return (
325
+ <div
326
+ onMouseDown={onMouseDown}
327
+ style={{
328
+ position: "fixed", left: pos.x, top: pos.y,
329
+ zIndex: 999999, cursor: "grab", userSelect: "none",
330
+ background: "rgba(10,11,16,0.97)",
331
+ border: "1px solid rgba(255,255,255,0.12)",
332
+ borderRadius: 8, padding: "10px 14px 10px",
333
+ minWidth: 260, maxWidth: 340,
334
+ fontFamily: "'SF Mono','Fira Code','Cascadia Code',monospace",
335
+ boxShadow: "0 8px 32px rgba(0,0,0,0.5), 0 2px 8px rgba(0,0,0,0.3)",
336
+ whiteSpace: "nowrap",
337
+ maxHeight: "80vh", overflowY: "auto",
338
+ scrollbarWidth: "thin", scrollbarColor: "#333 transparent",
339
+ }}
340
+ >
341
+ {/* header */}
342
+ <div style={{
343
+ display: "flex", justifyContent: "space-between", alignItems: "center",
344
+ marginBottom: 8, paddingBottom: 7,
345
+ borderBottom: "1px solid rgba(255,255,255,0.08)",
346
+ }}>
347
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
348
+ <span style={{
349
+ fontSize: 10, fontWeight: 700, color: "#4a90d9",
350
+ background: "rgba(74,144,217,0.15)",
351
+ border: "1px solid rgba(74,144,217,0.35)",
352
+ borderRadius: 4, padding: "0 6px", lineHeight: "18px",
353
+ }}>
354
+ {tagName}
355
+ </span>
356
+ <span style={{ fontSize: 9, letterSpacing: "0.14em", textTransform: "uppercase", color: "rgba(255,255,255,0.28)" }}>
357
+ box model
358
+ </span>
359
+ </div>
360
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
361
+ <span style={{ fontSize: 9, color: "rgba(255,255,255,0.18)" }}>T · R · B · L</span>
362
+ <span
363
+ data-nodrag="true"
364
+ onClick={onClose}
365
+ title="Close (ESC)"
366
+ style={{
367
+ cursor: "pointer", color: "rgba(255,255,255,0.35)",
368
+ fontSize: 14, lineHeight: 1, padding: "0 2px",
369
+ borderRadius: 3, userSelect: "none",
370
+ }}
371
+ onMouseEnter={e => e.currentTarget.style.color = "#ff6b6b"}
372
+ onMouseLeave={e => e.currentTarget.style.color = "rgba(255,255,255,0.35)"}
373
+ >✕</span>
374
+ </div>
375
+ </div>
376
+
377
+ {rows.map(({ key, color, label: rowLabel, vals }) => {
378
+ const allZero = vals.every(v => v === 0);
379
+ return (
380
+ <div key={key} style={{
381
+ display: "flex", alignItems: "center", gap: 8,
382
+ marginBottom: 5, opacity: allZero ? 0.25 : 1,
383
+ }}>
384
+ <span style={{
385
+ width: 7, height: 7, borderRadius: "50%",
386
+ background: color, flexShrink: 0,
387
+ boxShadow: `0 0 5px ${color}88`,
388
+ }} />
389
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.35)", width: 46, flexShrink: 0 }}>
390
+ {rowLabel}
391
+ </span>
392
+ <div style={{ display: "flex", gap: 3 }}>
393
+ {vals.map((v, i) => (
394
+ <span key={i} style={{
395
+ fontSize: 11, fontWeight: 600,
396
+ color: v === 0 ? "rgba(255,255,255,0.18)" : color,
397
+ background: v === 0 ? "transparent" : `${color}18`,
398
+ border: `1px solid ${v === 0 ? "rgba(255,255,255,0.05)" : `${color}44`}`,
399
+ borderRadius: 4, padding: "1px 5px",
400
+ minWidth: 32, textAlign: "center",
401
+ }}>
402
+ {f(v)}
403
+ </span>
404
+ ))}
405
+ </div>
406
+ </div>
407
+ );
408
+ })}
409
+
410
+ <div style={{
411
+ display: "flex", alignItems: "center", gap: 8,
412
+ marginTop: 8, paddingTop: 7,
413
+ borderTop: "1px solid rgba(255,255,255,0.07)",
414
+ }}>
415
+ <span style={{
416
+ width: 7, height: 7, borderRadius: 2,
417
+ background: C.content.text, flexShrink: 0,
418
+ boxShadow: `0 0 5px ${C.content.text}88`,
419
+ }} />
420
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.35)", width: 46 }}>content</span>
421
+ <span style={{ fontSize: 12, fontWeight: 600, color: C.content.text }}>
422
+ {cW} × {cH} px
423
+ </span>
424
+ </div>
425
+
426
+ <div
427
+ data-nodrag="true"
428
+ onClick={() => setExp(v => !v)}
429
+ style={{
430
+ display: "flex", alignItems: "center", justifyContent: "center",
431
+ gap: 5, marginTop: 10, paddingTop: 8,
432
+ borderTop: "1px solid rgba(255,255,255,0.07)",
433
+ cursor: "pointer", color: "rgba(255,255,255,0.35)",
434
+ fontSize: 10, letterSpacing: "0.05em",
435
+ }}
436
+ >
437
+ <span>All properties</span>
438
+ <span style={{
439
+ display: "inline-block",
440
+ transition: "transform 0.2s",
441
+ transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
442
+ fontSize: 11,
443
+ }}>
444
+
445
+ </span>
446
+ </div>
447
+
448
+ {expanded && computed && <ExtraSection computed={computed} />}
449
+
450
+ <div style={{
451
+ marginTop: 8, paddingTop: 6,
452
+ borderTop: "1px solid rgba(255,255,255,0.05)",
453
+ fontSize: 9, color: "rgba(255,255,255,0.12)",
454
+ textAlign: "center", letterSpacing: "0.05em",
455
+ }}>
456
+ ⠿ drag to move · ESC to close
457
+ </div>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ /* ─── long-press ripple feedback ──────────────────────────────────────────── */
463
+ function LongPressIndicator({ x, y, progress }) {
464
+ if (progress === 0) return null;
465
+
466
+ return createPortal(
467
+ <div style={{
468
+ position: "fixed",
469
+ left: x, top: y,
470
+ width: 0, height: 0,
471
+ pointerEvents: "none",
472
+ zIndex: 99999,
473
+ }}>
474
+ <div style={{
475
+ position: "absolute",
476
+ top: "50%", left: "50%",
477
+ width: 60, height: 60,
478
+ borderRadius: "50%",
479
+ background: `rgba(74,144,217,${0.1 + progress * 0.2})`,
480
+ border: `2px solid rgba(74,144,217,${0.3 + progress * 0.5})`,
481
+ transform: `translate(-50%, -50%) scale(${progress})`,
482
+ transition: "transform 0.05s linear",
483
+ }} />
484
+ </div>,
485
+ document.body
486
+ );
487
+ }
488
+
489
+ /* ─── main inspector component ────────────────────────────────────────────── */
490
+ export default function BoxModelInspector({
491
+ children,
492
+ longPressDuration = 500,
493
+ toggleKey = "`"
494
+ }) {
495
+ const [active, setActive] = useState(false);
496
+ const [target, setTarget] = useState(null);
497
+ const [box, setBox] = useState(null);
498
+ const [rect, setRect] = useState(null);
499
+ const [panelPos, setPanelPos] = useState(null);
500
+
501
+ const [longPressState, setLongPressState] = useState({ active: false, x: 0, y: 0, progress: 0 });
502
+
503
+ const timerRef = useRef(null);
504
+ const progressIntervalRef = useRef(null);
505
+
506
+ // Read box model from element
507
+ const readBox = (el) => {
508
+ if (!el) return;
509
+ const s = getComputedStyle(el);
510
+ const r = el.getBoundingClientRect();
511
+
512
+ const computed = {};
513
+ EXTRA_GROUPS.forEach(({ props }) => {
514
+ props.forEach(prop => {
515
+ computed[prop] = s[prop];
516
+ });
517
+ });
518
+
519
+ setBox({
520
+ width: r.width,
521
+ height: r.height,
522
+ margin: {
523
+ top: parseFloat(s.marginTop),
524
+ right: parseFloat(s.marginRight),
525
+ bottom: parseFloat(s.marginBottom),
526
+ left: parseFloat(s.marginLeft),
527
+ },
528
+ padding: {
529
+ top: parseFloat(s.paddingTop),
530
+ right: parseFloat(s.paddingRight),
531
+ bottom: parseFloat(s.paddingBottom),
532
+ left: parseFloat(s.paddingLeft),
533
+ },
534
+ border: {
535
+ top: parseFloat(s.borderTopWidth),
536
+ right: parseFloat(s.borderRightWidth),
537
+ bottom: parseFloat(s.borderBottomWidth),
538
+ left: parseFloat(s.borderLeftWidth),
539
+ },
540
+ computed,
541
+ });
542
+
543
+ setRect(r);
544
+ setPanelPos({
545
+ x: Math.min(r.right + 24, window.innerWidth - 320),
546
+ y: Math.max(20, r.top),
547
+ });
548
+ };
549
+
550
+ // Start long-press
551
+ const handlePointerDown = (e) => {
552
+ if (!active) return;
553
+ if (e.target.closest('[data-boxmodel-ui]')) return;
554
+
555
+ const el = e.target;
556
+
557
+ setLongPressState({
558
+ active: true,
559
+ x: e.clientX,
560
+ y: e.clientY,
561
+ progress: 0,
562
+ });
563
+
564
+ const startTime = Date.now();
565
+
566
+ progressIntervalRef.current = setInterval(() => {
567
+ const elapsed = Date.now() - startTime;
568
+ const progress = Math.min(elapsed / longPressDuration, 1);
569
+ setLongPressState(prev => ({ ...prev, progress }));
570
+ }, 16);
571
+
572
+ timerRef.current = setTimeout(() => {
573
+ setLongPressState({ active: false, x: 0, y: 0, progress: 0 });
574
+ clearInterval(progressIntervalRef.current);
575
+
576
+ // Set new target (replaces any previous one)
577
+ setTarget(el);
578
+ readBox(el);
579
+ }, longPressDuration);
580
+ };
581
+
582
+ // Cancel long-press
583
+ const handlePointerUp = () => {
584
+ if (timerRef.current) {
585
+ clearTimeout(timerRef.current);
586
+ timerRef.current = null;
587
+ }
588
+ if (progressIntervalRef.current) {
589
+ clearInterval(progressIntervalRef.current);
590
+ progressIntervalRef.current = null;
591
+ }
592
+ setLongPressState({ active: false, x: 0, y: 0, progress: 0 });
593
+ };
594
+
595
+ const handlePointerMove = (e) => {
596
+ if (!longPressState.active) return;
597
+
598
+ const dx = e.clientX - longPressState.x;
599
+ const dy = e.clientY - longPressState.y;
600
+ if (Math.sqrt(dx * dx + dy * dy) > 10) {
601
+ handlePointerUp();
602
+ }
603
+ };
604
+
605
+ // Close inspector
606
+ const close = () => {
607
+ setTarget(null);
608
+ setBox(null);
609
+ setRect(null);
610
+ setPanelPos(null);
611
+ };
612
+
613
+ // Keyboard shortcuts
614
+ useEffect(() => {
615
+ const handleKey = (e) => {
616
+ // ESC to close
617
+ if (e.key === "Escape" && target) {
618
+ close();
619
+ }
620
+ // Toggle key (`) to activate/deactivate
621
+ else if (e.key === toggleKey && !e.repeat) {
622
+ setActive(v => {
623
+ const newVal = !v;
624
+ if (!newVal && target) close(); // Close if deactivating
625
+ return newVal;
626
+ });
627
+ }
628
+ };
629
+
630
+ window.addEventListener("keydown", handleKey);
631
+ return () => window.removeEventListener("keydown", handleKey);
632
+ }, [target, toggleKey]);
633
+
634
+ // Live updates
635
+ useEffect(() => {
636
+ if (!target) return;
637
+
638
+ const update = () => readBox(target);
639
+
640
+ const ro = new ResizeObserver(update);
641
+ ro.observe(target);
642
+
643
+ const mo = new MutationObserver(update);
644
+ mo.observe(target, { attributes: true, attributeFilter: ["style", "class"] });
645
+
646
+ const handleScroll = () => update();
647
+ const handleResize = () => update();
648
+
649
+ window.addEventListener("scroll", handleScroll, { passive: true });
650
+ window.addEventListener("resize", handleResize);
651
+
652
+ return () => {
653
+ ro.disconnect();
654
+ mo.disconnect();
655
+ window.removeEventListener("scroll", handleScroll);
656
+ window.removeEventListener("resize", handleResize);
657
+ };
658
+ }, [target]);
659
+
660
+ // Event listeners for long-press
661
+ useEffect(() => {
662
+ if (!active) {
663
+ handlePointerUp();
664
+ return;
665
+ }
666
+
667
+ window.addEventListener("pointerdown", handlePointerDown, true);
668
+ window.addEventListener("pointerup", handlePointerUp, true);
669
+ window.addEventListener("pointermove", handlePointerMove, true);
670
+
671
+ return () => {
672
+ window.removeEventListener("pointerdown", handlePointerDown, true);
673
+ window.removeEventListener("pointerup", handlePointerUp, true);
674
+ window.removeEventListener("pointermove", handlePointerMove, true);
675
+ };
676
+ });
677
+
678
+ // Get element tag name
679
+ const tagName = target
680
+ ? `${target.tagName.toLowerCase()}${target.id ? `#${target.id}` : ""}${target.className ? `.${target.className.split(" ")[0]}` : ""}`
681
+ : "";
682
+
683
+ return (
684
+ <>
685
+ {children}
686
+
687
+ {/* Active indicator */}
688
+ {active && createPortal(
689
+ <div
690
+ data-boxmodel-ui="true"
691
+ style={{
692
+ position: "fixed",
693
+ top: 12, left: "50%",
694
+ transform: "translateX(-50%)",
695
+ zIndex: 999998,
696
+ background: "rgba(74,144,217,0.95)",
697
+ color: "#fff",
698
+ fontSize: 11,
699
+ fontFamily: "monospace",
700
+ padding: "6px 14px",
701
+ borderRadius: 6,
702
+ boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
703
+ pointerEvents: "none",
704
+ letterSpacing: "0.03em",
705
+ }}
706
+ >
707
+ 🔍 Inspector active · Long-press any element · Press <kbd style={{
708
+ background: "rgba(255,255,255,0.2)",
709
+ padding: "1px 5px",
710
+ borderRadius: 3,
711
+ fontWeight: 700,
712
+ }}>{toggleKey}</kbd> to toggle
713
+ </div>,
714
+ document.body
715
+ )}
716
+
717
+ {/* Long-press indicator */}
718
+ {longPressState.progress > 0 && (
719
+ <LongPressIndicator
720
+ x={longPressState.x}
721
+ y={longPressState.y}
722
+ progress={longPressState.progress}
723
+ />
724
+ )}
725
+
726
+ {/* Overlay - ONLY ONE at a time */}
727
+ {target && box && rect && createPortal(
728
+ <div data-boxmodel-ui="true">
729
+ <BoxOverlay box={box} rect={rect} tagName={tagName} />
730
+ </div>,
731
+ document.body
732
+ )}
733
+
734
+ {/* Panel - ONLY ONE at a time */}
735
+ {target && box && panelPos && createPortal(
736
+ <div data-boxmodel-ui="true">
737
+ <Panel box={box} tagName={tagName} initialPos={panelPos} onClose={close} />
738
+ </div>,
739
+ document.body
740
+ )}
741
+ </>
742
+ );
743
+ }