@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.
Files changed (32) hide show
  1. package/README.md +6 -0
  2. package/dist/components/viewers/diff-viewer/diff-viewer.js +1 -1
  3. package/dist/components/viewers/source-explorer/source-explorer.js +1 -1
  4. package/dist/recipes/devtools-dock/devtools-dock.d.ts +23 -0
  5. package/dist/recipes/devtools-dock/devtools-dock.d.ts.map +1 -0
  6. package/dist/recipes/devtools-dock/devtools-dock.js +343 -0
  7. package/dist/recipes/devtools-dock/devtools-dock.js.map +1 -0
  8. package/dist/recipes/devtools-dock/index.d.ts +2 -0
  9. package/dist/recipes/devtools-dock/index.js +2 -0
  10. package/dist/recipes/kabuki-devtools/kabuki-devtools.d.ts.map +1 -1
  11. package/dist/recipes/kabuki-devtools/kabuki-devtools.js +4 -1
  12. package/dist/recipes/kabuki-devtools/kabuki-devtools.js.map +1 -1
  13. package/dist/recipes/kabuki-devtools/map/canvas/canvas.d.ts.map +1 -1
  14. package/dist/recipes/kabuki-devtools/map/canvas/canvas.js +13 -0
  15. package/dist/recipes/kabuki-devtools/map/canvas/canvas.js.map +1 -1
  16. package/dist/recipes/kabuki-devtools/scene/tree.js +1 -1
  17. package/dist/recipes/kabuki-devtools/scene/tree.js.map +1 -1
  18. package/dist/recipes/kabuki-devtools/sheet/sheet.d.ts.map +1 -1
  19. package/dist/recipes/kabuki-devtools/sheet/sheet.js +84 -78
  20. package/dist/recipes/kabuki-devtools/sheet/sheet.js.map +1 -1
  21. package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts +6 -1
  22. package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts.map +1 -1
  23. package/dist/recipes/kabuki-devtools/timeline/timeline.js +202 -16
  24. package/dist/recipes/kabuki-devtools/timeline/timeline.js.map +1 -1
  25. package/package.json +13 -13
  26. package/src/recipes/devtools-dock/devtools-dock.tsx +505 -0
  27. package/src/recipes/devtools-dock/index.ts +2 -0
  28. package/src/recipes/kabuki-devtools/kabuki-devtools.tsx +4 -0
  29. package/src/recipes/kabuki-devtools/map/canvas/canvas.tsx +17 -1
  30. package/src/recipes/kabuki-devtools/scene/tree.ts +1 -1
  31. package/src/recipes/kabuki-devtools/sheet/sheet.tsx +95 -107
  32. 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;
@@ -0,0 +1,2 @@
1
+ export { DevtoolsDock } from './devtools-dock.tsx';
2
+ export type { DockLayout } from './devtools-dock.tsx';
@@ -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!;
@@ -161,7 +161,7 @@ export const treeOf = (
161
161
  held.definition,
162
162
  holders.flatMap((holder) => asList(holder.children[name])),
163
163
  slot,
164
- held.many,
164
+ held.kind === 'each',
165
165
  below,
166
166
  );
167
167
  });