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,48 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Adds comments to the canvas and player inside it. The site's server passes
|
|
3
|
+
comment requests on to a visual-comments server, and signs viewers in.
|
|
4
|
+
Place it in the viewer layout. Frames are the surfaces that take comments;
|
|
5
|
+
every frame of the prototype shares one page of threads.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { onMount, untrack, type Snippet } from 'svelte';
|
|
9
|
+
import { createVisualComments, type VisualComments } from 'visual-comments';
|
|
10
|
+
import { setComments } from './internal/comments.js';
|
|
11
|
+
|
|
12
|
+
let { endpoint = '/_comments', auth = '/auth', page = 'prototype', children }: {
|
|
13
|
+
/** Where the site's server answers comment requests (visual-comments' `commentsProxy`). */
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
/** The site's sign-in routes (`@convex-works/sign-in`). */
|
|
16
|
+
auth?: string;
|
|
17
|
+
/** The key every frame's threads share. */
|
|
18
|
+
page?: string;
|
|
19
|
+
children: Snippet;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let current = $state<VisualComments>();
|
|
23
|
+
setComments({ get current() { return current; } });
|
|
24
|
+
|
|
25
|
+
onMount(() => {
|
|
26
|
+
const comments = createVisualComments({
|
|
27
|
+
...untrack(() => ({ endpoint, auth, page })),
|
|
28
|
+
// The canvas and player have their own buttons and keys.
|
|
29
|
+
toolbar: false,
|
|
30
|
+
// A frame is ready once it has hydrated, not when its HTML has loaded.
|
|
31
|
+
documentOf: (surface, frame) => {
|
|
32
|
+
try {
|
|
33
|
+
const doc = frame.contentDocument;
|
|
34
|
+
return doc?.documentElement.dataset.pfFrameReady === surface.getAttribute('data-vc-surface') ? doc : undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
current = comments;
|
|
41
|
+
return () => {
|
|
42
|
+
current = undefined;
|
|
43
|
+
comments.destroy();
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
{@render children()}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Where the site's server answers comment requests (visual-comments' `commentsProxy`). */
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
/** The site's sign-in routes (`@convex-works/sign-in`). */
|
|
6
|
+
auth?: string;
|
|
7
|
+
/** The key every frame's threads share. */
|
|
8
|
+
page?: string;
|
|
9
|
+
children: Snippet;
|
|
10
|
+
};
|
|
11
|
+
declare const Comments: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type Comments = ReturnType<typeof Comments>;
|
|
13
|
+
export default Comments;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Renders one frame on its own page. The player and canvas load this page in
|
|
3
|
+
iframes; it can also be opened directly. Links inside the frame tell the
|
|
4
|
+
player to navigate, or navigate this page when opened directly.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { onMount } from 'svelte';
|
|
8
|
+
import { goto } from '$app/navigation';
|
|
9
|
+
import { isEditable, PLAYER_KEYS, postToHost } from './messages.js';
|
|
10
|
+
import { setFrameNav, type FrameNav } from './nav.js';
|
|
11
|
+
import type { Prototype, ResolvedFrame } from './types.js';
|
|
12
|
+
|
|
13
|
+
let { prototype, resolved }: { prototype: Prototype; resolved: ResolvedFrame } = $props();
|
|
14
|
+
|
|
15
|
+
type Mode = 'standalone' | 'player' | 'canvas';
|
|
16
|
+
let mode: Mode = 'standalone';
|
|
17
|
+
|
|
18
|
+
const frame = $derived(resolved.frame);
|
|
19
|
+
|
|
20
|
+
// Elements that do something on their own; clicking them never shows hotspot hints.
|
|
21
|
+
// Mark anything else that handles clicks (e.g. a selectable table row) with data-pf-interactive.
|
|
22
|
+
const INTERACTIVE =
|
|
23
|
+
'a[href],button,input,select,textarea,label,summary,[contenteditable],[tabindex],' +
|
|
24
|
+
'[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=switch],[role=option],' +
|
|
25
|
+
'[data-pf-link],[data-pf-interactive]';
|
|
26
|
+
|
|
27
|
+
function go(to: string) {
|
|
28
|
+
if (!prototype.get(to)) {
|
|
29
|
+
console.warn(`[paraframe] No frame "${to}"`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (mode === 'player') postToHost({ type: 'paraframe:navigate', to });
|
|
33
|
+
else if (mode === 'standalone') goto(prototype.paths.frame(to));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const nav: FrameNav = {
|
|
37
|
+
get current() {
|
|
38
|
+
return frame.id;
|
|
39
|
+
},
|
|
40
|
+
href: (to) => prototype.paths.play(to),
|
|
41
|
+
go,
|
|
42
|
+
hotspot: (to) => (node) => {
|
|
43
|
+
node.dataset.pfLink = to;
|
|
44
|
+
if (!node.matches('a[href],button,input,select,textarea,[tabindex]')) node.tabIndex = 0;
|
|
45
|
+
if (!node.hasAttribute('role')) node.setAttribute('role', 'link');
|
|
46
|
+
|
|
47
|
+
const onclick = (event: MouseEvent) => {
|
|
48
|
+
if (event.defaultPrevented) return;
|
|
49
|
+
// A button or link nested inside the hotspot handles its own click.
|
|
50
|
+
const inner = (event.target as Element).closest(INTERACTIVE);
|
|
51
|
+
if (inner && inner !== node) return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
go(to);
|
|
54
|
+
};
|
|
55
|
+
const onkeydown = (event: KeyboardEvent) => {
|
|
56
|
+
if (event.key === 'Enter' && event.target === node) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
go(to);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
node.addEventListener('click', onclick);
|
|
62
|
+
node.addEventListener('keydown', onkeydown);
|
|
63
|
+
return () => {
|
|
64
|
+
node.removeEventListener('click', onclick);
|
|
65
|
+
node.removeEventListener('keydown', onkeydown);
|
|
66
|
+
delete node.dataset.pfLink;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
setFrameNav(nav);
|
|
71
|
+
|
|
72
|
+
let hintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
|
|
74
|
+
function flashHints() {
|
|
75
|
+
const root = document.documentElement;
|
|
76
|
+
root.classList.add('pf-hints');
|
|
77
|
+
clearTimeout(hintTimer);
|
|
78
|
+
hintTimer = setTimeout(() => root.classList.remove('pf-hints'), 700);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onclick(event: MouseEvent) {
|
|
82
|
+
if (event.defaultPrevented || mode === 'canvas') return;
|
|
83
|
+
if (!(event.target as Element).closest?.(INTERACTIVE)) flashHints();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onkeydown(event: KeyboardEvent) {
|
|
87
|
+
if (mode !== 'player' || !PLAYER_KEYS.has(event.key)) return;
|
|
88
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isEditable(event.target)) return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
postToHost({ type: 'paraframe:key', key: event.key });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onMount(() => {
|
|
94
|
+
const host = window.frameElement;
|
|
95
|
+
mode = host?.hasAttribute('data-pf-player')
|
|
96
|
+
? 'player'
|
|
97
|
+
: host?.hasAttribute('data-pf-canvas')
|
|
98
|
+
? 'canvas'
|
|
99
|
+
: 'standalone';
|
|
100
|
+
return () => clearTimeout(hintTimer);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
$effect(() => {
|
|
104
|
+
const id = frame.id;
|
|
105
|
+
let cancelled = false;
|
|
106
|
+
document.fonts.ready.then(() => {
|
|
107
|
+
if (cancelled) return;
|
|
108
|
+
// Build-time captures can distinguish a hydrated frame from its initial HTML.
|
|
109
|
+
document.documentElement.dataset.pfFrameReady = id;
|
|
110
|
+
postToHost({ type: 'paraframe:ready', frame: id });
|
|
111
|
+
});
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
delete document.documentElement.dataset.pfFrameReady;
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<svelte:head>
|
|
120
|
+
<title>{frame.title} – {prototype.title}</title>
|
|
121
|
+
</svelte:head>
|
|
122
|
+
|
|
123
|
+
<svelte:window {onclick} {onkeydown} />
|
|
124
|
+
|
|
125
|
+
{#key frame.id}
|
|
126
|
+
<resolved.component {...frame.props} />
|
|
127
|
+
{/key}
|
|
128
|
+
|
|
129
|
+
<style>
|
|
130
|
+
:global(html.pf-hints [data-pf-link]) {
|
|
131
|
+
outline: 2px solid rgb(37 99 235 / 0.75) !important;
|
|
132
|
+
outline-offset: 1px;
|
|
133
|
+
box-shadow: inset 0 0 0 999px rgb(37 99 235 / 0.12) !important;
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Prototype, ResolvedFrame } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
prototype: Prototype;
|
|
4
|
+
resolved: ResolvedFrame;
|
|
5
|
+
};
|
|
6
|
+
declare const FrameHost: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type FrameHost = ReturnType<typeof FrameHost>;
|
|
8
|
+
export default FrameHost;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- A link to another frame. Modified clicks (new tab, etc.) open the player at that frame. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
4
|
+
import { useFrameNav } from './nav.js';
|
|
5
|
+
|
|
6
|
+
let { to, children, ...rest }: { to: string } & Omit<HTMLAnchorAttributes, 'href'> = $props();
|
|
7
|
+
|
|
8
|
+
const nav = useFrameNav();
|
|
9
|
+
|
|
10
|
+
function onclick(event: MouseEvent) {
|
|
11
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
nav.go(to);
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<a {...rest} href={nav.href(to)} target="_top" data-pf-link={to} {onclick}>{@render children?.()}</a>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
to: string;
|
|
4
|
+
} & Omit<HTMLAnchorAttributes, 'href'>;
|
|
5
|
+
declare const FrameLink: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type FrameLink = ReturnType<typeof FrameLink>;
|
|
7
|
+
export default FrameLink;
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Shows one frame at a time, scaled to the window, like a design tool's
|
|
3
|
+
prototype view. Links inside frames move between frames; arrow keys step
|
|
4
|
+
through frames in reading order.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { untrack } from 'svelte';
|
|
8
|
+
import { goto } from '$app/navigation';
|
|
9
|
+
import Icon from './internal/Icon.svelte';
|
|
10
|
+
import Menu from './internal/Menu.svelte';
|
|
11
|
+
import MenuItem from './internal/MenuItem.svelte';
|
|
12
|
+
import { readStored, storageKey, writeStored } from './internal/storage.js';
|
|
13
|
+
import { isEditable, isFrameMessage, PLAYER_KEYS } from './messages.js';
|
|
14
|
+
import { describeFrame, useCommentsHolder, type CommentThreadPin } from './internal/comments.js';
|
|
15
|
+
import type { Flow, Prototype } from './types.js';
|
|
16
|
+
|
|
17
|
+
let { prototype, frameId }: { prototype: Prototype; frameId: string } = $props();
|
|
18
|
+
|
|
19
|
+
type Scale = 'fit' | 'width' | 'actual';
|
|
20
|
+
const SCALES: { value: Scale; label: string }[] = [
|
|
21
|
+
{ value: 'fit', label: 'Fit to window' },
|
|
22
|
+
{ value: 'width', label: 'Fit width' },
|
|
23
|
+
{ value: 'actual', label: 'Actual size' }
|
|
24
|
+
];
|
|
25
|
+
const GUTTER = 32;
|
|
26
|
+
|
|
27
|
+
const frame = $derived(prototype.get(frameId));
|
|
28
|
+
const keys = $derived({
|
|
29
|
+
scale: storageKey(prototype.title, 'scale'),
|
|
30
|
+
flow: storageKey(prototype.title, 'flow'),
|
|
31
|
+
last: storageKey(prototype.title, 'last')
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let scaleMode = $state<Scale>('fit');
|
|
35
|
+
let flowId = $state<string | undefined>();
|
|
36
|
+
let stageWidth = $state(0);
|
|
37
|
+
let stageHeight = $state(0);
|
|
38
|
+
|
|
39
|
+
const flow = $derived(prototype.flows.find((f) => f.id === flowId));
|
|
40
|
+
|
|
41
|
+
const holder = useCommentsHolder();
|
|
42
|
+
const comments = $derived(holder?.current);
|
|
43
|
+
const commenting = $derived(comments?.active ?? false);
|
|
44
|
+
|
|
45
|
+
const view = $derived.by(() => {
|
|
46
|
+
const { width, height } = frame?.size ?? prototype.size;
|
|
47
|
+
if (!stageWidth || !stageHeight) return { scale: 1, height };
|
|
48
|
+
if (scaleMode === 'actual') return { scale: 1, height };
|
|
49
|
+
if (scaleMode === 'width') {
|
|
50
|
+
const scale = stageWidth / width;
|
|
51
|
+
return { scale, height: Math.max(height, stageHeight / scale) };
|
|
52
|
+
}
|
|
53
|
+
const scale = Math.min(
|
|
54
|
+
(stageWidth - GUTTER * 2) / width,
|
|
55
|
+
(stageHeight - GUTTER * 2) / height,
|
|
56
|
+
1
|
|
57
|
+
);
|
|
58
|
+
return { scale: Math.max(scale, 0.05), height };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Two iframes overlap while the next frame loads, so navigation never flashes blank.
|
|
62
|
+
type Slot = { key: number; id: string; ready: boolean };
|
|
63
|
+
let nextKey = 1;
|
|
64
|
+
let slots = $state<Slot[]>(untrack(() => [{ key: 0, id: frameId, ready: true }]));
|
|
65
|
+
const iframes = new Map<number, HTMLIFrameElement>();
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
const id = frameId;
|
|
69
|
+
untrack(() => {
|
|
70
|
+
if (slots.at(-1)?.id === id) return;
|
|
71
|
+
slots.push({ key: nextKey++, id, ready: false });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function markReady(key: number) {
|
|
76
|
+
const index = slots.findIndex((s) => s.key === key);
|
|
77
|
+
if (index === -1) return;
|
|
78
|
+
slots[index].ready = true;
|
|
79
|
+
if (index > 0) slots = slots.slice(index);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$effect(() => {
|
|
83
|
+
scaleMode = readStored('local', keys.scale, 'fit');
|
|
84
|
+
flowId = readStored('session', keys.flow, undefined);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
$effect(() => writeStored('session', keys.last, frameId));
|
|
88
|
+
|
|
89
|
+
function setScale(value: Scale) {
|
|
90
|
+
scaleMode = value;
|
|
91
|
+
writeStored('local', keys.scale, value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function show(id: string | undefined) {
|
|
95
|
+
if (id && prototype.get(id) && id !== frameId) {
|
|
96
|
+
goto(prototype.paths.play(id), { reset: false });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function startFlow(next: Flow) {
|
|
101
|
+
flowId = next.id;
|
|
102
|
+
writeStored('session', keys.flow, next.id);
|
|
103
|
+
show(next.start);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function restart() {
|
|
107
|
+
if (flow) show(flow.start);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handleKey(key: string) {
|
|
111
|
+
if (key === 'ArrowRight') show(prototype.next(frameId)?.id);
|
|
112
|
+
else if (key === 'ArrowLeft') show(prototype.prev(frameId)?.id);
|
|
113
|
+
else if ((key === 'r' || key === 'R') && flow) restart();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// While the player is shown, a thread opened from the list or a link shows its frame.
|
|
117
|
+
$effect(() => {
|
|
118
|
+
const current = comments;
|
|
119
|
+
if (!current) return;
|
|
120
|
+
current.configure({
|
|
121
|
+
show: (thread: CommentThreadPin) => {
|
|
122
|
+
if (!prototype.get(thread.surface)) return false;
|
|
123
|
+
show(thread.surface);
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
describe: (id) => describeFrame(prototype, id)
|
|
127
|
+
});
|
|
128
|
+
return () => current.configure({ show: undefined });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function onkeydown(event: KeyboardEvent) {
|
|
132
|
+
if (!PLAYER_KEYS.has(event.key)) return;
|
|
133
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isEditable(event.target)) return;
|
|
134
|
+
if (document.querySelector(':popover-open')) return;
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
handleKey(event.key);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function onmessage(event: MessageEvent) {
|
|
140
|
+
if (event.origin !== location.origin || !isFrameMessage(event.data)) return;
|
|
141
|
+
const key = [...iframes].find(([, el]) => el.contentWindow === event.source)?.[0];
|
|
142
|
+
if (key === undefined) return;
|
|
143
|
+
const message = event.data;
|
|
144
|
+
if (message.type === 'paraframe:ready') markReady(key);
|
|
145
|
+
// Only the frame on top may navigate; a frame still fading out may not.
|
|
146
|
+
else if (key === slots.at(-1)?.key) {
|
|
147
|
+
if (message.type === 'paraframe:navigate') show(message.to);
|
|
148
|
+
else if (message.type === 'paraframe:key') handleKey(message.key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function track(key: number) {
|
|
153
|
+
return (el: HTMLIFrameElement) => {
|
|
154
|
+
iframes.set(key, el);
|
|
155
|
+
return () => iframes.delete(key);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback for frames that never report ready.
|
|
160
|
+
function onload(key: number) {
|
|
161
|
+
setTimeout(() => markReady(key), 1500);
|
|
162
|
+
}
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<svelte:window {onkeydown} {onmessage} />
|
|
166
|
+
|
|
167
|
+
<div class="pf-root flex h-dvh flex-col bg-pf-stage font-pf text-[13px] text-pf-canvas-ink">
|
|
168
|
+
<header class="pf-toolbar z-20 m-3 mb-0 shrink-0">
|
|
169
|
+
<div class="pf-island pr-4">
|
|
170
|
+
<a
|
|
171
|
+
href={prototype.paths.canvas()}
|
|
172
|
+
class="pf-tool"
|
|
173
|
+
title="All frames (canvas)"
|
|
174
|
+
aria-label="All frames (canvas)"
|
|
175
|
+
>
|
|
176
|
+
<Icon name="canvas" />
|
|
177
|
+
</a>
|
|
178
|
+
<p class="flex min-w-0 items-baseline gap-1.5 pl-1">
|
|
179
|
+
{#if frame}
|
|
180
|
+
<span class="hidden shrink-0 text-pf-island-muted sm:inline">{frame.section.title}</span>
|
|
181
|
+
<span class="hidden text-pf-island-muted sm:inline" aria-hidden="true">/</span>
|
|
182
|
+
{/if}
|
|
183
|
+
<span class="truncate font-medium">{frame?.title ?? frameId}</span>
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="pf-island ml-auto flex-wrap justify-end">
|
|
188
|
+
<nav class="flex items-center gap-1" aria-label="Step through frames">
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
class="pf-tool"
|
|
192
|
+
onclick={() => handleKey('ArrowLeft')}
|
|
193
|
+
disabled={!prototype.prev(frameId)}
|
|
194
|
+
title="Previous frame (←)"
|
|
195
|
+
aria-label="Previous frame"
|
|
196
|
+
>
|
|
197
|
+
<Icon name="chevron-left" />
|
|
198
|
+
</button>
|
|
199
|
+
<span class="min-w-16 text-center text-pf-island-muted tabular-nums">
|
|
200
|
+
{(frame?.index ?? 0) + 1} of {prototype.frames.length}
|
|
201
|
+
</span>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="pf-tool"
|
|
205
|
+
onclick={() => handleKey('ArrowRight')}
|
|
206
|
+
disabled={!prototype.next(frameId)}
|
|
207
|
+
title="Next frame (→)"
|
|
208
|
+
aria-label="Next frame"
|
|
209
|
+
>
|
|
210
|
+
<Icon name="chevron-right" />
|
|
211
|
+
</button>
|
|
212
|
+
</nav>
|
|
213
|
+
|
|
214
|
+
{#if prototype.flows.length}
|
|
215
|
+
<Menu label="Flows" align="end" class="pf-tool gap-1.5 px-3">
|
|
216
|
+
{#snippet trigger()}
|
|
217
|
+
<span class="max-w-48 truncate">{flow?.title ?? 'Flows'}</span>
|
|
218
|
+
<Icon name="chevron-down" size={14} />
|
|
219
|
+
{/snippet}
|
|
220
|
+
{#snippet children(close)}
|
|
221
|
+
{#each prototype.flows as item (item.id)}
|
|
222
|
+
<MenuItem
|
|
223
|
+
checked={item.id === flowId}
|
|
224
|
+
onselect={() => {
|
|
225
|
+
close();
|
|
226
|
+
startFlow(item);
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{item.title}
|
|
230
|
+
</MenuItem>
|
|
231
|
+
{/each}
|
|
232
|
+
{/snippet}
|
|
233
|
+
</Menu>
|
|
234
|
+
{/if}
|
|
235
|
+
|
|
236
|
+
{#if comments}
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
class="pf-tool relative"
|
|
240
|
+
aria-pressed={commenting}
|
|
241
|
+
aria-label="Comments"
|
|
242
|
+
title="Comments (C)"
|
|
243
|
+
onclick={() => comments.toggle()}
|
|
244
|
+
>
|
|
245
|
+
<Icon name="comment" />
|
|
246
|
+
{#if comments.unreadCount && !commenting}
|
|
247
|
+
<span class="absolute top-1 right-1 size-2 rounded-full bg-pf-comment"></span>
|
|
248
|
+
{/if}
|
|
249
|
+
</button>
|
|
250
|
+
{/if}
|
|
251
|
+
|
|
252
|
+
{#if flow}
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
class="pf-tool"
|
|
256
|
+
onclick={restart}
|
|
257
|
+
title="Restart “{flow.title}” (R)"
|
|
258
|
+
aria-label="Restart “{flow.title}”"
|
|
259
|
+
>
|
|
260
|
+
<Icon name="restart" />
|
|
261
|
+
</button>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
264
|
+
<Menu label="Jump to a frame" align="end" class="pf-tool gap-1.5 px-3">
|
|
265
|
+
{#snippet trigger()}
|
|
266
|
+
<span>Frames</span>
|
|
267
|
+
<Icon name="chevron-down" size={14} />
|
|
268
|
+
{/snippet}
|
|
269
|
+
{#snippet children(close)}
|
|
270
|
+
{#each prototype.sections as section (section.id)}
|
|
271
|
+
<p class="px-2 pt-2 pb-1 text-xs text-pf-muted">{section.title}</p>
|
|
272
|
+
{#each section.frames as item (item.id)}
|
|
273
|
+
<MenuItem
|
|
274
|
+
checked={item.id === frameId}
|
|
275
|
+
onselect={() => {
|
|
276
|
+
close();
|
|
277
|
+
show(item.id);
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{item.title}
|
|
281
|
+
</MenuItem>
|
|
282
|
+
{/each}
|
|
283
|
+
{/each}
|
|
284
|
+
{/snippet}
|
|
285
|
+
</Menu>
|
|
286
|
+
|
|
287
|
+
<Menu label="Scale" align="end" class="pf-tool gap-1.5 px-3">
|
|
288
|
+
{#snippet trigger()}
|
|
289
|
+
<span class="tabular-nums">{Math.round(view.scale * 100)}%</span>
|
|
290
|
+
<Icon name="chevron-down" size={14} />
|
|
291
|
+
{/snippet}
|
|
292
|
+
{#snippet children(close)}
|
|
293
|
+
{#each SCALES as option (option.value)}
|
|
294
|
+
<MenuItem
|
|
295
|
+
checked={option.value === scaleMode}
|
|
296
|
+
onselect={() => {
|
|
297
|
+
close();
|
|
298
|
+
setScale(option.value);
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{option.label}
|
|
302
|
+
</MenuItem>
|
|
303
|
+
{/each}
|
|
304
|
+
{/snippet}
|
|
305
|
+
</Menu>
|
|
306
|
+
|
|
307
|
+
<a
|
|
308
|
+
href={prototype.paths.frame(frameId)}
|
|
309
|
+
target="_blank"
|
|
310
|
+
rel="noopener"
|
|
311
|
+
class="pf-tool"
|
|
312
|
+
title="Open this frame on its own"
|
|
313
|
+
aria-label="Open this frame on its own"
|
|
314
|
+
>
|
|
315
|
+
<Icon name="external" />
|
|
316
|
+
</a>
|
|
317
|
+
{#if comments}<span data-vc-account></span>{/if}
|
|
318
|
+
</div>
|
|
319
|
+
</header>
|
|
320
|
+
|
|
321
|
+
<div class="flex min-h-0 flex-1">
|
|
322
|
+
<main
|
|
323
|
+
class="flex min-h-0 flex-1 overflow-auto"
|
|
324
|
+
bind:clientWidth={stageWidth}
|
|
325
|
+
bind:clientHeight={stageHeight}
|
|
326
|
+
>
|
|
327
|
+
{#if frame}
|
|
328
|
+
<div
|
|
329
|
+
class="relative m-auto shrink-0"
|
|
330
|
+
style:width="{frame.size.width * view.scale}px"
|
|
331
|
+
style:height="{view.height * view.scale}px"
|
|
332
|
+
>
|
|
333
|
+
<div class={['absolute inset-0 overflow-hidden bg-white', scaleMode !== 'width' && 'rounded-md']}>
|
|
334
|
+
{#each slots as slot, index (slot.key)}
|
|
335
|
+
{@const shown = prototype.get(slot.id)}
|
|
336
|
+
{#if shown}
|
|
337
|
+
<!-- The frame on top takes comments; one still fading out does not. -->
|
|
338
|
+
<iframe
|
|
339
|
+
{@attach track(slot.key)}
|
|
340
|
+
data-pf-player
|
|
341
|
+
data-vc-surface={index === slots.length - 1 ? slot.id : undefined}
|
|
342
|
+
data-vc-title={shown.title}
|
|
343
|
+
allow="clipboard-read; clipboard-write; fullscreen"
|
|
344
|
+
src={prototype.paths.frame(slot.id)}
|
|
345
|
+
title={shown.title}
|
|
346
|
+
onload={() => onload(slot.key)}
|
|
347
|
+
class="absolute top-0 left-0 origin-top-left border-0 bg-white"
|
|
348
|
+
style:width="{shown.size.width}px"
|
|
349
|
+
style:height="{view.height}px"
|
|
350
|
+
style:transform="scale({view.scale})"
|
|
351
|
+
style:opacity={slot.ready ? 1 : 0}
|
|
352
|
+
></iframe>
|
|
353
|
+
{/if}
|
|
354
|
+
{/each}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
{:else}
|
|
358
|
+
<p class="m-auto text-pf-canvas-muted">
|
|
359
|
+
No frame “{frameId}”. <a class="underline" href={prototype.paths.canvas()}>Open the canvas</a>
|
|
360
|
+
</p>
|
|
361
|
+
{/if}
|
|
362
|
+
</main>
|
|
363
|
+
{#if commenting}
|
|
364
|
+
<!-- Where the comments put their list, beside the stage. -->
|
|
365
|
+
<div data-vc-panel class="my-3 mr-3 w-80 shrink-0"></div>
|
|
366
|
+
{/if}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Prototype } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
prototype: Prototype;
|
|
4
|
+
frameId: string;
|
|
5
|
+
};
|
|
6
|
+
declare const Player: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type Player = ReturnType<typeof Player>;
|
|
8
|
+
export default Player;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { VisualComments } from 'visual-comments';
|
|
2
|
+
export { default as Comments } from './Comments.svelte';
|
|
3
|
+
/** Undefined while signed-out viewers cannot see comments: before mount, or without a `Comments` provider. */
|
|
4
|
+
export declare function useComments(): VisualComments | undefined;
|
package/dist/comments.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useCommentsHolder } from './internal/comments.js';
|
|
2
|
+
export { default as Comments } from './Comments.svelte';
|
|
3
|
+
/** Undefined while signed-out viewers cannot see comments: before mount, or without a `Comments` provider. */
|
|
4
|
+
export function useComments() {
|
|
5
|
+
// Only `Comments` provides the holder, and it holds a `VisualComments`.
|
|
6
|
+
return useCommentsHolder()?.current;
|
|
7
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as Canvas } from './Canvas.svelte';
|
|
2
|
+
export { default as Player } from './Player.svelte';
|
|
3
|
+
export { default as FrameHost } from './FrameHost.svelte';
|
|
4
|
+
export { default as FrameLink } from './FrameLink.svelte';
|
|
5
|
+
export { useFrameNav, type FrameNav } from './nav.js';
|
|
6
|
+
export { definePrototype, frameEntries, resolveFrame, DEFAULT_SIZE } from './prototype.js';
|
|
7
|
+
export type * from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Canvas } from './Canvas.svelte';
|
|
2
|
+
export { default as Player } from './Player.svelte';
|
|
3
|
+
export { default as FrameHost } from './FrameHost.svelte';
|
|
4
|
+
export { default as FrameLink } from './FrameLink.svelte';
|
|
5
|
+
export { useFrameNav } from './nav.js';
|
|
6
|
+
export { definePrototype, frameEntries, resolveFrame, DEFAULT_SIZE } from './prototype.js';
|