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.
- package/LICENSE +200 -0
- package/README.md +78 -0
- package/bin/dekit.ts +78 -0
- package/dist/editor/assets/index-Bx5YsS46.css +9 -0
- package/dist/editor/assets/index-DK9iczjI.js +57 -0
- package/dist/editor/index.html +13 -0
- package/editor/index.html +12 -0
- package/editor/src/App.tsx +713 -0
- package/editor/src/devices.test.ts +50 -0
- package/editor/src/devices.ts +33 -0
- package/editor/src/main.tsx +13 -0
- package/editor/src/styles/editor.css +451 -0
- package/editor/src/types.ts +43 -0
- package/editor/vite.config.ts +11 -0
- package/package.json +42 -0
- package/src/design-server.ts +24 -0
- package/src/editor-server.ts +69 -0
- package/src/injected/inspector.ts +314 -0
- package/src/injected/register-components.ts +57 -0
- package/src/parser.test.ts +68 -0
- package/src/parser.ts +90 -0
- package/src/server.ts +35 -0
- package/src/types.ts +17 -0
- package/src/vite-plugin-dekit.test.ts +55 -0
- package/src/vite-plugin-dekit.ts +107 -0
|
@@ -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"><{node.tagName}></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}><{node.tagName}></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
|
+
}
|