@kestack/web-platform 0.0.1 → 0.0.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 +6 -0
- package/dist/components/viewers/diff-viewer/diff-viewer.js +1 -1
- package/dist/components/viewers/source-explorer/source-explorer.js +1 -1
- package/dist/recipes/devtools-dock/devtools-dock.d.ts +23 -0
- package/dist/recipes/devtools-dock/devtools-dock.d.ts.map +1 -0
- package/dist/recipes/devtools-dock/devtools-dock.js +343 -0
- package/dist/recipes/devtools-dock/devtools-dock.js.map +1 -0
- package/dist/recipes/devtools-dock/index.d.ts +2 -0
- package/dist/recipes/devtools-dock/index.js +2 -0
- package/dist/recipes/kabuki-devtools/kabuki-devtools.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/kabuki-devtools.js +4 -1
- package/dist/recipes/kabuki-devtools/kabuki-devtools.js.map +1 -1
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.js +13 -0
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.js.map +1 -1
- package/dist/recipes/kabuki-devtools/scene/tree.js +1 -1
- package/dist/recipes/kabuki-devtools/scene/tree.js.map +1 -1
- package/dist/recipes/kabuki-devtools/sheet/sheet.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/sheet/sheet.js +84 -78
- package/dist/recipes/kabuki-devtools/sheet/sheet.js.map +1 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts +6 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.js +202 -16
- package/dist/recipes/kabuki-devtools/timeline/timeline.js.map +1 -1
- package/package.json +13 -13
- package/src/recipes/devtools-dock/devtools-dock.tsx +505 -0
- package/src/recipes/devtools-dock/index.ts +2 -0
- package/src/recipes/kabuki-devtools/kabuki-devtools.tsx +4 -0
- package/src/recipes/kabuki-devtools/map/canvas/canvas.tsx +17 -1
- package/src/recipes/kabuki-devtools/scene/tree.ts +1 -1
- package/src/recipes/kabuki-devtools/sheet/sheet.tsx +95 -107
- package/src/recipes/kabuki-devtools/timeline/timeline.tsx +238 -19
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { CSSProperties, PointerEvent, ReactNode } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
AppWindow,
|
|
5
|
+
Maximize2,
|
|
6
|
+
Network,
|
|
7
|
+
PanelBottom,
|
|
8
|
+
PanelRight,
|
|
9
|
+
X,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
import {
|
|
12
|
+
ContextMenu,
|
|
13
|
+
ContextMenuContent,
|
|
14
|
+
ContextMenuLabel,
|
|
15
|
+
ContextMenuRadioGroup,
|
|
16
|
+
ContextMenuRadioItem,
|
|
17
|
+
ContextMenuTrigger,
|
|
18
|
+
} from '#components/ui/context-menu';
|
|
19
|
+
import { useIsMobile } from '#hooks/use-mobile';
|
|
20
|
+
import { cn } from '#lib/utils';
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* The Devtools Dock: a page with devtools beside it, opened from one button
|
|
24
|
+
* that never moves.
|
|
25
|
+
*
|
|
26
|
+
* 1. Keep whether it is open, its Layout and each Layout's size, in
|
|
27
|
+
* `localStorage`, so a reload finds it as it was left.
|
|
28
|
+
* 2. Lay out the page fills what the devtools leave: a full-screen layer
|
|
29
|
+
* over it, a bar under it or a side panel on its right, both
|
|
30
|
+
* pushing it aside and resizable by their edge, or a floating
|
|
31
|
+
* window, moved by its title and resized by any corner. A page
|
|
32
|
+
* that scrolls the window, as a site does, is left in the
|
|
33
|
+
* document's flow: the bar and the panel are fixed over it, and
|
|
34
|
+
* the body is padded by their size so nothing hides under them.
|
|
35
|
+
* 3. Button two icons, fixed in the bottom-right corner at one size
|
|
36
|
+
* whatever is open or marked: open and close, and the Layout. A
|
|
37
|
+
* click on the Layout steps to the next one; a right click or a
|
|
38
|
+
* long press picks one from a menu. On a phone only full screen
|
|
39
|
+
* and the bottom bar make sense, so only they are offered, and a
|
|
40
|
+
* Layout kept from a wide screen shows as the nearest of them.
|
|
41
|
+
* It sits in the row of the devtools' bottom bar, which are
|
|
42
|
+
* handed the room it takes as `--dock-reserve`.
|
|
43
|
+
* 4. Keys Ctrl+/ or ⌘/ opens and closes them.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type DockLayout = 'full' | 'bottom' | 'side' | 'floating';
|
|
47
|
+
|
|
48
|
+
interface Kept {
|
|
49
|
+
readonly open: boolean;
|
|
50
|
+
readonly layout: DockLayout;
|
|
51
|
+
readonly bottom: number;
|
|
52
|
+
readonly side: number;
|
|
53
|
+
readonly floating: {
|
|
54
|
+
readonly x: number;
|
|
55
|
+
readonly y: number;
|
|
56
|
+
readonly width: number;
|
|
57
|
+
readonly height: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const INITIAL: Kept = {
|
|
62
|
+
open: false,
|
|
63
|
+
layout: 'full',
|
|
64
|
+
bottom: 360,
|
|
65
|
+
side: 480,
|
|
66
|
+
floating: { x: 48, y: 48, width: 720, height: 480 },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const LAYOUTS = [
|
|
70
|
+
{ value: 'full', label: 'Full screen', Icon: Maximize2, phone: true },
|
|
71
|
+
{ value: 'bottom', label: 'Bottom', Icon: PanelBottom, phone: true },
|
|
72
|
+
{ value: 'side', label: 'Right side', Icon: PanelRight, phone: false },
|
|
73
|
+
{ value: 'floating', label: 'Floating', Icon: AppWindow, phone: false },
|
|
74
|
+
] as const;
|
|
75
|
+
/** On a phone, what a wide screen's Layout shows as. */
|
|
76
|
+
const ON_PHONE: Record<DockLayout, DockLayout> = {
|
|
77
|
+
full: 'full',
|
|
78
|
+
bottom: 'bottom',
|
|
79
|
+
side: 'bottom',
|
|
80
|
+
floating: 'full',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const MIN = 200;
|
|
84
|
+
const clamp = (value: number, low: number, high: number) =>
|
|
85
|
+
Math.min(Math.max(value, low), Math.max(low, high));
|
|
86
|
+
|
|
87
|
+
export function DevtoolsDock({
|
|
88
|
+
devtools,
|
|
89
|
+
children,
|
|
90
|
+
label = 'Devtools',
|
|
91
|
+
status,
|
|
92
|
+
storageKey = 'devtools-dock',
|
|
93
|
+
scrolls = 'dock',
|
|
94
|
+
}: {
|
|
95
|
+
/** The devtools, drawn for the Layout they are in. */
|
|
96
|
+
readonly devtools: (layout: DockLayout) => ReactNode;
|
|
97
|
+
/** The page. */
|
|
98
|
+
readonly children: ReactNode;
|
|
99
|
+
readonly label?: string;
|
|
100
|
+
/** A mark on the button's corner, such as a dot for a page showing the past; it never changes the button's size. */
|
|
101
|
+
readonly status?: ReactNode;
|
|
102
|
+
/** Where the Dock keeps how it was left. */
|
|
103
|
+
readonly storageKey?: string;
|
|
104
|
+
/**
|
|
105
|
+
* What scrolls the page: `dock`, a box the height of the screen that the
|
|
106
|
+
* page fills, for an app that scrolls its own parts; or `window`, for a
|
|
107
|
+
* site whose page scrolls the window, which keeps its sticky headers and
|
|
108
|
+
* its scroll restoration.
|
|
109
|
+
*/
|
|
110
|
+
readonly scrolls?: 'dock' | 'window';
|
|
111
|
+
}) {
|
|
112
|
+
// 1. Keep
|
|
113
|
+
const [kept, setKept] = useState(INITIAL);
|
|
114
|
+
// Read after mount: the server renders it closed.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
try {
|
|
117
|
+
const stored = localStorage.getItem(storageKey);
|
|
118
|
+
if (stored) setKept({ ...INITIAL, ...JSON.parse(stored) });
|
|
119
|
+
} catch {}
|
|
120
|
+
}, [storageKey]);
|
|
121
|
+
const keep = (change: Partial<Kept> | ((was: Kept) => Partial<Kept>)) =>
|
|
122
|
+
setKept((was) => {
|
|
123
|
+
const next = {
|
|
124
|
+
...was,
|
|
125
|
+
...(typeof change === 'function' ? change(was) : change),
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
localStorage.setItem(storageKey, JSON.stringify(next));
|
|
129
|
+
} catch {}
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
const phone = useIsMobile();
|
|
133
|
+
const offered = LAYOUTS.filter((each) => !phone || each.phone);
|
|
134
|
+
const { open } = kept;
|
|
135
|
+
const layout = phone ? ON_PHONE[kept.layout] : kept.layout;
|
|
136
|
+
const current = offered.find((each) => each.value === layout)!;
|
|
137
|
+
const next = offered[(offered.indexOf(current) + 1) % offered.length]!;
|
|
138
|
+
|
|
139
|
+
// 4. Keys
|
|
140
|
+
const toggle = useRef(() => {});
|
|
141
|
+
toggle.current = () => keep((was) => ({ open: !was.open }));
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const onKey = (event: KeyboardEvent) => {
|
|
144
|
+
if (!((event.ctrlKey || event.metaKey) && event.key === '/')) return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
toggle.current();
|
|
147
|
+
};
|
|
148
|
+
window.addEventListener('keydown', onKey);
|
|
149
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
// 3. Button: how much room it takes from the corner.
|
|
153
|
+
const button = useRef<HTMLDivElement>(null);
|
|
154
|
+
const [reserve, setReserve] = useState(0);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const at = button.current;
|
|
157
|
+
if (!at) return;
|
|
158
|
+
const measure = () =>
|
|
159
|
+
setReserve(window.innerWidth - at.getBoundingClientRect().left);
|
|
160
|
+
const observer = new ResizeObserver(measure);
|
|
161
|
+
observer.observe(at);
|
|
162
|
+
window.addEventListener('resize', measure);
|
|
163
|
+
return () => {
|
|
164
|
+
observer.disconnect();
|
|
165
|
+
window.removeEventListener('resize', measure);
|
|
166
|
+
};
|
|
167
|
+
}, []);
|
|
168
|
+
const clear = { '--dock-reserve': `${reserve}px` } as CSSProperties;
|
|
169
|
+
|
|
170
|
+
// 2. Lay out
|
|
171
|
+
const docked = open && (layout === 'bottom' || layout === 'side');
|
|
172
|
+
const size = layout === 'bottom' ? kept.bottom : kept.side;
|
|
173
|
+
const inFlow = scrolls === 'window';
|
|
174
|
+
// A page in the window's flow makes room for the bar or the panel over it.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!inFlow || !docked) return;
|
|
177
|
+
const side = layout === 'bottom' ? 'paddingBottom' : 'paddingRight';
|
|
178
|
+
const body = document.body.style;
|
|
179
|
+
const before = body[side];
|
|
180
|
+
body[side] = `${size}px`;
|
|
181
|
+
return () => {
|
|
182
|
+
body[side] = before;
|
|
183
|
+
};
|
|
184
|
+
}, [inFlow, docked, layout, size]);
|
|
185
|
+
|
|
186
|
+
const aside = docked && (
|
|
187
|
+
<aside
|
|
188
|
+
// A new Layout lays the devtools out afresh: the Map fits again.
|
|
189
|
+
key={layout}
|
|
190
|
+
aria-label={label}
|
|
191
|
+
className={cn(
|
|
192
|
+
'bg-background flex shrink-0 flex-col',
|
|
193
|
+
layout === 'bottom' ? 'border-t' : 'border-l',
|
|
194
|
+
!inFlow
|
|
195
|
+
? 'relative'
|
|
196
|
+
: layout === 'bottom'
|
|
197
|
+
? 'fixed inset-x-0 bottom-0 z-40'
|
|
198
|
+
: 'fixed inset-y-0 right-0 z-40',
|
|
199
|
+
)}
|
|
200
|
+
style={{
|
|
201
|
+
...clear,
|
|
202
|
+
[layout === 'bottom' ? 'height' : 'width']: size,
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<Edge
|
|
206
|
+
along={layout === 'bottom' ? 'top' : 'left'}
|
|
207
|
+
onResize={(to) =>
|
|
208
|
+
keep(
|
|
209
|
+
layout === 'bottom'
|
|
210
|
+
? {
|
|
211
|
+
bottom: clamp(
|
|
212
|
+
window.innerHeight - to.y,
|
|
213
|
+
MIN,
|
|
214
|
+
window.innerHeight * 0.85,
|
|
215
|
+
),
|
|
216
|
+
}
|
|
217
|
+
: {
|
|
218
|
+
side: clamp(
|
|
219
|
+
window.innerWidth - to.x,
|
|
220
|
+
MIN + 120,
|
|
221
|
+
window.innerWidth * 0.85,
|
|
222
|
+
),
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
/>
|
|
227
|
+
<div className="min-h-0 flex-1">{devtools(layout)}</div>
|
|
228
|
+
</aside>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
{inFlow ? (
|
|
234
|
+
<>
|
|
235
|
+
{children}
|
|
236
|
+
{aside}
|
|
237
|
+
</>
|
|
238
|
+
) : (
|
|
239
|
+
<div
|
|
240
|
+
className={cn(
|
|
241
|
+
'flex h-dvh w-full overflow-hidden',
|
|
242
|
+
docked && layout === 'bottom' ? 'flex-col' : 'flex-row',
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
<div className="relative min-h-0 min-w-0 flex-1">{children}</div>
|
|
246
|
+
{aside}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{open && layout === 'full' && (
|
|
251
|
+
<div
|
|
252
|
+
role="dialog"
|
|
253
|
+
aria-label={label}
|
|
254
|
+
className="fixed inset-0 z-40"
|
|
255
|
+
style={clear}
|
|
256
|
+
>
|
|
257
|
+
{devtools(layout)}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{open && layout === 'floating' && (
|
|
262
|
+
<Floating
|
|
263
|
+
label={label}
|
|
264
|
+
rect={kept.floating}
|
|
265
|
+
onChange={(floating) => keep({ floating })}
|
|
266
|
+
onClose={() => keep({ open: false })}
|
|
267
|
+
>
|
|
268
|
+
{devtools(layout)}
|
|
269
|
+
</Floating>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{/* 3. Button: one size and place whatever is open, in the Timeline's row. */}
|
|
273
|
+
<div
|
|
274
|
+
ref={button}
|
|
275
|
+
className={cn(
|
|
276
|
+
'bg-background fixed right-3 bottom-[9px] z-50 flex h-9 items-center rounded-md border transition-shadow duration-150',
|
|
277
|
+
!open &&
|
|
278
|
+
'shadow-[0_1px_2px_rgb(0_0_0/0.04),0_4px_16px_-4px_rgb(0_0_0/0.12)]',
|
|
279
|
+
)}
|
|
280
|
+
>
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
aria-pressed={open}
|
|
284
|
+
aria-label={`${open ? 'Close' : 'Open'} ${label}`}
|
|
285
|
+
title={`${open ? 'Close' : 'Open'} ${label} (Ctrl /)`}
|
|
286
|
+
onClick={() => keep({ open: !open })}
|
|
287
|
+
className="text-muted-foreground hover:text-foreground aria-pressed:text-foreground aria-pressed:bg-muted relative grid h-full w-9 place-items-center rounded-l-[5px] transition-colors duration-150 outline-offset-2 focus-visible:outline-2 [&_svg]:size-4"
|
|
288
|
+
>
|
|
289
|
+
<Network />
|
|
290
|
+
{status && (
|
|
291
|
+
<span className="pointer-events-none absolute top-1.5 right-1.5">
|
|
292
|
+
{status}
|
|
293
|
+
</span>
|
|
294
|
+
)}
|
|
295
|
+
</button>
|
|
296
|
+
<ContextMenu>
|
|
297
|
+
<ContextMenuTrigger
|
|
298
|
+
render={
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
aria-label={`Devtools layout: ${current.label}`}
|
|
302
|
+
title={`${current.label}. Click for ${next.label.toLowerCase()}, right click to pick`}
|
|
303
|
+
onClick={() => keep({ layout: next.value, open: true })}
|
|
304
|
+
className="text-muted-foreground hover:text-foreground grid h-full w-9 place-items-center rounded-r-[5px] border-l transition-colors duration-150 outline-offset-2 focus-visible:outline-2 [&_svg]:size-4"
|
|
305
|
+
/>
|
|
306
|
+
}
|
|
307
|
+
>
|
|
308
|
+
<current.Icon />
|
|
309
|
+
</ContextMenuTrigger>
|
|
310
|
+
<ContextMenuContent className="w-44">
|
|
311
|
+
<ContextMenuRadioGroup
|
|
312
|
+
value={layout}
|
|
313
|
+
onValueChange={(value) =>
|
|
314
|
+
keep({ layout: value as DockLayout, open: true })
|
|
315
|
+
}
|
|
316
|
+
>
|
|
317
|
+
<ContextMenuLabel>Layout</ContextMenuLabel>
|
|
318
|
+
{offered.map(({ value, label: name, Icon }) => (
|
|
319
|
+
<ContextMenuRadioItem key={value} value={value} closeOnClick>
|
|
320
|
+
<Icon />
|
|
321
|
+
{name}
|
|
322
|
+
</ContextMenuRadioItem>
|
|
323
|
+
))}
|
|
324
|
+
</ContextMenuRadioGroup>
|
|
325
|
+
</ContextMenuContent>
|
|
326
|
+
</ContextMenu>
|
|
327
|
+
</div>
|
|
328
|
+
</>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Drag a pointer and follow it; the page keeps the cursor and selects nothing meanwhile. */
|
|
333
|
+
const drag =
|
|
334
|
+
(cursor: string, onMove: (event: globalThis.PointerEvent) => void) =>
|
|
335
|
+
(event: PointerEvent<HTMLElement>) => {
|
|
336
|
+
if (event.button !== 0) return;
|
|
337
|
+
event.preventDefault();
|
|
338
|
+
const target = event.currentTarget;
|
|
339
|
+
target.setPointerCapture(event.pointerId);
|
|
340
|
+
document.body.style.cursor = cursor;
|
|
341
|
+
document.body.style.userSelect = 'none';
|
|
342
|
+
const move = (each: globalThis.PointerEvent) => onMove(each);
|
|
343
|
+
const up = () => {
|
|
344
|
+
document.body.style.cursor = '';
|
|
345
|
+
document.body.style.userSelect = '';
|
|
346
|
+
target.removeEventListener('pointermove', move);
|
|
347
|
+
target.removeEventListener('pointerup', up);
|
|
348
|
+
target.removeEventListener('pointercancel', up);
|
|
349
|
+
};
|
|
350
|
+
target.addEventListener('pointermove', move);
|
|
351
|
+
target.addEventListener('pointerup', up);
|
|
352
|
+
target.addEventListener('pointercancel', up);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** The edge a docked panel is resized by. */
|
|
356
|
+
function Edge({
|
|
357
|
+
along,
|
|
358
|
+
onResize,
|
|
359
|
+
}: {
|
|
360
|
+
readonly along: 'top' | 'left';
|
|
361
|
+
readonly onResize: (to: { x: number; y: number }) => void;
|
|
362
|
+
}) {
|
|
363
|
+
const cursor = along === 'top' ? 'row-resize' : 'col-resize';
|
|
364
|
+
return (
|
|
365
|
+
<div
|
|
366
|
+
role="separator"
|
|
367
|
+
aria-orientation={along === 'top' ? 'horizontal' : 'vertical'}
|
|
368
|
+
aria-label="Drag to resize"
|
|
369
|
+
onPointerDown={drag(cursor, (event) =>
|
|
370
|
+
onResize({ x: event.clientX, y: event.clientY }),
|
|
371
|
+
)}
|
|
372
|
+
className={cn(
|
|
373
|
+
'hover:after:bg-primary/40 absolute z-20 touch-none after:absolute after:transition-[background-color] after:duration-150',
|
|
374
|
+
along === 'top'
|
|
375
|
+
? 'inset-x-0 -top-1 h-2 cursor-row-resize after:inset-x-0 after:top-1 after:h-0.5'
|
|
376
|
+
: 'inset-y-0 -left-1 w-2 cursor-col-resize after:inset-y-0 after:left-1 after:w-0.5',
|
|
377
|
+
)}
|
|
378
|
+
/>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A window over the page, moved by its title bar and resized by any corner. */
|
|
383
|
+
function Floating({
|
|
384
|
+
label,
|
|
385
|
+
rect,
|
|
386
|
+
onChange,
|
|
387
|
+
onClose,
|
|
388
|
+
children,
|
|
389
|
+
}: {
|
|
390
|
+
readonly label: string;
|
|
391
|
+
readonly rect: Kept['floating'];
|
|
392
|
+
readonly onChange: (rect: Kept['floating']) => void;
|
|
393
|
+
readonly onClose: () => void;
|
|
394
|
+
readonly children: ReactNode;
|
|
395
|
+
}) {
|
|
396
|
+
const width = clamp(rect.width, MIN_WIDTH, window.innerWidth - 16);
|
|
397
|
+
const height = clamp(rect.height, MIN_HEIGHT, window.innerHeight - 16);
|
|
398
|
+
const x = clamp(rect.x, 8, window.innerWidth - width - 8);
|
|
399
|
+
const y = clamp(rect.y, 8, window.innerHeight - height - 8);
|
|
400
|
+
return (
|
|
401
|
+
<div
|
|
402
|
+
role="dialog"
|
|
403
|
+
aria-label={label}
|
|
404
|
+
className="fixed z-40"
|
|
405
|
+
style={{ left: x, top: y, width, height }}
|
|
406
|
+
>
|
|
407
|
+
{/* Rounded and clipped inside; the corners sit outside the clip, so all of each stays grabbable. */}
|
|
408
|
+
<div className="bg-background flex h-full flex-col overflow-hidden rounded-xl border shadow-[0_1px_2px_rgb(0_0_0/0.06),0_16px_48px_rgb(0_0_0/0.2)]">
|
|
409
|
+
<div
|
|
410
|
+
onPointerDown={(start) => {
|
|
411
|
+
const from = { x: start.clientX - x, y: start.clientY - y };
|
|
412
|
+
drag('grabbing', (event) =>
|
|
413
|
+
onChange({
|
|
414
|
+
...rect,
|
|
415
|
+
width,
|
|
416
|
+
height,
|
|
417
|
+
x: event.clientX - from.x,
|
|
418
|
+
y: event.clientY - from.y,
|
|
419
|
+
}),
|
|
420
|
+
)(start);
|
|
421
|
+
}}
|
|
422
|
+
className="text-muted-foreground relative flex h-8 shrink-0 cursor-grab items-center justify-center border-b text-xs font-medium select-none active:cursor-grabbing"
|
|
423
|
+
>
|
|
424
|
+
{label}
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
aria-label={`Close ${label}`}
|
|
428
|
+
title={`Close ${label} (Ctrl /)`}
|
|
429
|
+
// A press on it closes the window; it never starts a move.
|
|
430
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
431
|
+
onClick={onClose}
|
|
432
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted absolute right-1.5 z-30 grid size-6 cursor-default place-items-center rounded-md transition-[background-color,color,scale] duration-150 ease-out outline-none focus-visible:ring-2 active:scale-[0.92] [&_svg]:size-3.5"
|
|
433
|
+
>
|
|
434
|
+
<X />
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="min-h-0 flex-1">{children}</div>
|
|
438
|
+
</div>
|
|
439
|
+
{CORNERS.map(({ name, left, top, cursor, place }) => (
|
|
440
|
+
<div
|
|
441
|
+
key={name}
|
|
442
|
+
aria-label={`Drag the ${name} corner to resize`}
|
|
443
|
+
onPointerDown={(start) => {
|
|
444
|
+
// The corner across from this one stays where it is.
|
|
445
|
+
const right = x + width;
|
|
446
|
+
const bottom = y + height;
|
|
447
|
+
drag(cursor, (event) => {
|
|
448
|
+
const across = left
|
|
449
|
+
? right - clamp(event.clientX, 8, right - MIN_WIDTH)
|
|
450
|
+
: clamp(event.clientX, x + MIN_WIDTH, window.innerWidth - 8) -
|
|
451
|
+
x;
|
|
452
|
+
const down = top
|
|
453
|
+
? bottom - clamp(event.clientY, 8, bottom - MIN_HEIGHT)
|
|
454
|
+
: clamp(event.clientY, y + MIN_HEIGHT, window.innerHeight - 8) -
|
|
455
|
+
y;
|
|
456
|
+
onChange({
|
|
457
|
+
x: left ? right - across : x,
|
|
458
|
+
y: top ? bottom - down : y,
|
|
459
|
+
width: across,
|
|
460
|
+
height: down,
|
|
461
|
+
});
|
|
462
|
+
})(start);
|
|
463
|
+
}}
|
|
464
|
+
className={cn('absolute z-20 size-5 touch-none', place)}
|
|
465
|
+
style={{ cursor }}
|
|
466
|
+
/>
|
|
467
|
+
))}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const MIN_WIDTH = 360;
|
|
473
|
+
const MIN_HEIGHT = 240;
|
|
474
|
+
|
|
475
|
+
/** The four corners a floating window is resized by. */
|
|
476
|
+
const CORNERS = [
|
|
477
|
+
{
|
|
478
|
+
name: 'top left',
|
|
479
|
+
left: true,
|
|
480
|
+
top: true,
|
|
481
|
+
cursor: 'nwse-resize',
|
|
482
|
+
place: '-top-1 -left-1',
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: 'top right',
|
|
486
|
+
left: false,
|
|
487
|
+
top: true,
|
|
488
|
+
cursor: 'nesw-resize',
|
|
489
|
+
place: '-top-1 -right-1',
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: 'bottom left',
|
|
493
|
+
left: true,
|
|
494
|
+
top: false,
|
|
495
|
+
cursor: 'nesw-resize',
|
|
496
|
+
place: '-bottom-1 -left-1',
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'bottom right',
|
|
500
|
+
left: false,
|
|
501
|
+
top: false,
|
|
502
|
+
cursor: 'nwse-resize',
|
|
503
|
+
place: '-right-1 -bottom-1',
|
|
504
|
+
},
|
|
505
|
+
] as const;
|
|
@@ -54,6 +54,7 @@ type Statuses = NonNullable<Parameters<typeof sceneOf>[0]['statuses']>;
|
|
|
54
54
|
* Message asked for are worked out again from the Step before,
|
|
55
55
|
* once each.
|
|
56
56
|
* 3. Workers live and running, what each Instance's Workers are doing.
|
|
57
|
+
* 4. Window how many entries the History keeps, changed at any time.
|
|
57
58
|
*/
|
|
58
59
|
export function KabukiDevtools({
|
|
59
60
|
runtime,
|
|
@@ -133,6 +134,9 @@ export function KabukiDevtools({
|
|
|
133
134
|
onShow: (index) =>
|
|
134
135
|
runtime.show(index === 0 ? 'init' : steps[index - 1]!.id),
|
|
135
136
|
onLive: () => runtime.show(null),
|
|
137
|
+
head: steps === branch,
|
|
138
|
+
window: history.window(),
|
|
139
|
+
onWindow: history.keep,
|
|
136
140
|
}}
|
|
137
141
|
/>
|
|
138
142
|
);
|
|
@@ -11,7 +11,8 @@ import { ink } from '../../ink/index.ts';
|
|
|
11
11
|
* 1. Place at 100%, never zoomed in: centred if the whole drawing fits,
|
|
12
12
|
* else centred across with its top a little below the surface's,
|
|
13
13
|
* running down. From then on it stays where the reader leaves
|
|
14
|
-
* it: the surface never moves by itself
|
|
14
|
+
* it: the surface never moves by itself, but when it is resized
|
|
15
|
+
* what was at its centre stays at its centre.
|
|
15
16
|
* 2. Pan drag with a finger or the pointer, from anywhere, over what is
|
|
16
17
|
* drawn too: a press becomes a drag once it moves, and the click
|
|
17
18
|
* it would have made is swallowed. A press that stays put clicks
|
|
@@ -109,6 +110,21 @@ export function Canvas({
|
|
|
109
110
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- placed once, where the drawing first is; later it stays where the reader leaves it.
|
|
110
111
|
}, [x, y, scale]);
|
|
111
112
|
|
|
113
|
+
// 1. Place: a resize keeps the centre where it was.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const element = surface.current!;
|
|
116
|
+
let width = element.clientWidth;
|
|
117
|
+
let height = element.clientHeight;
|
|
118
|
+
const observer = new ResizeObserver(() => {
|
|
119
|
+
x.set(x.get() + (element.clientWidth - width) / 2);
|
|
120
|
+
y.set(y.get() + (element.clientHeight - height) / 2);
|
|
121
|
+
width = element.clientWidth;
|
|
122
|
+
height = element.clientHeight;
|
|
123
|
+
});
|
|
124
|
+
observer.observe(element);
|
|
125
|
+
return () => observer.disconnect();
|
|
126
|
+
}, [x, y]);
|
|
127
|
+
|
|
112
128
|
// 3. Zoom, from the wheel: a passive listener could not stop the page scrolling.
|
|
113
129
|
useEffect(() => {
|
|
114
130
|
const element = surface.current!;
|