@orbytes/astrolab 0.4.0-next.1 → 0.4.0-next.2
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/README.md +184 -84
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +7 -20
- package/docs/PIN-CONTRACT.md +76 -10
- package/docs/PIN.md +93 -23
- package/index.d.ts +1 -7
- package/index.mjs +14 -81
- package/package.json +2 -2
- package/src/Home.astro +7 -8
- package/src/LabHead.astro +1 -1
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +9 -2
- package/src/chrome/Nav.astro +36 -10
- package/src/chrome/Panel.astro +17 -4
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +20 -6
- package/src/chrome/StoryView.astro +103 -162
- package/src/chrome/Tree.astro +56 -53
- package/src/chrome/ViewportControls.astro +136 -61
- package/src/chrome/ViewportStage.astro +26 -3
- package/src/chrome/icons.ts +9 -0
- package/src/chrome/marks-client.ts +26 -53
- package/src/chrome/model.ts +14 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +42 -9
- package/src/chrome/shell-client.ts +99 -3
- package/src/chrome/trees.ts +112 -7
- package/src/chrome/viewport-client.ts +68 -242
- package/src/chrome/views/Assets.astro +21 -6
- package/src/chrome/views/Pages.astro +90 -54
- package/src/chrome/views/Placeholder.astro +3 -3
- package/src/chrome/views/Tasks.astro +12 -40
- package/src/core/LICENSE-astrobook +5 -0
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/pin/board.mjs +25 -15
- package/src/pin/index.mjs +34 -20
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +81 -3
- package/src/shell/Browse.astro +35 -10
- package/src/shell/lab-index.ts +5 -4
- package/src/shell/lab-params.ts +113 -6
- package/src/shell/live-files.mjs +212 -10
- package/src/shell/marks.mjs +17 -41
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +4 -3
- package/src/ui/lab.css +2167 -566
- package/virtual.d.ts +0 -4
- package/bin/lab-cull.mjs +0 -401
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The navbar's own behaviour on the component page (./StoryView.astro) and the page view
|
|
2
|
+
// (./views/Pages.astro). The viewport half is ./viewport-client.ts; the menus are native popovers
|
|
3
|
+
// placed by ./shell-client.ts.
|
|
4
|
+
//
|
|
5
|
+
// · THE PROPERTIES PANEL (./Properties.astro) — the eye beside the name. Hovering it opens the
|
|
6
|
+
// panel after a short delay, and the panel stays while the pointer is on the eye or on it;
|
|
7
|
+
// clicking the eye (Enter / Space) pins it open until Escape or a click outside. Focus goes
|
|
8
|
+
// back to the eye when it closes. `popover="manual"`, because light dismiss would close it
|
|
9
|
+
// on the very click that pins it.
|
|
10
|
+
// · COMMENT — the pin board's picker, confined to the canvas. The picker is the dev toolbar's pin
|
|
11
|
+
// app (../pin/toolbar.js): this sends it `orbytes-pin:command` and follows its
|
|
12
|
+
// `orbytes-pin:state`, so there is one picker and one composer, never a second copy. The
|
|
13
|
+
// button is pressed while the app is on (blue, as pins are), and a hint names the gesture
|
|
14
|
+
// while it is picking.
|
|
15
|
+
// · THE PINS — one marker per ticket left on the framed page, drawn over the frame at its
|
|
16
|
+
// element: blue while open, grey once resolved. ⋯ › Pins governs them — Show pins (on by
|
|
17
|
+
// default), Show resolved (off) — remembered per viewer. A ticket whose selector finds nothing
|
|
18
|
+
// here, or more than one thing, draws no marker rather than a guessed one (the same rule as
|
|
19
|
+
// the pin app's own markers). The list is the pin app's, re-read after every write, so a new
|
|
20
|
+
// pin appears the moment its ticket lands.
|
|
21
|
+
//
|
|
22
|
+
// Every marker and the hint carry `data-orbytes-pin-ui`, which the picker skips, so a pick can
|
|
23
|
+
// never land on the lab's own marker instead of the element under it.
|
|
24
|
+
|
|
25
|
+
interface PinTicket {
|
|
26
|
+
id: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
status?: string;
|
|
29
|
+
url?: string | null;
|
|
30
|
+
selector?: string | null;
|
|
31
|
+
}
|
|
32
|
+
interface PinState {
|
|
33
|
+
active: boolean;
|
|
34
|
+
mode: "idle" | "picking" | "selected";
|
|
35
|
+
tickets: PinTicket[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare global {
|
|
39
|
+
interface Window {
|
|
40
|
+
__orbytesPin?: PinState;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const read = (key: string): string | null => {
|
|
45
|
+
try {
|
|
46
|
+
return localStorage.getItem(key);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const write = (key: string, value: string) => {
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(key, value);
|
|
54
|
+
} catch {
|
|
55
|
+
/* private mode — the choice holds for this page */
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- the Properties panel ---------------------------------------------------------------------
|
|
60
|
+
const trigger = document.querySelector<HTMLElement>("[data-lab-props-trigger]");
|
|
61
|
+
const panel = document.querySelector<HTMLElement>("[data-lab-props]");
|
|
62
|
+
|
|
63
|
+
if (trigger && panel) {
|
|
64
|
+
const OPEN_DELAY = 150;
|
|
65
|
+
const CLOSE_DELAY = 200;
|
|
66
|
+
let pinned = false;
|
|
67
|
+
let openTimer = 0;
|
|
68
|
+
let closeTimer = 0;
|
|
69
|
+
|
|
70
|
+
const isOpen = () => panel.matches(":popover-open");
|
|
71
|
+
const place = () => {
|
|
72
|
+
const box = trigger.getBoundingClientRect();
|
|
73
|
+
const width = panel.offsetWidth;
|
|
74
|
+
const height = panel.offsetHeight;
|
|
75
|
+
const left = Math.max(8, Math.min(box.left, window.innerWidth - width - 8));
|
|
76
|
+
let top = box.bottom + 4;
|
|
77
|
+
if (top + height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - height - 8);
|
|
78
|
+
panel.style.left = `${left}px`;
|
|
79
|
+
panel.style.top = `${top}px`;
|
|
80
|
+
};
|
|
81
|
+
const open = () => {
|
|
82
|
+
clearTimeout(openTimer);
|
|
83
|
+
clearTimeout(closeTimer);
|
|
84
|
+
if (!isOpen()) panel.showPopover();
|
|
85
|
+
place();
|
|
86
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
87
|
+
};
|
|
88
|
+
const close = (returnFocus = false) => {
|
|
89
|
+
clearTimeout(openTimer);
|
|
90
|
+
clearTimeout(closeTimer);
|
|
91
|
+
pinned = false;
|
|
92
|
+
delete trigger.dataset.pinned;
|
|
93
|
+
if (isOpen()) panel.hidePopover();
|
|
94
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
95
|
+
if (returnFocus) trigger.focus();
|
|
96
|
+
};
|
|
97
|
+
const closeSoon = () => {
|
|
98
|
+
clearTimeout(closeTimer);
|
|
99
|
+
if (!pinned) closeTimer = window.setTimeout(() => close(), CLOSE_DELAY);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
trigger.addEventListener("mouseenter", () => {
|
|
103
|
+
clearTimeout(closeTimer);
|
|
104
|
+
if (!isOpen()) openTimer = window.setTimeout(open, OPEN_DELAY);
|
|
105
|
+
});
|
|
106
|
+
trigger.addEventListener("mouseleave", () => {
|
|
107
|
+
clearTimeout(openTimer);
|
|
108
|
+
closeSoon();
|
|
109
|
+
});
|
|
110
|
+
panel.addEventListener("mouseenter", () => clearTimeout(closeTimer));
|
|
111
|
+
panel.addEventListener("mouseleave", closeSoon);
|
|
112
|
+
trigger.addEventListener("click", () => {
|
|
113
|
+
if (pinned) {
|
|
114
|
+
close();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
pinned = true;
|
|
118
|
+
trigger.dataset.pinned = "";
|
|
119
|
+
open();
|
|
120
|
+
});
|
|
121
|
+
document.addEventListener(
|
|
122
|
+
"pointerdown",
|
|
123
|
+
(event) => {
|
|
124
|
+
if (!isOpen()) return;
|
|
125
|
+
const path = event.composedPath();
|
|
126
|
+
if (path.includes(panel) || path.includes(trigger)) return;
|
|
127
|
+
close();
|
|
128
|
+
},
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
// A click inside the preview frame never reaches this document; it takes the focus with it, so
|
|
132
|
+
// the lab's window losing focus to a frame is the same outside click.
|
|
133
|
+
window.addEventListener("blur", () => {
|
|
134
|
+
requestAnimationFrame(() => {
|
|
135
|
+
if (isOpen() && document.activeElement instanceof HTMLIFrameElement) close();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
document.addEventListener("keydown", (event) => {
|
|
139
|
+
if (event.key !== "Escape" || !isOpen()) return;
|
|
140
|
+
const inside = panel.contains(document.activeElement) || document.activeElement === trigger;
|
|
141
|
+
close(inside);
|
|
142
|
+
});
|
|
143
|
+
window.addEventListener("resize", () => isOpen() && place());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- the pin switches in ⋯ --------------------------------------------------------------------
|
|
147
|
+
const SHOW_KEY = "lab-pins-show";
|
|
148
|
+
const RESOLVED_KEY = "lab-pins-resolved";
|
|
149
|
+
const view = {
|
|
150
|
+
show: read(SHOW_KEY) !== "0",
|
|
151
|
+
resolved: read(RESOLVED_KEY) === "1",
|
|
152
|
+
};
|
|
153
|
+
const toggles = [...document.querySelectorAll<HTMLElement>("[data-lab-pins-toggle]")];
|
|
154
|
+
const paintToggles = () => {
|
|
155
|
+
for (const item of toggles) {
|
|
156
|
+
const on = item.dataset.labPinsToggle === "resolved" ? view.resolved : view.show;
|
|
157
|
+
item.setAttribute("aria-checked", String(on));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
for (const item of toggles) {
|
|
161
|
+
item.addEventListener("click", () => {
|
|
162
|
+
if (item.dataset.labPinsToggle === "resolved") {
|
|
163
|
+
view.resolved = !view.resolved;
|
|
164
|
+
write(RESOLVED_KEY, view.resolved ? "1" : "0");
|
|
165
|
+
} else {
|
|
166
|
+
view.show = !view.show;
|
|
167
|
+
write(SHOW_KEY, view.show ? "1" : "0");
|
|
168
|
+
}
|
|
169
|
+
paintToggles();
|
|
170
|
+
drawPins();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
paintToggles();
|
|
174
|
+
|
|
175
|
+
// ---- Comment ----------------------------------------------------------------------------------
|
|
176
|
+
const comment = document.querySelector<HTMLButtonElement>("[data-lab-comment]");
|
|
177
|
+
const hint = document.querySelector<HTMLElement>("[data-lab-pin-hint]");
|
|
178
|
+
const stage = document.querySelector<HTMLElement>("[data-lab-vp-stage]");
|
|
179
|
+
let pin: PinState | null = window.__orbytesPin ?? null;
|
|
180
|
+
|
|
181
|
+
const paintComment = () => {
|
|
182
|
+
if (comment) {
|
|
183
|
+
comment.disabled = !pin;
|
|
184
|
+
comment.setAttribute("aria-pressed", String(Boolean(pin?.active)));
|
|
185
|
+
}
|
|
186
|
+
if (hint) hint.hidden = pin?.mode !== "picking";
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
comment?.addEventListener("click", () => {
|
|
190
|
+
if (!pin) return;
|
|
191
|
+
window.dispatchEvent(
|
|
192
|
+
new CustomEvent("orbytes-pin:command", {
|
|
193
|
+
detail: pin.active ? { action: "stop" } : { action: "pick", within: stage },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
window.addEventListener("orbytes-pin:state", (event) => {
|
|
199
|
+
pin = (event as CustomEvent<PinState>).detail;
|
|
200
|
+
paintComment();
|
|
201
|
+
drawPins();
|
|
202
|
+
});
|
|
203
|
+
paintComment();
|
|
204
|
+
// The pin app starts when the dev toolbar is idle; if it never does, say why the button is dead.
|
|
205
|
+
window.setTimeout(() => {
|
|
206
|
+
if (!pin && comment) comment.title = "Comment is waiting for the pin board's toolbar app, which has not started";
|
|
207
|
+
}, 5000);
|
|
208
|
+
|
|
209
|
+
// ---- the pins on the frame --------------------------------------------------------------------
|
|
210
|
+
const layer = document.querySelector<HTMLElement>("[data-lab-vp-pins]");
|
|
211
|
+
const frame = document.querySelector<HTMLIFrameElement>("[data-lab-vp-frame]");
|
|
212
|
+
const box = document.querySelector<HTMLElement>("[data-lab-vp-box]");
|
|
213
|
+
const PIN = 24;
|
|
214
|
+
|
|
215
|
+
/** `/a/` and `/a` are the same page; a query string is part of the address. */
|
|
216
|
+
const norm = (value: string) => {
|
|
217
|
+
const cut = value.indexOf("?");
|
|
218
|
+
const path = cut === -1 ? value : value.slice(0, cut);
|
|
219
|
+
return (path.length > 1 ? path.replace(/\/+$/, "") : path) + (cut === -1 ? "" : value.slice(cut));
|
|
220
|
+
};
|
|
221
|
+
const statusOf = (raw?: string) => {
|
|
222
|
+
const s = String(raw ?? "").trim().toLowerCase();
|
|
223
|
+
return s === "resolved" || s === "done" ? "resolved" : s === "cancelled" || s === "canceled" ? "cancelled" : "open";
|
|
224
|
+
};
|
|
225
|
+
const numberOf = (id: string) => Number(/(\d+)/.exec(id)?.[1] ?? 0);
|
|
226
|
+
|
|
227
|
+
function framePath(): string | null {
|
|
228
|
+
try {
|
|
229
|
+
const win = frame!.contentWindow!;
|
|
230
|
+
return win.location.pathname + win.location.search;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function drawPins() {
|
|
237
|
+
if (!layer || !frame || !box) return;
|
|
238
|
+
layer.textContent = "";
|
|
239
|
+
const here = framePath();
|
|
240
|
+
const doc = (() => {
|
|
241
|
+
try {
|
|
242
|
+
return frame.contentDocument;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
if (!view.show || !pin || !here || !doc?.documentElement) return;
|
|
248
|
+
|
|
249
|
+
// Every ticket left on this page, numbered in the order it was made: a pin keeps its number
|
|
250
|
+
// whether or not the resolved ones are showing.
|
|
251
|
+
const onPage = pin.tickets
|
|
252
|
+
.filter((t) => t.url && norm(t.url) === norm(here) && statusOf(t.status) !== "cancelled")
|
|
253
|
+
.sort((a, b) => numberOf(a.id) - numberOf(b.id));
|
|
254
|
+
|
|
255
|
+
const frameBox = frame.getBoundingClientRect();
|
|
256
|
+
const origin = box.getBoundingClientRect();
|
|
257
|
+
const sx = frame.offsetWidth ? frameBox.width / frame.offsetWidth : 1;
|
|
258
|
+
const sy = frame.offsetHeight ? frameBox.height / frame.offsetHeight : 1;
|
|
259
|
+
const viewH = frame.contentWindow?.innerHeight ?? frame.offsetHeight;
|
|
260
|
+
const viewW = frame.contentWindow?.innerWidth ?? frame.offsetWidth;
|
|
261
|
+
const href = layer.dataset.tasksHref;
|
|
262
|
+
|
|
263
|
+
onPage.forEach((ticket, i) => {
|
|
264
|
+
const state = statusOf(ticket.status);
|
|
265
|
+
if (state === "resolved" && !view.resolved) return;
|
|
266
|
+
if (!ticket.selector) return;
|
|
267
|
+
let found: NodeListOf<Element>;
|
|
268
|
+
try {
|
|
269
|
+
found = doc.querySelectorAll(ticket.selector);
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (found.length !== 1) return;
|
|
274
|
+
const r = found[0]!.getBoundingClientRect();
|
|
275
|
+
if ((!r.width && !r.height) || r.bottom < 0 || r.top > viewH || r.right < 0 || r.left > viewW) return;
|
|
276
|
+
// The pin's bottom-left corner is its anchor, set on the element's top-left corner.
|
|
277
|
+
const x = frameBox.left - origin.left + Math.max(0, r.left) * sx;
|
|
278
|
+
const y = frameBox.top - origin.top + Math.max(0, r.top) * sy - PIN;
|
|
279
|
+
const marker = document.createElement(href ? "a" : "span");
|
|
280
|
+
marker.className = "lab-pin";
|
|
281
|
+
marker.dataset.state = state;
|
|
282
|
+
marker.setAttribute("data-orbytes-pin-ui", "lab-pin");
|
|
283
|
+
marker.textContent = String(i + 1);
|
|
284
|
+
const label = `${ticket.id} · ${state === "resolved" ? "resolved" : (ticket.status ?? "open")} — ${ticket.title ?? ""}`.trim();
|
|
285
|
+
marker.title = label;
|
|
286
|
+
marker.setAttribute("aria-label", label);
|
|
287
|
+
if (href && marker instanceof HTMLAnchorElement) marker.href = href;
|
|
288
|
+
marker.style.left = `${Math.max(0, Math.min(x, origin.width - PIN))}px`;
|
|
289
|
+
marker.style.top = `${Math.max(0, Math.min(y, origin.height - PIN))}px`;
|
|
290
|
+
layer.append(marker);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (layer && frame) {
|
|
295
|
+
let queued = false;
|
|
296
|
+
const redraw = () => {
|
|
297
|
+
if (queued) return;
|
|
298
|
+
queued = true;
|
|
299
|
+
requestAnimationFrame(() => {
|
|
300
|
+
queued = false;
|
|
301
|
+
drawPins();
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
document.querySelector("[data-lab-vp]")?.addEventListener("lab:viewport", redraw);
|
|
305
|
+
window.addEventListener("resize", redraw);
|
|
306
|
+
// The frame scrolls, reflows and navigates on its own; follow it each time it loads.
|
|
307
|
+
const follow = () => {
|
|
308
|
+
try {
|
|
309
|
+
const win = frame.contentWindow;
|
|
310
|
+
const doc = frame.contentDocument;
|
|
311
|
+
if (!win || !doc?.documentElement) return;
|
|
312
|
+
win.addEventListener("scroll", redraw, { passive: true });
|
|
313
|
+
win.addEventListener("resize", redraw);
|
|
314
|
+
new ResizeObserver(redraw).observe(doc.documentElement);
|
|
315
|
+
} catch {
|
|
316
|
+
/* cross-origin — no pins can be placed on it anyway */
|
|
317
|
+
}
|
|
318
|
+
redraw();
|
|
319
|
+
};
|
|
320
|
+
frame.addEventListener("load", follow);
|
|
321
|
+
if (frame.contentDocument?.readyState === "complete") follow();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export {};
|