@hozu/devtools 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/overlay/api.d.ts +30 -0
- package/dist/overlay/api.d.ts.map +1 -0
- package/dist/overlay/api.js +83 -0
- package/dist/overlay/api.js.map +1 -0
- package/dist/overlay/app.d.ts +2 -0
- package/dist/overlay/app.d.ts.map +1 -0
- package/dist/overlay/app.js +1430 -0
- package/dist/overlay/app.js.map +1 -0
- package/dist/overlay/dom.d.ts +7 -0
- package/dist/overlay/dom.d.ts.map +1 -0
- package/dist/overlay/dom.js +33 -0
- package/dist/overlay/dom.js.map +1 -0
- package/dist/overlay/index.d.ts +2 -0
- package/dist/overlay/index.d.ts.map +1 -0
- package/dist/overlay/index.js +4 -0
- package/dist/overlay/index.js.map +1 -0
- package/dist/overlay/layers.d.ts +12 -0
- package/dist/overlay/layers.d.ts.map +1 -0
- package/dist/overlay/layers.js +131 -0
- package/dist/overlay/layers.js.map +1 -0
- package/dist/overlay/logo.d.ts +2 -0
- package/dist/overlay/logo.d.ts.map +1 -0
- package/dist/overlay/logo.js +2 -0
- package/dist/overlay/logo.js.map +1 -0
- package/dist/overlay/look.d.ts +6 -0
- package/dist/overlay/look.d.ts.map +1 -0
- package/dist/overlay/look.js +119 -0
- package/dist/overlay/look.js.map +1 -0
- package/dist/overlay/style.d.ts +3 -0
- package/dist/overlay/style.d.ts.map +1 -0
- package/dist/overlay/style.js +334 -0
- package/dist/overlay/style.js.map +1 -0
- package/dist/overlay/text.d.ts +9 -0
- package/dist/overlay/text.d.ts.map +1 -0
- package/dist/overlay/text.js +51 -0
- package/dist/overlay/text.js.map +1 -0
- package/dist/plain.d.ts +10 -0
- package/dist/plain.d.ts.map +1 -0
- package/dist/plain.js +103 -0
- package/dist/plain.js.map +1 -0
- package/dist/prompt.d.ts +51 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +178 -0
- package/dist/prompt.js.map +1 -0
- package/dist/requests.d.ts +20 -0
- package/dist/requests.d.ts.map +1 -0
- package/dist/requests.js +88 -0
- package/dist/requests.js.map +1 -0
- package/dist/theme.d.ts +20 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +179 -0
- package/dist/theme.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
import { describeFor, friendlyName, questionFor } from '../plain.js';
|
|
2
|
+
import { joinRequests, labelOf, openRequestsLine, requestMarkdown, scopesFor, } from '../prompt.js';
|
|
3
|
+
import { held, hold, node, one, page, remove, save, saved, theme, tree } from './api.js';
|
|
4
|
+
import { h, read, write } from './dom.js';
|
|
5
|
+
import { previewLabel, renderLayers } from './layers.js';
|
|
6
|
+
import { logo } from './logo.js';
|
|
7
|
+
import { lookSection, preview } from './look.js';
|
|
8
|
+
import { css, outlineCss } from './style.js';
|
|
9
|
+
import { firstText, previewText, restoreText, textSection } from './text.js';
|
|
10
|
+
const key = `hozu-devtools:${location.host}`;
|
|
11
|
+
const given = document.querySelector('script[src$="/_hozu/devtools/overlay/index.js"]')
|
|
12
|
+
?.dataset.mode;
|
|
13
|
+
const state = {
|
|
14
|
+
mode: 'browse',
|
|
15
|
+
picks: [],
|
|
16
|
+
active: 0,
|
|
17
|
+
panel: null,
|
|
18
|
+
opened: null,
|
|
19
|
+
tab: 'draft',
|
|
20
|
+
dock: null,
|
|
21
|
+
audience: given === 'developer' ? 'developer' : 'builder',
|
|
22
|
+
excerpt: false,
|
|
23
|
+
folded: false,
|
|
24
|
+
spot: null,
|
|
25
|
+
view: 'overlay',
|
|
26
|
+
theme: 'system',
|
|
27
|
+
device: { name: 'Phone', width: 390, height: 844 },
|
|
28
|
+
...read(key, {}),
|
|
29
|
+
};
|
|
30
|
+
const persist = () => write(key, state);
|
|
31
|
+
const host = document.createElement('hozu-devtools');
|
|
32
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
33
|
+
const root = h('div', { class: 'root' });
|
|
34
|
+
const hover = h('div', { class: 'box', hidden: true }, [h('div', { class: 'tag' })]);
|
|
35
|
+
const boxes = h('div');
|
|
36
|
+
const dock = h('div', { class: 'dock', role: 'toolbar', 'aria-label': 'Hozu DevTools' });
|
|
37
|
+
const panel = h('div', {
|
|
38
|
+
class: 'panel',
|
|
39
|
+
role: 'dialog',
|
|
40
|
+
'aria-label': 'Hozu DevTools inspector',
|
|
41
|
+
hidden: true,
|
|
42
|
+
});
|
|
43
|
+
const bench = h('div', { class: 'bench', hidden: true });
|
|
44
|
+
shadow.append(h('style', {}, [css]), root);
|
|
45
|
+
root.append(bench, panel, dock);
|
|
46
|
+
document.documentElement.append(host);
|
|
47
|
+
const darkScheme = matchMedia('(prefers-color-scheme: dark)');
|
|
48
|
+
function applyTheme() {
|
|
49
|
+
root.dataset.theme = state.theme === 'system' ? (darkScheme.matches ? 'dark' : 'light') : state.theme;
|
|
50
|
+
}
|
|
51
|
+
darkScheme.addEventListener('change', applyTheme);
|
|
52
|
+
applyTheme();
|
|
53
|
+
let doc = document;
|
|
54
|
+
let win = window;
|
|
55
|
+
let frameEl = null;
|
|
56
|
+
const frames = h('div');
|
|
57
|
+
let outline = null;
|
|
58
|
+
function mountOutline(target) {
|
|
59
|
+
outline?.remove();
|
|
60
|
+
const el = target.createElement('hozu-devtools-outline');
|
|
61
|
+
el.style.cssText = 'all:initial;position:fixed;inset:0;pointer-events:none;z-index:2147483646';
|
|
62
|
+
el.attachShadow({ mode: 'open' }).append(h('style', {}, [outlineCss]), frames, boxes, hover);
|
|
63
|
+
target.documentElement.append(el);
|
|
64
|
+
outline = el;
|
|
65
|
+
}
|
|
66
|
+
mountOutline(document);
|
|
67
|
+
const frameOf = new WeakMap();
|
|
68
|
+
const framed = (box, selected) => {
|
|
69
|
+
const frame = h('div', { class: selected ? 'frame selected' : 'frame', hidden: true });
|
|
70
|
+
frames.append(frame);
|
|
71
|
+
frameOf.set(box, frame);
|
|
72
|
+
return box;
|
|
73
|
+
};
|
|
74
|
+
framed(hover, false);
|
|
75
|
+
const ours = (event) => event.composedPath().includes(host);
|
|
76
|
+
const parentId = (id) => id.slice(0, id.lastIndexOf('/'));
|
|
77
|
+
const isText = (n) => n?.kind === 'text';
|
|
78
|
+
function elementOf(pick) {
|
|
79
|
+
const all = doc.querySelectorAll(`[data-hz="${CSS.escape(pick.id)}"]`);
|
|
80
|
+
if (all.length)
|
|
81
|
+
return all[Math.min(pick.index, all.length - 1)] ?? null;
|
|
82
|
+
return pick.id.includes('/') ? elementOf({ id: parentId(pick.id), index: pick.index }) : null;
|
|
83
|
+
}
|
|
84
|
+
function indexOf(el) {
|
|
85
|
+
const id = el.getAttribute('data-hz') ?? '';
|
|
86
|
+
return [...doc.querySelectorAll(`[data-hz="${CSS.escape(id)}"]`)].indexOf(el);
|
|
87
|
+
}
|
|
88
|
+
const visibleOf = (el) => (el?.innerText ?? '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
89
|
+
function place(box, el) {
|
|
90
|
+
const r = el?.getBoundingClientRect();
|
|
91
|
+
const frame = frameOf.get(box);
|
|
92
|
+
const hide = !r || (r.width === 0 && r.height === 0);
|
|
93
|
+
box.hidden = hide;
|
|
94
|
+
if (frame)
|
|
95
|
+
frame.hidden = hide;
|
|
96
|
+
if (!r || hide)
|
|
97
|
+
return;
|
|
98
|
+
for (const target of frame ? [box, frame] : [box]) {
|
|
99
|
+
target.style.left = `${r.left - 2}px`;
|
|
100
|
+
target.style.top = `${r.top - 2}px`;
|
|
101
|
+
target.style.width = `${r.width + 4}px`;
|
|
102
|
+
target.style.height = `${r.height + 4}px`;
|
|
103
|
+
}
|
|
104
|
+
const tag = box.firstElementChild;
|
|
105
|
+
if (!tag?.classList.contains('tag'))
|
|
106
|
+
return;
|
|
107
|
+
tag.style.left = '';
|
|
108
|
+
const below = r.top < 28 || r.top - tag.offsetHeight - 8 < 0;
|
|
109
|
+
box.classList.toggle('below', below);
|
|
110
|
+
const view = win.innerWidth;
|
|
111
|
+
const overflow = r.left - 3 + tag.offsetWidth - (view - 4);
|
|
112
|
+
tag.style.left =
|
|
113
|
+
overflow > 0 ? `${Math.max(-r.left + 4, -1 - overflow)}px` : r.left < 2 ? `${2 - r.left}px` : '';
|
|
114
|
+
}
|
|
115
|
+
let hovered = null;
|
|
116
|
+
function tagText(n, el) {
|
|
117
|
+
if (!n)
|
|
118
|
+
return [el.tagName.toLowerCase()];
|
|
119
|
+
if (state.audience === 'builder')
|
|
120
|
+
return [
|
|
121
|
+
friendlyName(n),
|
|
122
|
+
n.component && n.component.uses > 1 ? h('b', {}, [` shared · ${n.component.uses} places`]) : '',
|
|
123
|
+
];
|
|
124
|
+
return [
|
|
125
|
+
h('b', {}, [n.kind === 'element' ? '' : `${n.kind} `]),
|
|
126
|
+
labelOf(n),
|
|
127
|
+
n.location ? ` ${n.location.file}:${n.location.line}` : '',
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
let forced = false;
|
|
131
|
+
async function showHover(el, force = false) {
|
|
132
|
+
hovered = el;
|
|
133
|
+
forced = force;
|
|
134
|
+
if (!el || (state.mode !== 'select' && !force)) {
|
|
135
|
+
hover.hidden = true;
|
|
136
|
+
const frame = frameOf.get(hover);
|
|
137
|
+
if (frame)
|
|
138
|
+
frame.hidden = true;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
place(hover, el);
|
|
142
|
+
const tag = hover.firstElementChild;
|
|
143
|
+
tag.replaceChildren(el.tagName.toLowerCase());
|
|
144
|
+
const n = await node(el.getAttribute('data-hz') ?? '');
|
|
145
|
+
if (hovered === el)
|
|
146
|
+
tag.replaceChildren(...tagText(n, el));
|
|
147
|
+
}
|
|
148
|
+
function drawPicks() {
|
|
149
|
+
for (const p of state.picks) {
|
|
150
|
+
preview(elementOf(p), p.style ?? []);
|
|
151
|
+
previewText(elementOf(p), p.text);
|
|
152
|
+
}
|
|
153
|
+
for (const box of boxes.children)
|
|
154
|
+
frameOf.get(box)?.remove();
|
|
155
|
+
boxes.replaceChildren(...state.picks.map((p, i) => {
|
|
156
|
+
const box = framed(h('div', { class: 'box selected' }, [
|
|
157
|
+
state.picks.length > 1 ? h('div', { class: 'badge' }, [String(i + 1)]) : null,
|
|
158
|
+
]), true);
|
|
159
|
+
place(box, state.mode === 'select' ? elementOf(p) : null);
|
|
160
|
+
return box;
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
function frame() {
|
|
164
|
+
if (state.picks.length) {
|
|
165
|
+
let i = 0;
|
|
166
|
+
for (const box of boxes.children) {
|
|
167
|
+
const p = state.picks[i++];
|
|
168
|
+
if (p && state.mode === 'select')
|
|
169
|
+
place(box, elementOf(p));
|
|
170
|
+
else
|
|
171
|
+
place(box, null);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (hovered && (state.mode === 'select' || forced))
|
|
175
|
+
place(hover, hovered);
|
|
176
|
+
requestAnimationFrame(frame);
|
|
177
|
+
}
|
|
178
|
+
const hasContent = (p) => p.note.trim() !== '' || (p.style?.length ?? 0) > 0 || !!p.text;
|
|
179
|
+
const drafted = () => state.picks.filter(hasContent);
|
|
180
|
+
function select(pick, add) {
|
|
181
|
+
const same = (p) => p.id === pick.id && p.index === pick.index;
|
|
182
|
+
if (!add)
|
|
183
|
+
state.picks = state.picks.filter((p) => hasContent(p) || same(p));
|
|
184
|
+
const existing = state.picks.findIndex(same);
|
|
185
|
+
if (existing >= 0)
|
|
186
|
+
state.active = existing;
|
|
187
|
+
else {
|
|
188
|
+
state.picks.push({ ...pick, note: '', scope: null });
|
|
189
|
+
state.active = state.picks.length - 1;
|
|
190
|
+
}
|
|
191
|
+
state.panel = 'inspector';
|
|
192
|
+
persist();
|
|
193
|
+
drawPicks();
|
|
194
|
+
void renderPanel();
|
|
195
|
+
}
|
|
196
|
+
function markedFrom(event) {
|
|
197
|
+
for (const target of event.composedPath())
|
|
198
|
+
if (target !== host && target.nodeType === 1 && target.hasAttribute('data-hz'))
|
|
199
|
+
return target;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
async function selectText(event) {
|
|
203
|
+
const el = markedFrom(event);
|
|
204
|
+
if (!el)
|
|
205
|
+
return;
|
|
206
|
+
const n = await node(el.getAttribute('data-hz') ?? '');
|
|
207
|
+
const texts = n?.children.filter((c) => c.kind === 'text') ?? [];
|
|
208
|
+
if (!n || !texts.length)
|
|
209
|
+
return;
|
|
210
|
+
const caret = doc.caretPositionFromPoint?.(event.clientX, event.clientY)?.offsetNode;
|
|
211
|
+
const domTexts = [...el.childNodes].filter((c) => c.nodeType === 3 && c.textContent?.trim());
|
|
212
|
+
const at = caret ? domTexts.indexOf(caret) : -1;
|
|
213
|
+
const hit = caret?.textContent?.trim() ?? '';
|
|
214
|
+
const match = texts.find((t) => t.source?.kind === 'literal' && t.source.detail.trim() === hit) ??
|
|
215
|
+
(at >= 0 && domTexts.length === texts.length ? texts[at] : texts[0]);
|
|
216
|
+
if (match)
|
|
217
|
+
select({ id: match.id, index: indexOf(el), visible: hit || visibleOf(el) }, event.shiftKey);
|
|
218
|
+
}
|
|
219
|
+
const blocked = [
|
|
220
|
+
'pointerdown',
|
|
221
|
+
'mousedown',
|
|
222
|
+
'pointerup',
|
|
223
|
+
'mouseup',
|
|
224
|
+
'click',
|
|
225
|
+
'dblclick',
|
|
226
|
+
'auxclick',
|
|
227
|
+
'submit',
|
|
228
|
+
];
|
|
229
|
+
function listen(target) {
|
|
230
|
+
const stop = new AbortController();
|
|
231
|
+
const signal = stop.signal;
|
|
232
|
+
for (const type of blocked)
|
|
233
|
+
target.addEventListener(type, (event) => {
|
|
234
|
+
if (state.mode !== 'select' || ours(event))
|
|
235
|
+
return;
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
event.stopImmediatePropagation();
|
|
238
|
+
if (type === 'dblclick')
|
|
239
|
+
void selectText(event);
|
|
240
|
+
if (type !== 'click')
|
|
241
|
+
return;
|
|
242
|
+
const mouse = event;
|
|
243
|
+
const clicked = markedFrom(event);
|
|
244
|
+
const el = mouse.altKey ? (clicked?.parentElement?.closest('[data-hz]') ?? clicked) : clicked;
|
|
245
|
+
if (!el)
|
|
246
|
+
return;
|
|
247
|
+
select({ id: el.getAttribute('data-hz') ?? '', index: indexOf(el), visible: visibleOf(el) }, mouse.shiftKey);
|
|
248
|
+
}, { capture: true, signal });
|
|
249
|
+
target.addEventListener('pointermove', (event) => {
|
|
250
|
+
if (state.mode !== 'select')
|
|
251
|
+
return;
|
|
252
|
+
const el = ours(event) ? null : markedFrom(event);
|
|
253
|
+
if (el !== hovered)
|
|
254
|
+
void showHover(el);
|
|
255
|
+
}, { capture: true, passive: true, signal });
|
|
256
|
+
target.addEventListener('keydown', onKey, { capture: true, signal });
|
|
257
|
+
return stop;
|
|
258
|
+
}
|
|
259
|
+
function walk(direction) {
|
|
260
|
+
const active = state.picks[state.active];
|
|
261
|
+
if (!active)
|
|
262
|
+
return;
|
|
263
|
+
const el = elementOf(active);
|
|
264
|
+
const own = el?.getAttribute('data-hz') === active.id;
|
|
265
|
+
const next = direction === 'up'
|
|
266
|
+
? own
|
|
267
|
+
? el?.parentElement?.closest('[data-hz]')
|
|
268
|
+
: el
|
|
269
|
+
: own
|
|
270
|
+
? el?.querySelector('[data-hz]')
|
|
271
|
+
: null;
|
|
272
|
+
if (!next)
|
|
273
|
+
return;
|
|
274
|
+
state.picks[state.active] = {
|
|
275
|
+
...active,
|
|
276
|
+
id: next.getAttribute('data-hz') ?? '',
|
|
277
|
+
index: indexOf(next),
|
|
278
|
+
visible: visibleOf(next),
|
|
279
|
+
scope: null,
|
|
280
|
+
};
|
|
281
|
+
persist();
|
|
282
|
+
drawPicks();
|
|
283
|
+
void renderPanel();
|
|
284
|
+
}
|
|
285
|
+
function setMode(mode) {
|
|
286
|
+
state.mode = mode;
|
|
287
|
+
if (mode === 'select')
|
|
288
|
+
state.folded = false;
|
|
289
|
+
if (mode === 'browse')
|
|
290
|
+
void showHover(null);
|
|
291
|
+
persist();
|
|
292
|
+
renderDock();
|
|
293
|
+
}
|
|
294
|
+
function onKey(event) {
|
|
295
|
+
const typing = event
|
|
296
|
+
.composedPath()
|
|
297
|
+
.some((t) => ['TEXTAREA', 'INPUT', 'SELECT'].includes(t.tagName));
|
|
298
|
+
if (event.altKey && event.shiftKey && event.code === 'KeyS') {
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
return setMode(state.mode === 'select' ? 'browse' : 'select');
|
|
301
|
+
}
|
|
302
|
+
if (typing)
|
|
303
|
+
return;
|
|
304
|
+
if (event.key === 'Escape' && state.mode === 'select')
|
|
305
|
+
return setMode('browse');
|
|
306
|
+
if (event.key === 'Escape' && state.panel) {
|
|
307
|
+
state.panel = null;
|
|
308
|
+
persist();
|
|
309
|
+
return void renderPanel();
|
|
310
|
+
}
|
|
311
|
+
if (state.mode === 'select' &&
|
|
312
|
+
(event.key === 'ArrowUp' || event.key === 'ArrowDown') &&
|
|
313
|
+
state.picks.length) {
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
walk(event.key === 'ArrowUp' ? 'up' : 'down');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let listening = listen(window);
|
|
319
|
+
const reload = () => win.location.reload();
|
|
320
|
+
function layersHost(close) {
|
|
321
|
+
return {
|
|
322
|
+
plain: state.audience === 'builder',
|
|
323
|
+
shown: (id) => doc.querySelector(`[data-hz="${CSS.escape(id)}"]`),
|
|
324
|
+
hover: (el) => void showHover(el, true),
|
|
325
|
+
pick: (id, el) => {
|
|
326
|
+
void showHover(null);
|
|
327
|
+
select({ id, index: indexOf(el), visible: visibleOf(el) }, false);
|
|
328
|
+
},
|
|
329
|
+
hold: (p) => hold(p, reload),
|
|
330
|
+
close,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function renderDock() {
|
|
334
|
+
dock.hidden = state.view === 'workbench';
|
|
335
|
+
if (state.view === 'workbench')
|
|
336
|
+
return renderBar();
|
|
337
|
+
const button = (label, mode) => h('button', {
|
|
338
|
+
class: 'mode',
|
|
339
|
+
type: 'button',
|
|
340
|
+
'aria-pressed': String(state.mode === mode),
|
|
341
|
+
onclick: () => setMode(mode),
|
|
342
|
+
}, [label]);
|
|
343
|
+
const grip = h('div', {
|
|
344
|
+
class: 'grip',
|
|
345
|
+
role: 'button',
|
|
346
|
+
tabindex: '0',
|
|
347
|
+
'aria-expanded': String(!state.folded),
|
|
348
|
+
'aria-label': state.folded ? 'Open Hozu DevTools' : 'Fold Hozu DevTools',
|
|
349
|
+
title: 'Hozu DevTools · click to fold · drag to move · Alt+Shift+S toggles Select',
|
|
350
|
+
onkeydown: (e) => {
|
|
351
|
+
const key = e.key;
|
|
352
|
+
if (key === 'Enter' || key === ' ') {
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
fold();
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
}, [h('img', { src: logo, alt: '', width: '20', height: '20', draggable: 'false' })]);
|
|
358
|
+
dock.classList.toggle('folded', state.folded);
|
|
359
|
+
if (state.folded) {
|
|
360
|
+
dock.replaceChildren(grip);
|
|
361
|
+
placeDock();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
dock.replaceChildren(...present([
|
|
365
|
+
grip,
|
|
366
|
+
h('div', { class: 'seg' }, [button('Browse', 'browse'), button('Select', 'select')]),
|
|
367
|
+
h('button', {
|
|
368
|
+
class: 'act',
|
|
369
|
+
type: 'button',
|
|
370
|
+
onclick: () => {
|
|
371
|
+
if (state.panel === 'changes')
|
|
372
|
+
open(state.picks.length ? 'inspector' : null);
|
|
373
|
+
else
|
|
374
|
+
open('changes', null, drafted().length ? 'draft' : 'saved');
|
|
375
|
+
},
|
|
376
|
+
}, ['Changes', h('span', { class: 'count', 'data-count': true })]),
|
|
377
|
+
h('button', {
|
|
378
|
+
class: 'act',
|
|
379
|
+
type: 'button',
|
|
380
|
+
title: 'Select this page: its title, description and other head fields',
|
|
381
|
+
onclick: async () => {
|
|
382
|
+
const n = await page(win.location.pathname);
|
|
383
|
+
if (n)
|
|
384
|
+
select({ id: n.id, index: 0, visible: doc.title }, state.mode === 'select' && state.picks.length > 0);
|
|
385
|
+
},
|
|
386
|
+
}, ['Page']),
|
|
387
|
+
h('button', {
|
|
388
|
+
class: 'act',
|
|
389
|
+
type: 'button',
|
|
390
|
+
title: 'The parts of this page, and its states that are not on screen',
|
|
391
|
+
onclick: () => {
|
|
392
|
+
state.panel = state.panel === 'layers' ? (state.picks.length ? 'inspector' : null) : 'layers';
|
|
393
|
+
persist();
|
|
394
|
+
void renderPanel();
|
|
395
|
+
},
|
|
396
|
+
}, ['Layers']),
|
|
397
|
+
h('button', {
|
|
398
|
+
class: 'act',
|
|
399
|
+
type: 'button',
|
|
400
|
+
title: 'The page at an exact size, with its parts on the left and the inspector on the right',
|
|
401
|
+
onclick: () => {
|
|
402
|
+
state.panel = state.picks.length ? 'inspector' : null;
|
|
403
|
+
openBench();
|
|
404
|
+
},
|
|
405
|
+
}, ['Workbench']),
|
|
406
|
+
h('button', {
|
|
407
|
+
class: 'act gear',
|
|
408
|
+
type: 'button',
|
|
409
|
+
title: 'Settings',
|
|
410
|
+
'aria-label': 'Settings',
|
|
411
|
+
onclick: () => {
|
|
412
|
+
state.panel = state.panel === 'settings' ? (state.picks.length ? 'inspector' : null) : 'settings';
|
|
413
|
+
persist();
|
|
414
|
+
void renderPanel();
|
|
415
|
+
},
|
|
416
|
+
}, ['⚙']),
|
|
417
|
+
held()
|
|
418
|
+
? h('button', {
|
|
419
|
+
class: 'previewing',
|
|
420
|
+
type: 'button',
|
|
421
|
+
title: 'Exit the preview',
|
|
422
|
+
onclick: () => hold(null, reload),
|
|
423
|
+
}, [h('span', { 'data-preview': true }, ['Preview']), ' · Exit'])
|
|
424
|
+
: null,
|
|
425
|
+
state.mode === 'select' && !held()
|
|
426
|
+
? h('div', { class: 'hint' }, ['Click selects · Shift adds · Alt goes up · Esc stops'])
|
|
427
|
+
: null,
|
|
428
|
+
]));
|
|
429
|
+
const holding = held();
|
|
430
|
+
if (holding)
|
|
431
|
+
void tree(win.location.pathname).then((t) => {
|
|
432
|
+
const label = dock.querySelector('[data-preview]');
|
|
433
|
+
if (label)
|
|
434
|
+
label.textContent = `Preview: ${previewLabel(t, holding)}`;
|
|
435
|
+
});
|
|
436
|
+
const count = dock.querySelector('[data-count]');
|
|
437
|
+
if (count)
|
|
438
|
+
count.textContent = drafted().length ? String(drafted().length) : '';
|
|
439
|
+
placeDock();
|
|
440
|
+
}
|
|
441
|
+
function placeDock() {
|
|
442
|
+
const r = dock.getBoundingClientRect();
|
|
443
|
+
const x = state.dock
|
|
444
|
+
? Math.min(Math.max(0, state.dock.x), innerWidth - r.width)
|
|
445
|
+
: (innerWidth - r.width) / 2;
|
|
446
|
+
const y = state.dock
|
|
447
|
+
? Math.min(Math.max(0, state.dock.y), innerHeight - r.height)
|
|
448
|
+
: innerHeight - r.height - 24;
|
|
449
|
+
dock.style.left = `${x}px`;
|
|
450
|
+
dock.style.top = `${y}px`;
|
|
451
|
+
}
|
|
452
|
+
function fold() {
|
|
453
|
+
state.folded = !state.folded;
|
|
454
|
+
if (state.folded) {
|
|
455
|
+
state.mode = 'browse';
|
|
456
|
+
state.panel = null;
|
|
457
|
+
void showHover(null);
|
|
458
|
+
void renderPanel();
|
|
459
|
+
}
|
|
460
|
+
persist();
|
|
461
|
+
renderDock();
|
|
462
|
+
}
|
|
463
|
+
dock.addEventListener('pointerdown', (event) => {
|
|
464
|
+
if (!event.target.closest('.grip'))
|
|
465
|
+
return;
|
|
466
|
+
const r = dock.getBoundingClientRect();
|
|
467
|
+
const dx = event.clientX - r.left;
|
|
468
|
+
const dy = event.clientY - r.top;
|
|
469
|
+
let moved = false;
|
|
470
|
+
const move = (e) => {
|
|
471
|
+
if (!moved && Math.hypot(e.clientX - event.clientX, e.clientY - event.clientY) < 4)
|
|
472
|
+
return;
|
|
473
|
+
moved = true;
|
|
474
|
+
state.dock = { x: e.clientX - dx, y: e.clientY - dy };
|
|
475
|
+
placeDock();
|
|
476
|
+
};
|
|
477
|
+
const up = () => {
|
|
478
|
+
if (moved)
|
|
479
|
+
persist();
|
|
480
|
+
else
|
|
481
|
+
fold();
|
|
482
|
+
removeEventListener('pointermove', move);
|
|
483
|
+
removeEventListener('pointerup', up);
|
|
484
|
+
};
|
|
485
|
+
addEventListener('pointermove', move);
|
|
486
|
+
addEventListener('pointerup', up);
|
|
487
|
+
});
|
|
488
|
+
addEventListener('resize', placeDock);
|
|
489
|
+
function placePanel() {
|
|
490
|
+
if (state.view === 'workbench' || !state.spot) {
|
|
491
|
+
panel.style.left = panel.style.top = panel.style.right = '';
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const r = panel.getBoundingClientRect();
|
|
495
|
+
const x = Math.min(Math.max(0, state.spot.x), innerWidth - Math.min(r.width, innerWidth));
|
|
496
|
+
const y = Math.min(Math.max(0, state.spot.y), innerHeight - 48);
|
|
497
|
+
panel.style.right = 'auto';
|
|
498
|
+
panel.style.left = `${x}px`;
|
|
499
|
+
panel.style.top = `${y}px`;
|
|
500
|
+
}
|
|
501
|
+
panel.addEventListener('pointerdown', (event) => {
|
|
502
|
+
const target = event.target;
|
|
503
|
+
if (state.view === 'workbench' ||
|
|
504
|
+
!target.closest('.head') ||
|
|
505
|
+
target.closest('button, input, select, textarea, a'))
|
|
506
|
+
return;
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
const head = target.closest('.head');
|
|
509
|
+
head.setPointerCapture(event.pointerId);
|
|
510
|
+
const r = panel.getBoundingClientRect();
|
|
511
|
+
const dx = event.clientX - r.left;
|
|
512
|
+
const dy = event.clientY - r.top;
|
|
513
|
+
const move = (e) => {
|
|
514
|
+
state.spot = { x: e.clientX - dx, y: e.clientY - dy };
|
|
515
|
+
placePanel();
|
|
516
|
+
};
|
|
517
|
+
const up = () => {
|
|
518
|
+
persist();
|
|
519
|
+
head.removeEventListener('pointermove', move);
|
|
520
|
+
head.removeEventListener('pointerup', up);
|
|
521
|
+
};
|
|
522
|
+
head.addEventListener('pointermove', move);
|
|
523
|
+
head.addEventListener('pointerup', up);
|
|
524
|
+
});
|
|
525
|
+
panel.addEventListener('dblclick', (event) => {
|
|
526
|
+
const target = event.target;
|
|
527
|
+
if (!target.closest('.head') || target.closest('button, input, select, textarea, a'))
|
|
528
|
+
return;
|
|
529
|
+
state.spot = null;
|
|
530
|
+
persist();
|
|
531
|
+
placePanel();
|
|
532
|
+
});
|
|
533
|
+
addEventListener('resize', placePanel);
|
|
534
|
+
const where = (what, l) => l
|
|
535
|
+
? h('div', { class: 'loc' }, [
|
|
536
|
+
h('span', { class: 'what' }, [what]),
|
|
537
|
+
h('span', {}, [`${l.file}:${l.line}:${l.column}`]),
|
|
538
|
+
h('button', {
|
|
539
|
+
type: 'button',
|
|
540
|
+
title: 'Copy the location',
|
|
541
|
+
onclick: () => void copy(`${l.file}:${l.line}:${l.column}`),
|
|
542
|
+
}, ['Copy']),
|
|
543
|
+
])
|
|
544
|
+
: null;
|
|
545
|
+
function excerpt(n) {
|
|
546
|
+
if (!n.excerpt)
|
|
547
|
+
return null;
|
|
548
|
+
return h('pre', {}, n.excerpt.lines.map((line, i) => {
|
|
549
|
+
const number = n.excerpt.start + i;
|
|
550
|
+
return h('span', { class: number === n.location?.line ? 'on' : '' }, [
|
|
551
|
+
h('i', {}, [String(number).padStart(3)]),
|
|
552
|
+
line,
|
|
553
|
+
]);
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
const present = (nodes) => nodes.filter((n) => n !== null);
|
|
557
|
+
const section = (label, children) => children.some(Boolean)
|
|
558
|
+
? h('div', { class: 'sec' }, [h('div', { class: 'label' }, [label]), ...children])
|
|
559
|
+
: null;
|
|
560
|
+
const kindTitle = {
|
|
561
|
+
element: 'Element',
|
|
562
|
+
component: 'Component use',
|
|
563
|
+
text: 'Text',
|
|
564
|
+
query: 'Query',
|
|
565
|
+
when: 'State branch',
|
|
566
|
+
if: 'Branch',
|
|
567
|
+
list: 'List',
|
|
568
|
+
html: 'Raw HTML',
|
|
569
|
+
embed: 'Embed',
|
|
570
|
+
other: 'Node',
|
|
571
|
+
};
|
|
572
|
+
function kicker(n) {
|
|
573
|
+
if (state.audience === 'builder')
|
|
574
|
+
return n.page ? `Page ${n.page.path}` : `On ${win.location.pathname}`;
|
|
575
|
+
const kind = n.component
|
|
576
|
+
? 'Component use'
|
|
577
|
+
: n.kind === 'text' && n.source?.kind === 'data'
|
|
578
|
+
? 'Data text'
|
|
579
|
+
: (kindTitle[n.kind] ?? n.kind);
|
|
580
|
+
if (n.page)
|
|
581
|
+
return `Page · route ${n.page.path}`;
|
|
582
|
+
return `${kind} · view ${n.owner?.feature}.${n.owner?.view}`;
|
|
583
|
+
}
|
|
584
|
+
function titleFor(n, active) {
|
|
585
|
+
const text = (active.visible || n.text || '').slice(0, 40);
|
|
586
|
+
if (state.audience === 'builder')
|
|
587
|
+
return [friendlyName(n), text && !n.page ? h('span', { class: 'quote' }, [`“${text}”`]) : null];
|
|
588
|
+
return [
|
|
589
|
+
n.page
|
|
590
|
+
? n.page.head.title || n.page.route
|
|
591
|
+
: n.tag
|
|
592
|
+
? `<${n.tag}>`
|
|
593
|
+
: n.kind === 'text'
|
|
594
|
+
? `“${text}”`
|
|
595
|
+
: (kindTitle[n.kind] ?? n.kind),
|
|
596
|
+
n.component ? h('code', {}, [n.component.ref]) : null,
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
function textRows(n) {
|
|
600
|
+
const sources = n.source
|
|
601
|
+
? [{ id: n.id, source: n.source }]
|
|
602
|
+
: n.children.flatMap((c) => (c.source ? [{ id: c.id, source: c.source }] : []));
|
|
603
|
+
return sources.map(({ source }) => h('div', { class: 'row' }, [
|
|
604
|
+
h('b', {}, [source.kind === 'literal' ? 'text ' : `${source.kind} `]),
|
|
605
|
+
source.kind === 'literal' ? `“${source.detail}”` : source.detail,
|
|
606
|
+
source.location ? ` ${source.location.file}:${source.location.line}` : '',
|
|
607
|
+
(source.uses ?? 1) > 1 ? h('span', { class: 'warn' }, [` · shared by ${source.uses} places`]) : null,
|
|
608
|
+
]));
|
|
609
|
+
}
|
|
610
|
+
async function copy(text) {
|
|
611
|
+
try {
|
|
612
|
+
await navigator.clipboard.writeText(text);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
const area = h('textarea');
|
|
616
|
+
area.value = text;
|
|
617
|
+
shadow.append(area);
|
|
618
|
+
area.select();
|
|
619
|
+
document.execCommand('copy');
|
|
620
|
+
area.remove();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function request() {
|
|
624
|
+
const active = state.picks[state.active];
|
|
625
|
+
const chosen = drafted().length ? drafted() : active ? [active] : [];
|
|
626
|
+
const nodes = await Promise.all(chosen.map((p) => node(p.id)));
|
|
627
|
+
return {
|
|
628
|
+
items: chosen.flatMap((p, i) => {
|
|
629
|
+
const n = nodes[i];
|
|
630
|
+
return n
|
|
631
|
+
? [
|
|
632
|
+
{
|
|
633
|
+
node: n,
|
|
634
|
+
note: p.note,
|
|
635
|
+
scope: p.scope ?? scopesFor(n)[0].scope,
|
|
636
|
+
visible: p.visible,
|
|
637
|
+
style: p.style ?? [],
|
|
638
|
+
...(p.text ? { text: p.text } : {}),
|
|
639
|
+
},
|
|
640
|
+
]
|
|
641
|
+
: [];
|
|
642
|
+
}),
|
|
643
|
+
context: {
|
|
644
|
+
path: win.location.pathname + win.location.search,
|
|
645
|
+
viewport: { width: win.innerWidth, height: win.innerHeight },
|
|
646
|
+
preview: held() ? previewLabel(await tree(win.location.pathname), held()) : null,
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function status(el, kind, parts) {
|
|
651
|
+
el.className = `status ${kind}`;
|
|
652
|
+
el.replaceChildren(...parts);
|
|
653
|
+
}
|
|
654
|
+
const open = (panelName, opened = null, tab = state.tab) => {
|
|
655
|
+
state.panel = panelName;
|
|
656
|
+
state.opened = opened;
|
|
657
|
+
state.tab = tab;
|
|
658
|
+
persist();
|
|
659
|
+
void renderPanel();
|
|
660
|
+
};
|
|
661
|
+
const closeButton = (onclick) => h('button', { class: 'close', type: 'button', 'aria-label': 'Close', onclick }, ['×']);
|
|
662
|
+
function choose(active, id, visible) {
|
|
663
|
+
state.picks[state.active] = { ...active, id, scope: null, visible };
|
|
664
|
+
persist();
|
|
665
|
+
drawPicks();
|
|
666
|
+
void renderPanel();
|
|
667
|
+
}
|
|
668
|
+
function removeActive() {
|
|
669
|
+
const gone = state.picks[state.active];
|
|
670
|
+
if (gone)
|
|
671
|
+
preview(elementOf(gone), []);
|
|
672
|
+
if (gone)
|
|
673
|
+
restoreText(elementOf(gone), gone.text);
|
|
674
|
+
state.picks.splice(state.active, 1);
|
|
675
|
+
state.active = Math.max(0, state.active - 1);
|
|
676
|
+
if (!state.picks.length)
|
|
677
|
+
state.panel = null;
|
|
678
|
+
persist();
|
|
679
|
+
drawPicks();
|
|
680
|
+
void renderPanel();
|
|
681
|
+
}
|
|
682
|
+
function scopeField(n, active) {
|
|
683
|
+
const scopes = scopesFor(n);
|
|
684
|
+
if (scopes.length < 2)
|
|
685
|
+
return null;
|
|
686
|
+
const scope = active.scope ?? scopes[0].scope;
|
|
687
|
+
const asked = state.audience === 'builder' ? questionFor(n) : null;
|
|
688
|
+
return h('fieldset', {}, [
|
|
689
|
+
asked ? h('legend', {}, [asked.question]) : null,
|
|
690
|
+
...scopes.map((s) => h('label', {}, [
|
|
691
|
+
h('input', {
|
|
692
|
+
type: 'radio',
|
|
693
|
+
name: 'scope',
|
|
694
|
+
value: s.scope,
|
|
695
|
+
checked: s.scope === scope,
|
|
696
|
+
onchange: () => {
|
|
697
|
+
active.scope = s.scope;
|
|
698
|
+
persist();
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
asked?.options[s.scope] ?? s.label,
|
|
702
|
+
])),
|
|
703
|
+
]);
|
|
704
|
+
}
|
|
705
|
+
let notice = null;
|
|
706
|
+
function clearDraft() {
|
|
707
|
+
for (const p of state.picks) {
|
|
708
|
+
preview(elementOf(p), []);
|
|
709
|
+
restoreText(elementOf(p), p.text);
|
|
710
|
+
}
|
|
711
|
+
state.picks = [];
|
|
712
|
+
state.active = 0;
|
|
713
|
+
persist();
|
|
714
|
+
drawPicks();
|
|
715
|
+
}
|
|
716
|
+
function exportBlock() {
|
|
717
|
+
const count = (drafted().length || (state.picks[state.active] ? 1 : 0));
|
|
718
|
+
const result = h('div', { class: 'status', role: 'status' });
|
|
719
|
+
const draft = h('textarea', {
|
|
720
|
+
class: 'draft',
|
|
721
|
+
'aria-label': 'The request, as the agent will read it',
|
|
722
|
+
hidden: true,
|
|
723
|
+
});
|
|
724
|
+
const generate = async () => requestMarkdown(await request(), { excerpt: state.excerpt, theme: await theme() });
|
|
725
|
+
const markdown = async () => (draft.hidden ? generate() : draft.value);
|
|
726
|
+
const edit = h('button', {
|
|
727
|
+
class: 'link',
|
|
728
|
+
type: 'button',
|
|
729
|
+
onclick: async () => {
|
|
730
|
+
if (draft.hidden)
|
|
731
|
+
draft.value = await generate();
|
|
732
|
+
draft.hidden = !draft.hidden;
|
|
733
|
+
edit.textContent = draft.hidden ? 'Edit before sending' : 'Discard edits';
|
|
734
|
+
},
|
|
735
|
+
}, ['Edit before sending']);
|
|
736
|
+
const many = count > 1 ? ` (${count})` : '';
|
|
737
|
+
return h('div', { class: 'export' }, [
|
|
738
|
+
h('div', { class: 'row-end' }, [edit]),
|
|
739
|
+
draft,
|
|
740
|
+
h('div', { class: 'actions' }, [
|
|
741
|
+
h('button', {
|
|
742
|
+
class: 'primary',
|
|
743
|
+
type: 'button',
|
|
744
|
+
onclick: async () => {
|
|
745
|
+
await copy(await markdown());
|
|
746
|
+
status(result, 'ok', [
|
|
747
|
+
`Copied ${count === 1 ? 'the change' : `all ${count} changes`}. Paste it to your agent.`,
|
|
748
|
+
]);
|
|
749
|
+
},
|
|
750
|
+
}, [`Copy for AI${many}`]),
|
|
751
|
+
h('button', {
|
|
752
|
+
type: 'button',
|
|
753
|
+
onclick: async () => {
|
|
754
|
+
try {
|
|
755
|
+
const done = await save(await markdown());
|
|
756
|
+
const ask = `Do the Hozu request ${done.file}`;
|
|
757
|
+
await copy(ask);
|
|
758
|
+
notice = ['Saved ', h('code', {}, [done.file]), `. Copied “${ask}”: paste it to your agent.`];
|
|
759
|
+
clearDraft();
|
|
760
|
+
renderDock();
|
|
761
|
+
open('changes', null, 'saved');
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
status(result, 'err', [`Not saved: ${e.message}`]);
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
}, [`Save request${many}`]),
|
|
768
|
+
h('button', {
|
|
769
|
+
type: 'button',
|
|
770
|
+
onclick: async () => {
|
|
771
|
+
const a = h('a', {
|
|
772
|
+
href: URL.createObjectURL(new Blob([await markdown()], { type: 'text/markdown' })),
|
|
773
|
+
download: 'hozu-request.md',
|
|
774
|
+
});
|
|
775
|
+
a.click();
|
|
776
|
+
status(result, 'ok', ['Downloaded hozu-request.md']);
|
|
777
|
+
},
|
|
778
|
+
}, ['Download .md']),
|
|
779
|
+
]),
|
|
780
|
+
result,
|
|
781
|
+
]);
|
|
782
|
+
}
|
|
783
|
+
function textFor(n, active) {
|
|
784
|
+
const from = active.text?.from ?? (n.kind === 'text' ? active.visible || null : firstText(elementOf(active)));
|
|
785
|
+
if (!from || n.page)
|
|
786
|
+
return null;
|
|
787
|
+
return textSection(from, active.text, state.audience === 'builder', (change) => {
|
|
788
|
+
const had = hasContent(active);
|
|
789
|
+
restoreText(elementOf(active), active.text);
|
|
790
|
+
if (change)
|
|
791
|
+
active.text = change;
|
|
792
|
+
else
|
|
793
|
+
delete active.text;
|
|
794
|
+
previewText(elementOf(active), active.text);
|
|
795
|
+
persist();
|
|
796
|
+
if (had !== hasContent(active))
|
|
797
|
+
renderDock();
|
|
798
|
+
void renderPanel();
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function requestSection(n, active) {
|
|
802
|
+
const others = state.picks.filter((p) => p !== active && hasContent(p)).length;
|
|
803
|
+
const note = h('textarea', {
|
|
804
|
+
'aria-label': 'What should change?',
|
|
805
|
+
placeholder: 'What should change? For example: “Make it bigger and use the brand red”',
|
|
806
|
+
oninput: (event) => {
|
|
807
|
+
const had = hasContent(active);
|
|
808
|
+
active.note = event.target.value;
|
|
809
|
+
persist();
|
|
810
|
+
if (had !== hasContent(active))
|
|
811
|
+
renderDock();
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
note.value = active.note;
|
|
815
|
+
return h('div', { class: 'sec' }, [
|
|
816
|
+
h('div', { class: 'label' }, ['Change']),
|
|
817
|
+
scopeField(n, active),
|
|
818
|
+
note,
|
|
819
|
+
others
|
|
820
|
+
? h('div', { class: 'plain more' }, [
|
|
821
|
+
`This request also has ${others} other ${others === 1 ? 'change' : 'changes'}. `,
|
|
822
|
+
h('button', { class: 'link', type: 'button', onclick: () => open('changes', null, 'draft') }, [
|
|
823
|
+
'Review all',
|
|
824
|
+
]),
|
|
825
|
+
])
|
|
826
|
+
: h('div', { class: 'hint-text' }, [
|
|
827
|
+
'Described parts stay in this request when you select the next one.',
|
|
828
|
+
]),
|
|
829
|
+
exportBlock(),
|
|
830
|
+
]);
|
|
831
|
+
}
|
|
832
|
+
function builderSections(n) {
|
|
833
|
+
const children = n.children.filter((c) => c.kind !== 'text');
|
|
834
|
+
return [
|
|
835
|
+
section('About it', describeFor(n).map((line) => h('div', { class: 'plain' }, [line]))),
|
|
836
|
+
h('div', { class: 'sec' }, [
|
|
837
|
+
h('div', { class: 'nav' }, [
|
|
838
|
+
h('button', { type: 'button', onclick: () => walk('up') }, ['↑ Select the area around it']),
|
|
839
|
+
children.length && !isText(n)
|
|
840
|
+
? h('button', { type: 'button', onclick: () => walk('down') }, ['↓ Inside'])
|
|
841
|
+
: null,
|
|
842
|
+
h('button', { type: 'button', onclick: removeActive }, ['Remove']),
|
|
843
|
+
]),
|
|
844
|
+
n.location
|
|
845
|
+
? h('div', { class: 'file' }, [
|
|
846
|
+
`${n.location.file}:${n.location.line}`,
|
|
847
|
+
h('button', {
|
|
848
|
+
class: 'link',
|
|
849
|
+
type: 'button',
|
|
850
|
+
onclick: () => void copy(`${n.location.file}:${n.location.line}`),
|
|
851
|
+
}, ['Copy']),
|
|
852
|
+
])
|
|
853
|
+
: null,
|
|
854
|
+
]),
|
|
855
|
+
];
|
|
856
|
+
}
|
|
857
|
+
function developerSections(n, active) {
|
|
858
|
+
const children = n.children.filter((c) => c.kind !== 'text' || c.text);
|
|
859
|
+
return [
|
|
860
|
+
section('Where', [
|
|
861
|
+
where(n.page ? 'page' : 'view', n.location),
|
|
862
|
+
n.page ? where('route', n.page.routeLocation) : null,
|
|
863
|
+
n.component ? where('component', n.component.declaration) : null,
|
|
864
|
+
n.events.length ? where('machine', n.machine) : null,
|
|
865
|
+
excerpt(n),
|
|
866
|
+
]),
|
|
867
|
+
section('Component', n.component
|
|
868
|
+
? [
|
|
869
|
+
h('div', {}, [
|
|
870
|
+
h('span', { class: 'chip red' }, [n.component.ref]),
|
|
871
|
+
h('span', { class: 'chip' }, [
|
|
872
|
+
`used in ${n.component.uses} ${n.component.uses === 1 ? 'place' : 'places'}`,
|
|
873
|
+
]),
|
|
874
|
+
...Object.entries(n.component.variant).map(([k, v]) => h('span', { class: 'chip' }, [`${k}=${v}`])),
|
|
875
|
+
]),
|
|
876
|
+
]
|
|
877
|
+
: []),
|
|
878
|
+
section('Head', n.page
|
|
879
|
+
? [
|
|
880
|
+
...['title', 'description', 'image'].map((f) => h('div', { class: 'row' }, [h('b', {}, [`${f} `]), n.page.head[f] ?? '—'])),
|
|
881
|
+
h('div', { class: 'row' }, [h('b', {}, ['views ']), n.page.views.join(', ')]),
|
|
882
|
+
n.page.head.noindex ? h('div', { class: 'row' }, [h('b', {}, ['noindex'])]) : null,
|
|
883
|
+
n.page.head.query
|
|
884
|
+
? h('div', { class: 'row' }, [h('b', {}, ['query ']), n.page.head.query])
|
|
885
|
+
: null,
|
|
886
|
+
]
|
|
887
|
+
: []),
|
|
888
|
+
section('Text', textRows(n)),
|
|
889
|
+
section('Shown when', n.conditions.map((c) => h('div', { class: 'row' }, [
|
|
890
|
+
h('b', {}, [`${c.kind} `]),
|
|
891
|
+
c.detail,
|
|
892
|
+
c.location ? ` ${c.location.file}:${c.location.line}` : '',
|
|
893
|
+
]))),
|
|
894
|
+
section('Behaviour', n.events.map((e) => h('div', { class: 'row' }, [
|
|
895
|
+
h('span', { class: 'chip red' }, [`on ${e.dom}`]),
|
|
896
|
+
h('span', { class: 'chip' }, [e.event]),
|
|
897
|
+
...e.transitions.map((t) => h('span', { class: 'chip' }, [`${t.from} → ${t.to}${t.guarded ? ' if …' : ''}`])),
|
|
898
|
+
]))),
|
|
899
|
+
section('Inside', [
|
|
900
|
+
children.length
|
|
901
|
+
? h('div', { class: 'kids' }, children.map((c) => h('button', {
|
|
902
|
+
type: 'button',
|
|
903
|
+
onclick: () => choose(active, c.id, c.kind === 'text' ? (c.source?.detail ?? '') : active.visible),
|
|
904
|
+
}, [c.kind === 'text' ? `text ${c.text}` : `${c.kind} ${c.id.split('/').slice(-1)[0]}`])))
|
|
905
|
+
: null,
|
|
906
|
+
h('div', { class: 'nav' }, [
|
|
907
|
+
h('button', { type: 'button', onclick: () => walk('up') }, ['↑ Parent']),
|
|
908
|
+
isText(n) ? null : h('button', { type: 'button', onclick: () => walk('down') }, ['↓ Child']),
|
|
909
|
+
h('button', { type: 'button', onclick: removeActive }, ['Remove']),
|
|
910
|
+
]),
|
|
911
|
+
]),
|
|
912
|
+
h('div', { class: 'sec meta' }, [`node ${n.id}`, n.pointer ? ` · ${n.pointer}` : '']),
|
|
913
|
+
];
|
|
914
|
+
}
|
|
915
|
+
async function renderPanel() {
|
|
916
|
+
placePanel();
|
|
917
|
+
if (state.panel === 'changes')
|
|
918
|
+
return state.opened ? renderRequest(state.opened) : renderChanges();
|
|
919
|
+
if (state.panel === 'settings')
|
|
920
|
+
return renderSettings();
|
|
921
|
+
if (state.panel === 'layers')
|
|
922
|
+
return renderLayers(panel, await tree(win.location.pathname), layersHost(() => open(state.picks.length ? 'inspector' : null)));
|
|
923
|
+
const active = state.picks[state.active];
|
|
924
|
+
if (state.panel !== 'inspector' || !active) {
|
|
925
|
+
panel.hidden = state.view !== 'workbench';
|
|
926
|
+
if (state.view === 'workbench')
|
|
927
|
+
panel.replaceChildren(h('div', { class: 'empty' }, [
|
|
928
|
+
h('b', {}, ['Nothing selected. ']),
|
|
929
|
+
'Choose Select and click a part of the page, or pick one in Layers.',
|
|
930
|
+
]));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const n = await node(active.id);
|
|
934
|
+
if (!n) {
|
|
935
|
+
panel.hidden = false;
|
|
936
|
+
panel.replaceChildren(h('div', { class: 'empty' }, [
|
|
937
|
+
h('b', {}, ['This part is gone.']),
|
|
938
|
+
' The code changed since you selected it: select it again.',
|
|
939
|
+
]));
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const nodes = await Promise.all(state.picks.map((p) => node(p.id)));
|
|
943
|
+
panel.hidden = false;
|
|
944
|
+
panel.replaceChildren(...present([
|
|
945
|
+
h('div', { class: 'head' }, [
|
|
946
|
+
h('div', { class: 'kicker' }, [kicker(n)]),
|
|
947
|
+
h('h2', { class: 'title' }, titleFor(n, active)),
|
|
948
|
+
closeButton(() => open(null)),
|
|
949
|
+
state.picks.length > 1
|
|
950
|
+
? h('div', { class: 'picks' }, state.picks.map((p, i) => h('button', {
|
|
951
|
+
type: 'button',
|
|
952
|
+
'aria-current': String(i === state.active),
|
|
953
|
+
onclick: () => {
|
|
954
|
+
state.active = i;
|
|
955
|
+
persist();
|
|
956
|
+
void renderPanel();
|
|
957
|
+
},
|
|
958
|
+
}, [
|
|
959
|
+
`${i + 1} ${nodes[i] ? (state.audience === 'builder' ? friendlyName(nodes[i]) : labelOf(nodes[i])) : p.id}`,
|
|
960
|
+
])))
|
|
961
|
+
: null,
|
|
962
|
+
]),
|
|
963
|
+
...(state.audience === 'builder' ? builderSections(n) : developerSections(n, active)),
|
|
964
|
+
textFor(n, active),
|
|
965
|
+
n.kind === 'element' || n.kind === 'component'
|
|
966
|
+
? lookSection(elementOf(active), active.style ?? [], await theme(), state.audience === 'builder', (changes) => {
|
|
967
|
+
active.style = changes;
|
|
968
|
+
persist();
|
|
969
|
+
preview(elementOf(active), changes);
|
|
970
|
+
void renderPanel();
|
|
971
|
+
})
|
|
972
|
+
: null,
|
|
973
|
+
requestSection(n, active),
|
|
974
|
+
]));
|
|
975
|
+
}
|
|
976
|
+
function tabs(current, saved) {
|
|
977
|
+
const tab = (name, label) => h('button', {
|
|
978
|
+
class: 'tab',
|
|
979
|
+
type: 'button',
|
|
980
|
+
'aria-pressed': String(current === name),
|
|
981
|
+
onclick: () => open('changes', null, name),
|
|
982
|
+
}, [label]);
|
|
983
|
+
return h('div', { class: 'tabs' }, [
|
|
984
|
+
tab('draft', `This request · ${drafted().length}`),
|
|
985
|
+
tab('saved', `Saved · ${saved}`),
|
|
986
|
+
]);
|
|
987
|
+
}
|
|
988
|
+
async function renderChanges() {
|
|
989
|
+
const list = await saved();
|
|
990
|
+
const current = state.tab;
|
|
991
|
+
panel.hidden = false;
|
|
992
|
+
const head = h('div', { class: 'head' }, [
|
|
993
|
+
h('div', { class: 'kicker' }, [current === 'draft' ? 'Not sent yet' : '.hozu/requests']),
|
|
994
|
+
h('h2', { class: 'title' }, ['Changes']),
|
|
995
|
+
closeButton(() => open(state.picks.length ? 'inspector' : null)),
|
|
996
|
+
tabs(current, list.length),
|
|
997
|
+
]);
|
|
998
|
+
if (current === 'saved') {
|
|
999
|
+
const message = notice;
|
|
1000
|
+
notice = null;
|
|
1001
|
+
const result = h('div', { class: 'status', role: 'status' });
|
|
1002
|
+
const row = (r) => {
|
|
1003
|
+
let armed = false;
|
|
1004
|
+
const done = h('button', {
|
|
1005
|
+
class: 'link done',
|
|
1006
|
+
type: 'button',
|
|
1007
|
+
title: 'Done or not needed: remove it',
|
|
1008
|
+
onclick: async () => {
|
|
1009
|
+
if (!armed) {
|
|
1010
|
+
armed = true;
|
|
1011
|
+
done.textContent = 'Remove?';
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
await remove(r.number);
|
|
1015
|
+
void renderPanel();
|
|
1016
|
+
},
|
|
1017
|
+
}, ['Done']);
|
|
1018
|
+
return h('div', { class: 'req' }, [
|
|
1019
|
+
h('span', { class: 'n' }, [r.number]),
|
|
1020
|
+
h('button', { class: 't open-req', type: 'button', onclick: () => open('changes', r.number, 'saved') }, [r.title]),
|
|
1021
|
+
done,
|
|
1022
|
+
r.locations.length ? h('span', { class: 'l' }, [r.locations.join(' · ')]) : null,
|
|
1023
|
+
]);
|
|
1024
|
+
};
|
|
1025
|
+
panel.replaceChildren(...present([
|
|
1026
|
+
head,
|
|
1027
|
+
message ? h('div', { class: 'notice' }, message) : null,
|
|
1028
|
+
...(list.length
|
|
1029
|
+
? [...list].reverse().map(row)
|
|
1030
|
+
: [h('div', { class: 'empty' }, ['No open requests. Done ones are removed.'])]),
|
|
1031
|
+
list.length
|
|
1032
|
+
? h('div', { class: 'sec' }, [
|
|
1033
|
+
h('div', { class: 'choice' }, [
|
|
1034
|
+
h('button', {
|
|
1035
|
+
class: 'primary',
|
|
1036
|
+
type: 'button',
|
|
1037
|
+
onclick: async () => {
|
|
1038
|
+
const full = await Promise.all(list.map((r) => one(r.number)));
|
|
1039
|
+
await copy(joinRequests(full));
|
|
1040
|
+
status(result, 'ok', [
|
|
1041
|
+
`Copied all ${list.length} as one prompt. Paste it to your agent.`,
|
|
1042
|
+
]);
|
|
1043
|
+
},
|
|
1044
|
+
}, [`Copy all ${list.length} for AI`]),
|
|
1045
|
+
h('span', { class: 'why' }, [
|
|
1046
|
+
'One prompt with every open request: the fewest tokens and no file reads. Works in any chat.',
|
|
1047
|
+
]),
|
|
1048
|
+
h('button', {
|
|
1049
|
+
type: 'button',
|
|
1050
|
+
onclick: async () => {
|
|
1051
|
+
await copy(openRequestsLine(list));
|
|
1052
|
+
status(result, 'ok', ['Copied the file paths.']);
|
|
1053
|
+
},
|
|
1054
|
+
}, ['Copy file paths only']),
|
|
1055
|
+
h('span', { class: 'why' }, [
|
|
1056
|
+
'For an agent in this project that should read the files itself (or run hozu requests --full).',
|
|
1057
|
+
]),
|
|
1058
|
+
]),
|
|
1059
|
+
result,
|
|
1060
|
+
])
|
|
1061
|
+
: null,
|
|
1062
|
+
]));
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const items = drafted();
|
|
1066
|
+
const nodes = await Promise.all(items.map((p) => node(p.id)));
|
|
1067
|
+
panel.replaceChildren(...present([
|
|
1068
|
+
head,
|
|
1069
|
+
items.length
|
|
1070
|
+
? h('div', { class: 'sec' }, items.map((p, i) => {
|
|
1071
|
+
const n = nodes[i];
|
|
1072
|
+
return h('div', { class: 'item' }, [
|
|
1073
|
+
h('span', { class: 'n' }, [String(i + 1)]),
|
|
1074
|
+
h('div', { class: 'what' }, [
|
|
1075
|
+
h('b', {}, [n ? (state.audience === 'builder' ? friendlyName(n) : labelOf(n)) : p.id]),
|
|
1076
|
+
h('span', {}, [
|
|
1077
|
+
[
|
|
1078
|
+
p.note.trim(),
|
|
1079
|
+
p.style?.length
|
|
1080
|
+
? `${p.style.length} style ${p.style.length === 1 ? 'change' : 'changes'}`
|
|
1081
|
+
: '',
|
|
1082
|
+
]
|
|
1083
|
+
.filter(Boolean)
|
|
1084
|
+
.join(' · '),
|
|
1085
|
+
]),
|
|
1086
|
+
]),
|
|
1087
|
+
h('button', {
|
|
1088
|
+
class: 'link',
|
|
1089
|
+
type: 'button',
|
|
1090
|
+
onclick: () => {
|
|
1091
|
+
state.active = state.picks.indexOf(p);
|
|
1092
|
+
open('inspector');
|
|
1093
|
+
},
|
|
1094
|
+
}, ['Edit']),
|
|
1095
|
+
h('button', {
|
|
1096
|
+
class: 'link',
|
|
1097
|
+
type: 'button',
|
|
1098
|
+
onclick: () => {
|
|
1099
|
+
preview(elementOf(p), []);
|
|
1100
|
+
restoreText(elementOf(p), p.text);
|
|
1101
|
+
state.picks.splice(state.picks.indexOf(p), 1);
|
|
1102
|
+
state.active = Math.max(0, Math.min(state.active, state.picks.length - 1));
|
|
1103
|
+
persist();
|
|
1104
|
+
drawPicks();
|
|
1105
|
+
renderDock();
|
|
1106
|
+
void renderPanel();
|
|
1107
|
+
},
|
|
1108
|
+
}, ['Remove']),
|
|
1109
|
+
]);
|
|
1110
|
+
}))
|
|
1111
|
+
: h('div', { class: 'empty' }, [
|
|
1112
|
+
h('b', {}, ['Nothing yet. ']),
|
|
1113
|
+
'Choose Select, click a part and describe the change. Every described part is added here.',
|
|
1114
|
+
]),
|
|
1115
|
+
items.length ? h('div', { class: 'sec' }, [exportBlock()]) : null,
|
|
1116
|
+
]));
|
|
1117
|
+
}
|
|
1118
|
+
async function renderRequest(number) {
|
|
1119
|
+
panel.hidden = false;
|
|
1120
|
+
let r;
|
|
1121
|
+
try {
|
|
1122
|
+
r = await one(number);
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return open('changes', null, 'saved');
|
|
1126
|
+
}
|
|
1127
|
+
const result = h('div', { class: 'status', role: 'status' });
|
|
1128
|
+
let armed = false;
|
|
1129
|
+
const del = h('button', {
|
|
1130
|
+
type: 'button',
|
|
1131
|
+
class: 'danger',
|
|
1132
|
+
onclick: async () => {
|
|
1133
|
+
if (!armed) {
|
|
1134
|
+
armed = true;
|
|
1135
|
+
del.textContent = 'Click again to remove';
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
await remove(r.number);
|
|
1139
|
+
renderDock();
|
|
1140
|
+
open('changes', null, 'saved');
|
|
1141
|
+
},
|
|
1142
|
+
}, ['Done · remove']);
|
|
1143
|
+
panel.replaceChildren(h('div', { class: 'head' }, [
|
|
1144
|
+
h('button', { class: 'back', type: 'button', onclick: () => open('changes', null, 'saved') }, [
|
|
1145
|
+
'← Saved',
|
|
1146
|
+
]),
|
|
1147
|
+
h('h2', { class: 'title' }, [r.title]),
|
|
1148
|
+
h('div', { class: 'kicker' }, [r.file]),
|
|
1149
|
+
closeButton(() => open(state.picks.length ? 'inspector' : null)),
|
|
1150
|
+
]), h('div', { class: 'sec' }, [h('pre', { class: 'md' }, [r.markdown])]), h('div', { class: 'sec' }, [
|
|
1151
|
+
h('div', { class: 'actions' }, [
|
|
1152
|
+
h('button', {
|
|
1153
|
+
class: 'primary',
|
|
1154
|
+
type: 'button',
|
|
1155
|
+
onclick: async () => {
|
|
1156
|
+
await copy(`Do the Hozu request ${r.file}`);
|
|
1157
|
+
status(result, 'ok', [`Copied “Do the Hozu request ${r.file}”.`]);
|
|
1158
|
+
},
|
|
1159
|
+
}, ['Copy “do this request”']),
|
|
1160
|
+
h('button', {
|
|
1161
|
+
type: 'button',
|
|
1162
|
+
onclick: async () => {
|
|
1163
|
+
await copy(r.markdown);
|
|
1164
|
+
status(result, 'ok', ['Copied the whole request.']);
|
|
1165
|
+
},
|
|
1166
|
+
}, ['Copy request']),
|
|
1167
|
+
del,
|
|
1168
|
+
]),
|
|
1169
|
+
result,
|
|
1170
|
+
]));
|
|
1171
|
+
}
|
|
1172
|
+
function renderSettings() {
|
|
1173
|
+
panel.hidden = false;
|
|
1174
|
+
const choice = (value, label, detail) => h('label', { class: 'option' }, [
|
|
1175
|
+
h('input', {
|
|
1176
|
+
type: 'radio',
|
|
1177
|
+
name: 'audience',
|
|
1178
|
+
checked: state.audience === value,
|
|
1179
|
+
onchange: () => {
|
|
1180
|
+
state.audience = value;
|
|
1181
|
+
persist();
|
|
1182
|
+
renderDock();
|
|
1183
|
+
void renderPanel();
|
|
1184
|
+
},
|
|
1185
|
+
}),
|
|
1186
|
+
h('span', {}, [h('b', {}, [label]), h('span', {}, [detail])]),
|
|
1187
|
+
]);
|
|
1188
|
+
panel.replaceChildren(h('div', { class: 'head' }, [
|
|
1189
|
+
h('div', { class: 'kicker' }, ['Hozu DevTools']),
|
|
1190
|
+
h('h2', { class: 'title' }, ['Settings']),
|
|
1191
|
+
closeButton(() => open(state.picks.length ? 'inspector' : null)),
|
|
1192
|
+
]), h('div', { class: 'sec' }, [
|
|
1193
|
+
h('div', { class: 'label' }, ['Appearance']),
|
|
1194
|
+
h('div', { class: 'tabs', role: 'radiogroup', 'aria-label': 'Appearance' }, [
|
|
1195
|
+
['system', 'System'],
|
|
1196
|
+
['light', 'Light'],
|
|
1197
|
+
['dark', 'Dark'],
|
|
1198
|
+
].map(([value, label]) => h('button', {
|
|
1199
|
+
class: 'tab',
|
|
1200
|
+
type: 'button',
|
|
1201
|
+
role: 'radio',
|
|
1202
|
+
'aria-checked': String(state.theme === value),
|
|
1203
|
+
'aria-pressed': String(state.theme === value),
|
|
1204
|
+
onclick: () => {
|
|
1205
|
+
state.theme = value;
|
|
1206
|
+
persist();
|
|
1207
|
+
applyTheme();
|
|
1208
|
+
renderSettings();
|
|
1209
|
+
},
|
|
1210
|
+
}, [label]))),
|
|
1211
|
+
]), h('div', { class: 'sec' }, [
|
|
1212
|
+
h('div', { class: 'label' }, ['Show']),
|
|
1213
|
+
h('fieldset', {}, [
|
|
1214
|
+
choice('builder', 'Builder', 'Plain words: what it is, what a change reaches. No code names.'),
|
|
1215
|
+
choice('developer', 'Developer', 'Files, the code excerpt, components, conditions, transitions and node ids.'),
|
|
1216
|
+
]),
|
|
1217
|
+
]), h('div', { class: 'sec' }, [
|
|
1218
|
+
h('div', { class: 'label' }, ['Requests']),
|
|
1219
|
+
h('label', { class: 'option' }, [
|
|
1220
|
+
h('input', {
|
|
1221
|
+
type: 'checkbox',
|
|
1222
|
+
checked: state.excerpt,
|
|
1223
|
+
onchange: (event) => {
|
|
1224
|
+
state.excerpt = event.target.checked;
|
|
1225
|
+
persist();
|
|
1226
|
+
},
|
|
1227
|
+
}),
|
|
1228
|
+
h('span', {}, [
|
|
1229
|
+
h('b', {}, ['Add the code excerpt']),
|
|
1230
|
+
h('span', {}, [
|
|
1231
|
+
'Seven lines around each place. The agent reads the file anyway, so it is off by default.',
|
|
1232
|
+
]),
|
|
1233
|
+
]),
|
|
1234
|
+
]),
|
|
1235
|
+
]), h('div', { class: 'sec' }, [
|
|
1236
|
+
h('div', { class: 'label' }, ['Keys']),
|
|
1237
|
+
...[
|
|
1238
|
+
['Alt+Shift+S', 'Select on or off'],
|
|
1239
|
+
['Click', 'Select'],
|
|
1240
|
+
['Shift+click', 'Add to the request'],
|
|
1241
|
+
['Alt+click', 'Select the area around it'],
|
|
1242
|
+
['Double-click', 'Select a text'],
|
|
1243
|
+
['↑ ↓', 'Around it / inside it'],
|
|
1244
|
+
['Esc', 'Back to Browse'],
|
|
1245
|
+
].map(([k, v]) => h('div', { class: 'keyrow' }, [h('kbd', {}, [k]), v])),
|
|
1246
|
+
]));
|
|
1247
|
+
}
|
|
1248
|
+
const devices = [
|
|
1249
|
+
{ name: 'Phone', width: 390, height: 844 },
|
|
1250
|
+
{ name: 'Tablet', width: 820, height: 1180 },
|
|
1251
|
+
{ name: 'Laptop', width: 1280, height: 800 },
|
|
1252
|
+
{ name: 'Desktop', width: 1440, height: 900 },
|
|
1253
|
+
];
|
|
1254
|
+
const left = h('div', { class: 'bench-left' });
|
|
1255
|
+
const stage = h('div', { class: 'stage' });
|
|
1256
|
+
const sizer = h('div', { class: 'sizer' });
|
|
1257
|
+
const bar = h('div', { class: 'bench-bar' });
|
|
1258
|
+
function retarget(frame) {
|
|
1259
|
+
listening.abort();
|
|
1260
|
+
const w = frame?.contentWindow ?? null;
|
|
1261
|
+
frameEl = w ? frame : null;
|
|
1262
|
+
win = w ?? window;
|
|
1263
|
+
doc = w?.document ?? document;
|
|
1264
|
+
listening = listen(win);
|
|
1265
|
+
hovered = null;
|
|
1266
|
+
mountOutline(doc);
|
|
1267
|
+
drawPicks();
|
|
1268
|
+
}
|
|
1269
|
+
let dragScale = null;
|
|
1270
|
+
function layout() {
|
|
1271
|
+
if (!frameEl)
|
|
1272
|
+
return;
|
|
1273
|
+
const box = stage.getBoundingClientRect();
|
|
1274
|
+
const { width, height } = state.device;
|
|
1275
|
+
const scale = dragScale ?? Math.min(1, (box.width - 48) / width, (box.height - 48) / height);
|
|
1276
|
+
sizer.style.width = `${width * scale}px`;
|
|
1277
|
+
sizer.style.height = `${height * scale}px`;
|
|
1278
|
+
frameEl.style.width = `${width}px`;
|
|
1279
|
+
frameEl.style.height = `${height}px`;
|
|
1280
|
+
frameEl.style.transform = `scale(${scale})`;
|
|
1281
|
+
const size = bar.querySelector('[data-size]');
|
|
1282
|
+
if (size)
|
|
1283
|
+
size.textContent = `${width} × ${height}${scale < 1 ? ` · ${Math.round(scale * 100)}%` : ''}`;
|
|
1284
|
+
}
|
|
1285
|
+
function setDevice(device) {
|
|
1286
|
+
state.device = {
|
|
1287
|
+
...device,
|
|
1288
|
+
width: Math.max(320, Math.round(device.width)),
|
|
1289
|
+
height: Math.max(400, Math.round(device.height)),
|
|
1290
|
+
};
|
|
1291
|
+
persist();
|
|
1292
|
+
renderBar();
|
|
1293
|
+
layout();
|
|
1294
|
+
}
|
|
1295
|
+
function renderBar() {
|
|
1296
|
+
const mode = (label, value) => h('button', {
|
|
1297
|
+
class: 'mode',
|
|
1298
|
+
type: 'button',
|
|
1299
|
+
'aria-pressed': String(state.mode === value),
|
|
1300
|
+
onclick: () => setMode(value),
|
|
1301
|
+
}, [label]);
|
|
1302
|
+
const select = h('select', {
|
|
1303
|
+
'aria-label': 'Device',
|
|
1304
|
+
onchange: (e) => {
|
|
1305
|
+
const d = devices.find((x) => x.name === e.target.value);
|
|
1306
|
+
if (d)
|
|
1307
|
+
setDevice(d);
|
|
1308
|
+
},
|
|
1309
|
+
}, [
|
|
1310
|
+
...devices.map((d) => h('option', { value: d.name, selected: d.name === state.device.name }, [`${d.name} ${d.width}`])),
|
|
1311
|
+
devices.some((d) => d.name === state.device.name)
|
|
1312
|
+
? null
|
|
1313
|
+
: h('option', { value: 'Custom', selected: true }, ['Custom']),
|
|
1314
|
+
]);
|
|
1315
|
+
bar.replaceChildren(...present([
|
|
1316
|
+
h('div', { class: 'grip' }, [h('img', { src: logo, alt: 'Hozu', width: '18', height: '18' })]),
|
|
1317
|
+
h('div', { class: 'seg' }, [mode('Browse', 'browse'), mode('Select', 'select')]),
|
|
1318
|
+
select,
|
|
1319
|
+
h('button', {
|
|
1320
|
+
class: 'act',
|
|
1321
|
+
type: 'button',
|
|
1322
|
+
title: 'Rotate',
|
|
1323
|
+
'aria-label': 'Rotate',
|
|
1324
|
+
onclick: () => setDevice({ name: state.device.name, width: state.device.height, height: state.device.width }),
|
|
1325
|
+
}, ['⟲']),
|
|
1326
|
+
h('span', { class: 'size', 'data-size': true }),
|
|
1327
|
+
h('span', { class: 'spacer' }),
|
|
1328
|
+
held()
|
|
1329
|
+
? h('button', { class: 'previewing', type: 'button', onclick: () => hold(null, reload) }, [
|
|
1330
|
+
'Preview · Exit',
|
|
1331
|
+
])
|
|
1332
|
+
: null,
|
|
1333
|
+
h('button', {
|
|
1334
|
+
class: 'act',
|
|
1335
|
+
type: 'button',
|
|
1336
|
+
onclick: () => open(state.panel === 'changes' ? null : 'changes', null, drafted().length ? 'draft' : 'saved'),
|
|
1337
|
+
}, ['Changes', h('span', { class: 'count' }, [drafted().length ? String(drafted().length) : ''])]),
|
|
1338
|
+
h('button', {
|
|
1339
|
+
class: 'act gear',
|
|
1340
|
+
type: 'button',
|
|
1341
|
+
'aria-label': 'Settings',
|
|
1342
|
+
onclick: () => open(state.panel === 'settings' ? null : 'settings'),
|
|
1343
|
+
}, ['⚙']),
|
|
1344
|
+
h('button', { class: 'act exit', type: 'button', onclick: () => closeBench() }, ['Exit workbench']),
|
|
1345
|
+
]));
|
|
1346
|
+
}
|
|
1347
|
+
async function renderLeft() {
|
|
1348
|
+
await renderLayers(left, await tree(win.location.pathname), layersHost(() => { }));
|
|
1349
|
+
}
|
|
1350
|
+
function openBench() {
|
|
1351
|
+
state.view = 'workbench';
|
|
1352
|
+
persist();
|
|
1353
|
+
root.classList.add('benching');
|
|
1354
|
+
bench.hidden = false;
|
|
1355
|
+
const frame = h('iframe', {
|
|
1356
|
+
src: location.href,
|
|
1357
|
+
title: 'The page in Workbench',
|
|
1358
|
+
'data-hozu-bench': true,
|
|
1359
|
+
});
|
|
1360
|
+
frame.addEventListener('load', () => {
|
|
1361
|
+
retarget(frame);
|
|
1362
|
+
renderBar();
|
|
1363
|
+
layout();
|
|
1364
|
+
void renderLeft();
|
|
1365
|
+
void renderPanel();
|
|
1366
|
+
});
|
|
1367
|
+
const grip = h('div', { class: 'resize', title: 'Drag to resize', 'aria-hidden': 'true' });
|
|
1368
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
1369
|
+
event.preventDefault();
|
|
1370
|
+
grip.setPointerCapture(event.pointerId);
|
|
1371
|
+
const start = { x: event.clientX, y: event.clientY, ...state.device };
|
|
1372
|
+
dragScale = sizer.getBoundingClientRect().width / state.device.width;
|
|
1373
|
+
sizer.classList.add('dragging');
|
|
1374
|
+
let next = { ...state.device };
|
|
1375
|
+
let queued = 0;
|
|
1376
|
+
const move = (e) => {
|
|
1377
|
+
next = {
|
|
1378
|
+
name: 'Custom',
|
|
1379
|
+
width: Math.max(320, Math.round(start.width + (e.clientX - start.x) / (dragScale ?? 1))),
|
|
1380
|
+
height: Math.max(400, Math.round(start.height + (e.clientY - start.y) / (dragScale ?? 1))),
|
|
1381
|
+
};
|
|
1382
|
+
if (queued)
|
|
1383
|
+
return;
|
|
1384
|
+
queued = requestAnimationFrame(() => {
|
|
1385
|
+
queued = 0;
|
|
1386
|
+
state.device = next;
|
|
1387
|
+
layout();
|
|
1388
|
+
});
|
|
1389
|
+
};
|
|
1390
|
+
const up = () => {
|
|
1391
|
+
grip.removeEventListener('pointermove', move);
|
|
1392
|
+
grip.removeEventListener('pointerup', up);
|
|
1393
|
+
grip.removeEventListener('pointercancel', up);
|
|
1394
|
+
cancelAnimationFrame(queued);
|
|
1395
|
+
dragScale = null;
|
|
1396
|
+
sizer.classList.remove('dragging');
|
|
1397
|
+
setDevice(next);
|
|
1398
|
+
};
|
|
1399
|
+
grip.addEventListener('pointermove', move);
|
|
1400
|
+
grip.addEventListener('pointerup', up);
|
|
1401
|
+
grip.addEventListener('pointercancel', up);
|
|
1402
|
+
});
|
|
1403
|
+
sizer.replaceChildren(frame, grip);
|
|
1404
|
+
stage.replaceChildren(sizer);
|
|
1405
|
+
bench.replaceChildren(left, h('div', { class: 'bench-center' }, [bar, stage]));
|
|
1406
|
+
renderBar();
|
|
1407
|
+
renderDock();
|
|
1408
|
+
requestAnimationFrame(layout);
|
|
1409
|
+
}
|
|
1410
|
+
function closeBench() {
|
|
1411
|
+
const at = frameEl?.contentWindow?.location.href;
|
|
1412
|
+
state.view = 'overlay';
|
|
1413
|
+
persist();
|
|
1414
|
+
if (at && at !== location.href)
|
|
1415
|
+
return void location.assign(at);
|
|
1416
|
+
retarget(null);
|
|
1417
|
+
root.classList.remove('benching');
|
|
1418
|
+
bench.hidden = true;
|
|
1419
|
+
bench.replaceChildren();
|
|
1420
|
+
renderDock();
|
|
1421
|
+
void renderPanel();
|
|
1422
|
+
}
|
|
1423
|
+
addEventListener('resize', layout);
|
|
1424
|
+
renderDock();
|
|
1425
|
+
drawPicks();
|
|
1426
|
+
void renderPanel();
|
|
1427
|
+
requestAnimationFrame(frame);
|
|
1428
|
+
if (state.view === 'workbench')
|
|
1429
|
+
openBench();
|
|
1430
|
+
//# sourceMappingURL=app.js.map
|