dekit-cli 0.1.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,713 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import type { EditorConfig, NodeInfo, DOMTreeNode, IframeMessage } from "./types.js";
3
+ import { DEVICE_PRESETS, calcScale } from "./devices.js";
4
+ import type { DevicePreset } from "./types.js";
5
+ import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
6
+ import { MdScreenRotation, MdClose } from "react-icons/md";
7
+ import { TbMarquee } from "react-icons/tb";
8
+
9
+ function flattenDomTree(nodes: DOMTreeNode[]): string[] {
10
+ const result: string[] = [];
11
+ for (const node of nodes) {
12
+ result.push(node.path);
13
+ result.push(...flattenDomTree(node.children));
14
+ }
15
+ return result;
16
+ }
17
+
18
+ export function App() {
19
+ const [config, setConfig] = useState<EditorConfig | null>(null);
20
+ const [currentPage, setCurrentPage] = useState<string>("");
21
+ const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
22
+ const [domTree, setDomTree] = useState<DOMTreeNode[]>([]);
23
+ const [selectedPath, setSelectedPath] = useState<string>("");
24
+ const iframeRef = useRef<HTMLIFrameElement>(null);
25
+ const [deviceMode, setDeviceMode] = useState("responsive");
26
+ const [deviceWidth, setDeviceWidth] = useState(0);
27
+ const [deviceHeight, setDeviceHeight] = useState(0);
28
+ const [zoomMode, setZoomMode] = useState<"fit" | number>("fit");
29
+ const [inspectMode, setInspectMode] = useState(false);
30
+ const [inspectLocked, setInspectLocked] = useState(false);
31
+ const inspectModeRef = useRef(false);
32
+ const altPressedRef = useRef(false);
33
+ const currentPageRef = useRef("");
34
+ const canvasAreaRef = useRef<HTMLDivElement>(null);
35
+ const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
36
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; domPath?: string; page: string } | null>(null);
37
+ const [focusedPanel, setFocusedPanel] = useState<"pages" | "layers" | null>(null);
38
+
39
+ const configRef = useRef<EditorConfig | null>(null);
40
+ configRef.current = config;
41
+
42
+ const getPageRef = useCallback((page: string) => {
43
+ const filePath = configRef.current?.pagePaths?.[page] ?? page;
44
+ return "$$" + "{" + filePath + "}";
45
+ }, []);
46
+
47
+ const getNodeRef = useCallback((page: string, domPath: string) => {
48
+ const filePath = configRef.current?.pagePaths?.[page] ?? page;
49
+ return "$$" + "{" + filePath + "#" + domPath + "}";
50
+ }, []);
51
+
52
+ // Fetch config
53
+ useEffect(() => {
54
+ fetch("/api/config")
55
+ .then((r) => r.json())
56
+ .then((data: EditorConfig) => {
57
+ setConfig(data);
58
+ if (data.pages.length > 0) {
59
+ setCurrentPage(data.pages[0]);
60
+ }
61
+ });
62
+ }, []);
63
+
64
+ // Listen for iframe messages
65
+ useEffect(() => {
66
+ function handleMessage(e: MessageEvent) {
67
+ const msg = e.data;
68
+ if (!msg || !msg.type) return;
69
+ if (msg.type === "NODE_SELECTED") {
70
+ setSelectedNode(msg.node);
71
+ setSelectedPath(msg.node.path);
72
+ if (inspectModeRef.current && currentPageRef.current) {
73
+ navigator.clipboard.writeText(getNodeRef(currentPageRef.current, msg.node.path));
74
+ iframeRef.current?.contentWindow?.postMessage({ type: "COPY_FEEDBACK" }, "*");
75
+ }
76
+ } else if (msg.type === "DOM_TREE") {
77
+ setDomTree(msg.tree);
78
+ } else if (msg.type === "INSPECT_KEY") {
79
+ if (msg.pressed) {
80
+ altPressedRef.current = true;
81
+ setInspectMode(true);
82
+ } else {
83
+ altPressedRef.current = false;
84
+ setInspectMode(inspectLocked);
85
+ }
86
+ }
87
+ }
88
+ window.addEventListener("message", handleMessage);
89
+ return () => window.removeEventListener("message", handleMessage);
90
+ }, [inspectLocked]);
91
+
92
+ // Request DOM tree when page loads in iframe
93
+ const handleIframeLoad = useCallback(() => {
94
+ iframeRef.current?.contentWindow?.postMessage({ type: "GET_DOM_TREE" }, "*");
95
+ iframeRef.current?.contentWindow?.postMessage({ type: "SET_INSPECT_MODE", enabled: inspectMode }, "*");
96
+ iframeRef.current?.contentWindow?.postMessage({ type: "SET_PAGE_NAME", page: currentPage }, "*");
97
+ }, [inspectMode, currentPage]);
98
+
99
+ // Click on layer tree node
100
+ const handleLayerClick = useCallback((path: string) => {
101
+ iframeRef.current?.contentWindow?.postMessage(
102
+ { type: "HIGHLIGHT_NODE", path },
103
+ "*"
104
+ );
105
+ setSelectedPath(path);
106
+ }, []);
107
+
108
+ // Hover on layer tree node
109
+ const handleLayerHover = useCallback((path: string | null) => {
110
+ if (path) {
111
+ iframeRef.current?.contentWindow?.postMessage(
112
+ { type: "HOVER_NODE", path },
113
+ "*"
114
+ );
115
+ } else {
116
+ iframeRef.current?.contentWindow?.postMessage(
117
+ { type: "CLEAR_HIGHLIGHT" },
118
+ "*"
119
+ );
120
+ }
121
+ }, []);
122
+
123
+ // Track canvas area size for auto-fit calculation
124
+ useEffect(() => {
125
+ const el = canvasAreaRef.current;
126
+ if (!el) return;
127
+ const ro = new ResizeObserver((entries) => {
128
+ const { width, height } = entries[0].contentRect;
129
+ setCanvasSize({ width, height });
130
+ });
131
+ ro.observe(el);
132
+ return () => ro.disconnect();
133
+ }, [config]);
134
+
135
+ // In responsive mode, sync device size to canvas size
136
+ useEffect(() => {
137
+ if (deviceMode === "responsive" && canvasSize.width > 0 && canvasSize.height > 0) {
138
+ setDeviceWidth(Math.round(canvasSize.width));
139
+ setDeviceHeight(Math.round(canvasSize.height));
140
+ }
141
+ }, [deviceMode, canvasSize]);
142
+
143
+ // Sync inspect mode to iframe
144
+ useEffect(() => {
145
+ inspectModeRef.current = inspectMode;
146
+ iframeRef.current?.contentWindow?.postMessage(
147
+ { type: "SET_INSPECT_MODE", enabled: inspectMode },
148
+ "*"
149
+ );
150
+ }, [inspectMode]);
151
+
152
+ useEffect(() => {
153
+ currentPageRef.current = currentPage;
154
+ }, [currentPage]);
155
+
156
+ // Dismiss context menu on click or iframe focus
157
+ useEffect(() => {
158
+ if (!contextMenu) return;
159
+ function dismiss() { setContextMenu(null); }
160
+ window.addEventListener("click", dismiss);
161
+ window.addEventListener("blur", dismiss);
162
+ return () => {
163
+ window.removeEventListener("click", dismiss);
164
+ window.removeEventListener("blur", dismiss);
165
+ };
166
+ }, [contextMenu]);
167
+
168
+ // Hold Alt to temporarily enable inspect mode
169
+ useEffect(() => {
170
+ function handleKeyDown(e: KeyboardEvent) {
171
+ if (e.key === "Alt") {
172
+ altPressedRef.current = true;
173
+ setInspectMode(true);
174
+ }
175
+ }
176
+ function handleKeyUp(e: KeyboardEvent) {
177
+ if (e.key === "Alt") {
178
+ altPressedRef.current = false;
179
+ setInspectMode(inspectLocked);
180
+ }
181
+ }
182
+ window.addEventListener("keydown", handleKeyDown);
183
+ window.addEventListener("keyup", handleKeyUp);
184
+ return () => {
185
+ window.removeEventListener("keydown", handleKeyDown);
186
+ window.removeEventListener("keyup", handleKeyUp);
187
+ };
188
+ }, [inspectLocked]);
189
+
190
+ const handleRotate = useCallback(() => {
191
+ const w = deviceWidth;
192
+ const h = deviceHeight;
193
+ setDeviceWidth(h);
194
+ setDeviceHeight(w);
195
+ }, [deviceWidth, deviceHeight]);
196
+
197
+ // Calculate scale and iframe dimensions
198
+ const isDeviceActive = deviceWidth > 0 && deviceHeight > 0;
199
+
200
+ let scaleFactor = 1;
201
+ if (isDeviceActive && canvasSize.width > 0 && canvasSize.height > 0) {
202
+ if (zoomMode === "fit") {
203
+ scaleFactor = calcScale(
204
+ deviceWidth,
205
+ deviceHeight,
206
+ canvasSize.width,
207
+ canvasSize.height
208
+ );
209
+ } else {
210
+ scaleFactor = Math.max(0.1, zoomMode / 100);
211
+ }
212
+ }
213
+ if (!config) {
214
+ return <div className="empty-state">Loading...</div>;
215
+ }
216
+
217
+ const iframeSrc = currentPage
218
+ ? `${config.designServerUrl}/page/${currentPage}`
219
+ : "";
220
+
221
+ return (
222
+ <div className="editor-layout">
223
+ {/* Left Panel */}
224
+ <div className="left-panel">
225
+ <div className="panel-header">Pages</div>
226
+ <OverlayScrollbarsComponent className="page-list-scroll" defer>
227
+ <ul
228
+ className={`page-list ${focusedPanel === "pages" ? "focused" : ""}`}
229
+ tabIndex={0}
230
+ onFocus={() => setFocusedPanel("pages")}
231
+ onBlur={(e) => {
232
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
233
+ setFocusedPanel((prev) => prev === "pages" ? null : prev);
234
+ }
235
+ }}
236
+ onKeyDown={(e) => {
237
+ if (focusedPanel !== "pages") return;
238
+ const pages = config.pages;
239
+ const idx = pages.indexOf(currentPage);
240
+ if (e.key === "ArrowUp" && idx > 0) {
241
+ e.preventDefault();
242
+ const page = pages[idx - 1];
243
+ setCurrentPage(page);
244
+ setSelectedNode(null);
245
+ setDomTree([]);
246
+ setSelectedPath("");
247
+ } else if (e.key === "ArrowDown" && idx < pages.length - 1) {
248
+ e.preventDefault();
249
+ const page = pages[idx + 1];
250
+ setCurrentPage(page);
251
+ setSelectedNode(null);
252
+ setDomTree([]);
253
+ setSelectedPath("");
254
+ }
255
+ }}
256
+ >
257
+ {config.pages.map((page) => (
258
+ <li
259
+ key={page}
260
+ className={`page-item ${page === currentPage ? "active" : ""}`}
261
+ onClick={() => {
262
+ if (page !== currentPage) {
263
+ setCurrentPage(page);
264
+ setSelectedNode(null);
265
+ setDomTree([]);
266
+ setSelectedPath("");
267
+ }
268
+ setFocusedPanel("pages");
269
+ }}
270
+ onContextMenu={(e) => {
271
+ e.preventDefault();
272
+ setContextMenu({ x: e.clientX, y: e.clientY, page });
273
+ }}
274
+ >
275
+ {page}
276
+ </li>
277
+ ))}
278
+ </ul>
279
+ </OverlayScrollbarsComponent>
280
+ </div>
281
+
282
+ {/* Center Panel */}
283
+ <div className="center-panel">
284
+ <DeviceToolbar
285
+ deviceMode={deviceMode}
286
+ deviceWidth={deviceWidth}
287
+ deviceHeight={deviceHeight}
288
+ zoomMode={zoomMode}
289
+ inspectMode={inspectMode}
290
+ onDeviceChange={setDeviceMode}
291
+ onWidthChange={setDeviceWidth}
292
+ onHeightChange={setDeviceHeight}
293
+ onRotate={handleRotate}
294
+ onZoomChange={setZoomMode}
295
+ onInspectToggle={() => {
296
+ const next = !inspectLocked;
297
+ setInspectLocked(next);
298
+ setInspectMode(next);
299
+ }}
300
+ />
301
+ <div
302
+ ref={canvasAreaRef}
303
+ className="canvas-area with-grid"
304
+ >
305
+ {iframeSrc ? (
306
+ isDeviceActive ? (
307
+ <div
308
+ className="iframe-wrapper"
309
+ style={{
310
+ width: deviceWidth * scaleFactor,
311
+ height: deviceHeight * scaleFactor,
312
+ }}
313
+ >
314
+ <iframe
315
+ ref={iframeRef}
316
+ className="canvas-iframe"
317
+ src={iframeSrc}
318
+ onLoad={handleIframeLoad}
319
+ style={{
320
+ width: deviceWidth,
321
+ height: deviceHeight,
322
+ transform: `scale(${scaleFactor})`,
323
+ transformOrigin: "top left",
324
+ }}
325
+ />
326
+ </div>
327
+ ) : (
328
+ <div className="empty-state">Select a page</div>
329
+ )
330
+ ) : (
331
+ <div className="empty-state">Select a page</div>
332
+ )}
333
+ </div>
334
+ </div>
335
+
336
+ {/* Right Panel */}
337
+ <div className="right-panel">
338
+ <div className="right-panel-top">
339
+ <div className="panel-header">Layers</div>
340
+ <OverlayScrollbarsComponent className="layer-tree-scroll" defer>
341
+ <div
342
+ className={`layer-tree ${focusedPanel === "layers" ? "focused" : ""}`}
343
+ tabIndex={0}
344
+ onFocus={() => setFocusedPanel("layers")}
345
+ onBlur={(e) => {
346
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
347
+ setFocusedPanel((prev) => prev === "layers" ? null : prev);
348
+ }
349
+ }}
350
+ onKeyDown={(e) => {
351
+ if (focusedPanel !== "layers") return;
352
+ const paths = flattenDomTree(domTree);
353
+ const idx = paths.indexOf(selectedPath);
354
+ if (e.key === "ArrowUp" && idx > 0) {
355
+ e.preventDefault();
356
+ handleLayerClick(paths[idx - 1]);
357
+ } else if (e.key === "ArrowDown" && idx < paths.length - 1) {
358
+ e.preventDefault();
359
+ handleLayerClick(paths[idx + 1]);
360
+ }
361
+ }}
362
+ onClick={() => setFocusedPanel("layers")}
363
+ >
364
+ {domTree.map((node) => (
365
+ <LayerTreeNode
366
+ key={node.path}
367
+ node={node}
368
+ depth={0}
369
+ selectedPath={selectedPath}
370
+ onSelect={(path) => {
371
+ handleLayerClick(path);
372
+ setFocusedPanel("layers");
373
+ }}
374
+ onHover={handleLayerHover}
375
+ onContextMenu={(domPath, x, y) => setContextMenu({ x, y, domPath, page: currentPage })}
376
+ />
377
+ ))}
378
+ </div>
379
+ </OverlayScrollbarsComponent>
380
+ </div>
381
+ <div className="right-panel-bottom">
382
+ <div className="panel-header">Properties</div>
383
+ <OverlayScrollbarsComponent className="properties-scroll" defer>
384
+ {selectedNode ? (
385
+ <PropertyPanel node={selectedNode} nodeRef={getNodeRef(currentPage, selectedNode.path)} />
386
+ ) : (
387
+ <div className="empty-state">Select an element to inspect</div>
388
+ )}
389
+ </OverlayScrollbarsComponent>
390
+ </div>
391
+ </div>
392
+
393
+ {contextMenu && (
394
+ <div
395
+ className="context-menu"
396
+ style={{ left: contextMenu.x, top: contextMenu.y }}
397
+ onClick={(e) => e.stopPropagation()}
398
+ >
399
+ {contextMenu.domPath && (
400
+ <div
401
+ className="context-menu-item"
402
+ onClick={() => {
403
+ navigator.clipboard.writeText(getNodeRef(contextMenu.page, contextMenu.domPath!));
404
+ setContextMenu(null);
405
+ }}
406
+ >
407
+ Copy Ref
408
+ </div>
409
+ )}
410
+ <div
411
+ className="context-menu-item"
412
+ onClick={() => {
413
+ navigator.clipboard.writeText(getPageRef(contextMenu.page));
414
+ setContextMenu(null);
415
+ }}
416
+ >
417
+ Copy Page Ref
418
+ </div>
419
+ </div>
420
+ )}
421
+ </div>
422
+ );
423
+ }
424
+
425
+ function DeviceToolbar({
426
+ deviceMode,
427
+ deviceWidth,
428
+ deviceHeight,
429
+ zoomMode,
430
+ inspectMode,
431
+ onDeviceChange,
432
+ onWidthChange,
433
+ onHeightChange,
434
+ onRotate,
435
+ onZoomChange,
436
+ onInspectToggle,
437
+ }: {
438
+ deviceMode: string;
439
+ deviceWidth: number;
440
+ deviceHeight: number;
441
+ zoomMode: "fit" | number;
442
+ inspectMode: boolean;
443
+ onDeviceChange: (mode: string) => void;
444
+ onWidthChange: (w: number) => void;
445
+ onHeightChange: (h: number) => void;
446
+ onRotate: () => void;
447
+ onZoomChange: (zoom: "fit" | number) => void;
448
+ onInspectToggle: () => void;
449
+ }) {
450
+ const currentPreset = DEVICE_PRESETS.find((d) => d.name === deviceMode);
451
+ const isPreset = !!currentPreset;
452
+ const dpr = currentPreset?.dpr ?? 1;
453
+
454
+ const [draftWidth, setDraftWidth] = useState(String(deviceWidth || ""));
455
+ const [draftHeight, setDraftHeight] = useState(String(deviceHeight || ""));
456
+
457
+ // Sync drafts when external props change (e.g. device preset selection)
458
+ useEffect(() => {
459
+ setDraftWidth(String(deviceWidth || ""));
460
+ }, [deviceWidth]);
461
+ useEffect(() => {
462
+ setDraftHeight(String(deviceHeight || ""));
463
+ }, [deviceHeight]);
464
+
465
+ let selectLabel = "Responsive";
466
+ if (currentPreset) {
467
+ selectLabel = `${currentPreset.name} — ${currentPreset.width}×${currentPreset.height}`;
468
+ } else if (deviceMode === "custom") {
469
+ selectLabel = "Custom";
470
+ }
471
+
472
+ return (
473
+ <div className="device-toolbar">
474
+ <select
475
+ style={{ width: selectLabel.length + 4 + "ch" }}
476
+ value={
477
+ deviceMode === "responsive"
478
+ ? "responsive"
479
+ : currentPreset
480
+ ? deviceMode
481
+ : "custom"
482
+ }
483
+ onChange={(e) => {
484
+ const val = e.target.value;
485
+ if (val === "responsive") {
486
+ onDeviceChange("responsive");
487
+ onWidthChange(0);
488
+ onHeightChange(0);
489
+ } else if (val === "custom") {
490
+ onDeviceChange("custom");
491
+ } else {
492
+ const preset = DEVICE_PRESETS.find((d) => d.name === val);
493
+ if (preset) {
494
+ onDeviceChange(preset.name);
495
+ onWidthChange(preset.width);
496
+ onHeightChange(preset.height);
497
+ }
498
+ }
499
+ }}
500
+ >
501
+ <option value="responsive">Responsive</option>
502
+ {DEVICE_PRESETS.map((d) => (
503
+ <option key={d.name} value={d.name}>
504
+ {d.name} — {d.width}×{d.height}
505
+ </option>
506
+ ))}
507
+ {deviceMode === "custom" && <option value="custom">Custom</option>}
508
+ </select>
509
+
510
+ <input
511
+ className="dim-input"
512
+ type="number"
513
+ min={0}
514
+ value={draftWidth}
515
+ placeholder="W"
516
+ disabled={isPreset}
517
+ style={{ width: Math.max(3, draftWidth.length) + 1 + "ch" }}
518
+ onChange={(e) => setDraftWidth(e.target.value)}
519
+ onBlur={() => {
520
+ const w = parseInt(draftWidth) || 0;
521
+ onWidthChange(w);
522
+ }}
523
+ onKeyDown={(e) => {
524
+ if (e.key === "Enter") {
525
+ (e.target as HTMLInputElement).blur();
526
+ }
527
+ }}
528
+ />
529
+ <MdClose className="dim-separator" size={12} />
530
+ <input
531
+ className="dim-input"
532
+ type="number"
533
+ min={0}
534
+ value={draftHeight}
535
+ placeholder="H"
536
+ disabled={isPreset}
537
+ style={{ width: Math.max(3, draftHeight.length) + 1 + "ch" }}
538
+ onChange={(e) => setDraftHeight(e.target.value)}
539
+ onBlur={() => {
540
+ const h = parseInt(draftHeight) || 0;
541
+ onHeightChange(h);
542
+ }}
543
+ onKeyDown={(e) => {
544
+ if (e.key === "Enter") {
545
+ (e.target as HTMLInputElement).blur();
546
+ }
547
+ }}
548
+ />
549
+
550
+ <span className="toolbar-divider" />
551
+
552
+ <select
553
+ value={zoomMode}
554
+ onChange={(e) => {
555
+ const val = e.target.value;
556
+ onZoomChange(val === "fit" ? "fit" : parseInt(val));
557
+ }}
558
+ >
559
+ <option value="fit">Fit</option>
560
+ <option value={50}>50%</option>
561
+ <option value={75}>75%</option>
562
+ <option value={100}>100%</option>
563
+ <option value={125}>125%</option>
564
+ <option value={150}>150%</option>
565
+ </select>
566
+
567
+ <span className="dpr-badge">DPR: {dpr}</span>
568
+
569
+ <span className="toolbar-divider" />
570
+
571
+ <button
572
+ className="toolbar-btn"
573
+ title="Rotate"
574
+ onClick={onRotate}
575
+ disabled={!deviceWidth || !deviceHeight}
576
+ >
577
+ <MdScreenRotation size={14} />
578
+ </button>
579
+
580
+ <button
581
+ className={`toolbar-btn ${inspectMode ? "active" : ""}`}
582
+ title="Inspect element (hold Alt)"
583
+ onClick={onInspectToggle}
584
+ >
585
+ <TbMarquee size={16} />
586
+ </button>
587
+ </div>
588
+ );
589
+ }
590
+
591
+ function LayerTreeNode({
592
+ node,
593
+ depth,
594
+ selectedPath,
595
+ onSelect,
596
+ onHover,
597
+ onContextMenu,
598
+ }: {
599
+ node: DOMTreeNode;
600
+ depth: number;
601
+ selectedPath: string;
602
+ onSelect: (path: string) => void;
603
+ onHover: (path: string | null) => void;
604
+ onContextMenu: (path: string, x: number, y: number) => void;
605
+ }) {
606
+ return (
607
+ <>
608
+ <div
609
+ className={`layer-node ${node.path === selectedPath ? "selected" : ""}`}
610
+ style={{ paddingLeft: 12 + depth * 16 }}
611
+ onClick={() => onSelect(node.path)}
612
+ onMouseEnter={() => onHover(node.path)}
613
+ onMouseLeave={() => onHover(null)}
614
+ onContextMenu={(e) => {
615
+ e.preventDefault();
616
+ onContextMenu(node.path, e.clientX, e.clientY);
617
+ }}
618
+ >
619
+ <span className="layer-tag">&lt;{node.tagName}&gt;</span>
620
+ {node.className && (
621
+ <span className="layer-class">.{node.className.split(" ")[0]}</span>
622
+ )}
623
+ </div>
624
+ {node.children.map((child) => (
625
+ <LayerTreeNode
626
+ key={child.path}
627
+ node={child}
628
+ depth={depth + 1}
629
+ selectedPath={selectedPath}
630
+ onSelect={onSelect}
631
+ onHover={onHover}
632
+ onContextMenu={onContextMenu}
633
+ />
634
+ ))}
635
+ </>
636
+ );
637
+ }
638
+
639
+ function truncateMiddle(str: string, maxLen: number): string {
640
+ if (str.length <= maxLen) return str;
641
+ const ellipsis = "...";
642
+ const keepStart = Math.ceil((maxLen - ellipsis.length) / 2);
643
+ const keepEnd = Math.floor((maxLen - ellipsis.length) / 2);
644
+ return str.slice(0, keepStart) + ellipsis + str.slice(str.length - keepEnd);
645
+ }
646
+
647
+ function toKebabCase(str: string): string {
648
+ return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
649
+ }
650
+
651
+ function PropertyPanel({ node, nodeRef }: { node: NodeInfo; nodeRef: string }) {
652
+ const displayRef = truncateMiddle(nodeRef, 30);
653
+ const layoutStyles = ["width", "height", "display", "position",
654
+ "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
655
+ "marginTop", "marginRight", "marginBottom", "marginLeft"];
656
+ const visualStyles = ["backgroundColor", "color", "border",
657
+ "borderRadius", "opacity"];
658
+ const textStyles = ["fontFamily", "fontSize", "fontWeight", "lineHeight"];
659
+
660
+ function renderStyleGroup(title: string, keys: string[]) {
661
+ const entries = keys
662
+ .map((k) => [k, node.computedStyles[k]])
663
+ .filter(([, v]) => v && v !== "none" && v !== "normal" && v !== "0px");
664
+ if (entries.length === 0) return null;
665
+ return (
666
+ <div className="property-section">
667
+ <div className="property-section-title">{title}</div>
668
+ {entries.map(([k, v]) => (
669
+ <div key={k} className="property-row">
670
+ <span className="property-key">{toKebabCase(k)}</span>
671
+ <span className="property-value copyable" onClick={() => navigator.clipboard.writeText(v)} title={v}>{v}</span>
672
+ </div>
673
+ ))}
674
+ </div>
675
+ );
676
+ }
677
+
678
+ return (
679
+ <>
680
+ <div className="property-section">
681
+ <div className="property-section-title">Element</div>
682
+ <div className="property-row">
683
+ <span className="property-key">Ref</span>
684
+ <span className="property-value node-ref" onClick={() => navigator.clipboard.writeText(nodeRef)} title={nodeRef}>{displayRef}</span>
685
+ </div>
686
+ <div className="property-row">
687
+ <span className="property-key">Tag</span>
688
+ <span className="property-value copyable" onClick={() => navigator.clipboard.writeText(node.tagName)} title={node.tagName}>&lt;{node.tagName}&gt;</span>
689
+ </div>
690
+ {Object.entries(node.attributes).map(([k, v]) => (
691
+ <div key={k} className="property-row">
692
+ <span className="property-key">{k}</span>
693
+ <span className="property-value copyable" onClick={() => navigator.clipboard.writeText(v)} title={v}>{v}</span>
694
+ </div>
695
+ ))}
696
+ </div>
697
+ <div className="property-section">
698
+ <div className="property-section-title">Size</div>
699
+ <div className="property-row">
700
+ <span className="property-key">width</span>
701
+ <span className="property-value copyable" onClick={() => navigator.clipboard.writeText(`${Math.round(node.boundingRect.width)}px`)}>{Math.round(node.boundingRect.width)}px</span>
702
+ </div>
703
+ <div className="property-row">
704
+ <span className="property-key">height</span>
705
+ <span className="property-value copyable" onClick={() => navigator.clipboard.writeText(`${Math.round(node.boundingRect.height)}px`)}>{Math.round(node.boundingRect.height)}px</span>
706
+ </div>
707
+ </div>
708
+ {renderStyleGroup("Layout", layoutStyles)}
709
+ {renderStyleGroup("Visual", visualStyles)}
710
+ {renderStyleGroup("Typography", textStyles)}
711
+ </>
712
+ );
713
+ }