paraframe 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/dist/Canvas.svelte +693 -0
- package/dist/Canvas.svelte.d.ts +9 -0
- package/dist/Comments.svelte +48 -0
- package/dist/Comments.svelte.d.ts +13 -0
- package/dist/FrameHost.svelte +135 -0
- package/dist/FrameHost.svelte.d.ts +8 -0
- package/dist/FrameLink.svelte +17 -0
- package/dist/FrameLink.svelte.d.ts +7 -0
- package/dist/Player.svelte +368 -0
- package/dist/Player.svelte.d.ts +8 -0
- package/dist/comments.d.ts +4 -0
- package/dist/comments.js +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/internal/CanvasPreview.svelte +153 -0
- package/dist/internal/CanvasPreview.svelte.d.ts +15 -0
- package/dist/internal/Icon.svelte +44 -0
- package/dist/internal/Icon.svelte.d.ts +29 -0
- package/dist/internal/Menu.svelte +65 -0
- package/dist/internal/Menu.svelte.d.ts +13 -0
- package/dist/internal/MenuItem.svelte +38 -0
- package/dist/internal/MenuItem.svelte.d.ts +12 -0
- package/dist/internal/canvas-frame.d.ts +2 -0
- package/dist/internal/canvas-frame.js +88 -0
- package/dist/internal/canvas-load-queue.d.ts +7 -0
- package/dist/internal/canvas-load-queue.js +69 -0
- package/dist/internal/comments.d.ts +36 -0
- package/dist/internal/comments.js +14 -0
- package/dist/internal/storage.d.ts +3 -0
- package/dist/internal/storage.js +22 -0
- package/dist/messages.d.ts +24 -0
- package/dist/messages.js +20 -0
- package/dist/nav.d.ts +18 -0
- package/dist/nav.js +15 -0
- package/dist/prototype.d.ts +10 -0
- package/dist/prototype.js +77 -0
- package/dist/styles.css +133 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +1 -0
- package/package.json +90 -3
|
@@ -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,65 @@
|
|
|
1
|
+
<!-- A button that opens a popover menu below it. Items close the menu with `close()`. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
label,
|
|
7
|
+
trigger,
|
|
8
|
+
children,
|
|
9
|
+
align = 'start',
|
|
10
|
+
tone = 'light',
|
|
11
|
+
class: className = ''
|
|
12
|
+
}: {
|
|
13
|
+
/** Accessible name for the trigger. */
|
|
14
|
+
label: string;
|
|
15
|
+
trigger: Snippet;
|
|
16
|
+
children: Snippet<[close: () => void]>;
|
|
17
|
+
align?: 'start' | 'end';
|
|
18
|
+
tone?: 'light' | 'dark';
|
|
19
|
+
class?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
const id = $props.id();
|
|
23
|
+
let button = $state<HTMLButtonElement>();
|
|
24
|
+
let popover = $state<HTMLDivElement>();
|
|
25
|
+
let open = $state(false);
|
|
26
|
+
|
|
27
|
+
function place(event: ToggleEvent) {
|
|
28
|
+
open = event.newState === 'open';
|
|
29
|
+
if (event.newState !== 'open' || !button || !popover) return;
|
|
30
|
+
const rect = button.getBoundingClientRect();
|
|
31
|
+
const gutter = 12;
|
|
32
|
+
const left = align === 'start' ? rect.left : rect.right - popover.offsetWidth;
|
|
33
|
+
popover.style.left = `${Math.max(gutter, Math.min(left, window.innerWidth - popover.offsetWidth - gutter))}px`;
|
|
34
|
+
popover.style.top = `${Math.max(gutter, Math.min(rect.bottom + 6, window.innerHeight - popover.offsetHeight - gutter))}px`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const close = () => popover?.hidePopover();
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<button
|
|
41
|
+
bind:this={button}
|
|
42
|
+
type="button"
|
|
43
|
+
popovertarget="pf-menu-{id}"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
aria-expanded={open}
|
|
46
|
+
aria-controls="pf-menu-{id}"
|
|
47
|
+
title={label}
|
|
48
|
+
class={className}
|
|
49
|
+
>
|
|
50
|
+
{@render trigger()}
|
|
51
|
+
</button>
|
|
52
|
+
<div
|
|
53
|
+
bind:this={popover}
|
|
54
|
+
id="pf-menu-{id}"
|
|
55
|
+
popover="auto"
|
|
56
|
+
ontoggle={place}
|
|
57
|
+
class={[
|
|
58
|
+
'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',
|
|
59
|
+
tone === 'dark'
|
|
60
|
+
? 'border-white/10 bg-pf-ink text-white/90'
|
|
61
|
+
: 'border-pf-line bg-pf-surface text-pf-ink'
|
|
62
|
+
]}
|
|
63
|
+
>
|
|
64
|
+
{@render children(close)}
|
|
65
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Accessible name for the trigger. */
|
|
4
|
+
label: string;
|
|
5
|
+
trigger: Snippet;
|
|
6
|
+
children: Snippet<[close: () => void]>;
|
|
7
|
+
align?: 'start' | 'end';
|
|
8
|
+
tone?: 'light' | 'dark';
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type Menu = ReturnType<typeof Menu>;
|
|
13
|
+
export default Menu;
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
/** Shows 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="menuitem"
|
|
24
|
+
onclick={onselect}
|
|
25
|
+
class={[
|
|
26
|
+
'flex w-full items-center gap-2 rounded-[2px] px-2 py-1.5 text-left',
|
|
27
|
+
tone === 'dark' ? 'hover:bg-white/10 focus-visible:bg-white/10' : 'hover:bg-pf-wash focus-visible:bg-pf-wash',
|
|
28
|
+
'outline-none'
|
|
29
|
+
]}
|
|
30
|
+
>
|
|
31
|
+
{#if checked !== undefined}
|
|
32
|
+
<span class="w-4 shrink-0 text-pf-accent">
|
|
33
|
+
{#if checked}<Icon name="check" size={14} />{/if}
|
|
34
|
+
</span>
|
|
35
|
+
{/if}
|
|
36
|
+
<span class="min-w-0 flex-1 truncate">{@render children()}</span>
|
|
37
|
+
{#if hint}<span class="shrink-0 opacity-50">{hint}</span>{/if}
|
|
38
|
+
</button>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
onselect: () => void;
|
|
4
|
+
/** Shows 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,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,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;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Per-viewer conveniences only. Storage can be unavailable (private windows, blocked
|
|
2
|
+
// site data), so every access is guarded and failures fall back to the default.
|
|
3
|
+
export function readStored(store, key, fallback) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = (store === 'local' ? localStorage : sessionStorage).getItem(key);
|
|
6
|
+
return raw === null ? fallback : JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function writeStored(store, key, value) {
|
|
13
|
+
try {
|
|
14
|
+
(store === 'local' ? localStorage : sessionStorage).setItem(key, JSON.stringify(value));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Not persisting is fine.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function storageKey(prototypeTitle, name) {
|
|
21
|
+
return `paraframe:${prototypeTitle}:${name}`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type FrameMessage =
|
|
2
|
+
/** The frame has rendered and its fonts are loaded. */
|
|
3
|
+
{
|
|
4
|
+
type: 'paraframe:ready';
|
|
5
|
+
frame: string;
|
|
6
|
+
}
|
|
7
|
+
/** A link in the frame was followed. */
|
|
8
|
+
| {
|
|
9
|
+
type: 'paraframe:navigate';
|
|
10
|
+
to: string;
|
|
11
|
+
}
|
|
12
|
+
/** A navigation key was pressed while the frame had focus. */
|
|
13
|
+
| {
|
|
14
|
+
type: 'paraframe:key';
|
|
15
|
+
key: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function isFrameMessage(data: unknown): data is FrameMessage;
|
|
18
|
+
export declare function postToHost(message: FrameMessage): void;
|
|
19
|
+
/**
|
|
20
|
+
* Keys the player handles. Frames forward them so shortcuts work while a
|
|
21
|
+
* frame has focus. Comments listen in the frame themselves for C and Escape.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PLAYER_KEYS: Set<string>;
|
|
24
|
+
export declare function isEditable(target: EventTarget | null): boolean;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The protocol between a frame (inside an iframe) and the player around it.
|
|
2
|
+
const TYPES = new Set(['paraframe:ready', 'paraframe:navigate', 'paraframe:key']);
|
|
3
|
+
export function isFrameMessage(data) {
|
|
4
|
+
return (typeof data === 'object' &&
|
|
5
|
+
data !== null &&
|
|
6
|
+
TYPES.has(data.type));
|
|
7
|
+
}
|
|
8
|
+
export function postToHost(message) {
|
|
9
|
+
if (window.parent !== window)
|
|
10
|
+
window.parent.postMessage(message, location.origin);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Keys the player handles. Frames forward them so shortcuts work while a
|
|
14
|
+
* frame has focus. Comments listen in the frame themselves for C and Escape.
|
|
15
|
+
*/
|
|
16
|
+
export const PLAYER_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'r', 'R']);
|
|
17
|
+
export function isEditable(target) {
|
|
18
|
+
return (target instanceof HTMLElement &&
|
|
19
|
+
(target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)));
|
|
20
|
+
}
|