paraframe 0.0.0-stage → 0.2.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -2
  3. package/dist/Canvas.svelte +701 -0
  4. package/dist/Canvas.svelte.d.ts +9 -0
  5. package/dist/Comments.svelte +48 -0
  6. package/dist/Comments.svelte.d.ts +13 -0
  7. package/dist/FrameHost.svelte +135 -0
  8. package/dist/FrameHost.svelte.d.ts +8 -0
  9. package/dist/FrameLink.svelte +17 -0
  10. package/dist/FrameLink.svelte.d.ts +7 -0
  11. package/dist/Player.svelte +371 -0
  12. package/dist/Player.svelte.d.ts +8 -0
  13. package/dist/comments.d.ts +4 -0
  14. package/dist/comments.js +7 -0
  15. package/dist/index.d.ts +7 -0
  16. package/dist/index.js +6 -0
  17. package/dist/internal/CanvasPreview.svelte +153 -0
  18. package/dist/internal/CanvasPreview.svelte.d.ts +15 -0
  19. package/dist/internal/Icon.svelte +44 -0
  20. package/dist/internal/Icon.svelte.d.ts +29 -0
  21. package/dist/internal/Menu.svelte +138 -0
  22. package/dist/internal/Menu.svelte.d.ts +15 -0
  23. package/dist/internal/MenuItem.svelte +40 -0
  24. package/dist/internal/MenuItem.svelte.d.ts +12 -0
  25. package/dist/internal/canvas-frame.d.ts +2 -0
  26. package/dist/internal/canvas-frame.js +88 -0
  27. package/dist/internal/canvas-load-queue.d.ts +7 -0
  28. package/dist/internal/canvas-load-queue.js +69 -0
  29. package/dist/internal/comments.d.ts +36 -0
  30. package/dist/internal/comments.js +14 -0
  31. package/dist/internal/storage.d.ts +3 -0
  32. package/dist/internal/storage.js +22 -0
  33. package/dist/messages.d.ts +26 -0
  34. package/dist/messages.js +27 -0
  35. package/dist/nav.d.ts +18 -0
  36. package/dist/nav.js +15 -0
  37. package/dist/prototype.d.ts +10 -0
  38. package/dist/prototype.js +78 -0
  39. package/dist/styles.css +2 -0
  40. package/dist/types.d.ts +100 -0
  41. package/dist/types.js +1 -0
  42. package/package.json +97 -5
@@ -0,0 +1,701 @@
1
+ <!--
2
+ Every frame laid out by section on a board you can pan and zoom, like a
3
+ design tool's canvas. Frames here are previews: double-click one to open it
4
+ in the player.
5
+ -->
6
+ <script lang="ts">
7
+ import { onMount } from 'svelte';
8
+ import { SvelteSet } from 'svelte/reactivity';
9
+ import { devicePixelRatio } from 'svelte/reactivity/window';
10
+ import { goto } from '$app/navigation';
11
+ import Icon from './internal/Icon.svelte';
12
+ import CanvasPreview from './internal/CanvasPreview.svelte';
13
+ import { createCanvasLoadQueue } from './internal/canvas-load-queue.js';
14
+ import { canvasFrame } from './internal/canvas-frame.js';
15
+ import Menu from './internal/Menu.svelte';
16
+ import MenuItem from './internal/MenuItem.svelte';
17
+ import { readStored, storageKey, writeStored } from './internal/storage.js';
18
+ import { activatesOnEnter, isEditable } from './messages.js';
19
+ import { describeFrame, useCommentsHolder, type CommentThreadPin } from './internal/comments.js';
20
+ import type { Flow, Frame, Prototype, Section } from './types.js';
21
+
22
+ let {
23
+ prototype,
24
+ preview
25
+ }: {
26
+ prototype: Prototype;
27
+ /** Screenshot URL; visible frames become live when zoom exceeds its pixel density. */
28
+ preview?: (frame: Frame) => string;
29
+ } = $props();
30
+
31
+ // Canvas units are CSS pixels at 100% zoom.
32
+ const FRAME_GAP = 160;
33
+ const ROW_GAP = 320;
34
+ const SECTION_PAD = 280;
35
+ const SECTION_GAP = 400;
36
+ const MIN_ZOOM = 0.02;
37
+ const MAX_ZOOM = 4;
38
+ const FIT_PADDING = 56;
39
+ const LOAD_IDLE_MS = 50;
40
+ // Below this on-screen width, frame titles are hidden.
41
+ const MIN_LABEL_WIDTH = 72;
42
+ // Below this on-screen width, a frame's path is hidden and its title keeps the room.
43
+ const PATH_LABEL_WIDTH = 360;
44
+ // The comments panel floats over the right edge; fitting keeps frames clear of it.
45
+ const PANEL_INSET = 320 + 12 * 2;
46
+ const TOOLBAR_HEIGHT = 56;
47
+
48
+ type Box = { x: number; y: number; w: number; h: number };
49
+ type Camera = { x: number; y: number; z: number };
50
+
51
+ const layout = $derived.by(() => {
52
+ const frames = new Map<string, Box>();
53
+ const sections: { section: Section; box: Box }[] = [];
54
+ let top = 0;
55
+ let width = 0;
56
+ for (const section of prototype.sections) {
57
+ const columns = Math.max(1, section.columns ?? section.frames.length);
58
+ let x = SECTION_PAD;
59
+ let y = top + SECTION_PAD;
60
+ let rowHeight = 0;
61
+ let right = SECTION_PAD;
62
+ section.frames.forEach((frame, i) => {
63
+ if (i > 0 && i % columns === 0) {
64
+ x = SECTION_PAD;
65
+ y += rowHeight + ROW_GAP;
66
+ rowHeight = 0;
67
+ }
68
+ frames.set(frame.id, { x, y, w: frame.size.width, h: frame.size.height });
69
+ right = Math.max(right, x + frame.size.width);
70
+ x += frame.size.width + FRAME_GAP;
71
+ rowHeight = Math.max(rowHeight, frame.size.height);
72
+ });
73
+ const box = { x: 0, y: top, w: right + SECTION_PAD, h: y + rowHeight + SECTION_PAD - top };
74
+ sections.push({ section, box });
75
+ width = Math.max(width, box.w);
76
+ top += box.h + SECTION_GAP;
77
+ }
78
+ const bounds: Box = { x: 0, y: 0, w: Math.max(width, 1), h: Math.max(top - SECTION_GAP, 1) };
79
+ return { frames, sections, bounds };
80
+ });
81
+
82
+ const keys = $derived({
83
+ camera: storageKey(prototype.title, 'camera'),
84
+ selected: storageKey(prototype.title, 'selected'),
85
+ last: storageKey(prototype.title, 'last'),
86
+ flow: storageKey(prototype.title, 'flow')
87
+ });
88
+
89
+ let viewport = $state<HTMLDivElement>();
90
+ let width = $state(0);
91
+ let height = $state(0);
92
+ let camera = $state<Camera>({ x: 0, y: 0, z: 0.1 });
93
+ let ready = $state(false);
94
+ let selected = $state<string | undefined>();
95
+ let hovered = $state<string | undefined>();
96
+ let panning = $state(false);
97
+ let pageScale = $state(1);
98
+ const holder = useCommentsHolder();
99
+ const comments = $derived(holder?.current);
100
+ const commenting = $derived(comments?.active ?? false);
101
+ const rightInset = $derived(commenting ? PANEL_INSET : 0);
102
+ const pixelScale = $derived(camera.z * (devicePixelRatio.current ?? 1) * pageScale);
103
+ const loadQueue = createCanvasLoadQueue();
104
+
105
+ $effect(() => {
106
+ // Camera changes cover wheel, keyboard, toolbar and pointer gestures. Also
107
+ // wait for native pinch zoom and viewport/density changes to settle.
108
+ camera.x; camera.y; camera.z; pixelScale; width; height;
109
+ loadQueue.pause();
110
+ if (!ready || panning) return;
111
+ const timer = setTimeout(() => loadQueue.resume(), LOAD_IDLE_MS);
112
+ return () => clearTimeout(timer);
113
+ });
114
+
115
+ onMount(() => {
116
+ // Pinch zoom magnifies the visual viewport without changing devicePixelRatio.
117
+ const visual = window.visualViewport;
118
+ const update = () => { pageScale = visual?.scale ?? 1; };
119
+ update();
120
+ visual?.addEventListener('resize', update);
121
+ return () => visual?.removeEventListener('resize', update);
122
+ });
123
+
124
+ function nearViewport(b: Box) {
125
+ const margin = 96;
126
+ const left = b.x * camera.z + camera.x;
127
+ const top = b.y * camera.z + camera.y;
128
+ return left < width + margin && left + b.w * camera.z > -margin
129
+ && top < height + margin && top + b.h * camera.z > -margin;
130
+ }
131
+
132
+ // Keep thumbnails (or dev iframes) once seen; production live frames are culled separately.
133
+ const seen = new SvelteSet<string>();
134
+
135
+ $effect(() => {
136
+ if (!ready) return;
137
+ const { x, y, z } = camera;
138
+ const margin = 0.25;
139
+ const left = (-x - width * margin) / z;
140
+ const right = (width * (1 + margin) - x) / z;
141
+ const top = (-y - height * margin) / z;
142
+ const bottom = (height * (1 + margin) - y) / z;
143
+ for (const [id, b] of layout.frames) {
144
+ if (seen.has(id)) continue;
145
+ if (b.x < right && b.x + b.w > left && b.y < bottom && b.y + b.h > top) seen.add(id);
146
+ }
147
+ });
148
+
149
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
150
+
151
+ function fitCamera(box: Box, padding = FIT_PADDING, maxZoom = MAX_ZOOM): Camera {
152
+ const visible = width - rightInset;
153
+ const z = clamp(
154
+ Math.min((visible - padding * 2) / box.w, (height - padding * 2) / box.h),
155
+ MIN_ZOOM,
156
+ maxZoom
157
+ );
158
+ return { z, x: (visible - box.w * z) / 2 - box.x * z, y: (height - box.h * z) / 2 - box.y * z };
159
+ }
160
+
161
+ function fit(box: Box, padding = FIT_PADDING) {
162
+ stopAnimation();
163
+ camera = fitCamera(box, padding);
164
+ persist();
165
+ }
166
+
167
+ let animation = 0;
168
+ function stopAnimation() {
169
+ cancelAnimationFrame(animation);
170
+ animation = 0;
171
+ }
172
+
173
+ // Glides to a camera. The point at the centre of the view moves linearly and
174
+ // the zoom changes geometrically, so zooming and panning feel even.
175
+ function animateTo(target: Camera, duration = 480) {
176
+ stopAnimation();
177
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
178
+ camera = target;
179
+ persist();
180
+ return;
181
+ }
182
+ const from = { ...camera };
183
+ const cx = width / 2;
184
+ const cy = height / 2;
185
+ const a = { x: (cx - from.x) / from.z, y: (cy - from.y) / from.z };
186
+ const b = { x: (cx - target.x) / target.z, y: (cy - target.y) / target.z };
187
+ const start = performance.now();
188
+ const step = (time: number) => {
189
+ const t = Math.min(1, (time - start) / duration);
190
+ const e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
191
+ const z = from.z * (target.z / from.z) ** e;
192
+ camera = { z, x: cx - (a.x + (b.x - a.x) * e) * z, y: cy - (a.y + (b.y - a.y) * e) * z };
193
+ if (t < 1) animation = requestAnimationFrame(step);
194
+ else {
195
+ animation = 0;
196
+ persist();
197
+ }
198
+ };
199
+ animation = requestAnimationFrame(step);
200
+ }
201
+
202
+ function zoomAt(factor: number, sx = width / 2, sy = height / 2) {
203
+ stopAnimation();
204
+ const z = clamp(camera.z * factor, MIN_ZOOM, MAX_ZOOM);
205
+ const cx = (sx - camera.x) / camera.z;
206
+ const cy = (sy - camera.y) / camera.z;
207
+ camera = { z, x: sx - cx * z, y: sy - cy * z };
208
+ persist();
209
+ }
210
+
211
+ function zoomTo(z: number) {
212
+ zoomAt(z / camera.z);
213
+ }
214
+
215
+ function fitSelection() {
216
+ const box = selected && layout.frames.get(selected);
217
+ // Keep the frame and its title below the floating toolbar while centering it.
218
+ if (box) fit(box, 72);
219
+ }
220
+
221
+ const zoomActions = [
222
+ { label: 'Zoom in', hint: '+', run: () => zoomAt(1.25) },
223
+ { label: 'Zoom out', hint: '−', run: () => zoomAt(0.8) },
224
+ { label: 'Zoom to fit', hint: '⇧1', run: () => fit(layout.bounds) },
225
+ { label: 'Zoom to 100%', hint: '⇧0', run: () => zoomTo(1) },
226
+ { label: 'Focus selection', hint: 'F / ⇧2', run: fitSelection }
227
+ ];
228
+
229
+ let persistTimer: ReturnType<typeof setTimeout> | undefined;
230
+ function persist() {
231
+ clearTimeout(persistTimer);
232
+ persistTimer = setTimeout(() => {
233
+ writeStored('session', keys.camera, camera);
234
+ writeStored('session', keys.selected, selected ?? null);
235
+ }, 150);
236
+ }
237
+
238
+ function select(id: string | undefined) {
239
+ selected = id;
240
+ persist();
241
+ }
242
+
243
+ function play(id: string | undefined, flow?: Flow) {
244
+ const frame = (id && prototype.get(id)) || prototype.frames[0];
245
+ if (!frame) return;
246
+ // The player shows the active flow; playing a single frame leaves any flow.
247
+ writeStored('session', keys.flow, flow?.id ?? null);
248
+ goto(prototype.paths.play(frame.id));
249
+ }
250
+
251
+ const startFlow = (flow: Flow) => play(flow.start, flow);
252
+
253
+ // Flows that start on each frame, to mark starting points on the canvas.
254
+ const flowsByStart = $derived.by(() => {
255
+ const map = new Map<string, Flow[]>();
256
+ for (const flow of prototype.flows) map.set(flow.start, [...(map.get(flow.start) ?? []), flow]);
257
+ return map;
258
+ });
259
+
260
+ // The canvas point under the pointer.
261
+ function toCanvas(event: { clientX: number; clientY: number }) {
262
+ const rect = viewport!.getBoundingClientRect();
263
+ return {
264
+ x: (event.clientX - rect.left - camera.x) / camera.z,
265
+ y: (event.clientY - rect.top - camera.y) / camera.z
266
+ };
267
+ }
268
+
269
+ function hit(event: { clientX: number; clientY: number }): Frame | undefined {
270
+ if (!viewport) return undefined;
271
+ const { x, y } = toCanvas(event);
272
+ for (const [id, b] of layout.frames) {
273
+ if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return prototype.get(id);
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ // Pans and zooms to a thread's frame unless its pin is already in view.
279
+ function showThread(thread: CommentThreadPin) {
280
+ const box = layout.frames.get(thread.surface);
281
+ if (!box) return false;
282
+ const target = fitCamera(box, 72, Math.max(camera.z, 1));
283
+ const pin = { left: (box.x + thread.x) * camera.z + camera.x, top: (box.y + thread.y) * camera.z + camera.y };
284
+ const inView = pin.left > 40 && pin.left < width - rightInset - 360
285
+ && pin.top > TOOLBAR_HEIGHT + 40 && pin.top < height - 40;
286
+ if (!inView || camera.z < target.z * 0.5) animateTo(target);
287
+ return true;
288
+ }
289
+
290
+ // While the canvas is shown, threads opened from the list or a link move the camera to their frame.
291
+ $effect(() => {
292
+ const current = comments;
293
+ if (!current) return;
294
+ current.configure({ show: showThread, describe: (id) => describeFrame(prototype, id) });
295
+ return () => current.configure({ show: undefined });
296
+ });
297
+
298
+ let drag: { sx: number; sy: number; x: number; y: number; moved: boolean } | undefined;
299
+
300
+ function onpointerdown(event: PointerEvent) {
301
+ if (event.button !== 0 && event.button !== 1) return;
302
+ if ((event.target as Element).closest('[data-pf-ui]')) return;
303
+ stopAnimation();
304
+ viewport?.setPointerCapture(event.pointerId);
305
+ drag = { sx: event.clientX, sy: event.clientY, x: camera.x, y: camera.y, moved: false };
306
+ }
307
+
308
+ function onpointermove(event: PointerEvent) {
309
+ if (!drag) {
310
+ hovered = commenting ? undefined : hit(event)?.id;
311
+ return;
312
+ }
313
+ const dx = event.clientX - drag.sx;
314
+ const dy = event.clientY - drag.sy;
315
+ if (!drag.moved && Math.hypot(dx, dy) < 4) return;
316
+ drag.moved = true;
317
+ hovered = undefined;
318
+ panning = true;
319
+ camera.x = drag.x + dx;
320
+ camera.y = drag.y + dy;
321
+ }
322
+
323
+ function onpointerup(event: PointerEvent) {
324
+ if (!drag) return;
325
+ if (drag.moved) persist();
326
+ // In comment mode a click is the comments' to handle.
327
+ else if (event.button === 0 && !commenting) select(hit(event)?.id);
328
+ drag = undefined;
329
+ panning = false;
330
+ }
331
+
332
+ function ondblclick(event: MouseEvent) {
333
+ if ((event.target as Element).closest('[data-pf-ui]')) return;
334
+ const frame = hit(event);
335
+ if (frame) play(frame.id);
336
+ }
337
+
338
+ // Wheel listeners must be non-passive to stop the browser zooming the page.
339
+ function wheel(node: HTMLElement) {
340
+ const onwheel = (event: WheelEvent) => {
341
+ // Let an open thread scroll its replies.
342
+ const scroller = (event.target as Element).closest('[data-pf-scroll]');
343
+ if (scroller && !event.ctrlKey && !event.metaKey && scroller.scrollHeight > scroller.clientHeight) return;
344
+ event.preventDefault();
345
+ stopAnimation();
346
+ hovered = undefined;
347
+ const unit = event.deltaMode === 1 ? 16 : 1;
348
+ if (event.ctrlKey || event.metaKey) {
349
+ const rect = node.getBoundingClientRect();
350
+ const delta = clamp(event.deltaY * unit, -50, 50);
351
+ zoomAt(Math.exp(-delta * 0.01), event.clientX - rect.left, event.clientY - rect.top);
352
+ } else {
353
+ const horizontal = event.shiftKey && !event.deltaX;
354
+ camera.x -= (horizontal ? event.deltaY : event.deltaX) * unit;
355
+ camera.y -= (horizontal ? 0 : event.deltaY) * unit;
356
+ persist();
357
+ }
358
+ };
359
+ node.addEventListener('wheel', onwheel, { passive: false });
360
+ return () => node.removeEventListener('wheel', onwheel);
361
+ }
362
+
363
+ function step(offset: number) {
364
+ const index = selected ? (prototype.get(selected)?.index ?? -1) : -1;
365
+ const next = prototype.frames[clamp(index + offset, 0, prototype.frames.length - 1)];
366
+ if (!next) return;
367
+ select(next.id);
368
+ fitSelection();
369
+ }
370
+
371
+ function onkeydown(event: KeyboardEvent) {
372
+ if (isEditable(event.target) || document.querySelector(':popover-open')) return;
373
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
374
+ // A focused button or link handles Enter itself; Enter elsewhere plays the selection.
375
+ if (event.code === 'Enter' && activatesOnEnter(event.target)) return;
376
+ const actions: Record<string, () => void> = {
377
+ 'shift+Digit1': () => fit(layout.bounds),
378
+ 'shift+Digit0': () => zoomTo(1),
379
+ 'shift+Digit2': fitSelection,
380
+ KeyF: fitSelection,
381
+ Equal: () => zoomAt(1.25),
382
+ NumpadAdd: () => zoomAt(1.25),
383
+ Minus: () => zoomAt(0.8),
384
+ NumpadSubtract: () => zoomAt(0.8),
385
+ Enter: () => selected && play(selected),
386
+ Escape: () => {
387
+ // Escape closes comments first; they handle it themselves.
388
+ if (!commenting && comments?.openId === undefined) select(undefined);
389
+ },
390
+ ArrowRight: () => step(1),
391
+ ArrowLeft: () => step(-1)
392
+ };
393
+ const action = actions[event.shiftKey ? `shift+${event.code}` : event.code];
394
+ if (action) {
395
+ event.preventDefault();
396
+ action();
397
+ }
398
+ }
399
+
400
+ onMount(() => {
401
+ if (!viewport) return;
402
+ width = viewport.clientWidth;
403
+ height = viewport.clientHeight;
404
+ const stored = readStored<Camera | null>('session', keys.camera, null);
405
+ selected =
406
+ readStored<string | null>('session', keys.last, null) ??
407
+ readStored<string | null>('session', keys.selected, null) ??
408
+ undefined;
409
+ if (selected && !prototype.get(selected)) selected = undefined;
410
+ if (stored) camera = stored;
411
+ else fit(layout.bounds);
412
+ ready = true;
413
+ return () => {
414
+ clearTimeout(persistTimer);
415
+ stopAnimation();
416
+ loadQueue.destroy();
417
+ };
418
+ });
419
+
420
+ // Screen position of a canvas box.
421
+ const screen = (b: Box) => ({
422
+ left: b.x * camera.z + camera.x,
423
+ top: b.y * camera.z + camera.y,
424
+ width: b.w * camera.z,
425
+ height: b.h * camera.z
426
+ });
427
+
428
+ // A dot grid that pans and zooms with the canvas, kept between 16 and 64 px apart.
429
+ const grid = $derived.by(() => {
430
+ let size = 40 * camera.z;
431
+ while (size < 16) size *= 2;
432
+ while (size > 64) size /= 2;
433
+ return { size, x: camera.x % size, y: camera.y % size };
434
+ });
435
+
436
+ const selectedFrame = $derived(selected ? prototype.get(selected) : undefined);
437
+ </script>
438
+
439
+ <svelte:window {onkeydown} />
440
+
441
+ <div class="pf-root relative flex h-dvh flex-col bg-pf-canvas font-pf text-[13px] text-pf-canvas-ink">
442
+ <header class="pf-toolbar absolute inset-x-3 top-3 z-20">
443
+ <div class="pf-island min-h-10 gap-2.5 py-1.5 pr-5 pl-3.5">
444
+ <span class="shrink-0"><Icon name="canvas" size={18} /></span>
445
+ <div class="min-w-0">
446
+ <h1 class="truncate text-sm font-semibold leading-5">{prototype.title}</h1>
447
+ {#if prototype.description}
448
+ <p class="truncate text-xs text-pf-island-muted">{prototype.description}</p>
449
+ {/if}
450
+ </div>
451
+ </div>
452
+
453
+ <nav class="pf-island ml-auto shrink-0" aria-label="Canvas controls">
454
+ {#if prototype.flows.length}
455
+ <Menu label="Play a flow" align="end" class="pf-tool gap-1.5 px-3">
456
+ {#snippet trigger()}
457
+ <span>Flows</span>
458
+ <Icon name="chevron-down" size={14} />
459
+ {/snippet}
460
+ {#snippet children(close)}
461
+ {#each prototype.flows as flow (flow.id)}
462
+ <MenuItem
463
+ onselect={() => {
464
+ close();
465
+ startFlow(flow);
466
+ }}
467
+ hint={prototype.get(flow.start)?.title}
468
+ >
469
+ {flow.title}
470
+ </MenuItem>
471
+ {/each}
472
+ {/snippet}
473
+ </Menu>
474
+ {/if}
475
+
476
+ <Menu label="Zoom" align="end" class="pf-tool w-20 justify-between gap-1.5 px-3">
477
+ {#snippet trigger()}
478
+ <span class="tabular-nums">{Math.round(camera.z * 100)}%</span>
479
+ <Icon name="chevron-down" size={14} />
480
+ {/snippet}
481
+ {#snippet children(close)}
482
+ {#each zoomActions as entry (entry.label)}
483
+ <MenuItem
484
+ hint={entry.hint}
485
+ onselect={() => {
486
+ close();
487
+ entry.run();
488
+ }}
489
+ >
490
+ {entry.label}
491
+ </MenuItem>
492
+ {/each}
493
+ {/snippet}
494
+ {#snippet footer()}
495
+ <p class="max-w-60 border-t border-pf-line px-2 pt-2 pb-1 text-xs leading-relaxed text-pf-muted">
496
+ Scroll or drag to pan. Hold ⌘ or Ctrl and scroll to zoom. Double-click a frame to play it.
497
+ </p>
498
+ {/snippet}
499
+ </Menu>
500
+
501
+ {#if comments}
502
+ <button
503
+ type="button"
504
+ class="pf-tool relative"
505
+ aria-pressed={commenting}
506
+ aria-label="Comments"
507
+ title="Comments (C)"
508
+ onclick={() => comments.toggle()}
509
+ >
510
+ <Icon name="comment" />
511
+ {#if comments.unreadCount && !commenting}
512
+ <span class="absolute top-1 right-1 size-2 rounded-full bg-pf-comment"></span>
513
+ {/if}
514
+ </button>
515
+ {/if}
516
+
517
+ <button
518
+ type="button"
519
+ class="pf-tool pf-tool-primary"
520
+ onclick={() => play(selected)}
521
+ title={selectedFrame ? `Play from “${selectedFrame.title}”` : 'Play from the first frame'}
522
+ >
523
+ <Icon name="play" size={14} />
524
+ Play
525
+ </button>
526
+ {#if comments}<span data-vc-account></span>{/if}
527
+ </nav>
528
+ </header>
529
+
530
+ <div
531
+ bind:this={viewport}
532
+ bind:clientWidth={width}
533
+ bind:clientHeight={height}
534
+ {@attach wheel}
535
+ {onpointerdown}
536
+ {onpointermove}
537
+ {onpointerup}
538
+ onpointercancel={onpointerup}
539
+ onpointerleave={() => (hovered = undefined)}
540
+ {ondblclick}
541
+ data-vc-gestures="host"
542
+ role="application"
543
+ aria-label="Canvas of all frames"
544
+ class={[
545
+ 'relative min-h-0 flex-1 touch-none overflow-hidden select-none',
546
+ panning && 'cursor-grabbing'
547
+ ]}
548
+ style:background-image="radial-gradient(circle, var(--pf-grid) 1px, transparent 1.4px)"
549
+ style:background-size="{grid.size}px {grid.size}px"
550
+ style:background-position="{grid.x}px {grid.y}px"
551
+ >
552
+ <div
553
+ class="absolute top-0 left-0 origin-top-left transition-opacity"
554
+ style:transform="translate({camera.x}px, {camera.y}px) scale({camera.z})"
555
+ style:opacity={ready ? 1 : 0}
556
+ >
557
+ {#each layout.sections as { section, box } (section.id)}
558
+ <div
559
+ class="absolute rounded-[16px] bg-pf-section"
560
+ style:left="{box.x}px"
561
+ style:top="{box.y}px"
562
+ style:width="{box.w}px"
563
+ style:height="{box.h}px"
564
+ ></div>
565
+ {/each}
566
+ {#each prototype.frames as frame (frame.id)}
567
+ {@const b = layout.frames.get(frame.id)}
568
+ {#if b}
569
+ <div
570
+ data-vc-surface={frame.id}
571
+ data-vc-title={frame.title}
572
+ class="absolute overflow-hidden bg-white"
573
+ style:left="{b.x}px"
574
+ style:top="{b.y}px"
575
+ style:width="{b.w}px"
576
+ style:height="{b.h}px"
577
+ >
578
+ {#if seen.has(frame.id)}
579
+ {#if preview}
580
+ <CanvasPreview
581
+ {frame}
582
+ src={prototype.paths.frame(frame.id)}
583
+ preview={preview(frame)}
584
+ {pixelScale}
585
+ nearViewport={nearViewport(b)}
586
+ hovered={hovered === frame.id && !panning}
587
+ scheduleLoad={loadQueue.request}
588
+ />
589
+ {:else}
590
+ <iframe
591
+ {@attach canvasFrame}
592
+ data-pf-canvas
593
+ data-pf-frame={frame.id}
594
+ data-vc-frame
595
+ src={prototype.paths.frame(frame.id)}
596
+ title={frame.title}
597
+ tabindex="-1"
598
+ class="pointer-events-none size-full border-0 data-[pf-hover=true]:pointer-events-auto"
599
+ ></iframe>
600
+ {/if}
601
+ {/if}
602
+ </div>
603
+ {/if}
604
+ {/each}
605
+ </div>
606
+
607
+ {#if ready}
608
+ {#each layout.sections as { section, box } (section.id)}
609
+ {@const s = screen(box)}
610
+ <p
611
+ class="pointer-events-none absolute truncate text-sm font-semibold text-pf-canvas-ink"
612
+ style:left="{s.left + 4}px"
613
+ style:top="{s.top - 26}px"
614
+ style:max-width="{Math.max(s.width, 160)}px"
615
+ >
616
+ {section.title}
617
+ </p>
618
+ {/each}
619
+
620
+ {#each prototype.frames as frame (frame.id)}
621
+ {@const b = layout.frames.get(frame.id)}
622
+ {#if b}
623
+ {@const s = screen(b)}
624
+ {#if s.width >= MIN_LABEL_WIDTH}
625
+ <div
626
+ data-pf-ui
627
+ class="absolute flex items-center gap-1"
628
+ style:left="{s.left}px"
629
+ style:top="{s.top - 21}px"
630
+ style:max-width="{s.width + FRAME_GAP * camera.z - 8}px"
631
+ >
632
+ <button
633
+ type="button"
634
+ class={[
635
+ 'min-w-0 truncate rounded px-1 text-xs',
636
+ frame.id === selected ? 'text-pf-selection' : 'text-pf-canvas-muted hover:text-pf-canvas-ink'
637
+ ]}
638
+ onclick={() => select(frame.id)}
639
+ ondblclick={() => play(frame.id)}
640
+ >
641
+ {frame.title}
642
+ <!-- The path only once the title has room beside it. -->
643
+ {#if frame.path && s.width >= PATH_LABEL_WIDTH}<span class="pl-1 text-pf-canvas-muted">{frame.path}</span>{/if}
644
+ </button>
645
+ {#if frame.id === selected || frame.id === hovered}
646
+ <button
647
+ type="button"
648
+ class="shrink-0 rounded p-0.5 text-pf-selection hover:bg-pf-selection/10"
649
+ onclick={() => play(frame.id)}
650
+ aria-label="Play “{frame.title}”"
651
+ title="Play"
652
+ >
653
+ <Icon name="play" size={12} />
654
+ </button>
655
+ {/if}
656
+ </div>
657
+ {/if}
658
+ {#if flowsByStart.has(frame.id) && s.width >= MIN_LABEL_WIDTH}
659
+ <!-- Flow starting points, pinned to the frame's top-left corner. -->
660
+ <div
661
+ data-pf-ui
662
+ class="absolute z-10 flex flex-col items-start gap-1"
663
+ style:left="{s.left + 6}px"
664
+ style:top="{s.top + 6}px"
665
+ style:max-width="{s.width - 12}px"
666
+ >
667
+ {#each flowsByStart.get(frame.id) ?? [] as flow (flow.id)}
668
+ <button
669
+ type="button"
670
+ class="inline-flex h-5 max-w-full items-center gap-1 rounded-[2px] bg-pf-accent pr-2 pl-1.5 text-[11px] font-medium text-pf-surface shadow-sm hover:brightness-110"
671
+ onclick={() => startFlow(flow)}
672
+ title="Play the flow “{flow.title}”"
673
+ >
674
+ <Icon name="play" size={10} />
675
+ <span class="truncate">{flow.title}</span>
676
+ </button>
677
+ {/each}
678
+ </div>
679
+ {/if}
680
+ {#if (frame.id === selected && !commenting) || (frame.id === hovered && !panning)}
681
+ <div
682
+ class={[
683
+ 'pointer-events-none absolute',
684
+ frame.id === selected ? 'ring-2 ring-pf-selection' : 'ring-1 ring-pf-selection/60'
685
+ ]}
686
+ style:left="{s.left}px"
687
+ style:top="{s.top}px"
688
+ style:width="{s.width}px"
689
+ style:height="{s.height}px"
690
+ ></div>
691
+ {/if}
692
+ {/if}
693
+ {/each}
694
+ {/if}
695
+ </div>
696
+
697
+ {#if commenting}
698
+ <!-- Where the comments put their list. -->
699
+ <div data-vc-panel class="pointer-events-none absolute top-[60px] right-3 bottom-3 w-80"></div>
700
+ {/if}
701
+ </div>
@@ -0,0 +1,9 @@
1
+ import type { Frame, Prototype } from './types.js';
2
+ type $$ComponentProps = {
3
+ prototype: Prototype;
4
+ /** Screenshot URL; visible frames become live when zoom exceeds its pixel density. */
5
+ preview?: (frame: Frame) => string;
6
+ };
7
+ declare const Canvas: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type Canvas = ReturnType<typeof Canvas>;
9
+ export default Canvas;