@formepdf/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/dist/build.d.ts +5 -0
- package/dist/build.js +81 -0
- package/dist/bundle.d.ts +2 -0
- package/dist/bundle.js +87 -0
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +290 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +84 -0
- package/dist/preview/index.html +2344 -0
- package/package.json +37 -0
|
@@ -0,0 +1,2344 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Forme Preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0a0a0b;
|
|
10
|
+
--surface: #18181b;
|
|
11
|
+
--surface-2: #1f1f23;
|
|
12
|
+
--border: #27272a;
|
|
13
|
+
--border-hover: #3f3f46;
|
|
14
|
+
--text: #fafafa;
|
|
15
|
+
--text-muted: #a1a1aa;
|
|
16
|
+
--text-dim: #71717a;
|
|
17
|
+
--accent: #3b82f6;
|
|
18
|
+
--accent-hover: #2563eb;
|
|
19
|
+
--inspector-width: 320px;
|
|
20
|
+
--left-sidebar-width: 280px;
|
|
21
|
+
--toolbar-height: 44px;
|
|
22
|
+
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
|
23
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
background: var(--bg);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
font-family: var(--font-sans);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
height: 100vh;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* -- Toolbar ----------------------------------------- */
|
|
37
|
+
#toolbar {
|
|
38
|
+
position: fixed;
|
|
39
|
+
top: 0;
|
|
40
|
+
left: 0;
|
|
41
|
+
right: 0;
|
|
42
|
+
z-index: 200;
|
|
43
|
+
height: var(--toolbar-height);
|
|
44
|
+
background: var(--surface);
|
|
45
|
+
border-bottom: 1px solid var(--border);
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
padding: 0 12px;
|
|
49
|
+
gap: 12px;
|
|
50
|
+
font-size: 13px;
|
|
51
|
+
-webkit-app-region: drag;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.toolbar-group {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 8px;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.wordmark {
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
font-size: 14px;
|
|
64
|
+
letter-spacing: -0.02em;
|
|
65
|
+
color: var(--text);
|
|
66
|
+
user-select: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.wordmark span { color: var(--text-dim); font-weight: 400; }
|
|
70
|
+
|
|
71
|
+
.toolbar-separator {
|
|
72
|
+
width: 1px;
|
|
73
|
+
height: 20px;
|
|
74
|
+
background: var(--border);
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* -- Segmented Control -------------------------------- */
|
|
79
|
+
.segmented-control {
|
|
80
|
+
display: flex;
|
|
81
|
+
background: var(--bg);
|
|
82
|
+
border: 1px solid var(--border);
|
|
83
|
+
border-radius: 6px;
|
|
84
|
+
padding: 2px;
|
|
85
|
+
gap: 1px;
|
|
86
|
+
-webkit-app-region: no-drag;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.segmented-control button {
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: var(--text-muted);
|
|
92
|
+
border: none;
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
padding: 4px 10px;
|
|
95
|
+
font-size: 11px;
|
|
96
|
+
font-weight: 500;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: all 0.15s ease;
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
position: relative;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.segmented-control button:hover {
|
|
104
|
+
color: var(--text);
|
|
105
|
+
background: var(--surface-2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.segmented-control button.active {
|
|
109
|
+
background: var(--surface-2);
|
|
110
|
+
color: var(--text);
|
|
111
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.segmented-control button .shortcut {
|
|
115
|
+
display: inline-block;
|
|
116
|
+
margin-left: 4px;
|
|
117
|
+
font-size: 9px;
|
|
118
|
+
color: var(--text-dim);
|
|
119
|
+
opacity: 0.6;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* -- Toolbar meta ------------------------------------ */
|
|
123
|
+
.toolbar-spacer { flex: 1; }
|
|
124
|
+
|
|
125
|
+
.badge {
|
|
126
|
+
display: inline-flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: 4px;
|
|
129
|
+
padding: 2px 8px;
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
font-size: 11px;
|
|
132
|
+
font-family: var(--font-mono);
|
|
133
|
+
background: var(--bg);
|
|
134
|
+
border: 1px solid var(--border);
|
|
135
|
+
color: var(--text-dim);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.badge.render-time { color: #4ade80; }
|
|
139
|
+
.badge.page-count { color: var(--text-muted); }
|
|
140
|
+
|
|
141
|
+
/* -- Zoom controls ----------------------------------- */
|
|
142
|
+
.zoom-controls {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 2px;
|
|
146
|
+
-webkit-app-region: no-drag;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.zoom-controls button {
|
|
150
|
+
background: transparent;
|
|
151
|
+
color: var(--text-muted);
|
|
152
|
+
border: 1px solid var(--border);
|
|
153
|
+
width: 28px;
|
|
154
|
+
height: 28px;
|
|
155
|
+
border-radius: 4px;
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
display: flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
transition: all 0.15s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.zoom-controls button:hover {
|
|
165
|
+
background: var(--surface-2);
|
|
166
|
+
color: var(--text);
|
|
167
|
+
border-color: var(--border-hover);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.zoom-level {
|
|
171
|
+
min-width: 44px;
|
|
172
|
+
text-align: center;
|
|
173
|
+
font-size: 11px;
|
|
174
|
+
font-family: var(--font-mono);
|
|
175
|
+
color: var(--text-muted);
|
|
176
|
+
user-select: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* -- Status dot -------------------------------------- */
|
|
180
|
+
.status-dot {
|
|
181
|
+
width: 6px;
|
|
182
|
+
height: 6px;
|
|
183
|
+
border-radius: 50%;
|
|
184
|
+
background: var(--text-dim);
|
|
185
|
+
flex-shrink: 0;
|
|
186
|
+
}
|
|
187
|
+
.status-dot.connected { background: #4ade80; }
|
|
188
|
+
.status-dot.disconnected { background: #ef4444; }
|
|
189
|
+
|
|
190
|
+
/* -- Toolbar button (Tree toggle, page size) --------- */
|
|
191
|
+
.toolbar-btn {
|
|
192
|
+
background: transparent;
|
|
193
|
+
color: var(--text-muted);
|
|
194
|
+
border: 1px solid var(--border);
|
|
195
|
+
border-radius: 4px;
|
|
196
|
+
padding: 4px 10px;
|
|
197
|
+
font-size: 11px;
|
|
198
|
+
font-weight: 500;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
transition: all 0.15s;
|
|
201
|
+
-webkit-app-region: no-drag;
|
|
202
|
+
white-space: nowrap;
|
|
203
|
+
}
|
|
204
|
+
.toolbar-btn:hover {
|
|
205
|
+
background: var(--surface-2);
|
|
206
|
+
color: var(--text);
|
|
207
|
+
border-color: var(--border-hover);
|
|
208
|
+
}
|
|
209
|
+
.toolbar-btn.active {
|
|
210
|
+
background: var(--surface-2);
|
|
211
|
+
color: var(--text);
|
|
212
|
+
border-color: var(--accent);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* -- Page size select -------------------------------- */
|
|
216
|
+
#page-size-select {
|
|
217
|
+
background: var(--bg);
|
|
218
|
+
color: var(--text-muted);
|
|
219
|
+
border: 1px solid var(--border);
|
|
220
|
+
border-radius: 4px;
|
|
221
|
+
padding: 4px 6px;
|
|
222
|
+
font-size: 11px;
|
|
223
|
+
font-family: var(--font-sans);
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
-webkit-app-region: no-drag;
|
|
226
|
+
}
|
|
227
|
+
#page-size-select:hover {
|
|
228
|
+
border-color: var(--border-hover);
|
|
229
|
+
color: var(--text);
|
|
230
|
+
}
|
|
231
|
+
.custom-size-inputs {
|
|
232
|
+
display: none;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 4px;
|
|
235
|
+
-webkit-app-region: no-drag;
|
|
236
|
+
}
|
|
237
|
+
.custom-size-inputs.visible { display: flex; }
|
|
238
|
+
.custom-size-inputs input {
|
|
239
|
+
width: 52px;
|
|
240
|
+
background: var(--bg);
|
|
241
|
+
color: var(--text);
|
|
242
|
+
border: 1px solid var(--border);
|
|
243
|
+
border-radius: 3px;
|
|
244
|
+
padding: 3px 6px;
|
|
245
|
+
font-size: 11px;
|
|
246
|
+
font-family: var(--font-mono);
|
|
247
|
+
text-align: center;
|
|
248
|
+
}
|
|
249
|
+
.custom-size-inputs input:focus {
|
|
250
|
+
outline: none;
|
|
251
|
+
border-color: var(--accent);
|
|
252
|
+
}
|
|
253
|
+
.custom-size-inputs span {
|
|
254
|
+
color: var(--text-dim);
|
|
255
|
+
font-size: 10px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* -- Left Sidebar ------------------------------------ */
|
|
259
|
+
#left-sidebar {
|
|
260
|
+
position: fixed;
|
|
261
|
+
top: var(--toolbar-height);
|
|
262
|
+
left: 0;
|
|
263
|
+
bottom: 0;
|
|
264
|
+
width: var(--left-sidebar-width);
|
|
265
|
+
background: #111113;
|
|
266
|
+
border-right: 1px solid var(--border);
|
|
267
|
+
z-index: 150;
|
|
268
|
+
transform: translateX(-100%);
|
|
269
|
+
transition: transform 0.2s ease-out;
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-direction: column;
|
|
272
|
+
font-size: 12px;
|
|
273
|
+
font-family: var(--font-mono);
|
|
274
|
+
}
|
|
275
|
+
#left-sidebar.open {
|
|
276
|
+
transform: translateX(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.sidebar-tabs {
|
|
280
|
+
display: flex;
|
|
281
|
+
border-bottom: 1px solid var(--border);
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
}
|
|
284
|
+
.sidebar-tab {
|
|
285
|
+
flex: 1;
|
|
286
|
+
padding: 8px 12px;
|
|
287
|
+
font-size: 10px;
|
|
288
|
+
font-weight: 600;
|
|
289
|
+
text-transform: uppercase;
|
|
290
|
+
letter-spacing: 0.05em;
|
|
291
|
+
color: var(--text-dim);
|
|
292
|
+
background: transparent;
|
|
293
|
+
border: none;
|
|
294
|
+
cursor: pointer;
|
|
295
|
+
text-align: center;
|
|
296
|
+
transition: color 0.15s;
|
|
297
|
+
border-bottom: 2px solid transparent;
|
|
298
|
+
}
|
|
299
|
+
.sidebar-tab:hover { color: var(--text-muted); }
|
|
300
|
+
.sidebar-tab.active {
|
|
301
|
+
color: var(--text);
|
|
302
|
+
border-bottom-color: var(--accent);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.sidebar-panel {
|
|
306
|
+
flex: 1;
|
|
307
|
+
overflow-y: auto;
|
|
308
|
+
display: none;
|
|
309
|
+
}
|
|
310
|
+
.sidebar-panel.active { display: flex; flex-direction: column; }
|
|
311
|
+
|
|
312
|
+
/* -- Component Tree ---------------------------------- */
|
|
313
|
+
#component-tree {
|
|
314
|
+
padding: 4px 0;
|
|
315
|
+
}
|
|
316
|
+
.tree-node {
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: center;
|
|
319
|
+
padding: 2px 8px 2px 0;
|
|
320
|
+
cursor: pointer;
|
|
321
|
+
white-space: nowrap;
|
|
322
|
+
transition: background 0.1s;
|
|
323
|
+
user-select: none;
|
|
324
|
+
}
|
|
325
|
+
.tree-node:hover { background: #1c1c1e; }
|
|
326
|
+
.tree-node.selected { background: #1e3a5f; color: #fff; }
|
|
327
|
+
.tree-node .arrow {
|
|
328
|
+
width: 16px;
|
|
329
|
+
flex-shrink: 0;
|
|
330
|
+
text-align: center;
|
|
331
|
+
font-size: 10px;
|
|
332
|
+
color: var(--text-dim);
|
|
333
|
+
cursor: pointer;
|
|
334
|
+
}
|
|
335
|
+
.tree-node .arrow.has-children { color: var(--text-muted); }
|
|
336
|
+
.tree-node .node-label {
|
|
337
|
+
font-size: 12px;
|
|
338
|
+
}
|
|
339
|
+
.tree-node .text-preview {
|
|
340
|
+
color: var(--text-dim);
|
|
341
|
+
margin-left: 6px;
|
|
342
|
+
font-size: 11px;
|
|
343
|
+
overflow: hidden;
|
|
344
|
+
text-overflow: ellipsis;
|
|
345
|
+
max-width: 140px;
|
|
346
|
+
}
|
|
347
|
+
.tree-node .dim-label {
|
|
348
|
+
color: var(--text-dim);
|
|
349
|
+
margin-left: 6px;
|
|
350
|
+
font-size: 10px;
|
|
351
|
+
}
|
|
352
|
+
.tree-children { display: none; }
|
|
353
|
+
.tree-children.expanded { display: block; }
|
|
354
|
+
|
|
355
|
+
/* -- Data Editor ------------------------------------- */
|
|
356
|
+
#data-panel {
|
|
357
|
+
flex: 1;
|
|
358
|
+
display: flex;
|
|
359
|
+
flex-direction: column;
|
|
360
|
+
}
|
|
361
|
+
.data-toolbar {
|
|
362
|
+
display: flex;
|
|
363
|
+
align-items: center;
|
|
364
|
+
gap: 8px;
|
|
365
|
+
padding: 8px 12px;
|
|
366
|
+
border-bottom: 1px solid var(--border);
|
|
367
|
+
flex-shrink: 0;
|
|
368
|
+
}
|
|
369
|
+
.data-save-btn {
|
|
370
|
+
background: var(--surface-2);
|
|
371
|
+
border: 1px solid var(--border);
|
|
372
|
+
border-radius: 4px;
|
|
373
|
+
padding: 4px 12px;
|
|
374
|
+
font-size: 11px;
|
|
375
|
+
color: var(--text-muted);
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
transition: all 0.15s;
|
|
378
|
+
}
|
|
379
|
+
.data-save-btn:hover {
|
|
380
|
+
border-color: var(--border-hover);
|
|
381
|
+
color: var(--text);
|
|
382
|
+
}
|
|
383
|
+
.data-feedback {
|
|
384
|
+
font-size: 11px;
|
|
385
|
+
color: #4ade80;
|
|
386
|
+
}
|
|
387
|
+
#data-editor {
|
|
388
|
+
flex: 1;
|
|
389
|
+
width: 100%;
|
|
390
|
+
background: var(--bg);
|
|
391
|
+
color: #e4e4e7;
|
|
392
|
+
border: none;
|
|
393
|
+
padding: 12px;
|
|
394
|
+
font-size: 12px;
|
|
395
|
+
font-family: var(--font-mono);
|
|
396
|
+
resize: none;
|
|
397
|
+
outline: none;
|
|
398
|
+
line-height: 1.5;
|
|
399
|
+
tab-size: 2;
|
|
400
|
+
}
|
|
401
|
+
#data-editor.error {
|
|
402
|
+
border-top: 2px solid #ef4444;
|
|
403
|
+
}
|
|
404
|
+
.data-error {
|
|
405
|
+
display: none;
|
|
406
|
+
padding: 6px 12px;
|
|
407
|
+
font-size: 11px;
|
|
408
|
+
color: #fca5a5;
|
|
409
|
+
background: #1c0a0a;
|
|
410
|
+
border-top: 1px solid #3f1111;
|
|
411
|
+
flex-shrink: 0;
|
|
412
|
+
}
|
|
413
|
+
.data-error.visible { display: block; }
|
|
414
|
+
|
|
415
|
+
/* -- Canvas Container -------------------------------- */
|
|
416
|
+
#canvas-container {
|
|
417
|
+
position: fixed;
|
|
418
|
+
top: var(--toolbar-height);
|
|
419
|
+
left: 0;
|
|
420
|
+
right: 0;
|
|
421
|
+
bottom: 0;
|
|
422
|
+
overflow: auto;
|
|
423
|
+
transition: margin-left 0.2s ease-out, margin-right 0.2s ease-out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#canvas-container.inspector-open {
|
|
427
|
+
margin-right: var(--inspector-width);
|
|
428
|
+
}
|
|
429
|
+
#canvas-container.sidebar-open {
|
|
430
|
+
margin-left: var(--left-sidebar-width);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#pages {
|
|
434
|
+
display: flex;
|
|
435
|
+
flex-direction: column;
|
|
436
|
+
align-items: center;
|
|
437
|
+
gap: 20px;
|
|
438
|
+
padding: 32px 20px 60px;
|
|
439
|
+
min-height: 100%;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.page-wrapper {
|
|
443
|
+
position: relative;
|
|
444
|
+
box-shadow: 0 2px 16px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
|
|
445
|
+
border-radius: 2px;
|
|
446
|
+
cursor: default;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.page-wrapper canvas.pdf-canvas {
|
|
450
|
+
display: block;
|
|
451
|
+
background: #fff;
|
|
452
|
+
border-radius: 2px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.page-wrapper canvas.overlay-canvas {
|
|
456
|
+
position: absolute;
|
|
457
|
+
top: 0;
|
|
458
|
+
left: 0;
|
|
459
|
+
pointer-events: none;
|
|
460
|
+
border-radius: 2px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.page-wrapper .hit-layer {
|
|
464
|
+
position: absolute;
|
|
465
|
+
top: 0;
|
|
466
|
+
left: 0;
|
|
467
|
+
width: 100%;
|
|
468
|
+
height: 100%;
|
|
469
|
+
cursor: default;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* -- Hover tooltip ----------------------------------- */
|
|
473
|
+
#hover-tooltip {
|
|
474
|
+
position: fixed;
|
|
475
|
+
z-index: 300;
|
|
476
|
+
pointer-events: none;
|
|
477
|
+
background: var(--surface);
|
|
478
|
+
border: 1px solid var(--border);
|
|
479
|
+
border-radius: 4px;
|
|
480
|
+
padding: 3px 8px;
|
|
481
|
+
font-size: 11px;
|
|
482
|
+
font-family: var(--font-mono);
|
|
483
|
+
color: var(--text-muted);
|
|
484
|
+
white-space: nowrap;
|
|
485
|
+
display: none;
|
|
486
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* -- Inspector Panel --------------------------------- */
|
|
490
|
+
#inspector {
|
|
491
|
+
position: fixed;
|
|
492
|
+
top: var(--toolbar-height);
|
|
493
|
+
right: 0;
|
|
494
|
+
bottom: 0;
|
|
495
|
+
width: var(--inspector-width);
|
|
496
|
+
background: var(--surface);
|
|
497
|
+
border-left: 1px solid var(--border);
|
|
498
|
+
z-index: 150;
|
|
499
|
+
overflow-y: auto;
|
|
500
|
+
transform: translateX(100%);
|
|
501
|
+
transition: transform 0.2s ease-out;
|
|
502
|
+
font-size: 12px;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#inspector.open {
|
|
506
|
+
transform: translateX(0);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.inspector-header {
|
|
510
|
+
padding: 12px 16px;
|
|
511
|
+
border-bottom: 1px solid var(--border);
|
|
512
|
+
display: flex;
|
|
513
|
+
align-items: center;
|
|
514
|
+
justify-content: space-between;
|
|
515
|
+
position: sticky;
|
|
516
|
+
top: 0;
|
|
517
|
+
background: var(--surface);
|
|
518
|
+
z-index: 1;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.breadcrumb {
|
|
522
|
+
font-size: 11px;
|
|
523
|
+
color: var(--text-dim);
|
|
524
|
+
font-family: var(--font-mono);
|
|
525
|
+
margin-bottom: 2px;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.inspector-header .node-label {
|
|
529
|
+
font-weight: 600;
|
|
530
|
+
font-size: 13px;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.inspector-header .node-dims {
|
|
534
|
+
font-family: var(--font-mono);
|
|
535
|
+
font-size: 11px;
|
|
536
|
+
color: var(--text-dim);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.inspector-header .close-btn {
|
|
540
|
+
background: transparent;
|
|
541
|
+
border: none;
|
|
542
|
+
color: var(--text-dim);
|
|
543
|
+
cursor: pointer;
|
|
544
|
+
font-size: 16px;
|
|
545
|
+
padding: 2px 6px;
|
|
546
|
+
border-radius: 4px;
|
|
547
|
+
line-height: 1;
|
|
548
|
+
}
|
|
549
|
+
.inspector-header .close-btn:hover {
|
|
550
|
+
background: var(--surface-2);
|
|
551
|
+
color: var(--text);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* -- Box Model Diagram ------------------------------- */
|
|
555
|
+
.box-model {
|
|
556
|
+
padding: 16px;
|
|
557
|
+
border-bottom: 1px solid var(--border);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.box-model-title {
|
|
561
|
+
font-size: 10px;
|
|
562
|
+
font-weight: 600;
|
|
563
|
+
text-transform: uppercase;
|
|
564
|
+
letter-spacing: 0.05em;
|
|
565
|
+
color: var(--text-dim);
|
|
566
|
+
margin-bottom: 12px;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.box-model-diagram {
|
|
570
|
+
position: relative;
|
|
571
|
+
width: 100%;
|
|
572
|
+
aspect-ratio: 1.6;
|
|
573
|
+
font-family: var(--font-mono);
|
|
574
|
+
font-size: 10px;
|
|
575
|
+
user-select: none;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.box-margin {
|
|
579
|
+
position: absolute;
|
|
580
|
+
inset: 0;
|
|
581
|
+
background: rgba(251, 146, 60, 0.12);
|
|
582
|
+
border: 1px dashed rgba(251, 146, 60, 0.4);
|
|
583
|
+
border-radius: 4px;
|
|
584
|
+
display: flex;
|
|
585
|
+
flex-direction: column;
|
|
586
|
+
align-items: center;
|
|
587
|
+
justify-content: center;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.box-border-area {
|
|
591
|
+
position: absolute;
|
|
592
|
+
inset: 18%;
|
|
593
|
+
background: rgba(96, 165, 250, 0.12);
|
|
594
|
+
border: 1px solid rgba(96, 165, 250, 0.4);
|
|
595
|
+
border-radius: 3px;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.box-padding {
|
|
599
|
+
position: absolute;
|
|
600
|
+
inset: 30%;
|
|
601
|
+
background: rgba(74, 222, 128, 0.12);
|
|
602
|
+
border: 1px dashed rgba(74, 222, 128, 0.4);
|
|
603
|
+
border-radius: 2px;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.box-content {
|
|
607
|
+
position: absolute;
|
|
608
|
+
inset: 42%;
|
|
609
|
+
background: rgba(96, 165, 250, 0.2);
|
|
610
|
+
border-radius: 2px;
|
|
611
|
+
display: flex;
|
|
612
|
+
align-items: center;
|
|
613
|
+
justify-content: center;
|
|
614
|
+
color: var(--text-muted);
|
|
615
|
+
font-size: 9px;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.box-label {
|
|
619
|
+
position: absolute;
|
|
620
|
+
color: var(--text-dim);
|
|
621
|
+
font-size: 9px;
|
|
622
|
+
}
|
|
623
|
+
.box-label.top { top: 2px; left: 50%; transform: translateX(-50%); }
|
|
624
|
+
.box-label.bottom { bottom: 2px; left: 50%; transform: translateX(-50%); }
|
|
625
|
+
.box-label.left { left: 4px; top: 50%; transform: translateY(-50%); }
|
|
626
|
+
.box-label.right { right: 4px; top: 50%; transform: translateY(-50%); }
|
|
627
|
+
.box-label.margin-label { color: #fb923c; }
|
|
628
|
+
.box-label.border-label { color: #60a5fa; }
|
|
629
|
+
.box-label.padding-label { color: #4ade80; }
|
|
630
|
+
|
|
631
|
+
/* -- Computed Styles --------------------------------- */
|
|
632
|
+
.style-section {
|
|
633
|
+
padding: 12px 16px;
|
|
634
|
+
border-bottom: 1px solid var(--border);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.style-section-title {
|
|
638
|
+
font-size: 10px;
|
|
639
|
+
font-weight: 600;
|
|
640
|
+
text-transform: uppercase;
|
|
641
|
+
letter-spacing: 0.05em;
|
|
642
|
+
color: var(--text-dim);
|
|
643
|
+
margin-bottom: 8px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.style-row {
|
|
647
|
+
display: flex;
|
|
648
|
+
justify-content: space-between;
|
|
649
|
+
align-items: baseline;
|
|
650
|
+
padding: 2px 0;
|
|
651
|
+
font-size: 11px;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.style-row .prop {
|
|
655
|
+
color: var(--text-muted);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.style-row .val {
|
|
659
|
+
font-family: var(--font-mono);
|
|
660
|
+
color: var(--text);
|
|
661
|
+
text-align: right;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.color-swatch {
|
|
665
|
+
display: inline-block;
|
|
666
|
+
width: 10px;
|
|
667
|
+
height: 10px;
|
|
668
|
+
border-radius: 2px;
|
|
669
|
+
border: 1px solid var(--border);
|
|
670
|
+
vertical-align: middle;
|
|
671
|
+
margin-right: 4px;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/* -- Inspector Actions ------------------------------- */
|
|
675
|
+
.inspector-actions {
|
|
676
|
+
padding: 12px 16px;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.copy-style-btn {
|
|
680
|
+
width: 100%;
|
|
681
|
+
background: var(--surface-2);
|
|
682
|
+
border: 1px solid var(--border);
|
|
683
|
+
border-radius: 4px;
|
|
684
|
+
padding: 8px 12px;
|
|
685
|
+
font-size: 12px;
|
|
686
|
+
color: var(--text-muted);
|
|
687
|
+
cursor: pointer;
|
|
688
|
+
transition: all 0.15s;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.copy-style-btn:hover {
|
|
692
|
+
border-color: var(--border-hover);
|
|
693
|
+
color: var(--text);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/* -- Error Overlay ----------------------------------- */
|
|
697
|
+
#error-overlay {
|
|
698
|
+
display: none;
|
|
699
|
+
position: fixed;
|
|
700
|
+
top: var(--toolbar-height);
|
|
701
|
+
left: 0;
|
|
702
|
+
right: 0;
|
|
703
|
+
z-index: 180;
|
|
704
|
+
background: #450a0a;
|
|
705
|
+
border-bottom: 1px solid #7f1d1d;
|
|
706
|
+
color: #fca5a5;
|
|
707
|
+
padding: 12px 20px;
|
|
708
|
+
font-family: var(--font-mono);
|
|
709
|
+
font-size: 12px;
|
|
710
|
+
white-space: pre-wrap;
|
|
711
|
+
word-break: break-word;
|
|
712
|
+
max-height: 200px;
|
|
713
|
+
overflow-y: auto;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
#error-overlay .error-dismiss {
|
|
717
|
+
position: absolute;
|
|
718
|
+
top: 8px;
|
|
719
|
+
right: 12px;
|
|
720
|
+
background: transparent;
|
|
721
|
+
border: none;
|
|
722
|
+
color: #fca5a5;
|
|
723
|
+
cursor: pointer;
|
|
724
|
+
font-size: 14px;
|
|
725
|
+
opacity: 0.6;
|
|
726
|
+
}
|
|
727
|
+
#error-overlay .error-dismiss:hover { opacity: 1; }
|
|
728
|
+
|
|
729
|
+
/* -- Editor dropdown --------------------------------- */
|
|
730
|
+
#editor-select {
|
|
731
|
+
background: var(--bg);
|
|
732
|
+
color: var(--text-muted);
|
|
733
|
+
border: 1px solid var(--border);
|
|
734
|
+
border-radius: 4px;
|
|
735
|
+
padding: 4px 6px;
|
|
736
|
+
font-size: 11px;
|
|
737
|
+
font-family: var(--font-sans);
|
|
738
|
+
cursor: pointer;
|
|
739
|
+
-webkit-app-region: no-drag;
|
|
740
|
+
}
|
|
741
|
+
#editor-select:hover {
|
|
742
|
+
border-color: var(--border-hover);
|
|
743
|
+
color: var(--text);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/* -- Source location in inspector -------------------- */
|
|
747
|
+
.node-source {
|
|
748
|
+
margin-top: 4px;
|
|
749
|
+
display: flex;
|
|
750
|
+
align-items: center;
|
|
751
|
+
gap: 6px;
|
|
752
|
+
}
|
|
753
|
+
.source-path {
|
|
754
|
+
font-family: var(--font-mono);
|
|
755
|
+
font-size: 10px;
|
|
756
|
+
color: var(--text-dim);
|
|
757
|
+
}
|
|
758
|
+
.open-editor-btn {
|
|
759
|
+
background: var(--surface-2);
|
|
760
|
+
border: 1px solid var(--border);
|
|
761
|
+
border-radius: 3px;
|
|
762
|
+
padding: 1px 6px;
|
|
763
|
+
font-size: 10px;
|
|
764
|
+
color: var(--text-muted);
|
|
765
|
+
cursor: pointer;
|
|
766
|
+
transition: all 0.15s;
|
|
767
|
+
}
|
|
768
|
+
.open-editor-btn:hover {
|
|
769
|
+
border-color: var(--border-hover);
|
|
770
|
+
color: var(--text);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* -- Crossfade animation ----------------------------- */
|
|
774
|
+
.page-wrapper.fading {
|
|
775
|
+
opacity: 0;
|
|
776
|
+
transition: opacity 0.15s ease;
|
|
777
|
+
}
|
|
778
|
+
.page-wrapper.visible {
|
|
779
|
+
opacity: 1;
|
|
780
|
+
transition: opacity 0.15s ease;
|
|
781
|
+
}
|
|
782
|
+
</style>
|
|
783
|
+
</head>
|
|
784
|
+
<body>
|
|
785
|
+
|
|
786
|
+
<div id="toolbar">
|
|
787
|
+
<div class="toolbar-group">
|
|
788
|
+
<div class="status-dot" id="status-dot"></div>
|
|
789
|
+
<div class="wordmark">forme <span>preview</span></div>
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
<div class="toolbar-separator"></div>
|
|
793
|
+
|
|
794
|
+
<button class="toolbar-btn" id="tree-toggle" title="Toggle component tree (T)">Tree</button>
|
|
795
|
+
|
|
796
|
+
<div class="toolbar-separator"></div>
|
|
797
|
+
|
|
798
|
+
<div class="segmented-control" id="mode-control">
|
|
799
|
+
<button data-mode="preview" class="active">Preview <span class="shortcut">1</span></button>
|
|
800
|
+
<button data-mode="layout">Layout <span class="shortcut">2</span></button>
|
|
801
|
+
<button data-mode="margins">Margins <span class="shortcut">3</span></button>
|
|
802
|
+
<button data-mode="breaks">Breaks <span class="shortcut">4</span></button>
|
|
803
|
+
</div>
|
|
804
|
+
|
|
805
|
+
<div class="toolbar-separator"></div>
|
|
806
|
+
|
|
807
|
+
<div class="toolbar-group">
|
|
808
|
+
<select id="page-size-select" title="Page size override">
|
|
809
|
+
<option value="default">Default</option>
|
|
810
|
+
<option value="letter">Letter (612 x 792)</option>
|
|
811
|
+
<option value="a4">A4 (595 x 842)</option>
|
|
812
|
+
<option value="legal">Legal (612 x 1008)</option>
|
|
813
|
+
<option value="tabloid">Tabloid (792 x 1224)</option>
|
|
814
|
+
<option value="a3">A3 (842 x 1191)</option>
|
|
815
|
+
<option value="a5">A5 (420 x 595)</option>
|
|
816
|
+
<option value="custom">Custom...</option>
|
|
817
|
+
</select>
|
|
818
|
+
<div class="custom-size-inputs" id="custom-size-inputs">
|
|
819
|
+
<input type="number" id="custom-width" placeholder="W" value="612" min="72" max="4000">
|
|
820
|
+
<span>x</span>
|
|
821
|
+
<input type="number" id="custom-height" placeholder="H" value="792" min="72" max="4000">
|
|
822
|
+
<span>pt</span>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
<div class="toolbar-spacer"></div>
|
|
827
|
+
|
|
828
|
+
<div class="badge render-time" id="render-badge" style="display:none">
|
|
829
|
+
<span id="render-time">0ms</span>
|
|
830
|
+
</div>
|
|
831
|
+
<div class="badge page-count" id="page-badge" style="display:none">
|
|
832
|
+
<span id="page-count">0</span> pages
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<div class="toolbar-separator"></div>
|
|
836
|
+
|
|
837
|
+
<div class="zoom-controls">
|
|
838
|
+
<button id="zoom-out" title="Zoom out (Cmd -)">−</button>
|
|
839
|
+
<span class="zoom-level" id="zoom-level">100%</span>
|
|
840
|
+
<button id="zoom-in" title="Zoom in (Cmd +)">+</button>
|
|
841
|
+
<button id="zoom-fit" title="Fit to window (Cmd 0)" style="font-size:11px; width:auto; padding: 0 8px;">Fit</button>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<div class="toolbar-separator"></div>
|
|
845
|
+
<div class="toolbar-group">
|
|
846
|
+
<select id="editor-select" title="Editor for Open in Editor">
|
|
847
|
+
<option value="vscode">VS Code</option>
|
|
848
|
+
<option value="cursor">Cursor</option>
|
|
849
|
+
<option value="webstorm">WebStorm</option>
|
|
850
|
+
</select>
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
|
|
854
|
+
<div id="error-overlay">
|
|
855
|
+
<button class="error-dismiss" id="error-dismiss">×</button>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div id="left-sidebar">
|
|
859
|
+
<div class="sidebar-tabs" id="sidebar-tabs">
|
|
860
|
+
<button class="sidebar-tab active" data-tab="components">Components</button>
|
|
861
|
+
<button class="sidebar-tab" data-tab="data" id="data-tab-btn" style="display:none">Data</button>
|
|
862
|
+
</div>
|
|
863
|
+
<div class="sidebar-panel active" id="components-panel">
|
|
864
|
+
<div id="component-tree"></div>
|
|
865
|
+
</div>
|
|
866
|
+
<div class="sidebar-panel" id="data-panel-container">
|
|
867
|
+
<div id="data-panel">
|
|
868
|
+
<div class="data-toolbar">
|
|
869
|
+
<button class="data-save-btn" id="data-save-btn">Save</button>
|
|
870
|
+
<span class="data-feedback" id="data-feedback"></span>
|
|
871
|
+
</div>
|
|
872
|
+
<textarea id="data-editor" spellcheck="false"></textarea>
|
|
873
|
+
<div class="data-error" id="data-error"></div>
|
|
874
|
+
</div>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<div id="canvas-container">
|
|
879
|
+
<div id="pages"></div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
<div id="hover-tooltip"></div>
|
|
883
|
+
|
|
884
|
+
<div id="inspector">
|
|
885
|
+
<div class="inspector-header">
|
|
886
|
+
<div>
|
|
887
|
+
<div class="breadcrumb" id="inspector-breadcrumb"></div>
|
|
888
|
+
<div class="node-label" id="inspector-label">View</div>
|
|
889
|
+
<div class="node-dims" id="inspector-dims">0 x 0 at (0, 0)</div>
|
|
890
|
+
<div class="node-source" id="inspector-source" style="display:none"></div>
|
|
891
|
+
</div>
|
|
892
|
+
<button class="close-btn" id="inspector-close">×</button>
|
|
893
|
+
</div>
|
|
894
|
+
|
|
895
|
+
<div class="box-model">
|
|
896
|
+
<div class="box-model-title">Box Model</div>
|
|
897
|
+
<div class="box-model-diagram" id="box-model-diagram">
|
|
898
|
+
<div class="box-margin">
|
|
899
|
+
<span class="box-label top margin-label" id="bm-margin-top">0</span>
|
|
900
|
+
<span class="box-label bottom margin-label" id="bm-margin-bottom">0</span>
|
|
901
|
+
<span class="box-label left margin-label" id="bm-margin-left">0</span>
|
|
902
|
+
<span class="box-label right margin-label" id="bm-margin-right">0</span>
|
|
903
|
+
</div>
|
|
904
|
+
<div class="box-border-area">
|
|
905
|
+
<span class="box-label top border-label" id="bm-border-top">0</span>
|
|
906
|
+
<span class="box-label bottom border-label" id="bm-border-bottom">0</span>
|
|
907
|
+
<span class="box-label left border-label" id="bm-border-left">0</span>
|
|
908
|
+
<span class="box-label right border-label" id="bm-border-right">0</span>
|
|
909
|
+
</div>
|
|
910
|
+
<div class="box-padding">
|
|
911
|
+
<span class="box-label top padding-label" id="bm-padding-top">0</span>
|
|
912
|
+
<span class="box-label bottom padding-label" id="bm-padding-bottom">0</span>
|
|
913
|
+
<span class="box-label left padding-label" id="bm-padding-left">0</span>
|
|
914
|
+
<span class="box-label right padding-label" id="bm-padding-right">0</span>
|
|
915
|
+
</div>
|
|
916
|
+
<div class="box-content" id="bm-content">0 x 0</div>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<div id="inspector-styles"></div>
|
|
921
|
+
<div class="inspector-actions" id="inspector-actions"></div>
|
|
922
|
+
</div>
|
|
923
|
+
|
|
924
|
+
<script type="module">
|
|
925
|
+
// -- PDF.js Setup ------------------------------------------------
|
|
926
|
+
const PDFJS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs';
|
|
927
|
+
const PDFJS_WORKER_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs';
|
|
928
|
+
|
|
929
|
+
const pdfjsLib = await import(PDFJS_CDN);
|
|
930
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN;
|
|
931
|
+
|
|
932
|
+
// -- State -------------------------------------------------------
|
|
933
|
+
const ZOOM_LEVELS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
|
934
|
+
let currentZoomIndex = 3; // 100%
|
|
935
|
+
let currentZoom = 1.0;
|
|
936
|
+
let currentMode = 'preview'; // preview | layout | margins | breaks
|
|
937
|
+
let layoutData = null;
|
|
938
|
+
let selectedElement = null;
|
|
939
|
+
let selectedAncestors = [];
|
|
940
|
+
let selectedAncestorElements = [];
|
|
941
|
+
let selectedPageIdx = -1;
|
|
942
|
+
let selectedPath = null; // index path like [0, 2, 1]
|
|
943
|
+
let inspectorOpen = false;
|
|
944
|
+
let sidebarOpen = localStorage.getItem('forme-sidebar') !== 'false';
|
|
945
|
+
let activeTab = 'components';
|
|
946
|
+
let hasDataFile = false;
|
|
947
|
+
let wsRef = null;
|
|
948
|
+
|
|
949
|
+
// -- DOM refs ----------------------------------------------------
|
|
950
|
+
const pagesEl = document.getElementById('pages');
|
|
951
|
+
const containerEl = document.getElementById('canvas-container');
|
|
952
|
+
const errorEl = document.getElementById('error-overlay');
|
|
953
|
+
const renderTimeEl = document.getElementById('render-time');
|
|
954
|
+
const renderBadge = document.getElementById('render-badge');
|
|
955
|
+
const pageCountEl = document.getElementById('page-count');
|
|
956
|
+
const pageBadge = document.getElementById('page-badge');
|
|
957
|
+
const statusDot = document.getElementById('status-dot');
|
|
958
|
+
const zoomLevelEl = document.getElementById('zoom-level');
|
|
959
|
+
const modeControl = document.getElementById('mode-control');
|
|
960
|
+
const inspectorEl = document.getElementById('inspector');
|
|
961
|
+
const inspectorLabel = document.getElementById('inspector-label');
|
|
962
|
+
const inspectorDims = document.getElementById('inspector-dims');
|
|
963
|
+
const inspectorStyles = document.getElementById('inspector-styles');
|
|
964
|
+
const inspectorBreadcrumb = document.getElementById('inspector-breadcrumb');
|
|
965
|
+
const inspectorActions = document.getElementById('inspector-actions');
|
|
966
|
+
const tooltip = document.getElementById('hover-tooltip');
|
|
967
|
+
const leftSidebar = document.getElementById('left-sidebar');
|
|
968
|
+
const treeToggle = document.getElementById('tree-toggle');
|
|
969
|
+
const componentTree = document.getElementById('component-tree');
|
|
970
|
+
const sidebarTabs = document.getElementById('sidebar-tabs');
|
|
971
|
+
const dataTabBtn = document.getElementById('data-tab-btn');
|
|
972
|
+
const componentsPanel = document.getElementById('components-panel');
|
|
973
|
+
const dataPanelContainer = document.getElementById('data-panel-container');
|
|
974
|
+
const dataEditor = document.getElementById('data-editor');
|
|
975
|
+
const dataError = document.getElementById('data-error');
|
|
976
|
+
const dataSaveBtn = document.getElementById('data-save-btn');
|
|
977
|
+
const dataFeedback = document.getElementById('data-feedback');
|
|
978
|
+
|
|
979
|
+
// -- Left Sidebar Toggle -----------------------------------------
|
|
980
|
+
function setSidebarOpen(open) {
|
|
981
|
+
sidebarOpen = open;
|
|
982
|
+
leftSidebar.classList.toggle('open', open);
|
|
983
|
+
containerEl.classList.toggle('sidebar-open', open);
|
|
984
|
+
treeToggle.classList.toggle('active', open);
|
|
985
|
+
localStorage.setItem('forme-sidebar', String(open));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
treeToggle.addEventListener('click', () => setSidebarOpen(!sidebarOpen));
|
|
989
|
+
setSidebarOpen(sidebarOpen);
|
|
990
|
+
|
|
991
|
+
// -- Sidebar Tabs ------------------------------------------------
|
|
992
|
+
function setActiveTab(tab) {
|
|
993
|
+
activeTab = tab;
|
|
994
|
+
sidebarTabs.querySelectorAll('.sidebar-tab').forEach(t => {
|
|
995
|
+
t.classList.toggle('active', t.dataset.tab === tab);
|
|
996
|
+
});
|
|
997
|
+
componentsPanel.classList.toggle('active', tab === 'components');
|
|
998
|
+
dataPanelContainer.classList.toggle('active', tab === 'data');
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
sidebarTabs.addEventListener('click', (e) => {
|
|
1002
|
+
const tab = e.target.closest('.sidebar-tab');
|
|
1003
|
+
if (tab && tab.dataset.tab) setActiveTab(tab.dataset.tab);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// -- Mode Toggle -------------------------------------------------
|
|
1007
|
+
function setMode(mode) {
|
|
1008
|
+
currentMode = mode;
|
|
1009
|
+
modeControl.querySelectorAll('button').forEach(btn => {
|
|
1010
|
+
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
1011
|
+
});
|
|
1012
|
+
drawOverlays();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
modeControl.addEventListener('click', (e) => {
|
|
1016
|
+
const btn = e.target.closest('button');
|
|
1017
|
+
if (btn && btn.dataset.mode) setMode(btn.dataset.mode);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// -- Page Size Switcher ------------------------------------------
|
|
1021
|
+
const PAGE_SIZES = {
|
|
1022
|
+
letter: { width: 612, height: 792 },
|
|
1023
|
+
a4: { width: 595, height: 842 },
|
|
1024
|
+
legal: { width: 612, height: 1008 },
|
|
1025
|
+
tabloid:{ width: 792, height: 1224 },
|
|
1026
|
+
a3: { width: 842, height: 1191 },
|
|
1027
|
+
a5: { width: 420, height: 595 },
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const pageSizeSelect = document.getElementById('page-size-select');
|
|
1031
|
+
const customSizeInputs = document.getElementById('custom-size-inputs');
|
|
1032
|
+
const customWidthInput = document.getElementById('custom-width');
|
|
1033
|
+
const customHeightInput = document.getElementById('custom-height');
|
|
1034
|
+
|
|
1035
|
+
// Restore from localStorage
|
|
1036
|
+
const savedPageSize = localStorage.getItem('forme-pagesize');
|
|
1037
|
+
if (savedPageSize) {
|
|
1038
|
+
pageSizeSelect.value = savedPageSize;
|
|
1039
|
+
if (savedPageSize === 'custom') {
|
|
1040
|
+
customSizeInputs.classList.add('visible');
|
|
1041
|
+
const savedW = localStorage.getItem('forme-pagesize-w');
|
|
1042
|
+
const savedH = localStorage.getItem('forme-pagesize-h');
|
|
1043
|
+
if (savedW) customWidthInput.value = savedW;
|
|
1044
|
+
if (savedH) customHeightInput.value = savedH;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
pageSizeSelect.addEventListener('change', () => {
|
|
1049
|
+
const val = pageSizeSelect.value;
|
|
1050
|
+
localStorage.setItem('forme-pagesize', val);
|
|
1051
|
+
customSizeInputs.classList.toggle('visible', val === 'custom');
|
|
1052
|
+
|
|
1053
|
+
if (val === 'default') {
|
|
1054
|
+
sendWs({ type: 'clearPageSize' });
|
|
1055
|
+
} else if (val === 'custom') {
|
|
1056
|
+
applyCustomSize();
|
|
1057
|
+
} else if (PAGE_SIZES[val]) {
|
|
1058
|
+
const { width, height } = PAGE_SIZES[val];
|
|
1059
|
+
sendWs({ type: 'setPageSize', width, height });
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
function applyCustomSize() {
|
|
1064
|
+
const w = parseInt(customWidthInput.value, 10);
|
|
1065
|
+
const h = parseInt(customHeightInput.value, 10);
|
|
1066
|
+
if (w > 0 && h > 0) {
|
|
1067
|
+
localStorage.setItem('forme-pagesize-w', String(w));
|
|
1068
|
+
localStorage.setItem('forme-pagesize-h', String(h));
|
|
1069
|
+
sendWs({ type: 'setPageSize', width: w, height: h });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
customWidthInput.addEventListener('change', applyCustomSize);
|
|
1074
|
+
customHeightInput.addEventListener('change', applyCustomSize);
|
|
1075
|
+
customWidthInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') applyCustomSize(); });
|
|
1076
|
+
customHeightInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') applyCustomSize(); });
|
|
1077
|
+
|
|
1078
|
+
// Re-send page size override on reconnect
|
|
1079
|
+
function resendPageSizeOverride() {
|
|
1080
|
+
const val = pageSizeSelect.value;
|
|
1081
|
+
if (val === 'default') return;
|
|
1082
|
+
if (val === 'custom') {
|
|
1083
|
+
applyCustomSize();
|
|
1084
|
+
} else if (PAGE_SIZES[val]) {
|
|
1085
|
+
const { width, height } = PAGE_SIZES[val];
|
|
1086
|
+
sendWs({ type: 'setPageSize', width, height });
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// -- Zoom --------------------------------------------------------
|
|
1091
|
+
function setZoom(level) {
|
|
1092
|
+
currentZoom = level;
|
|
1093
|
+
let closest = 0;
|
|
1094
|
+
let minDist = Infinity;
|
|
1095
|
+
ZOOM_LEVELS.forEach((z, i) => {
|
|
1096
|
+
const d = Math.abs(z - level);
|
|
1097
|
+
if (d < minDist) { minDist = d; closest = i; }
|
|
1098
|
+
});
|
|
1099
|
+
currentZoomIndex = closest;
|
|
1100
|
+
zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%';
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function zoomIn() {
|
|
1104
|
+
if (currentZoomIndex < ZOOM_LEVELS.length - 1) {
|
|
1105
|
+
currentZoomIndex++;
|
|
1106
|
+
setZoom(ZOOM_LEVELS[currentZoomIndex]);
|
|
1107
|
+
reRenderPages();
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function zoomOut() {
|
|
1112
|
+
if (currentZoomIndex > 0) {
|
|
1113
|
+
currentZoomIndex--;
|
|
1114
|
+
setZoom(ZOOM_LEVELS[currentZoomIndex]);
|
|
1115
|
+
reRenderPages();
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function zoomToFit() {
|
|
1120
|
+
if (!layoutData || !layoutData.pages.length) return;
|
|
1121
|
+
const page = layoutData.pages[0];
|
|
1122
|
+
const availW = containerEl.clientWidth - 80;
|
|
1123
|
+
const availH = containerEl.clientHeight - 100;
|
|
1124
|
+
const fitW = availW / page.width;
|
|
1125
|
+
const fitH = availH / page.height;
|
|
1126
|
+
const fit = Math.min(fitW, fitH, 2.0);
|
|
1127
|
+
setZoom(Math.round(fit * 100) / 100);
|
|
1128
|
+
reRenderPages();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
document.getElementById('zoom-in').addEventListener('click', zoomIn);
|
|
1132
|
+
document.getElementById('zoom-out').addEventListener('click', zoomOut);
|
|
1133
|
+
document.getElementById('zoom-fit').addEventListener('click', zoomToFit);
|
|
1134
|
+
|
|
1135
|
+
// -- Inspector ---------------------------------------------------
|
|
1136
|
+
function openInspector(element, pageIdx) {
|
|
1137
|
+
selectedElement = element;
|
|
1138
|
+
selectedPageIdx = pageIdx;
|
|
1139
|
+
inspectorOpen = true;
|
|
1140
|
+
inspectorEl.classList.add('open');
|
|
1141
|
+
containerEl.classList.add('inspector-open');
|
|
1142
|
+
updateInspector();
|
|
1143
|
+
drawOverlays();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function closeInspector() {
|
|
1147
|
+
selectedElement = null;
|
|
1148
|
+
selectedAncestors = [];
|
|
1149
|
+
selectedAncestorElements = [];
|
|
1150
|
+
selectedPageIdx = -1;
|
|
1151
|
+
selectedPath = null;
|
|
1152
|
+
inspectorOpen = false;
|
|
1153
|
+
inspectorEl.classList.remove('open');
|
|
1154
|
+
containerEl.classList.remove('inspector-open');
|
|
1155
|
+
updateTreeSelection(null);
|
|
1156
|
+
drawOverlays();
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
document.getElementById('inspector-close').addEventListener('click', closeInspector);
|
|
1160
|
+
document.getElementById('error-dismiss').addEventListener('click', () => {
|
|
1161
|
+
errorEl.style.display = 'none';
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
function updateInspector() {
|
|
1165
|
+
if (!selectedElement) return;
|
|
1166
|
+
const el = selectedElement;
|
|
1167
|
+
const s = el.style;
|
|
1168
|
+
|
|
1169
|
+
// Breadcrumb
|
|
1170
|
+
if (selectedAncestors.length > 0) {
|
|
1171
|
+
inspectorBreadcrumb.textContent = selectedAncestors.join(' > ');
|
|
1172
|
+
inspectorBreadcrumb.style.display = '';
|
|
1173
|
+
} else {
|
|
1174
|
+
inspectorBreadcrumb.textContent = '';
|
|
1175
|
+
inspectorBreadcrumb.style.display = 'none';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Header
|
|
1179
|
+
const nodeTypeColors = {
|
|
1180
|
+
View: '#3b82f6', Text: '#eab308', Image: '#a855f7',
|
|
1181
|
+
Table: '#22c55e', TableRow: '#22c55e', TableCell: '#22c55e',
|
|
1182
|
+
FixedHeader: '#ef4444', FixedFooter: '#ef4444',
|
|
1183
|
+
};
|
|
1184
|
+
const color = nodeTypeColors[el.nodeType] || '#a1a1aa';
|
|
1185
|
+
inspectorLabel.innerHTML = `<span style="color:${color}">${el.nodeType}</span>`;
|
|
1186
|
+
inspectorDims.textContent = `${fmt(el.width)} \u00d7 ${fmt(el.height)} at (${fmt(el.x)}, ${fmt(el.y)})`;
|
|
1187
|
+
|
|
1188
|
+
// Source location (bubble up from ancestors if element doesn't have one)
|
|
1189
|
+
const sourceEl = document.getElementById('inspector-source');
|
|
1190
|
+
let sl = el.sourceLocation;
|
|
1191
|
+
if (!sl) {
|
|
1192
|
+
for (let i = selectedAncestorElements.length - 1; i >= 0; i--) {
|
|
1193
|
+
if (selectedAncestorElements[i].sourceLocation) {
|
|
1194
|
+
sl = selectedAncestorElements[i].sourceLocation;
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (sl) {
|
|
1200
|
+
const fileName = sl.file.split('/').pop();
|
|
1201
|
+
sourceEl.innerHTML = `<span class="source-path">${fileName}:${sl.line}:${sl.column}</span> <button class="open-editor-btn" id="open-editor-btn">Open</button>`;
|
|
1202
|
+
sourceEl.style.display = '';
|
|
1203
|
+
document.getElementById('open-editor-btn').addEventListener('click', () => openInEditor(sl));
|
|
1204
|
+
} else {
|
|
1205
|
+
sourceEl.style.display = 'none';
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Box model
|
|
1209
|
+
setText('bm-margin-top', fmt(s.margin.top));
|
|
1210
|
+
setText('bm-margin-bottom', fmt(s.margin.bottom));
|
|
1211
|
+
setText('bm-margin-left', fmt(s.margin.left));
|
|
1212
|
+
setText('bm-margin-right', fmt(s.margin.right));
|
|
1213
|
+
setText('bm-border-top', fmt(s.borderWidth.top));
|
|
1214
|
+
setText('bm-border-bottom', fmt(s.borderWidth.bottom));
|
|
1215
|
+
setText('bm-border-left', fmt(s.borderWidth.left));
|
|
1216
|
+
setText('bm-border-right', fmt(s.borderWidth.right));
|
|
1217
|
+
setText('bm-padding-top', fmt(s.padding.top));
|
|
1218
|
+
setText('bm-padding-bottom', fmt(s.padding.bottom));
|
|
1219
|
+
setText('bm-padding-left', fmt(s.padding.left));
|
|
1220
|
+
setText('bm-padding-right', fmt(s.padding.right));
|
|
1221
|
+
|
|
1222
|
+
const cw = el.width - s.padding.left - s.padding.right - s.borderWidth.left - s.borderWidth.right;
|
|
1223
|
+
const ch = el.height - s.padding.top - s.padding.bottom - s.borderWidth.top - s.borderWidth.bottom;
|
|
1224
|
+
setText('bm-content', `${fmt(Math.max(0, cw))} \u00d7 ${fmt(Math.max(0, ch))}`);
|
|
1225
|
+
|
|
1226
|
+
// Computed styles
|
|
1227
|
+
let html = '';
|
|
1228
|
+
|
|
1229
|
+
const posProps = [];
|
|
1230
|
+
if (s.position && s.position !== 'Relative') {
|
|
1231
|
+
posProps.push(['position', s.position.toLowerCase()]);
|
|
1232
|
+
if (s.top != null) posProps.push(['top', fmt(s.top) + 'pt']);
|
|
1233
|
+
if (s.right != null) posProps.push(['right', fmt(s.right) + 'pt']);
|
|
1234
|
+
if (s.bottom != null) posProps.push(['bottom', fmt(s.bottom) + 'pt']);
|
|
1235
|
+
if (s.left != null) posProps.push(['left', fmt(s.left) + 'pt']);
|
|
1236
|
+
}
|
|
1237
|
+
if (posProps.length) {
|
|
1238
|
+
html += renderStyleSection('Positioning', posProps);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const layoutProps = [];
|
|
1242
|
+
if (s.flexDirection !== 'Column') layoutProps.push(['flex-direction', s.flexDirection]);
|
|
1243
|
+
if (s.justifyContent !== 'FlexStart') layoutProps.push(['justify-content', s.justifyContent]);
|
|
1244
|
+
if (s.alignItems !== 'Stretch') layoutProps.push(['align-items', s.alignItems]);
|
|
1245
|
+
if (s.flexWrap !== 'NoWrap') layoutProps.push(['flex-wrap', s.flexWrap]);
|
|
1246
|
+
if (s.gap > 0) layoutProps.push(['gap', fmt(s.gap) + 'pt']);
|
|
1247
|
+
if (layoutProps.length) {
|
|
1248
|
+
html += renderStyleSection('Layout', layoutProps);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const spacingProps = [];
|
|
1252
|
+
const marginStr = edgesShorthand(s.margin);
|
|
1253
|
+
const paddingStr = edgesShorthand(s.padding);
|
|
1254
|
+
const borderStr = edgesShorthand(s.borderWidth);
|
|
1255
|
+
if (marginStr !== '0') spacingProps.push(['margin', marginStr]);
|
|
1256
|
+
if (paddingStr !== '0') spacingProps.push(['padding', paddingStr]);
|
|
1257
|
+
if (borderStr !== '0') spacingProps.push(['border-width', borderStr]);
|
|
1258
|
+
if (spacingProps.length) {
|
|
1259
|
+
html += renderStyleSection('Spacing', spacingProps);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const typoProps = [];
|
|
1263
|
+
typoProps.push(['font-family', s.fontFamily]);
|
|
1264
|
+
typoProps.push(['font-size', fmt(s.fontSize) + 'pt']);
|
|
1265
|
+
if (s.fontWeight !== 400) typoProps.push(['font-weight', String(s.fontWeight)]);
|
|
1266
|
+
if (s.fontStyle !== 'Normal') typoProps.push(['font-style', s.fontStyle]);
|
|
1267
|
+
if (s.lineHeight !== 1.4) typoProps.push(['line-height', fmt(s.lineHeight)]);
|
|
1268
|
+
if (s.textAlign !== 'Left') typoProps.push(['text-align', s.textAlign]);
|
|
1269
|
+
html += renderStyleSection('Typography', typoProps);
|
|
1270
|
+
|
|
1271
|
+
const visualProps = [];
|
|
1272
|
+
visualProps.push(['color', colorHtml(s.color)]);
|
|
1273
|
+
if (s.backgroundColor) visualProps.push(['background', colorHtml(s.backgroundColor)]);
|
|
1274
|
+
if (s.opacity < 1) visualProps.push(['opacity', fmt(s.opacity)]);
|
|
1275
|
+
const radiusStr = cornerShorthand(s.borderRadius);
|
|
1276
|
+
if (radiusStr !== '0') visualProps.push(['border-radius', radiusStr]);
|
|
1277
|
+
html += renderStyleSection('Background & Border', visualProps);
|
|
1278
|
+
|
|
1279
|
+
// Link & Bookmark info
|
|
1280
|
+
const linkProps = [];
|
|
1281
|
+
if (el.href) linkProps.push(['href', escapeHtml(el.href)]);
|
|
1282
|
+
if (el.bookmark) linkProps.push(['bookmark', escapeHtml(el.bookmark)]);
|
|
1283
|
+
if (linkProps.length) {
|
|
1284
|
+
html += renderStyleSection('Link & Bookmark', linkProps);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
inspectorStyles.innerHTML = html;
|
|
1288
|
+
|
|
1289
|
+
// Copy Style button
|
|
1290
|
+
const styleStr = buildJsxStyleString(s);
|
|
1291
|
+
if (styleStr) {
|
|
1292
|
+
inspectorActions.innerHTML = `<button class="copy-style-btn" id="copy-style-btn">Copy Style</button>`;
|
|
1293
|
+
document.getElementById('copy-style-btn').addEventListener('click', () => {
|
|
1294
|
+
navigator.clipboard.writeText(styleStr);
|
|
1295
|
+
const btn = document.getElementById('copy-style-btn');
|
|
1296
|
+
btn.textContent = 'Copied!';
|
|
1297
|
+
setTimeout(() => { btn.textContent = 'Copy Style'; }, 1500);
|
|
1298
|
+
});
|
|
1299
|
+
} else {
|
|
1300
|
+
inspectorActions.innerHTML = '';
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function renderStyleSection(title, props) {
|
|
1305
|
+
let html = `<div class="style-section"><div class="style-section-title">${title}</div>`;
|
|
1306
|
+
for (const [prop, val] of props) {
|
|
1307
|
+
html += `<div class="style-row"><span class="prop">${prop}</span><span class="val">${val}</span></div>`;
|
|
1308
|
+
}
|
|
1309
|
+
html += '</div>';
|
|
1310
|
+
return html;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// -- Helpers -----------------------------------------------------
|
|
1314
|
+
function setText(id, text) { document.getElementById(id).textContent = text; }
|
|
1315
|
+
function fmt(n) { return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ''); }
|
|
1316
|
+
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1317
|
+
|
|
1318
|
+
function colorToHex(c) {
|
|
1319
|
+
if (!c) return 'transparent';
|
|
1320
|
+
const r = Math.round(c.r * 255).toString(16).padStart(2, '0');
|
|
1321
|
+
const g = Math.round(c.g * 255).toString(16).padStart(2, '0');
|
|
1322
|
+
const b = Math.round(c.b * 255).toString(16).padStart(2, '0');
|
|
1323
|
+
return `#${r}${g}${b}`;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function colorHtml(c) {
|
|
1327
|
+
const hex = colorToHex(c);
|
|
1328
|
+
return `<span class="color-swatch" style="background:${hex}"></span>${hex}`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function edgesShorthand(e) {
|
|
1332
|
+
const t = fmt(e.top), r = fmt(e.right), b = fmt(e.bottom), l = fmt(e.left);
|
|
1333
|
+
if (t === r && r === b && b === l) return t;
|
|
1334
|
+
if (t === b && l === r) return `${t} ${r}`;
|
|
1335
|
+
return `${t} ${r} ${b} ${l}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function cornerShorthand(c) {
|
|
1339
|
+
const tl = fmt(c.top_left), tr = fmt(c.top_right), br = fmt(c.bottom_right), bl = fmt(c.bottom_left);
|
|
1340
|
+
if (tl === tr && tr === br && br === bl) return tl;
|
|
1341
|
+
return `${tl} ${tr} ${br} ${bl}`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// -- JSX Style Builder -------------------------------------------
|
|
1345
|
+
function buildJsxStyleString(s) {
|
|
1346
|
+
const enumMap = {
|
|
1347
|
+
'Column': 'column', 'Row': 'row',
|
|
1348
|
+
'FlexStart': 'flex-start', 'FlexEnd': 'flex-end',
|
|
1349
|
+
'Center': 'center', 'SpaceBetween': 'space-between',
|
|
1350
|
+
'SpaceAround': 'space-around', 'SpaceEvenly': 'space-evenly',
|
|
1351
|
+
'Stretch': 'stretch', 'Baseline': 'baseline',
|
|
1352
|
+
'NoWrap': 'nowrap', 'Wrap': 'wrap',
|
|
1353
|
+
'Normal': 'normal', 'Italic': 'italic',
|
|
1354
|
+
'Left': 'left', 'Right': 'right', 'Justify': 'justify',
|
|
1355
|
+
};
|
|
1356
|
+
function mapEnum(v) { return enumMap[v] || v; }
|
|
1357
|
+
|
|
1358
|
+
function colorEq(c, r, g, b) {
|
|
1359
|
+
return c && Math.abs(c.r - r) < 0.001 && Math.abs(c.g - g) < 0.001 && Math.abs(c.b - b) < 0.001;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function edgesAllZero(e) { return e.top === 0 && e.right === 0 && e.bottom === 0 && e.left === 0; }
|
|
1363
|
+
function edgesUniform(e) { return e.top === e.right && e.right === e.bottom && e.bottom === e.left; }
|
|
1364
|
+
function cornersAllZero(c) { return c.top_left === 0 && c.top_right === 0 && c.bottom_right === 0 && c.bottom_left === 0; }
|
|
1365
|
+
function cornersUniform(c) { return c.top_left === c.top_right && c.top_right === c.bottom_right && c.bottom_right === c.bottom_left; }
|
|
1366
|
+
|
|
1367
|
+
function fmtEdges(e) {
|
|
1368
|
+
if (edgesUniform(e)) return String(e.top);
|
|
1369
|
+
return `{ top: ${e.top}, right: ${e.right}, bottom: ${e.bottom}, left: ${e.left} }`;
|
|
1370
|
+
}
|
|
1371
|
+
function fmtCorners(c) {
|
|
1372
|
+
if (cornersUniform(c)) return String(c.top_left);
|
|
1373
|
+
return `{ topLeft: ${c.top_left}, topRight: ${c.top_right}, bottomRight: ${c.bottom_right}, bottomLeft: ${c.bottom_left} }`;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const props = [];
|
|
1377
|
+
|
|
1378
|
+
if (s.flexDirection !== 'Column') props.push(`flexDirection: '${mapEnum(s.flexDirection)}'`);
|
|
1379
|
+
if (s.justifyContent !== 'FlexStart') props.push(`justifyContent: '${mapEnum(s.justifyContent)}'`);
|
|
1380
|
+
if (s.alignItems !== 'Stretch') props.push(`alignItems: '${mapEnum(s.alignItems)}'`);
|
|
1381
|
+
if (s.flexWrap !== 'NoWrap') props.push(`flexWrap: '${mapEnum(s.flexWrap)}'`);
|
|
1382
|
+
if (s.gap > 0) props.push(`gap: ${s.gap}`);
|
|
1383
|
+
|
|
1384
|
+
if (!edgesAllZero(s.margin)) props.push(`margin: ${fmtEdges(s.margin)}`);
|
|
1385
|
+
if (!edgesAllZero(s.padding)) props.push(`padding: ${fmtEdges(s.padding)}`);
|
|
1386
|
+
if (!edgesAllZero(s.borderWidth)) props.push(`borderWidth: ${fmtEdges(s.borderWidth)}`);
|
|
1387
|
+
|
|
1388
|
+
if (s.fontFamily !== 'Helvetica') props.push(`fontFamily: '${s.fontFamily}'`);
|
|
1389
|
+
if (s.fontSize !== 12) props.push(`fontSize: ${s.fontSize}`);
|
|
1390
|
+
if (s.fontWeight !== 400) props.push(`fontWeight: ${s.fontWeight}`);
|
|
1391
|
+
if (s.fontStyle !== 'Normal') props.push(`fontStyle: '${mapEnum(s.fontStyle)}'`);
|
|
1392
|
+
if (s.lineHeight !== 1.4) props.push(`lineHeight: ${s.lineHeight}`);
|
|
1393
|
+
if (s.textAlign !== 'Left') props.push(`textAlign: '${mapEnum(s.textAlign)}'`);
|
|
1394
|
+
|
|
1395
|
+
if (s.opacity < 1) props.push(`opacity: ${s.opacity}`);
|
|
1396
|
+
if (!colorEq(s.color, 0, 0, 0)) props.push(`color: '${colorToHex(s.color)}'`);
|
|
1397
|
+
if (s.backgroundColor) props.push(`backgroundColor: '${colorToHex(s.backgroundColor)}'`);
|
|
1398
|
+
if (!cornersAllZero(s.borderRadius)) props.push(`borderRadius: ${fmtCorners(s.borderRadius)}`);
|
|
1399
|
+
|
|
1400
|
+
if (props.length === 0) return null;
|
|
1401
|
+
return `{ ${props.join(', ')} }`;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// -- Editor Integration ------------------------------------------
|
|
1405
|
+
const editorSelect = document.getElementById('editor-select');
|
|
1406
|
+
const savedEditor = localStorage.getItem('forme-editor');
|
|
1407
|
+
if (savedEditor && editorSelect.querySelector(`option[value="${savedEditor}"]`)) {
|
|
1408
|
+
editorSelect.value = savedEditor;
|
|
1409
|
+
}
|
|
1410
|
+
editorSelect.addEventListener('change', () => {
|
|
1411
|
+
localStorage.setItem('forme-editor', editorSelect.value);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const editorUrls = {
|
|
1415
|
+
vscode: (s) => `vscode://file/${s.file}:${s.line}:${s.column}`,
|
|
1416
|
+
cursor: (s) => `cursor://file/${s.file}:${s.line}:${s.column}`,
|
|
1417
|
+
webstorm: (s) => `webstorm://open?file=${encodeURIComponent(s.file)}&line=${s.line}&column=${s.column}`,
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
function openInEditor(source) {
|
|
1421
|
+
const editor = editorSelect.value;
|
|
1422
|
+
const url = editorUrls[editor](source);
|
|
1423
|
+
window.open(url, '_blank');
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// -- Component Tree ----------------------------------------------
|
|
1427
|
+
const NODE_TYPE_COLORS = {
|
|
1428
|
+
View: '#3b82f6',
|
|
1429
|
+
Text: '#eab308',
|
|
1430
|
+
TextLine: '#eab308',
|
|
1431
|
+
Image: '#a855f7',
|
|
1432
|
+
Table: '#22c55e',
|
|
1433
|
+
TableRow: '#10b981',
|
|
1434
|
+
TableCell: '#34d399',
|
|
1435
|
+
FixedHeader: '#ef4444',
|
|
1436
|
+
FixedFooter: '#ef4444',
|
|
1437
|
+
Page: '#a1a1aa',
|
|
1438
|
+
Rect: '#6b7280',
|
|
1439
|
+
None: '#6b7280',
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
function renderComponentTree() {
|
|
1443
|
+
if (!layoutData) {
|
|
1444
|
+
componentTree.innerHTML = '<div style="padding:12px;color:var(--text-dim)">No layout data</div>';
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
let html = '';
|
|
1449
|
+
for (let pi = 0; pi < layoutData.pages.length; pi++) {
|
|
1450
|
+
const page = layoutData.pages[pi];
|
|
1451
|
+
html += renderTreeNode({
|
|
1452
|
+
nodeType: 'Page',
|
|
1453
|
+
width: page.width,
|
|
1454
|
+
height: page.height,
|
|
1455
|
+
children: page.elements,
|
|
1456
|
+
}, [pi], 0, true);
|
|
1457
|
+
}
|
|
1458
|
+
componentTree.innerHTML = html;
|
|
1459
|
+
|
|
1460
|
+
// Restore expanded state from defaults
|
|
1461
|
+
componentTree.querySelectorAll('.tree-children').forEach(el => {
|
|
1462
|
+
const depth = parseInt(el.dataset.depth, 10);
|
|
1463
|
+
if (depth < 2) {
|
|
1464
|
+
el.classList.add('expanded');
|
|
1465
|
+
const arrow = el.previousElementSibling?.querySelector('.arrow');
|
|
1466
|
+
if (arrow && arrow.classList.contains('has-children')) {
|
|
1467
|
+
arrow.textContent = '\u25BE';
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
// Restore selection
|
|
1473
|
+
if (selectedPath) {
|
|
1474
|
+
updateTreeSelection(selectedPath);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderTreeNode(el, path, depth, isExpanded) {
|
|
1479
|
+
const pathStr = JSON.stringify(path);
|
|
1480
|
+
const hasChildren = el.children && el.children.length > 0;
|
|
1481
|
+
const color = NODE_TYPE_COLORS[el.nodeType] || '#6b7280';
|
|
1482
|
+
const indent = depth * 16;
|
|
1483
|
+
|
|
1484
|
+
let extra = '';
|
|
1485
|
+
if (el.nodeType === 'TextLine' && el.textContent) {
|
|
1486
|
+
const preview = el.textContent.length > 30
|
|
1487
|
+
? el.textContent.substring(0, 30) + '...'
|
|
1488
|
+
: el.textContent;
|
|
1489
|
+
const escaped = preview.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
1490
|
+
extra = `<span class="text-preview">"${escaped}"</span>`;
|
|
1491
|
+
} else if (el.nodeType === 'Page' && el.width) {
|
|
1492
|
+
extra = `<span class="dim-label">${fmt(el.width)} x ${fmt(el.height)}</span>`;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const arrowChar = hasChildren ? (isExpanded ? '\u25BE' : '\u25B8') : '';
|
|
1496
|
+
const arrowClass = hasChildren ? 'arrow has-children' : 'arrow';
|
|
1497
|
+
|
|
1498
|
+
let html = `<div class="tree-node" data-path='${pathStr}' style="padding-left:${indent + 4}px">`;
|
|
1499
|
+
html += `<span class="${arrowClass}" data-toggle='${pathStr}'>${arrowChar}</span>`;
|
|
1500
|
+
html += `<span class="node-label" style="color:${color}">${el.nodeType}</span>`;
|
|
1501
|
+
html += extra;
|
|
1502
|
+
html += `</div>`;
|
|
1503
|
+
|
|
1504
|
+
if (hasChildren) {
|
|
1505
|
+
html += `<div class="tree-children" data-depth="${depth}" data-path='${pathStr}'>`;
|
|
1506
|
+
for (let ci = 0; ci < el.children.length; ci++) {
|
|
1507
|
+
const childPath = [...path, ci];
|
|
1508
|
+
html += renderTreeNode(el.children[ci], childPath, depth + 1, false);
|
|
1509
|
+
}
|
|
1510
|
+
html += `</div>`;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return html;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Tree click handlers (delegated)
|
|
1517
|
+
componentTree.addEventListener('click', (e) => {
|
|
1518
|
+
// Toggle expand/collapse
|
|
1519
|
+
const arrow = e.target.closest('.arrow.has-children');
|
|
1520
|
+
if (arrow) {
|
|
1521
|
+
const pathStr = arrow.dataset.toggle;
|
|
1522
|
+
const children = componentTree.querySelector(`.tree-children[data-path='${pathStr}']`);
|
|
1523
|
+
if (children) {
|
|
1524
|
+
const isExpanded = children.classList.toggle('expanded');
|
|
1525
|
+
arrow.textContent = isExpanded ? '\u25BE' : '\u25B8';
|
|
1526
|
+
}
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Select node
|
|
1531
|
+
const node = e.target.closest('.tree-node');
|
|
1532
|
+
if (node) {
|
|
1533
|
+
const path = JSON.parse(node.dataset.path);
|
|
1534
|
+
selectByPath(path);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// Tree hover handlers (delegated)
|
|
1539
|
+
componentTree.addEventListener('mouseover', (e) => {
|
|
1540
|
+
const node = e.target.closest('.tree-node');
|
|
1541
|
+
if (!node) return;
|
|
1542
|
+
const path = JSON.parse(node.dataset.path);
|
|
1543
|
+
const resolved = resolveElementByPath(path);
|
|
1544
|
+
if (resolved) {
|
|
1545
|
+
drawOverlays(resolved.element, resolved.pageIdx);
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
componentTree.addEventListener('mouseleave', () => {
|
|
1550
|
+
drawOverlays();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
function selectByPath(path) {
|
|
1554
|
+
selectedPath = path;
|
|
1555
|
+
const resolved = resolveElementByPath(path);
|
|
1556
|
+
if (!resolved) return;
|
|
1557
|
+
|
|
1558
|
+
const { element, pageIdx, ancestors, ancestorElements } = resolved;
|
|
1559
|
+
selectedAncestors = ancestors;
|
|
1560
|
+
selectedAncestorElements = ancestorElements;
|
|
1561
|
+
openInspector(element, pageIdx);
|
|
1562
|
+
updateTreeSelection(path);
|
|
1563
|
+
|
|
1564
|
+
// Scroll PDF to show element
|
|
1565
|
+
scrollToElement(element, pageIdx);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function resolveElementByPath(path) {
|
|
1569
|
+
if (!layoutData || !path || path.length === 0) return null;
|
|
1570
|
+
|
|
1571
|
+
const pageIdx = path[0];
|
|
1572
|
+
const page = layoutData.pages[pageIdx];
|
|
1573
|
+
if (!page) return null;
|
|
1574
|
+
|
|
1575
|
+
if (path.length === 1) {
|
|
1576
|
+
// Page-level selection
|
|
1577
|
+
return {
|
|
1578
|
+
element: { nodeType: 'Page', x: 0, y: 0, width: page.width, height: page.height,
|
|
1579
|
+
style: page.elements[0]?.style || {}, children: page.elements },
|
|
1580
|
+
pageIdx,
|
|
1581
|
+
ancestors: [],
|
|
1582
|
+
ancestorElements: [],
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
let current = page.elements;
|
|
1587
|
+
let element = null;
|
|
1588
|
+
const ancestors = ['Page'];
|
|
1589
|
+
const ancestorElements = [];
|
|
1590
|
+
|
|
1591
|
+
for (let i = 1; i < path.length; i++) {
|
|
1592
|
+
const idx = path[i];
|
|
1593
|
+
if (!current || idx >= current.length) return null;
|
|
1594
|
+
element = current[idx];
|
|
1595
|
+
if (i < path.length - 1) {
|
|
1596
|
+
ancestors.push(element.nodeType);
|
|
1597
|
+
ancestorElements.push(element);
|
|
1598
|
+
current = element.children;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
return element ? { element, pageIdx, ancestors, ancestorElements } : null;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function findPathForElement(pageIdx, target) {
|
|
1606
|
+
if (!layoutData) return null;
|
|
1607
|
+
const page = layoutData.pages[pageIdx];
|
|
1608
|
+
if (!page) return null;
|
|
1609
|
+
|
|
1610
|
+
function walk(elements, pathSoFar) {
|
|
1611
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1612
|
+
const el = elements[i];
|
|
1613
|
+
if (el === target) return [...pathSoFar, i];
|
|
1614
|
+
if (el.children && el.children.length) {
|
|
1615
|
+
const found = walk(el.children, [...pathSoFar, i]);
|
|
1616
|
+
if (found) return found;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
return walk(page.elements, [pageIdx]);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function updateTreeSelection(path) {
|
|
1626
|
+
// Clear previous selection
|
|
1627
|
+
componentTree.querySelectorAll('.tree-node.selected').forEach(n => n.classList.remove('selected'));
|
|
1628
|
+
|
|
1629
|
+
if (!path) return;
|
|
1630
|
+
|
|
1631
|
+
const pathStr = JSON.stringify(path);
|
|
1632
|
+
const node = componentTree.querySelector(`.tree-node[data-path='${pathStr}']`);
|
|
1633
|
+
if (node) {
|
|
1634
|
+
node.classList.add('selected');
|
|
1635
|
+
|
|
1636
|
+
// Expand parent tree nodes to make this visible
|
|
1637
|
+
let parent = node.parentElement;
|
|
1638
|
+
while (parent && parent !== componentTree) {
|
|
1639
|
+
if (parent.classList.contains('tree-children')) {
|
|
1640
|
+
parent.classList.add('expanded');
|
|
1641
|
+
const arrow = parent.previousElementSibling?.querySelector('.arrow');
|
|
1642
|
+
if (arrow && arrow.classList.contains('has-children')) {
|
|
1643
|
+
arrow.textContent = '\u25BE';
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
parent = parent.parentElement;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Scroll tree to show the node
|
|
1650
|
+
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function scrollToElement(element, pageIdx) {
|
|
1655
|
+
const wrapper = pagesEl.querySelector(`.page-wrapper[data-page-index="${pageIdx}"]`);
|
|
1656
|
+
if (!wrapper) return;
|
|
1657
|
+
|
|
1658
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
1659
|
+
const containerRect = containerEl.getBoundingClientRect();
|
|
1660
|
+
const elementTop = wrapperRect.top - containerRect.top + containerEl.scrollTop + element.y * currentZoom;
|
|
1661
|
+
|
|
1662
|
+
// Only scroll if element is not visible
|
|
1663
|
+
const viewTop = containerEl.scrollTop;
|
|
1664
|
+
const viewBottom = viewTop + containerEl.clientHeight;
|
|
1665
|
+
if (elementTop < viewTop || elementTop > viewBottom - 50) {
|
|
1666
|
+
containerEl.scrollTo({ top: elementTop - 100, behavior: 'smooth' });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// -- Data Editor -------------------------------------------------
|
|
1671
|
+
let dataDebounceTimer = null;
|
|
1672
|
+
|
|
1673
|
+
dataEditor.addEventListener('input', () => {
|
|
1674
|
+
if (dataDebounceTimer) clearTimeout(dataDebounceTimer);
|
|
1675
|
+
dataDebounceTimer = setTimeout(() => {
|
|
1676
|
+
const content = dataEditor.value;
|
|
1677
|
+
try {
|
|
1678
|
+
const parsed = JSON.parse(content);
|
|
1679
|
+
dataEditor.classList.remove('error');
|
|
1680
|
+
dataError.classList.remove('visible');
|
|
1681
|
+
dataError.textContent = '';
|
|
1682
|
+
sendWs({ type: 'updateData', data: parsed });
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
dataEditor.classList.add('error');
|
|
1685
|
+
dataError.textContent = `Invalid JSON: ${err.message}`;
|
|
1686
|
+
dataError.classList.add('visible');
|
|
1687
|
+
}
|
|
1688
|
+
}, 500);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
dataSaveBtn.addEventListener('click', () => {
|
|
1692
|
+
const content = dataEditor.value;
|
|
1693
|
+
try {
|
|
1694
|
+
JSON.parse(content); // validate
|
|
1695
|
+
sendWs({ type: 'saveData', content });
|
|
1696
|
+
dataFeedback.textContent = 'Saved!';
|
|
1697
|
+
setTimeout(() => { dataFeedback.textContent = ''; }, 2000);
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
dataEditor.classList.add('error');
|
|
1700
|
+
dataError.textContent = `Invalid JSON: ${err.message}`;
|
|
1701
|
+
dataError.classList.add('visible');
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
// -- Link Following -----------------------------------------------
|
|
1706
|
+
/// Walk the hit element and its ancestors to find the nearest href.
|
|
1707
|
+
function findHrefInChain(element, ancestorElements) {
|
|
1708
|
+
if (element && element.href) return element.href;
|
|
1709
|
+
for (let i = ancestorElements.length - 1; i >= 0; i--) {
|
|
1710
|
+
if (ancestorElements[i].href) return ancestorElements[i].href;
|
|
1711
|
+
}
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/// Search all pages for an element with a matching bookmark title.
|
|
1716
|
+
/// Returns { element, pageIdx } or null.
|
|
1717
|
+
function findBookmarkTarget(title) {
|
|
1718
|
+
if (!layoutData) return null;
|
|
1719
|
+
for (let pi = 0; pi < layoutData.pages.length; pi++) {
|
|
1720
|
+
const found = findBookmarkInElements(layoutData.pages[pi].elements, pi, title);
|
|
1721
|
+
if (found) return found;
|
|
1722
|
+
}
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function findBookmarkInElements(elements, pageIdx, title) {
|
|
1727
|
+
for (const el of elements) {
|
|
1728
|
+
if (el.bookmark === title) return { element: el, pageIdx };
|
|
1729
|
+
if (el.children && el.children.length) {
|
|
1730
|
+
const found = findBookmarkInElements(el.children, pageIdx, title);
|
|
1731
|
+
if (found) return found;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return null;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function followHref(href) {
|
|
1738
|
+
if (href.startsWith('#')) {
|
|
1739
|
+
const anchor = href.slice(1);
|
|
1740
|
+
const target = findBookmarkTarget(anchor);
|
|
1741
|
+
if (target) {
|
|
1742
|
+
scrollToElement(target.element, target.pageIdx);
|
|
1743
|
+
// Also select the target in the inspector
|
|
1744
|
+
const path = findPathForElement(target.pageIdx, target.element);
|
|
1745
|
+
if (path) selectByPath(path);
|
|
1746
|
+
}
|
|
1747
|
+
} else {
|
|
1748
|
+
window.open(href, '_blank');
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// -- Cmd/Ctrl key tracking for link hover cursor ------------------
|
|
1753
|
+
let lastHoverClientX = 0;
|
|
1754
|
+
let lastHoverClientY = 0;
|
|
1755
|
+
let cmdHeld = false;
|
|
1756
|
+
|
|
1757
|
+
function getHitLayerUnderPoint(cx, cy) {
|
|
1758
|
+
// Use elementFromPoint to find the hit-layer currently under the cursor,
|
|
1759
|
+
// avoiding stale references after page re-renders.
|
|
1760
|
+
const el = document.elementFromPoint(cx, cy);
|
|
1761
|
+
if (el && el.classList.contains('hit-layer')) return el;
|
|
1762
|
+
// Could be a child or parent; walk up
|
|
1763
|
+
if (el) {
|
|
1764
|
+
const parent = el.closest('.hit-layer');
|
|
1765
|
+
if (parent) return parent;
|
|
1766
|
+
}
|
|
1767
|
+
return null;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function reevaluateLinkCursor(metaHeld) {
|
|
1771
|
+
if (!layoutData) return;
|
|
1772
|
+
|
|
1773
|
+
const hitLayer = getHitLayerUnderPoint(lastHoverClientX, lastHoverClientY);
|
|
1774
|
+
if (!hitLayer) return;
|
|
1775
|
+
|
|
1776
|
+
const pageIdx = parseInt(hitLayer.dataset.pageIndex, 10);
|
|
1777
|
+
const page = layoutData.pages[pageIdx];
|
|
1778
|
+
if (!page) return;
|
|
1779
|
+
|
|
1780
|
+
const rect = hitLayer.getBoundingClientRect();
|
|
1781
|
+
const px = (lastHoverClientX - rect.left) / currentZoom;
|
|
1782
|
+
const py = (lastHoverClientY - rect.top) / currentZoom;
|
|
1783
|
+
|
|
1784
|
+
if (metaHeld) {
|
|
1785
|
+
const { element: hit, ancestorElements } = hitTest(page.elements, px, py);
|
|
1786
|
+
const href = hit ? findHrefInChain(hit, ancestorElements) : null;
|
|
1787
|
+
if (href) {
|
|
1788
|
+
hitLayer.style.cursor = 'pointer';
|
|
1789
|
+
tooltip.textContent = href.startsWith('#') ? `Go to: ${href.slice(1)}` : href;
|
|
1790
|
+
tooltip.style.display = 'block';
|
|
1791
|
+
tooltip.style.left = (lastHoverClientX + 12) + 'px';
|
|
1792
|
+
tooltip.style.top = (lastHoverClientY + 12) + 'px';
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
hitLayer.style.cursor = '';
|
|
1797
|
+
tooltip.style.display = 'none';
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Track mouse position globally so we always have the latest coords
|
|
1801
|
+
document.addEventListener('mousemove', (e) => {
|
|
1802
|
+
lastHoverClientX = e.clientX;
|
|
1803
|
+
lastHoverClientY = e.clientY;
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
document.addEventListener('keydown', (e) => {
|
|
1807
|
+
if (e.key === 'Meta' || e.key === 'Control') {
|
|
1808
|
+
cmdHeld = true;
|
|
1809
|
+
reevaluateLinkCursor(true);
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
document.addEventListener('keyup', (e) => {
|
|
1814
|
+
if (e.key === 'Meta' || e.key === 'Control') {
|
|
1815
|
+
cmdHeld = false;
|
|
1816
|
+
reevaluateLinkCursor(false);
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
// -- Hit Testing -------------------------------------------------
|
|
1821
|
+
function hitTest(elements, px, py) {
|
|
1822
|
+
let best = null;
|
|
1823
|
+
let bestAncestorNames = [];
|
|
1824
|
+
let bestAncestorElements = [];
|
|
1825
|
+
const nameStack = [];
|
|
1826
|
+
const elemStack = [];
|
|
1827
|
+
function walk(els) {
|
|
1828
|
+
for (const el of els) {
|
|
1829
|
+
const inside = px >= el.x && px <= el.x + el.width && py >= el.y && py <= el.y + el.height;
|
|
1830
|
+
if (inside) {
|
|
1831
|
+
best = el;
|
|
1832
|
+
bestAncestorNames = nameStack.slice();
|
|
1833
|
+
bestAncestorElements = elemStack.slice();
|
|
1834
|
+
}
|
|
1835
|
+
if (el.children && el.children.length) {
|
|
1836
|
+
nameStack.push(el.nodeType);
|
|
1837
|
+
elemStack.push(el);
|
|
1838
|
+
walk(el.children);
|
|
1839
|
+
nameStack.pop();
|
|
1840
|
+
elemStack.pop();
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
walk(elements);
|
|
1845
|
+
return { element: best, ancestors: bestAncestorNames, ancestorElements: bestAncestorElements };
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// -- Render PDF pages --------------------------------------------
|
|
1849
|
+
let currentPdfDoc = null;
|
|
1850
|
+
|
|
1851
|
+
async function renderPdfPages() {
|
|
1852
|
+
const resp = await fetch('/pdf');
|
|
1853
|
+
if (!resp.ok) return;
|
|
1854
|
+
const buffer = await resp.arrayBuffer();
|
|
1855
|
+
|
|
1856
|
+
currentPdfDoc = await pdfjsLib.getDocument({ data: buffer }).promise;
|
|
1857
|
+
await reRenderPages();
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
async function reRenderPages() {
|
|
1861
|
+
if (!currentPdfDoc) return;
|
|
1862
|
+
const pdf = currentPdfDoc;
|
|
1863
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1864
|
+
|
|
1865
|
+
const scrollTop = containerEl.scrollTop;
|
|
1866
|
+
|
|
1867
|
+
pagesEl.innerHTML = '';
|
|
1868
|
+
|
|
1869
|
+
pageBadge.style.display = 'inline-flex';
|
|
1870
|
+
pageCountEl.textContent = String(pdf.numPages);
|
|
1871
|
+
|
|
1872
|
+
for (let i = 1; i <= pdf.numPages; i++) {
|
|
1873
|
+
const page = await pdf.getPage(i);
|
|
1874
|
+
const viewport = page.getViewport({ scale: currentZoom });
|
|
1875
|
+
|
|
1876
|
+
const wrapper = document.createElement('div');
|
|
1877
|
+
wrapper.className = 'page-wrapper';
|
|
1878
|
+
wrapper.dataset.pageIndex = String(i - 1);
|
|
1879
|
+
|
|
1880
|
+
const pdfCanvas = document.createElement('canvas');
|
|
1881
|
+
pdfCanvas.className = 'pdf-canvas';
|
|
1882
|
+
pdfCanvas.width = viewport.width * dpr;
|
|
1883
|
+
pdfCanvas.height = viewport.height * dpr;
|
|
1884
|
+
pdfCanvas.style.width = viewport.width + 'px';
|
|
1885
|
+
pdfCanvas.style.height = viewport.height + 'px';
|
|
1886
|
+
|
|
1887
|
+
const overlayCanvas = document.createElement('canvas');
|
|
1888
|
+
overlayCanvas.className = 'overlay-canvas';
|
|
1889
|
+
overlayCanvas.width = viewport.width * dpr;
|
|
1890
|
+
overlayCanvas.height = viewport.height * dpr;
|
|
1891
|
+
overlayCanvas.style.width = viewport.width + 'px';
|
|
1892
|
+
overlayCanvas.style.height = viewport.height + 'px';
|
|
1893
|
+
|
|
1894
|
+
const hitLayer = document.createElement('div');
|
|
1895
|
+
hitLayer.className = 'hit-layer';
|
|
1896
|
+
hitLayer.dataset.pageIndex = String(i - 1);
|
|
1897
|
+
|
|
1898
|
+
wrapper.appendChild(pdfCanvas);
|
|
1899
|
+
wrapper.appendChild(overlayCanvas);
|
|
1900
|
+
wrapper.appendChild(hitLayer);
|
|
1901
|
+
pagesEl.appendChild(wrapper);
|
|
1902
|
+
|
|
1903
|
+
const ctx = pdfCanvas.getContext('2d');
|
|
1904
|
+
const scaledViewport = page.getViewport({ scale: currentZoom * dpr });
|
|
1905
|
+
await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise;
|
|
1906
|
+
|
|
1907
|
+
hitLayer.addEventListener('click', onCanvasClick);
|
|
1908
|
+
hitLayer.addEventListener('mousemove', onCanvasHover);
|
|
1909
|
+
hitLayer.addEventListener('mouseleave', (e) => {
|
|
1910
|
+
e.currentTarget.style.cursor = '';
|
|
1911
|
+
tooltip.style.display = 'none';
|
|
1912
|
+
drawOverlays();
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
containerEl.scrollTop = scrollTop;
|
|
1917
|
+
drawOverlays();
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function getPageCoords(e) {
|
|
1921
|
+
const hitLayer = e.currentTarget;
|
|
1922
|
+
const pageIdx = parseInt(hitLayer.dataset.pageIndex, 10);
|
|
1923
|
+
const rect = hitLayer.getBoundingClientRect();
|
|
1924
|
+
const px = (e.clientX - rect.left) / currentZoom;
|
|
1925
|
+
const py = (e.clientY - rect.top) / currentZoom;
|
|
1926
|
+
return { pageIdx, px, py };
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function onCanvasClick(e) {
|
|
1930
|
+
if (!layoutData) return;
|
|
1931
|
+
const { pageIdx, px, py } = getPageCoords(e);
|
|
1932
|
+
const page = layoutData.pages[pageIdx];
|
|
1933
|
+
if (!page) return;
|
|
1934
|
+
|
|
1935
|
+
const { element: hit, ancestors, ancestorElements } = hitTest(page.elements, px, py);
|
|
1936
|
+
if (hit) {
|
|
1937
|
+
// Cmd/Ctrl+click follows links
|
|
1938
|
+
if ((e.metaKey || e.ctrlKey) && hit) {
|
|
1939
|
+
const href = findHrefInChain(hit, ancestorElements);
|
|
1940
|
+
if (href) {
|
|
1941
|
+
followHref(href);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
selectedAncestors = ancestors;
|
|
1947
|
+
selectedAncestorElements = ancestorElements;
|
|
1948
|
+
openInspector(hit, pageIdx);
|
|
1949
|
+
|
|
1950
|
+
// Sync tree selection
|
|
1951
|
+
const path = findPathForElement(pageIdx, hit);
|
|
1952
|
+
if (path) {
|
|
1953
|
+
selectedPath = path;
|
|
1954
|
+
updateTreeSelection(path);
|
|
1955
|
+
}
|
|
1956
|
+
} else {
|
|
1957
|
+
closeInspector();
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function onCanvasHover(e) {
|
|
1962
|
+
const hitLayer = e.currentTarget;
|
|
1963
|
+
|
|
1964
|
+
// Cmd/Ctrl held: show pointer cursor over linked elements
|
|
1965
|
+
if ((e.metaKey || e.ctrlKey) && layoutData) {
|
|
1966
|
+
const { pageIdx, px, py } = getPageCoords(e);
|
|
1967
|
+
const page = layoutData.pages[pageIdx];
|
|
1968
|
+
if (page) {
|
|
1969
|
+
const { element: hit, ancestorElements } = hitTest(page.elements, px, py);
|
|
1970
|
+
const href = hit ? findHrefInChain(hit, ancestorElements) : null;
|
|
1971
|
+
if (href) {
|
|
1972
|
+
hitLayer.style.cursor = 'pointer';
|
|
1973
|
+
tooltip.textContent = href.startsWith('#') ? `Go to: ${href.slice(1)}` : href;
|
|
1974
|
+
tooltip.style.display = 'block';
|
|
1975
|
+
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
1976
|
+
tooltip.style.top = (e.clientY + 12) + 'px';
|
|
1977
|
+
drawOverlays(hit, pageIdx);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
hitLayer.style.cursor = '';
|
|
1984
|
+
|
|
1985
|
+
if (currentMode !== 'layout' || !layoutData) {
|
|
1986
|
+
tooltip.style.display = 'none';
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const { pageIdx, px, py } = getPageCoords(e);
|
|
1990
|
+
const page = layoutData.pages[pageIdx];
|
|
1991
|
+
if (!page) return;
|
|
1992
|
+
|
|
1993
|
+
const { element: hit } = hitTest(page.elements, px, py);
|
|
1994
|
+
if (hit) {
|
|
1995
|
+
tooltip.textContent = `${hit.nodeType} ${fmt(hit.width)}\u00d7${fmt(hit.height)}`;
|
|
1996
|
+
tooltip.style.display = 'block';
|
|
1997
|
+
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
1998
|
+
tooltip.style.top = (e.clientY + 12) + 'px';
|
|
1999
|
+
|
|
2000
|
+
drawOverlays(hit, pageIdx);
|
|
2001
|
+
} else {
|
|
2002
|
+
tooltip.style.display = 'none';
|
|
2003
|
+
drawOverlays();
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// -- Fetch Layout ------------------------------------------------
|
|
2008
|
+
async function fetchLayout() {
|
|
2009
|
+
try {
|
|
2010
|
+
const resp = await fetch('/layout');
|
|
2011
|
+
if (resp.ok) {
|
|
2012
|
+
layoutData = await resp.json();
|
|
2013
|
+
renderComponentTree();
|
|
2014
|
+
}
|
|
2015
|
+
} catch { /* ignore */ }
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// -- Draw Debug Overlays -----------------------------------------
|
|
2019
|
+
function drawOverlays(hoverElement, hoverPageIdx) {
|
|
2020
|
+
const wrappers = pagesEl.querySelectorAll('.page-wrapper');
|
|
2021
|
+
wrappers.forEach((wrapper) => {
|
|
2022
|
+
const overlay = wrapper.querySelector('.overlay-canvas');
|
|
2023
|
+
const pageIdx = parseInt(wrapper.dataset.pageIndex, 10);
|
|
2024
|
+
const dpr = window.devicePixelRatio || 1;
|
|
2025
|
+
const ctx = overlay.getContext('2d');
|
|
2026
|
+
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
2027
|
+
ctx.save();
|
|
2028
|
+
ctx.scale(dpr, dpr);
|
|
2029
|
+
|
|
2030
|
+
if (!layoutData || !layoutData.pages[pageIdx]) { ctx.restore(); return; }
|
|
2031
|
+
const page = layoutData.pages[pageIdx];
|
|
2032
|
+
|
|
2033
|
+
if (currentMode === 'layout') {
|
|
2034
|
+
drawLayoutOverlay(ctx, page.elements);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
if (currentMode === 'margins') {
|
|
2038
|
+
drawMarginsOverlay(ctx, page);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (currentMode === 'breaks') {
|
|
2042
|
+
drawBreaksOverlay(ctx, page, pageIdx);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Hover highlight
|
|
2046
|
+
if (hoverElement && hoverPageIdx === pageIdx) {
|
|
2047
|
+
const color = NODE_TYPE_COLORS[hoverElement.nodeType] || '#a1a1aa';
|
|
2048
|
+
ctx.fillStyle = hexToRgba(color, 0.1);
|
|
2049
|
+
ctx.fillRect(
|
|
2050
|
+
hoverElement.x * currentZoom,
|
|
2051
|
+
hoverElement.y * currentZoom,
|
|
2052
|
+
hoverElement.width * currentZoom,
|
|
2053
|
+
hoverElement.height * currentZoom
|
|
2054
|
+
);
|
|
2055
|
+
ctx.strokeStyle = hexToRgba(color, 0.6);
|
|
2056
|
+
ctx.lineWidth = 1;
|
|
2057
|
+
ctx.strokeRect(
|
|
2058
|
+
hoverElement.x * currentZoom,
|
|
2059
|
+
hoverElement.y * currentZoom,
|
|
2060
|
+
hoverElement.width * currentZoom,
|
|
2061
|
+
hoverElement.height * currentZoom
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Selected element highlight
|
|
2066
|
+
if (selectedElement && selectedPageIdx === pageIdx) {
|
|
2067
|
+
ctx.strokeStyle = '#3b82f6';
|
|
2068
|
+
ctx.lineWidth = 2;
|
|
2069
|
+
ctx.setLineDash([]);
|
|
2070
|
+
ctx.strokeRect(
|
|
2071
|
+
selectedElement.x * currentZoom,
|
|
2072
|
+
selectedElement.y * currentZoom,
|
|
2073
|
+
selectedElement.width * currentZoom,
|
|
2074
|
+
selectedElement.height * currentZoom
|
|
2075
|
+
);
|
|
2076
|
+
|
|
2077
|
+
if (inspectorOpen && selectedElement.style) {
|
|
2078
|
+
const s = selectedElement.style;
|
|
2079
|
+
const m = s.margin;
|
|
2080
|
+
const ex = selectedElement.x * currentZoom;
|
|
2081
|
+
const ey = selectedElement.y * currentZoom;
|
|
2082
|
+
const ew = selectedElement.width * currentZoom;
|
|
2083
|
+
const eh = selectedElement.height * currentZoom;
|
|
2084
|
+
|
|
2085
|
+
ctx.fillStyle = 'rgba(251, 146, 60, 0.1)';
|
|
2086
|
+
if (m.top > 0) ctx.fillRect(ex, ey - m.top * currentZoom, ew, m.top * currentZoom);
|
|
2087
|
+
if (m.bottom > 0) ctx.fillRect(ex, ey + eh, ew, m.bottom * currentZoom);
|
|
2088
|
+
if (m.left > 0) ctx.fillRect(ex - m.left * currentZoom, ey, m.left * currentZoom, eh);
|
|
2089
|
+
if (m.right > 0) ctx.fillRect(ex + ew, ey, m.right * currentZoom, eh);
|
|
2090
|
+
|
|
2091
|
+
const p = s.padding;
|
|
2092
|
+
const bw = s.borderWidth;
|
|
2093
|
+
ctx.fillStyle = 'rgba(74, 222, 128, 0.1)';
|
|
2094
|
+
const innerX = ex + bw.left * currentZoom;
|
|
2095
|
+
const innerY = ey + bw.top * currentZoom;
|
|
2096
|
+
const innerW = ew - (bw.left + bw.right) * currentZoom;
|
|
2097
|
+
const innerH = eh - (bw.top + bw.bottom) * currentZoom;
|
|
2098
|
+
if (p.top > 0) ctx.fillRect(innerX, innerY, innerW, p.top * currentZoom);
|
|
2099
|
+
if (p.bottom > 0) ctx.fillRect(innerX, innerY + innerH - p.bottom * currentZoom, innerW, p.bottom * currentZoom);
|
|
2100
|
+
if (p.left > 0) ctx.fillRect(innerX, innerY + p.top * currentZoom, p.left * currentZoom, innerH - (p.top + p.bottom) * currentZoom);
|
|
2101
|
+
if (p.right > 0) ctx.fillRect(innerX + innerW - p.right * currentZoom, innerY + p.top * currentZoom, p.right * currentZoom, innerH - (p.top + p.bottom) * currentZoom);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
ctx.restore();
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function drawLayoutOverlay(ctx, elements) {
|
|
2110
|
+
for (const el of elements) {
|
|
2111
|
+
const color = NODE_TYPE_COLORS[el.nodeType] || '#6b7280';
|
|
2112
|
+
ctx.strokeStyle = hexToRgba(color, 0.5);
|
|
2113
|
+
ctx.lineWidth = 1;
|
|
2114
|
+
ctx.strokeRect(
|
|
2115
|
+
el.x * currentZoom,
|
|
2116
|
+
el.y * currentZoom,
|
|
2117
|
+
el.width * currentZoom,
|
|
2118
|
+
el.height * currentZoom
|
|
2119
|
+
);
|
|
2120
|
+
if (el.children && el.children.length) {
|
|
2121
|
+
drawLayoutOverlay(ctx, el.children);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function drawMarginsOverlay(ctx, page) {
|
|
2127
|
+
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
|
|
2128
|
+
ctx.lineWidth = 1;
|
|
2129
|
+
ctx.setLineDash([6, 4]);
|
|
2130
|
+
ctx.strokeRect(
|
|
2131
|
+
page.contentX * currentZoom,
|
|
2132
|
+
page.contentY * currentZoom,
|
|
2133
|
+
page.contentWidth * currentZoom,
|
|
2134
|
+
page.contentHeight * currentZoom
|
|
2135
|
+
);
|
|
2136
|
+
ctx.setLineDash([]);
|
|
2137
|
+
|
|
2138
|
+
ctx.fillStyle = 'rgba(59, 130, 246, 0.05)';
|
|
2139
|
+
const pw = page.width * currentZoom;
|
|
2140
|
+
const ph = page.height * currentZoom;
|
|
2141
|
+
const cx = page.contentX * currentZoom;
|
|
2142
|
+
const cy = page.contentY * currentZoom;
|
|
2143
|
+
const cw = page.contentWidth * currentZoom;
|
|
2144
|
+
const ch = page.contentHeight * currentZoom;
|
|
2145
|
+
ctx.fillRect(0, 0, pw, cy);
|
|
2146
|
+
ctx.fillRect(0, cy + ch, pw, ph - cy - ch);
|
|
2147
|
+
ctx.fillRect(0, cy, cx, ch);
|
|
2148
|
+
ctx.fillRect(cx + cw, cy, pw - cx - cw, ch);
|
|
2149
|
+
|
|
2150
|
+
drawElementSpacing(ctx, page.elements);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function drawElementSpacing(ctx, elements) {
|
|
2154
|
+
for (const el of elements) {
|
|
2155
|
+
if (el.style) {
|
|
2156
|
+
const m = el.style.margin;
|
|
2157
|
+
const p = el.style.padding;
|
|
2158
|
+
|
|
2159
|
+
if (m.top > 0 || m.bottom > 0 || m.left > 0 || m.right > 0) {
|
|
2160
|
+
ctx.fillStyle = 'rgba(251, 146, 60, 0.08)';
|
|
2161
|
+
const ex = el.x * currentZoom;
|
|
2162
|
+
const ey = el.y * currentZoom;
|
|
2163
|
+
const ew = el.width * currentZoom;
|
|
2164
|
+
const eh = el.height * currentZoom;
|
|
2165
|
+
if (m.top > 0) ctx.fillRect(ex, ey - m.top * currentZoom, ew, m.top * currentZoom);
|
|
2166
|
+
if (m.bottom > 0) ctx.fillRect(ex, ey + eh, ew, m.bottom * currentZoom);
|
|
2167
|
+
if (m.left > 0) ctx.fillRect(ex - m.left * currentZoom, ey, m.left * currentZoom, eh);
|
|
2168
|
+
if (m.right > 0) ctx.fillRect(ex + ew, ey, m.right * currentZoom, eh);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (p.top > 0 || p.bottom > 0 || p.left > 0 || p.right > 0) {
|
|
2172
|
+
ctx.fillStyle = 'rgba(74, 222, 128, 0.08)';
|
|
2173
|
+
const bw = el.style.borderWidth;
|
|
2174
|
+
const ix = (el.x + bw.left) * currentZoom;
|
|
2175
|
+
const iy = (el.y + bw.top) * currentZoom;
|
|
2176
|
+
const iw = (el.width - bw.left - bw.right) * currentZoom;
|
|
2177
|
+
const ih = (el.height - bw.top - bw.bottom) * currentZoom;
|
|
2178
|
+
if (p.top > 0) ctx.fillRect(ix, iy, iw, p.top * currentZoom);
|
|
2179
|
+
if (p.bottom > 0) ctx.fillRect(ix, iy + ih - p.bottom * currentZoom, iw, p.bottom * currentZoom);
|
|
2180
|
+
if (p.left > 0) ctx.fillRect(ix, iy + p.top * currentZoom, p.left * currentZoom, ih - (p.top + p.bottom) * currentZoom);
|
|
2181
|
+
if (p.right > 0) ctx.fillRect(ix + iw - p.right * currentZoom, iy + p.top * currentZoom, p.right * currentZoom, ih - (p.top + p.bottom) * currentZoom);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (el.children && el.children.length) {
|
|
2185
|
+
drawElementSpacing(ctx, el.children);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function drawBreaksOverlay(ctx, page, pageIdx) {
|
|
2191
|
+
if (pageIdx > 0) {
|
|
2192
|
+
const y = page.contentY * currentZoom;
|
|
2193
|
+
ctx.strokeStyle = '#ef4444';
|
|
2194
|
+
ctx.lineWidth = 1.5;
|
|
2195
|
+
ctx.setLineDash([8, 4]);
|
|
2196
|
+
ctx.beginPath();
|
|
2197
|
+
ctx.moveTo(0, y);
|
|
2198
|
+
ctx.lineTo(page.width * currentZoom, y);
|
|
2199
|
+
ctx.stroke();
|
|
2200
|
+
ctx.setLineDash([]);
|
|
2201
|
+
|
|
2202
|
+
ctx.fillStyle = '#ef4444';
|
|
2203
|
+
ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
|
|
2204
|
+
ctx.fillText(`break at y=${fmt(page.contentY)}`, 8 * currentZoom, y - 4);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
|
2208
|
+
ctx.font = `bold ${12 * currentZoom}px ${getComputedStyle(document.body).fontFamily}`;
|
|
2209
|
+
ctx.fillText(
|
|
2210
|
+
`Page ${pageIdx + 1}`,
|
|
2211
|
+
page.contentX * currentZoom + 4,
|
|
2212
|
+
(page.contentY + 14) * currentZoom
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function hexToRgba(hex, alpha) {
|
|
2217
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
2218
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
2219
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
2220
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// -- WebSocket helpers -------------------------------------------
|
|
2224
|
+
function sendWs(msg) {
|
|
2225
|
+
if (wsRef && wsRef.readyState === WebSocket.OPEN) {
|
|
2226
|
+
wsRef.send(JSON.stringify(msg));
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// -- Full reload cycle -------------------------------------------
|
|
2231
|
+
async function reload() {
|
|
2232
|
+
errorEl.style.display = 'none';
|
|
2233
|
+
await renderPdfPages();
|
|
2234
|
+
await fetchLayout();
|
|
2235
|
+
drawOverlays();
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// -- Keyboard shortcuts ------------------------------------------
|
|
2239
|
+
document.addEventListener('keydown', (e) => {
|
|
2240
|
+
// Don't intercept when typing in data editor
|
|
2241
|
+
if (e.target === dataEditor) return;
|
|
2242
|
+
|
|
2243
|
+
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
2244
|
+
if (e.key === '1') { e.preventDefault(); setMode('preview'); }
|
|
2245
|
+
if (e.key === '2') { e.preventDefault(); setMode('layout'); }
|
|
2246
|
+
if (e.key === '3') { e.preventDefault(); setMode('margins'); }
|
|
2247
|
+
if (e.key === '4') { e.preventDefault(); setMode('breaks'); }
|
|
2248
|
+
if (e.key === 'Escape') { e.preventDefault(); closeInspector(); }
|
|
2249
|
+
if (e.key === 't' || e.key === 'T') { e.preventDefault(); setSidebarOpen(!sidebarOpen); }
|
|
2250
|
+
if (e.key === 'Enter' && selectedElement) {
|
|
2251
|
+
let enterSl = selectedElement.sourceLocation;
|
|
2252
|
+
if (!enterSl) {
|
|
2253
|
+
for (let i = selectedAncestorElements.length - 1; i >= 0; i--) {
|
|
2254
|
+
if (selectedAncestorElements[i].sourceLocation) { enterSl = selectedAncestorElements[i].sourceLocation; break; }
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
if (enterSl) { e.preventDefault(); openInEditor(enterSl); }
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
if (e.metaKey || e.ctrlKey) {
|
|
2262
|
+
if (e.key === '=' || e.key === '+') { e.preventDefault(); zoomIn(); }
|
|
2263
|
+
if (e.key === '-') { e.preventDefault(); zoomOut(); }
|
|
2264
|
+
if (e.key === '0') { e.preventDefault(); zoomToFit(); }
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
containerEl.addEventListener('wheel', (e) => {
|
|
2269
|
+
if (e.metaKey || e.ctrlKey) {
|
|
2270
|
+
e.preventDefault();
|
|
2271
|
+
if (e.deltaY < 0) zoomIn();
|
|
2272
|
+
else zoomOut();
|
|
2273
|
+
}
|
|
2274
|
+
}, { passive: false });
|
|
2275
|
+
|
|
2276
|
+
// -- WebSocket ---------------------------------------------------
|
|
2277
|
+
function connectWs() {
|
|
2278
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
2279
|
+
const ws = new WebSocket(`${protocol}//${location.host}`);
|
|
2280
|
+
wsRef = ws;
|
|
2281
|
+
|
|
2282
|
+
ws.addEventListener('open', () => {
|
|
2283
|
+
statusDot.className = 'status-dot connected';
|
|
2284
|
+
// Re-send page size override on reconnect
|
|
2285
|
+
resendPageSizeOverride();
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
ws.addEventListener('close', () => {
|
|
2289
|
+
statusDot.className = 'status-dot disconnected';
|
|
2290
|
+
wsRef = null;
|
|
2291
|
+
setTimeout(connectWs, 1000);
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
ws.addEventListener('message', async (event) => {
|
|
2295
|
+
const msg = JSON.parse(event.data);
|
|
2296
|
+
|
|
2297
|
+
if (msg.type === 'init') {
|
|
2298
|
+
hasDataFile = msg.hasData;
|
|
2299
|
+
dataTabBtn.style.display = hasDataFile ? '' : 'none';
|
|
2300
|
+
if (hasDataFile && msg.dataContent) {
|
|
2301
|
+
dataEditor.value = msg.dataContent;
|
|
2302
|
+
}
|
|
2303
|
+
// Apply server-side page size override if reconnecting
|
|
2304
|
+
if (msg.pageSizeOverride) {
|
|
2305
|
+
// Server already has override; UI should already match via localStorage
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
if (msg.type === 'reload') {
|
|
2310
|
+
if (msg.renderTime) {
|
|
2311
|
+
renderTimeEl.textContent = msg.renderTime + 'ms';
|
|
2312
|
+
renderBadge.style.display = 'inline-flex';
|
|
2313
|
+
}
|
|
2314
|
+
await reload();
|
|
2315
|
+
// Auto-zoom to fit after page size changes
|
|
2316
|
+
setTimeout(zoomToFit, 300);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
if (msg.type === 'error') {
|
|
2320
|
+
errorEl.querySelector('.error-dismiss').nextSibling?.remove();
|
|
2321
|
+
errorEl.appendChild(document.createTextNode(msg.message));
|
|
2322
|
+
errorEl.style.display = 'block';
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
if (msg.type === 'dataUpdate') {
|
|
2326
|
+
// File changed on disk; update editor if not focused
|
|
2327
|
+
if (document.activeElement !== dataEditor) {
|
|
2328
|
+
dataEditor.value = msg.content;
|
|
2329
|
+
dataEditor.classList.remove('error');
|
|
2330
|
+
dataError.classList.remove('visible');
|
|
2331
|
+
dataError.textContent = '';
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// -- Init --------------------------------------------------------
|
|
2338
|
+
connectWs();
|
|
2339
|
+
reload().then(() => {
|
|
2340
|
+
setTimeout(zoomToFit, 100);
|
|
2341
|
+
}).catch(() => {});
|
|
2342
|
+
</script>
|
|
2343
|
+
</body>
|
|
2344
|
+
</html>
|