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,153 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { isFrameMessage } from '../messages.js';
4
+ import type { Frame } from '../types.js';
5
+ import type { ScheduleFrameLoad } from './canvas-load-queue.js';
6
+ import { canvasFrame } from './canvas-frame.js';
7
+
8
+ let {
9
+ frame,
10
+ src,
11
+ preview,
12
+ pixelScale,
13
+ nearViewport,
14
+ hovered,
15
+ scheduleLoad
16
+ }: {
17
+ frame: Frame;
18
+ src: string;
19
+ preview: string;
20
+ /** Device pixels per canvas CSS pixel, including canvas and browser zoom. */
21
+ pixelScale: number;
22
+ nearViewport: boolean;
23
+ hovered: boolean;
24
+ scheduleLoad: ScheduleFrameLoad;
25
+ } = $props();
26
+
27
+ const FADE_MS = 160;
28
+ const RETURN_TO_IMAGE = 0.85;
29
+ let imageWidth = $state(0);
30
+ let imageHeight = $state(0);
31
+ let imageFailed = $state(false);
32
+ let live = $state(false);
33
+ let mounted = $state(false);
34
+ let ready = $state(false);
35
+ let completeLoad = () => {};
36
+
37
+ // Direct image URLs have no srcset density correction: natural dimensions are
38
+ // the screenshot's available pixels. Use the weaker axis if sizes differ.
39
+ const sampling = $derived(imageFailed ? Infinity : imageWidth && imageHeight
40
+ ? pixelScale * Math.max(frame.size.width / imageWidth, frame.size.height / imageHeight)
41
+ : 0);
42
+
43
+ $effect(() => {
44
+ const nearby = nearViewport;
45
+ const ratio = sampling;
46
+ const hovering = hovered;
47
+ untrack(() => {
48
+ if (!nearby) live = false;
49
+ else if (hovering || ratio >= 1) live = true;
50
+ else if (ratio <= RETURN_TO_IMAGE) live = false;
51
+ });
52
+ });
53
+
54
+ $effect(() => {
55
+ if (live) {
56
+ if (untrack(() => mounted)) return;
57
+ // The canvas starts this job only while idle. Effect cleanup cancels it
58
+ // when this frame leaves the viewport or returns to thumbnail scale.
59
+ return scheduleLoad((complete) => {
60
+ completeLoad = complete;
61
+ mounted = true;
62
+ });
63
+ }
64
+ // Keep the image underneath throughout both fades. Off-screen frames and
65
+ // frames that never finished loading can be destroyed immediately.
66
+ if (!nearViewport || !untrack(() => ready)) {
67
+ mounted = false;
68
+ return;
69
+ }
70
+ const timer = setTimeout(() => { mounted = false; }, FADE_MS);
71
+ return () => clearTimeout(timer);
72
+ });
73
+
74
+ function trackFrame(node: HTMLIFrameElement) {
75
+ ready = false;
76
+ let loaded = false;
77
+ let hydrated = false;
78
+ let paint = 0;
79
+ function reveal() {
80
+ if (!loaded || !hydrated) return;
81
+ cancelAnimationFrame(paint);
82
+ // Allow ResizeObserver-driven charts to lay out after fonts and hydration.
83
+ paint = requestAnimationFrame(() => {
84
+ paint = requestAnimationFrame(() => {
85
+ ready = true;
86
+ completeLoad();
87
+ });
88
+ });
89
+ }
90
+ function onload() {
91
+ loaded = true;
92
+ reveal();
93
+ }
94
+ function onmessage(event: MessageEvent) {
95
+ if (event.origin !== location.origin || event.source !== node.contentWindow) return;
96
+ if (!isFrameMessage(event.data) || event.data.type !== 'paraframe:ready') return;
97
+ if (event.data.frame !== frame.id) return;
98
+ hydrated = true;
99
+ reveal();
100
+ }
101
+ node.addEventListener('load', onload);
102
+ window.addEventListener('message', onmessage);
103
+ return () => {
104
+ cancelAnimationFrame(paint);
105
+ completeLoad();
106
+ node.removeEventListener('load', onload);
107
+ window.removeEventListener('message', onmessage);
108
+ ready = false;
109
+ };
110
+ }
111
+ </script>
112
+
113
+ <img
114
+ data-pf-canvas
115
+ src={preview}
116
+ alt={frame.title}
117
+ width={frame.size.width}
118
+ height={frame.size.height}
119
+ decoding="async"
120
+ draggable="false"
121
+ onload={(event) => {
122
+ const image = event.currentTarget as HTMLImageElement;
123
+ imageWidth = image.naturalWidth;
124
+ imageHeight = image.naturalHeight;
125
+ imageFailed = false;
126
+ }}
127
+ onerror={() => { imageFailed = true; }}
128
+ class="pointer-events-none size-full"
129
+ />
130
+ {#if mounted}
131
+ <iframe
132
+ {@attach trackFrame}
133
+ {@attach canvasFrame}
134
+ data-pf-canvas
135
+ data-pf-frame={frame.id}
136
+ data-pf-ready={ready}
137
+ data-vc-frame
138
+ {src}
139
+ title={frame.title}
140
+ tabindex="-1"
141
+ aria-hidden="true"
142
+ class="pointer-events-none absolute inset-0 size-full border-0 data-[pf-hover=true]:pointer-events-auto"
143
+ style:pointer-events={live && ready ? undefined : 'none'}
144
+ style:opacity={live && ready ? 1 : 0}
145
+ style:transition="opacity {FADE_MS}ms ease"
146
+ ></iframe>
147
+ {/if}
148
+
149
+ <style>
150
+ @media (prefers-reduced-motion: reduce) {
151
+ iframe { transition: none !important; }
152
+ }
153
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Frame } from '../types.js';
2
+ import type { ScheduleFrameLoad } from './canvas-load-queue.js';
3
+ type $$ComponentProps = {
4
+ frame: Frame;
5
+ src: string;
6
+ preview: string;
7
+ /** Device pixels per canvas CSS pixel, including canvas and browser zoom. */
8
+ pixelScale: number;
9
+ nearViewport: boolean;
10
+ hovered: boolean;
11
+ scheduleLoad: ScheduleFrameLoad;
12
+ };
13
+ declare const CanvasPreview: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type CanvasPreview = ReturnType<typeof CanvasPreview>;
15
+ export default CanvasPreview;
@@ -0,0 +1,44 @@
1
+ <script lang="ts" module>
2
+ // 24×24 stroke icons.
3
+ const PATHS = {
4
+ canvas:
5
+ 'M3 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M3 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0',
6
+ 'chevron-left': 'M15 6l-6 6 6 6',
7
+ 'chevron-right': 'M9 6l6 6-6 6',
8
+ 'chevron-down': 'M6 9l6 6 6-6',
9
+ 'chevron-up': 'M6 15l6-6 6 6',
10
+ play: 'M7 5l12 7-12 7z',
11
+ external: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
12
+ restart: 'M4 12a8 8 0 1 0 2.4-5.7M4 4v4h4',
13
+ check: 'M5 12.5l4.5 4.5L19 7.5',
14
+ plus: 'M12 5v14M5 12h14',
15
+ minus: 'M5 12h14',
16
+ fit: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
17
+ comment: 'M12 3.5a8.5 8.5 0 1 1 0 17H3.5V12A8.5 8.5 0 0 1 12 3.5z',
18
+ search: 'M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM20 20l-4.4-4.4',
19
+ filter: 'M4 7h16M7 12h10M10 17h4',
20
+ more: 'M5 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM12 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM19 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2z',
21
+ resolve: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18zM8 12.2l2.7 2.7L16 9.6',
22
+ close: 'M6 6l12 12M18 6L6 18',
23
+ send: 'M12 19V5M6.5 10.5L12 5l5.5 5.5'
24
+ } as const;
25
+ export type IconName = keyof typeof PATHS;
26
+ </script>
27
+
28
+ <script lang="ts">
29
+ let { name, size = 16 }: { name: IconName; size?: number } = $props();
30
+ </script>
31
+
32
+ <svg
33
+ width={size}
34
+ height={size}
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="1.75"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ aria-hidden="true"
42
+ >
43
+ <path d={PATHS[name]} />
44
+ </svg>
@@ -0,0 +1,29 @@
1
+ declare const PATHS: {
2
+ readonly canvas: "M3 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M3 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0";
3
+ readonly 'chevron-left': "M15 6l-6 6 6 6";
4
+ readonly 'chevron-right': "M9 6l6 6-6 6";
5
+ readonly 'chevron-down': "M6 9l6 6 6-6";
6
+ readonly 'chevron-up': "M6 15l6-6 6 6";
7
+ readonly play: "M7 5l12 7-12 7z";
8
+ readonly external: "M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5";
9
+ readonly restart: "M4 12a8 8 0 1 0 2.4-5.7M4 4v4h4";
10
+ readonly check: "M5 12.5l4.5 4.5L19 7.5";
11
+ readonly plus: "M12 5v14M5 12h14";
12
+ readonly minus: "M5 12h14";
13
+ readonly fit: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5";
14
+ readonly comment: "M12 3.5a8.5 8.5 0 1 1 0 17H3.5V12A8.5 8.5 0 0 1 12 3.5z";
15
+ readonly search: "M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM20 20l-4.4-4.4";
16
+ readonly filter: "M4 7h16M7 12h10M10 17h4";
17
+ readonly more: "M5 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM12 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM19 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2z";
18
+ readonly resolve: "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18zM8 12.2l2.7 2.7L16 9.6";
19
+ readonly close: "M6 6l12 12M18 6L6 18";
20
+ readonly send: "M12 19V5M6.5 10.5L12 5l5.5 5.5";
21
+ };
22
+ export type IconName = keyof typeof PATHS;
23
+ type $$ComponentProps = {
24
+ name: IconName;
25
+ size?: number;
26
+ };
27
+ declare const Icon: import("svelte").Component<$$ComponentProps, {}, "">;
28
+ type Icon = ReturnType<typeof Icon>;
29
+ export default Icon;
@@ -0,0 +1,138 @@
1
+ <!--
2
+ A button that opens a popover menu below it. Items close the menu with `close()`.
3
+ Follows the ARIA menu button pattern: opening moves focus to the checked item
4
+ (or the first), Up/Down/Home/End move between items, and Escape, Tab or
5
+ choosing an item closes the menu and returns focus to the trigger.
6
+ -->
7
+ <script lang="ts">
8
+ import type { Snippet } from 'svelte';
9
+
10
+ let {
11
+ label,
12
+ trigger,
13
+ children,
14
+ footer,
15
+ align = 'start',
16
+ tone = 'light',
17
+ class: className = ''
18
+ }: {
19
+ /** Accessible name for the trigger and the menu. */
20
+ label: string;
21
+ trigger: Snippet;
22
+ children: Snippet<[close: () => void]>;
23
+ /** Non-interactive content shown below the items, outside the menu role. */
24
+ footer?: Snippet;
25
+ align?: 'start' | 'end';
26
+ tone?: 'light' | 'dark';
27
+ class?: string;
28
+ } = $props();
29
+
30
+ const id = $props.id();
31
+ let button = $state<HTMLButtonElement>();
32
+ let popover = $state<HTMLDivElement>();
33
+ let menu = $state<HTMLDivElement>();
34
+ let open = $state(false);
35
+ // Which item gets focus when the menu opens: the checked one (or first) unless a key asked otherwise.
36
+ let initial: 'first' | 'last' | undefined;
37
+
38
+ const items = () => [...(menu?.querySelectorAll<HTMLElement>('[role^=menuitem]') ?? [])];
39
+
40
+ function beforetoggle(event: ToggleEvent) {
41
+ // Closing with focus inside the menu (Escape, Tab, an item was chosen): hand focus back to the trigger.
42
+ if (event.newState === 'closed' && popover?.contains(document.activeElement)) button?.focus();
43
+ }
44
+
45
+ function ontoggle(event: ToggleEvent) {
46
+ open = event.newState === 'open';
47
+ if (!open || !button || !popover) return;
48
+ const rect = button.getBoundingClientRect();
49
+ const gutter = 12;
50
+ const left = align === 'start' ? rect.left : rect.right - popover.offsetWidth;
51
+ popover.style.left = `${Math.max(gutter, Math.min(left, window.innerWidth - popover.offsetWidth - gutter))}px`;
52
+ popover.style.top = `${Math.max(gutter, Math.min(rect.bottom + 6, window.innerHeight - popover.offsetHeight - gutter))}px`;
53
+
54
+ focusItem(initial);
55
+ initial = undefined;
56
+ }
57
+
58
+ function focusItem(which?: 'first' | 'last') {
59
+ const all = items();
60
+ const checked = which ? undefined : all.find((item) => item.getAttribute('aria-checked') === 'true');
61
+ (checked ?? (which === 'last' ? all.at(-1) : all[0]))?.focus();
62
+ }
63
+
64
+ function ontriggerkeydown(event: KeyboardEvent) {
65
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
66
+ event.preventDefault();
67
+ const which = event.key === 'ArrowUp' ? 'last' : 'first';
68
+ if (open) return focusItem(which);
69
+ initial = which;
70
+ popover?.showPopover();
71
+ }
72
+
73
+ function onmenukeydown(event: KeyboardEvent) {
74
+ const all = items();
75
+ const index = all.indexOf(document.activeElement as HTMLElement);
76
+ let next: HTMLElement | undefined;
77
+ if (event.key === 'ArrowDown') next = all[(index + 1) % all.length];
78
+ else if (event.key === 'ArrowUp') next = all.at(index - 1);
79
+ else if (event.key === 'Home') next = all[0];
80
+ else if (event.key === 'End') next = all.at(-1);
81
+ else if (event.key === 'Escape') {
82
+ // Only the menu closes; the canvas and comments don't see this Escape.
83
+ event.preventDefault();
84
+ event.stopPropagation();
85
+ close();
86
+ return;
87
+ } else if (event.key === 'Tab') {
88
+ // Close and let Tab move on from the trigger.
89
+ close();
90
+ return;
91
+ } else return;
92
+ event.preventDefault();
93
+ next?.focus();
94
+ }
95
+
96
+ const close = () => popover?.hidePopover();
97
+ </script>
98
+
99
+ <button
100
+ bind:this={button}
101
+ type="button"
102
+ id="pf-menu-button-{id}"
103
+ popovertarget="pf-menu-{id}"
104
+ aria-label={label}
105
+ aria-haspopup="menu"
106
+ aria-expanded={open}
107
+ aria-controls="pf-menu-items-{id}"
108
+ title={label}
109
+ class={className}
110
+ onkeydown={ontriggerkeydown}
111
+ >
112
+ {@render trigger()}
113
+ </button>
114
+ <div
115
+ bind:this={popover}
116
+ id="pf-menu-{id}"
117
+ popover="auto"
118
+ onbeforetoggle={beforetoggle}
119
+ {ontoggle}
120
+ class={[
121
+ 'pf-menu fixed inset-auto m-0 max-h-[min(70vh,560px)] w-max min-w-52 max-w-[calc(100vw-24px)] overflow-y-auto rounded border p-1 text-[13px] shadow-lg',
122
+ tone === 'dark'
123
+ ? 'border-white/10 bg-pf-ink text-white/90'
124
+ : 'border-pf-line bg-pf-surface text-pf-ink'
125
+ ]}
126
+ >
127
+ <div
128
+ bind:this={menu}
129
+ id="pf-menu-items-{id}"
130
+ role="menu"
131
+ tabindex="-1"
132
+ aria-labelledby="pf-menu-button-{id}"
133
+ onkeydown={onmenukeydown}
134
+ >
135
+ {@render children(close)}
136
+ </div>
137
+ {@render footer?.()}
138
+ </div>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Accessible name for the trigger and the menu. */
4
+ label: string;
5
+ trigger: Snippet;
6
+ children: Snippet<[close: () => void]>;
7
+ /** Non-interactive content shown below the items, outside the menu role. */
8
+ footer?: Snippet;
9
+ align?: 'start' | 'end';
10
+ tone?: 'light' | 'dark';
11
+ class?: string;
12
+ };
13
+ declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type Menu = ReturnType<typeof Menu>;
15
+ export default Menu;
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Icon from './Icon.svelte';
4
+
5
+ let {
6
+ onselect,
7
+ checked,
8
+ children,
9
+ hint,
10
+ tone = 'light'
11
+ }: {
12
+ onselect: () => void;
13
+ /** Makes this a radio item (`menuitemradio` with `aria-checked`) with a check mark column when not undefined. */
14
+ checked?: boolean;
15
+ children: Snippet;
16
+ hint?: string;
17
+ tone?: 'light' | 'dark';
18
+ } = $props();
19
+ </script>
20
+
21
+ <button
22
+ type="button"
23
+ role={checked === undefined ? 'menuitem' : 'menuitemradio'}
24
+ aria-checked={checked}
25
+ tabindex="-1"
26
+ onclick={onselect}
27
+ class={[
28
+ 'flex w-full items-center gap-2 rounded-[2px] px-2 py-1.5 text-left',
29
+ tone === 'dark' ? 'hover:bg-white/10 focus-visible:bg-white/10' : 'hover:bg-pf-wash focus-visible:bg-pf-wash',
30
+ 'outline-none'
31
+ ]}
32
+ >
33
+ {#if checked !== undefined}
34
+ <span class="w-4 shrink-0 text-pf-accent" aria-hidden="true">
35
+ {#if checked}<Icon name="check" size={14} />{/if}
36
+ </span>
37
+ {/if}
38
+ <span class="min-w-0 flex-1 truncate">{@render children()}</span>
39
+ {#if hint}<span class="shrink-0 opacity-50">{hint}</span>{/if}
40
+ </button>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ onselect: () => void;
4
+ /** Makes this a radio item (`menuitemradio` with `aria-checked`) with a check mark column when not undefined. */
5
+ checked?: boolean;
6
+ children: Snippet;
7
+ hint?: string;
8
+ tone?: 'light' | 'dark';
9
+ };
10
+ declare const MenuItem: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type MenuItem = ReturnType<typeof MenuItem>;
12
+ export default MenuItem;
@@ -0,0 +1,2 @@
1
+ /** Let a canvas iframe receive hover while its host owns every other gesture. */
2
+ export declare function canvasFrame(node: HTMLIFrameElement): () => void;
@@ -0,0 +1,88 @@
1
+ /** Let a canvas iframe receive hover while its host owns every other gesture. */
2
+ export function canvasFrame(node) {
3
+ let disconnect = () => { };
4
+ function connect() {
5
+ disconnect();
6
+ delete node.dataset.pfHover;
7
+ const view = node.contentWindow;
8
+ // Frame routes are same-origin. Leave an inaccessible frame non-interactive.
9
+ try {
10
+ if (!view?.document.body)
11
+ return;
12
+ }
13
+ catch {
14
+ return;
15
+ }
16
+ const removers = [];
17
+ function listen(type, listener) {
18
+ view.addEventListener(type, listener, { capture: true, passive: false });
19
+ removers.push(() => view.removeEventListener(type, listener, { capture: true }));
20
+ }
21
+ function block(event) {
22
+ event.preventDefault();
23
+ event.stopImmediatePropagation();
24
+ }
25
+ function point(event) {
26
+ const rect = node.getBoundingClientRect();
27
+ // Frame events use local CSS pixels; canvas handlers use viewport pixels.
28
+ return {
29
+ bubbles: true, cancelable: true,
30
+ clientX: rect.left + event.clientX * rect.width / node.clientWidth,
31
+ clientY: rect.top + event.clientY * rect.height / node.clientHeight,
32
+ button: event.button, buttons: event.buttons,
33
+ altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey
34
+ };
35
+ }
36
+ for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']) {
37
+ listen(type, (event) => {
38
+ const pointer = event;
39
+ // Charts and tooltips still receive unpressed pointer movement.
40
+ if (type !== 'pointermove' || pointer.buttons)
41
+ block(event);
42
+ node.dispatchEvent(new PointerEvent(type, {
43
+ ...point(pointer), pointerId: pointer.pointerId, pointerType: pointer.pointerType,
44
+ isPrimary: pointer.isPrimary, pressure: pointer.pressure
45
+ }));
46
+ });
47
+ }
48
+ listen('dblclick', (event) => {
49
+ block(event);
50
+ node.dispatchEvent(new MouseEvent('dblclick', point(event)));
51
+ });
52
+ listen('wheel', (event) => {
53
+ block(event);
54
+ const wheel = event;
55
+ node.dispatchEvent(new WheelEvent('wheel', {
56
+ ...point(wheel), deltaX: wheel.deltaX, deltaY: wheel.deltaY,
57
+ deltaZ: wheel.deltaZ, deltaMode: wheel.deltaMode
58
+ }));
59
+ });
60
+ for (const type of ['keydown', 'keyup']) {
61
+ listen(type, (event) => {
62
+ block(event);
63
+ const key = event;
64
+ node.dispatchEvent(new KeyboardEvent(type, {
65
+ bubbles: true, cancelable: true, key: key.key, code: key.code, repeat: key.repeat,
66
+ altKey: key.altKey, ctrlKey: key.ctrlKey, metaKey: key.metaKey, shiftKey: key.shiftKey
67
+ }));
68
+ });
69
+ }
70
+ for (const type of [
71
+ 'mousedown', 'mouseup', 'click', 'auxclick', 'contextmenu', 'dragstart',
72
+ 'touchstart', 'touchmove', 'touchend', 'beforeinput', 'submit'
73
+ ])
74
+ listen(type, block);
75
+ // Enable hit testing only after the capture guards are in place.
76
+ node.dataset.pfHover = 'true';
77
+ disconnect = () => {
78
+ for (const remove of removers)
79
+ remove();
80
+ delete node.dataset.pfHover;
81
+ };
82
+ }
83
+ node.addEventListener('load', connect);
84
+ return () => {
85
+ node.removeEventListener('load', connect);
86
+ disconnect();
87
+ };
88
+ }
@@ -0,0 +1,7 @@
1
+ export type ScheduleFrameLoad = (start: (complete: () => void) => void) => () => void;
2
+ export declare function createCanvasLoadQueue(): {
3
+ request: ScheduleFrameLoad;
4
+ pause: () => void;
5
+ resume(): void;
6
+ destroy(): void;
7
+ };
@@ -0,0 +1,69 @@
1
+ // Loading and hydration share the canvas's main thread. Leave a short gap after
2
+ // each ready frame, and don't let a failed readiness message stall the queue.
3
+ const START_GAP_MS = 50;
4
+ const LOAD_TIMEOUT_MS = 5_000;
5
+ export function createCanvasLoadQueue() {
6
+ const pending = new Set();
7
+ let paused = true;
8
+ let destroyed = false;
9
+ let active;
10
+ let startTimer;
11
+ let loadTimer;
12
+ function schedule(delay = 0) {
13
+ if (paused || destroyed || active || startTimer !== undefined || !pending.size)
14
+ return;
15
+ startTimer = setTimeout(() => {
16
+ startTimer = undefined;
17
+ const entry = pending.values().next().value;
18
+ if (paused || destroyed || active || !entry)
19
+ return;
20
+ pending.delete(entry);
21
+ active = entry;
22
+ entry.start();
23
+ }, delay);
24
+ }
25
+ const request = (start) => {
26
+ if (destroyed)
27
+ return () => { };
28
+ const complete = () => {
29
+ if (active !== entry)
30
+ return;
31
+ clearTimeout(loadTimer);
32
+ loadTimer = undefined;
33
+ active = undefined;
34
+ schedule(START_GAP_MS);
35
+ };
36
+ const entry = {
37
+ start: () => {
38
+ loadTimer = setTimeout(complete, LOAD_TIMEOUT_MS);
39
+ start(complete);
40
+ }
41
+ };
42
+ pending.add(entry);
43
+ schedule();
44
+ return () => {
45
+ pending.delete(entry);
46
+ complete();
47
+ };
48
+ };
49
+ function pause() {
50
+ paused = true;
51
+ clearTimeout(startTimer);
52
+ startTimer = undefined;
53
+ }
54
+ return {
55
+ request,
56
+ pause,
57
+ resume() {
58
+ paused = false;
59
+ schedule();
60
+ },
61
+ destroy() {
62
+ destroyed = true;
63
+ pause();
64
+ clearTimeout(loadTimer);
65
+ pending.clear();
66
+ active = undefined;
67
+ }
68
+ };
69
+ }
@@ -0,0 +1,36 @@
1
+ import type { Prototype } from '../types.js';
2
+ /** The part of a comment thread the canvas and player read. */
3
+ export interface CommentThreadPin {
4
+ /** The frame it is on. */
5
+ surface: string;
6
+ /** Pin position in the frame's CSS pixels. */
7
+ x: number;
8
+ y: number;
9
+ }
10
+ export interface CommentsOptions {
11
+ show?: (thread: CommentThreadPin) => boolean | void;
12
+ describe?: (surface: string) => {
13
+ title?: string;
14
+ order?: number;
15
+ } | undefined;
16
+ }
17
+ /** What the canvas and player use of visual-comments' `VisualComments`. */
18
+ export interface CommentsController {
19
+ readonly active: boolean;
20
+ readonly openId: number | undefined;
21
+ readonly unreadCount: number;
22
+ toggle(): unknown;
23
+ configure(options: CommentsOptions): void;
24
+ }
25
+ /** The comments of the prototype, once they have started in the browser. */
26
+ export interface ParaframeComments {
27
+ readonly current: CommentsController | undefined;
28
+ }
29
+ export declare function setComments(comments: ParaframeComments): void;
30
+ /** The comments object itself, for components that read it reactively. */
31
+ export declare function useCommentsHolder(): ParaframeComments | undefined;
32
+ /** A frame's title and place in reading order, for threads on frames not on screen. */
33
+ export declare function describeFrame(prototype: Prototype, id: string): {
34
+ title: string;
35
+ order: number;
36
+ } | undefined;
@@ -0,0 +1,14 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol('paraframe.comments');
3
+ export function setComments(comments) {
4
+ setContext(KEY, comments);
5
+ }
6
+ /** The comments object itself, for components that read it reactively. */
7
+ export function useCommentsHolder() {
8
+ return getContext(KEY);
9
+ }
10
+ /** A frame's title and place in reading order, for threads on frames not on screen. */
11
+ export function describeFrame(prototype, id) {
12
+ const frame = prototype.get(id);
13
+ return frame && { title: frame.title, order: frame.index };
14
+ }
@@ -0,0 +1,3 @@
1
+ export declare function readStored<T>(store: 'local' | 'session', key: string, fallback: T): T;
2
+ export declare function writeStored(store: 'local' | 'session', key: string, value: unknown): void;
3
+ export declare function storageKey(prototypeTitle: string, name: string): string;