@protoyard/decks-ui 0.0.0-stage → 1.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 +172 -2
- package/dist/app/CustomSlide.svelte +42 -0
- package/dist/app/CustomSlide.svelte.d.ts +60 -0
- package/dist/app/DeckApp.svelte +527 -0
- package/dist/app/DeckApp.svelte.d.ts +41 -0
- package/dist/app/FreeBlock.svelte +66 -0
- package/dist/app/FreeBlock.svelte.d.ts +10 -0
- package/dist/app/FreeSlide.svelte +135 -0
- package/dist/app/FreeSlide.svelte.d.ts +5 -0
- package/dist/app/context.d.ts +14 -0
- package/dist/app/context.js +11 -0
- package/dist/app/fit.d.ts +39 -0
- package/dist/app/fit.js +97 -0
- package/dist/app/free-prefetch.d.ts +3 -0
- package/dist/app/free-prefetch.js +13 -0
- package/dist/app/index.d.ts +6 -0
- package/dist/app/index.js +5 -0
- package/dist/app/interactive.d.ts +3 -0
- package/dist/app/interactive.js +25 -0
- package/dist/app/renderers.d.ts +14 -0
- package/dist/app/renderers.js +49 -0
- package/dist/app/stage-deck.d.ts +40 -0
- package/dist/app/stage-deck.js +65 -0
- package/dist/debug/BlockOutline.svelte +117 -0
- package/dist/debug/BlockOutline.svelte.d.ts +12 -0
- package/dist/debug/DeckDebugPanel.svelte +205 -0
- package/dist/debug/DeckDebugPanel.svelte.d.ts +18 -0
- package/dist/debug/availability.d.ts +19 -0
- package/dist/debug/availability.js +27 -0
- package/dist/debug/format.d.ts +12 -0
- package/dist/debug/format.js +39 -0
- package/dist/debug/index.d.ts +10 -0
- package/dist/debug/index.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/layouts/CustomSlideHost.svelte +97 -0
- package/dist/layouts/CustomSlideHost.svelte.d.ts +14 -0
- package/dist/layouts/custom-slides.d.ts +28 -0
- package/dist/layouts/custom-slides.js +20 -0
- package/dist/layouts/index.d.ts +7 -0
- package/dist/layouts/index.js +7 -0
- package/dist/player.css +6 -0
- package/dist/player.css.d.ts +1 -0
- package/dist/studio/DeckStudioPanel.svelte +261 -0
- package/dist/studio/DeckStudioPanel.svelte.d.ts +8 -0
- package/dist/studio/StudioIcon.svelte +9 -0
- package/dist/studio/StudioIcon.svelte.d.ts +6 -0
- package/dist/studio/index.d.ts +26 -0
- package/dist/studio/index.js +21 -0
- package/dist/studio/studio.svelte.d.ts +565 -0
- package/dist/studio/studio.svelte.js +85 -0
- package/dist/styles.css +5 -0
- package/dist/styles.css.d.ts +1 -0
- package/package.json +106 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Block } from '@protoyard/blocks';
|
|
3
|
+
import type { DeckSlide, FreeSlide as FreeSlideModel } from '@texuli/deck/model';
|
|
4
|
+
import { fitText } from '@texuli/slides/layouts';
|
|
5
|
+
import {
|
|
6
|
+
createDeckStepContext,
|
|
7
|
+
type DeckSlideRendererProps,
|
|
8
|
+
deckSlideSteps,
|
|
9
|
+
getDeckStepContext,
|
|
10
|
+
} from '@texuli/slides/stage-core';
|
|
11
|
+
import { getDeckHostContext } from './context.js';
|
|
12
|
+
import FreeBlock from './FreeBlock.svelte';
|
|
13
|
+
import { fitContent } from './fit.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Protoyard's free slide renderer for the `@texuli/slides` stage: the
|
|
17
|
+
* author's `@protoyard/blocks` (the stage checked them with DeckApp's
|
|
18
|
+
* `validateBlock`, see `renderers.ts`), with a leading unstepped heading
|
|
19
|
+
* promoted to the slide title. Reveals use the stage's step machinery: the
|
|
20
|
+
* deck model's step table of the original slide, at the stage's step. The blocks zoom down (to a floor) when they would not fit
|
|
21
|
+
* the canvas. Texuli's `--tx-*` tokens feed Protoyard's blocks, so they follow the deck
|
|
22
|
+
* theme, the slide theme and the background's text color.
|
|
23
|
+
*/
|
|
24
|
+
let { slide, context }: DeckSlideRendererProps<FreeSlideModel> = $props();
|
|
25
|
+
const host = getDeckHostContext();
|
|
26
|
+
const fromContext = getDeckStepContext();
|
|
27
|
+
const stageStep = $derived(fromContext ?? context.step);
|
|
28
|
+
const original = $derived(host.freeSlides.get(slide.id));
|
|
29
|
+
// The deck passed DeckSchema (@protoyard/blocks) before it reached DeckApp.
|
|
30
|
+
const blocks = $derived((original?.blocks ?? []) as Block[]);
|
|
31
|
+
const steps = $derived(original ? deckSlideSteps(original as unknown as DeckSlide) : null);
|
|
32
|
+
const step = createDeckStepContext(() => ({
|
|
33
|
+
slide: (original ?? slide) as unknown as DeckSlide,
|
|
34
|
+
steps: steps ?? stageStep.steps,
|
|
35
|
+
step: stageStep.step,
|
|
36
|
+
active: stageStep.active,
|
|
37
|
+
direction: stageStep.direction,
|
|
38
|
+
}));
|
|
39
|
+
const lead = $derived(
|
|
40
|
+
blocks[0]?.type === 'heading' && blocks[0].step === undefined ? blocks[0] : null
|
|
41
|
+
);
|
|
42
|
+
const start = $derived(lead ? 1 : 0);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="py-deck-free" data-py-deck-free={slide.id}>
|
|
46
|
+
{#if lead && lead.type === 'heading'}
|
|
47
|
+
<header class="py-deck-free__heading" data-slot="heading">
|
|
48
|
+
{#if lead.eyebrow}<p class="py-deck-free__eyebrow">{lead.eyebrow}</p>{/if}
|
|
49
|
+
<h2 class="py-deck-free__title" data-slot="title" {@attach fitText(40)}>{lead.text}</h2>
|
|
50
|
+
{#if lead.subtitle}<p class="py-deck-free__subtitle">{lead.subtitle}</p>{/if}
|
|
51
|
+
</header>
|
|
52
|
+
{/if}
|
|
53
|
+
<!-- Safe centering: content taller than the slot starts at the top, under the heading. -->
|
|
54
|
+
<div class="py-deck-free__blocks" data-slot="blocks" {@attach fitContent()}>
|
|
55
|
+
{#each blocks.slice(start) as block, offset (start + offset)}
|
|
56
|
+
<FreeBlock {block} index={start + offset} {step} />
|
|
57
|
+
{/each}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.py-deck-free {
|
|
63
|
+
/* Text on the slide follows the background's ink (`--tx-deck-bg-text`). */
|
|
64
|
+
--deck-free-foreground: var(--tx-deck-bg-text, var(--tx-foreground));
|
|
65
|
+
/*
|
|
66
|
+
* The deck's radius choice: the stage writes it to --tx-deck-radius only,
|
|
67
|
+
* so Texuli components on the slide (buttons, cards, inputs) would keep the
|
|
68
|
+
* theme radius.
|
|
69
|
+
*/
|
|
70
|
+
--tx-radius: var(--tx-deck-radius);
|
|
71
|
+
--color-foreground: var(--deck-free-foreground);
|
|
72
|
+
--color-border: var(--tx-border);
|
|
73
|
+
--color-primary: var(--tx-primary);
|
|
74
|
+
--color-muted-foreground: var(--tx-muted-foreground);
|
|
75
|
+
--font-sans: var(--tx-font-sans);
|
|
76
|
+
--font-heading: var(--tx-font-heading);
|
|
77
|
+
/* One frame over the slide body, as the stage's layout frames: a fixed box the content fits in. */
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset: 0;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
gap: 30px;
|
|
84
|
+
box-sizing: border-box;
|
|
85
|
+
padding-block: 64px;
|
|
86
|
+
padding-inline: 77px;
|
|
87
|
+
color: var(--deck-free-foreground);
|
|
88
|
+
font-family: var(--tx-font-sans);
|
|
89
|
+
font-size: 24px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.py-deck-free__heading {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
flex-shrink: 0;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.py-deck-free__eyebrow {
|
|
100
|
+
margin: 0;
|
|
101
|
+
color: var(--tx-primary);
|
|
102
|
+
font-size: 20px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
letter-spacing: 0.08em;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.py-deck-free__title {
|
|
109
|
+
display: -webkit-box;
|
|
110
|
+
margin: 0;
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
font-family: var(--tx-font-heading);
|
|
113
|
+
font-size: 56px;
|
|
114
|
+
font-weight: 700;
|
|
115
|
+
line-height: 1.1;
|
|
116
|
+
-webkit-box-orient: vertical;
|
|
117
|
+
-webkit-line-clamp: 2;
|
|
118
|
+
line-clamp: 2;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.py-deck-free__subtitle {
|
|
122
|
+
margin: 0;
|
|
123
|
+
color: var(--tx-muted-foreground);
|
|
124
|
+
font-size: 28px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.py-deck-free__blocks {
|
|
128
|
+
display: flex;
|
|
129
|
+
flex: 1;
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
justify-content: safe center;
|
|
132
|
+
gap: 26px;
|
|
133
|
+
min-block-size: 0;
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FreeSlide as FreeSlideModel } from '@texuli/deck/model';
|
|
2
|
+
import { type DeckSlideRendererProps } from '@texuli/slides/stage-core';
|
|
3
|
+
declare const FreeSlide: import("svelte").Component<DeckSlideRendererProps<FreeSlideModel>, {}, "">;
|
|
4
|
+
type FreeSlide = ReturnType<typeof FreeSlide>;
|
|
5
|
+
export default FreeSlide;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FreeSlide } from '@protoyard/decks/schema';
|
|
2
|
+
import type { CustomSlideRegistry } from '../layouts/custom-slides.ts';
|
|
3
|
+
/**
|
|
4
|
+
* What DeckApp hands its slide renderers through Svelte context (the
|
|
5
|
+
* `@texuli/slides` stage passes renderers only the slide and its render
|
|
6
|
+
* context): the generated deck's custom slide registry and its free slides.
|
|
7
|
+
*/
|
|
8
|
+
export interface DeckHostContext {
|
|
9
|
+
readonly customSlides: CustomSlideRegistry | undefined;
|
|
10
|
+
/** The deck's free slides by id (the stage sees them as host slides, see `stage-deck.ts`). */
|
|
11
|
+
readonly freeSlides: ReadonlyMap<string, FreeSlide>;
|
|
12
|
+
}
|
|
13
|
+
export declare function setDeckHostContext(context: DeckHostContext): void;
|
|
14
|
+
export declare function getDeckHostContext(): DeckHostContext;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol.for('@protoyard/decks-ui/host');
|
|
3
|
+
export function setDeckHostContext(context) {
|
|
4
|
+
setContext(KEY, context);
|
|
5
|
+
}
|
|
6
|
+
export function getDeckHostContext() {
|
|
7
|
+
return (getContext(KEY) ?? {
|
|
8
|
+
customSlides: undefined,
|
|
9
|
+
freeSlides: new Map(),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-slide content fit: `@texuli/slides/layouts`' `fitContent` plus the
|
|
3
|
+
* oscillation guard Protoyard's former `design/fit.ts` had (`keepsZoom`).
|
|
4
|
+
*
|
|
5
|
+
* Texuli's attachment resets the zoom and measures again on every resize of
|
|
6
|
+
* the box. Blocks that lay out at their measured width (a Texuli chart, the
|
|
7
|
+
* chart explorer) reflow when the zoom is reset, the box resizes, the fit runs
|
|
8
|
+
* again and puts the zoom back: the zoom flips every frame and the slide never
|
|
9
|
+
* holds still (the flagship's `explore` slide did, in both levels). Remove this
|
|
10
|
+
* file and import `fitContent` from `@texuli/slides/layouts` again once the
|
|
11
|
+
* Texuli attachment keeps a zoom under which the content still fits.
|
|
12
|
+
*/
|
|
13
|
+
/** Why a fit runs again: a box resized, or the content itself changed. */
|
|
14
|
+
export type FitReason = 'resize' | 'content';
|
|
15
|
+
/**
|
|
16
|
+
* Whether a re-fit may keep the zoom it already applied instead of resetting
|
|
17
|
+
* and measuring again. Measuring with the zoom reset reads a layout the
|
|
18
|
+
* content has not reflowed to yet (a block sized for the zoomed width), which
|
|
19
|
+
* can find "it fits", drop the zoom, and flip back a frame later, forever. So
|
|
20
|
+
* a resize keeps a zoom under which the content still fits, or the floor zoom
|
|
21
|
+
* (content taller than the floor allows cannot shrink further, and measuring
|
|
22
|
+
* again only flips between the floor and a near guess), unless the space
|
|
23
|
+
* around it changed; new content always measures from scratch.
|
|
24
|
+
*/
|
|
25
|
+
export declare function keepsZoom(state: {
|
|
26
|
+
applied: number;
|
|
27
|
+
reason: FitReason;
|
|
28
|
+
spaceChanged: boolean;
|
|
29
|
+
fitsAtApplied: boolean;
|
|
30
|
+
/** The floor zoom (`fitContent`'s `min`). */
|
|
31
|
+
min: number;
|
|
32
|
+
}): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Zooms `node` down (CSS `zoom`, so layout follows) until its content fits its
|
|
35
|
+
* own box, never below `min` (0.75, the floor of the stage's layouts, keeps
|
|
36
|
+
* 24 px body text at the 18 px label minimum). Same contract and attribute
|
|
37
|
+
* (`data-tx-deck-fit`) as `@texuli/slides/layouts`' `fitContent`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fitContent(min?: number): (node: HTMLElement) => () => void;
|
package/dist/app/fit.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { fitScale } from '@texuli/slides/layouts';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a re-fit may keep the zoom it already applied instead of resetting
|
|
4
|
+
* and measuring again. Measuring with the zoom reset reads a layout the
|
|
5
|
+
* content has not reflowed to yet (a block sized for the zoomed width), which
|
|
6
|
+
* can find "it fits", drop the zoom, and flip back a frame later, forever. So
|
|
7
|
+
* a resize keeps a zoom under which the content still fits, or the floor zoom
|
|
8
|
+
* (content taller than the floor allows cannot shrink further, and measuring
|
|
9
|
+
* again only flips between the floor and a near guess), unless the space
|
|
10
|
+
* around it changed; new content always measures from scratch.
|
|
11
|
+
*/
|
|
12
|
+
export function keepsZoom(state) {
|
|
13
|
+
return (state.applied < 1 &&
|
|
14
|
+
state.reason === 'resize' &&
|
|
15
|
+
!state.spaceChanged &&
|
|
16
|
+
(state.fitsAtApplied || state.applied <= state.min));
|
|
17
|
+
}
|
|
18
|
+
function watch(node, run) {
|
|
19
|
+
if (typeof requestAnimationFrame !== 'function')
|
|
20
|
+
return () => { };
|
|
21
|
+
let frame = 0;
|
|
22
|
+
let pending = 'content';
|
|
23
|
+
const schedule = (reason) => {
|
|
24
|
+
// Content outranks a resize when both arrive in one frame.
|
|
25
|
+
pending = pending === 'content' || reason === 'content' ? 'content' : 'resize';
|
|
26
|
+
cancelAnimationFrame(frame);
|
|
27
|
+
frame = requestAnimationFrame(() => {
|
|
28
|
+
const why = pending;
|
|
29
|
+
pending = 'resize';
|
|
30
|
+
run(why);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
schedule('content');
|
|
34
|
+
const resized = () => schedule('resize');
|
|
35
|
+
const changed = () => schedule('content');
|
|
36
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resized) : null;
|
|
37
|
+
observer?.observe(node);
|
|
38
|
+
if (node.parentElement)
|
|
39
|
+
observer?.observe(node.parentElement);
|
|
40
|
+
// Images and fonts change the size after the first measure.
|
|
41
|
+
node.addEventListener('load', changed, true);
|
|
42
|
+
// Lazy blocks arrive after the first measure without resizing this box, so
|
|
43
|
+
// added content also triggers a re-fit (only nodes, never style changes).
|
|
44
|
+
const mutations = typeof MutationObserver === 'function' ? new MutationObserver(changed) : null;
|
|
45
|
+
mutations?.observe(node, { childList: true, subtree: true });
|
|
46
|
+
return () => {
|
|
47
|
+
cancelAnimationFrame(frame);
|
|
48
|
+
observer?.disconnect();
|
|
49
|
+
mutations?.disconnect();
|
|
50
|
+
node.removeEventListener('load', changed, true);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Zooms `node` down (CSS `zoom`, so layout follows) until its content fits its
|
|
55
|
+
* own box, never below `min` (0.75, the floor of the stage's layouts, keeps
|
|
56
|
+
* 24 px body text at the 18 px label minimum). Same contract and attribute
|
|
57
|
+
* (`data-tx-deck-fit`) as `@texuli/slides/layouts`' `fitContent`.
|
|
58
|
+
*/
|
|
59
|
+
export function fitContent(min = 0.75) {
|
|
60
|
+
return (node) => {
|
|
61
|
+
let applied = 1;
|
|
62
|
+
let space = '';
|
|
63
|
+
const spaceOf = () => {
|
|
64
|
+
const parent = node.parentElement;
|
|
65
|
+
return parent ? `${parent.clientWidth}x${parent.clientHeight}` : '';
|
|
66
|
+
};
|
|
67
|
+
return watch(node, (reason) => {
|
|
68
|
+
const now = spaceOf();
|
|
69
|
+
if (keepsZoom({
|
|
70
|
+
applied,
|
|
71
|
+
reason,
|
|
72
|
+
spaceChanged: now !== space,
|
|
73
|
+
fitsAtApplied: node.scrollHeight <= node.clientHeight + 2,
|
|
74
|
+
min,
|
|
75
|
+
})) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
node.style.zoom = '';
|
|
79
|
+
if (node.clientHeight === 0)
|
|
80
|
+
return;
|
|
81
|
+
let scale = fitScale(node.scrollHeight, node.clientHeight, min);
|
|
82
|
+
// Content reflows under the zoom (a wider box, shorter lines), so the
|
|
83
|
+
// first estimate is checked under the zoom itself and tightened until it
|
|
84
|
+
// fits there: the result is a fixed point, not a guess the next frame undoes.
|
|
85
|
+
for (let pass = 0; scale < 1 && pass < 6; pass += 1) {
|
|
86
|
+
node.style.zoom = String(scale);
|
|
87
|
+
if (node.scrollHeight <= node.clientHeight + 2 || scale <= min)
|
|
88
|
+
break;
|
|
89
|
+
scale = Math.max(min, Math.floor(scale * (node.clientHeight / node.scrollHeight) * 1000) / 1000);
|
|
90
|
+
}
|
|
91
|
+
node.style.zoom = scale < 1 ? String(scale) : '';
|
|
92
|
+
node.dataset.txDeckFit = scale < 1 ? String(scale) : '1';
|
|
93
|
+
applied = scale;
|
|
94
|
+
space = now;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { blockRendererRegistry } from '@protoyard/blocks-ui';
|
|
2
|
+
const BLOCK_TYPES = new Set(Object.keys(blockRendererRegistry));
|
|
3
|
+
/** Load the block families a free slide needs (lazy: this module is its own chunk). */
|
|
4
|
+
export function prefetchFreeSlide(slide) {
|
|
5
|
+
const families = new Set();
|
|
6
|
+
for (const block of slide.blocks) {
|
|
7
|
+
const type = block.type;
|
|
8
|
+
if (typeof type !== 'string' || !BLOCK_TYPES.has(type))
|
|
9
|
+
continue;
|
|
10
|
+
families.add(blockRendererRegistry[type].load);
|
|
11
|
+
}
|
|
12
|
+
return Promise.all([...families].map((load) => load()));
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@protoyard/decks-ui/app` — DeckApp, the one deck component that the
|
|
3
|
+
* player's deck entry and generated deck prototypes both mount (ADR-038).
|
|
4
|
+
*/
|
|
5
|
+
export type { CustomSlideComponent, CustomSlideLoader, CustomSlideProps, CustomSlideRegistry, } from '../layouts/custom-slides.ts';
|
|
6
|
+
export { default as DeckApp } from './DeckApp.svelte';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Blocks with their own controls: clicks and keys inside them never move the deck. */
|
|
2
|
+
const INTERACTIVE_TYPES = new Set([
|
|
3
|
+
'tabs',
|
|
4
|
+
'reveal',
|
|
5
|
+
'poll',
|
|
6
|
+
'quiz',
|
|
7
|
+
'calculator',
|
|
8
|
+
'counter',
|
|
9
|
+
'chart-controls',
|
|
10
|
+
'map',
|
|
11
|
+
'embed-app',
|
|
12
|
+
'form',
|
|
13
|
+
'chart',
|
|
14
|
+
'table',
|
|
15
|
+
'stat',
|
|
16
|
+
'code',
|
|
17
|
+
]);
|
|
18
|
+
/** True when `block` (or a block nested in it) has its own controls. */
|
|
19
|
+
export function containsDeckControl(block) {
|
|
20
|
+
if (INTERACTIVE_TYPES.has(block.type))
|
|
21
|
+
return true;
|
|
22
|
+
if (block.type === 'columns')
|
|
23
|
+
return block.columns.some((column) => column.some(containsDeckControl));
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FreeSlide } from '@protoyard/decks/schema';
|
|
2
|
+
import { type DeckDiagnostic, type PathSegment } from '@texuli/deck/schema';
|
|
3
|
+
import type { DeckRenderers } from '@texuli/slides/stage-core';
|
|
4
|
+
/**
|
|
5
|
+
* The stage's free-slide block check for Protoyard blocks (step fields already
|
|
6
|
+
* removed). DeckApp only receives a deck that passed `DeckSchema`, which
|
|
7
|
+
* validates every block body with `@protoyard/blocks`; that zod schema is far
|
|
8
|
+
* too large for the deck entry, so the stage checks only that each block is a
|
|
9
|
+
* typed object (`@protoyard/blocks-ui` shows a labelled card for a type it
|
|
10
|
+
* does not render). Returns no diagnostics to accept the block.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateProtoyardBlock(block: unknown, path: PathSegment[]): DeckDiagnostic[];
|
|
13
|
+
/** The renderer table for one deck (its free slides are looked up by id for prefetch). */
|
|
14
|
+
export declare function createProtoyardRenderers(freeSlides: ReadonlyMap<string, FreeSlide>): DeckRenderers;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createDiagnostic } from '@texuli/deck/schema';
|
|
2
|
+
import { layoutRenderers } from '@texuli/slides/layouts';
|
|
3
|
+
const free = { load: () => import('./FreeSlide.svelte') };
|
|
4
|
+
const custom = { load: () => import('./CustomSlide.svelte') };
|
|
5
|
+
/**
|
|
6
|
+
* The stage's free-slide block check for Protoyard blocks (step fields already
|
|
7
|
+
* removed). DeckApp only receives a deck that passed `DeckSchema`, which
|
|
8
|
+
* validates every block body with `@protoyard/blocks`; that zod schema is far
|
|
9
|
+
* too large for the deck entry, so the stage checks only that each block is a
|
|
10
|
+
* typed object (`@protoyard/blocks-ui` shows a labelled card for a type it
|
|
11
|
+
* does not render). Returns no diagnostics to accept the block.
|
|
12
|
+
*/
|
|
13
|
+
export function validateProtoyardBlock(block, path) {
|
|
14
|
+
const typed = block !== null &&
|
|
15
|
+
typeof block === 'object' &&
|
|
16
|
+
!Array.isArray(block) &&
|
|
17
|
+
typeof block.type === 'string' &&
|
|
18
|
+
block.type.length > 0;
|
|
19
|
+
if (typed)
|
|
20
|
+
return [];
|
|
21
|
+
return [
|
|
22
|
+
createDiagnostic('block-props', 'A free-slide block is an object with a "type".', path, {
|
|
23
|
+
fix: 'Check the block against the @protoyard/blocks schema for its type.',
|
|
24
|
+
}),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
/** The renderer table for one deck (its free slides are looked up by id for prefetch). */
|
|
28
|
+
export function createProtoyardRenderers(freeSlides) {
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
layout: (slide, lookup) => layoutRenderers.layout?.(slide, lookup),
|
|
31
|
+
free: () => free,
|
|
32
|
+
custom: () => custom,
|
|
33
|
+
validateBlock: validateProtoyardBlock,
|
|
34
|
+
prefetch(slide) {
|
|
35
|
+
if (slide.layout === 'custom')
|
|
36
|
+
return custom.load();
|
|
37
|
+
if (slide.layout !== 'free')
|
|
38
|
+
return layoutRenderers.prefetch?.(slide);
|
|
39
|
+
const original = freeSlides.get(slide.id);
|
|
40
|
+
// The block families load from a lazy module, so the deck entry never carries blocks-ui.
|
|
41
|
+
return Promise.all([
|
|
42
|
+
free.load(),
|
|
43
|
+
original
|
|
44
|
+
? import("./free-prefetch.js").then((module) => module.prefetchFreeSlide(original))
|
|
45
|
+
: undefined,
|
|
46
|
+
]);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Deck, FreeSlide } from '@protoyard/decks/schema';
|
|
2
|
+
import { type PreparedDeck } from '@texuli/slides/stage-core';
|
|
3
|
+
/**
|
|
4
|
+
* The deck DeckApp hands the `@texuli/slides` stage.
|
|
5
|
+
*
|
|
6
|
+
* - **Theme:** `style.theme` becomes Protoyard's theme key (a theme id, a
|
|
7
|
+
* palette seed as `seed-<hex>`, or the default theme) with the font pair
|
|
8
|
+
* (`stageThemeKey`), which DeckApp's `loadTheme` resolves through
|
|
9
|
+
* `@protoyard/themes`.
|
|
10
|
+
* - **Free slides** stay free slides: the stage checks their blocks with
|
|
11
|
+
* DeckApp's `validateBlock` (a typed-object check, see `renderers.ts`)
|
|
12
|
+
* and counts their steps itself. A free slide with no title takes its
|
|
13
|
+
* first heading's text, which the stage names the slide by. DeckApp's
|
|
14
|
+
* free-slide renderer paints the original `@protoyard/blocks` from
|
|
15
|
+
* `freeSlides`.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The theme key the stage loads and caches a theme by: the deck theme key,
|
|
19
|
+
* plus `-font-<pair>` when the deck picks a font pairing. The stage caches
|
|
20
|
+
* loaded themes by key, so a font pair change (Deck Studio) must change the
|
|
21
|
+
* key or the stage keeps the old fonts. Both parts are slugs, so the key is
|
|
22
|
+
* still a valid catalog theme id for the deck model.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stageThemeKey(style: Deck['style'] | undefined): string;
|
|
25
|
+
/** The deck theme key inside a stage theme key, given the font pair the deck has now. */
|
|
26
|
+
export declare function themeKeyOf(stageKey: string, fontPair: string | undefined): string;
|
|
27
|
+
export interface StageDeck {
|
|
28
|
+
input: Record<string, unknown>;
|
|
29
|
+
/** The original free slides, by id, for the free-slide renderer. */
|
|
30
|
+
freeSlides: ReadonlyMap<string, FreeSlide>;
|
|
31
|
+
}
|
|
32
|
+
export declare function stageDeck(deck: Deck): StageDeck;
|
|
33
|
+
/**
|
|
34
|
+
* Migrate a stage input (`stageDeck(deck).input`) to the deck model and validate it
|
|
35
|
+
* once with Protoyard's block check (`prepareDeckV2`, Texuli G83). An `ok` result is a
|
|
36
|
+
* prepared deck: at the `developer` tier the stage paints it on its first render
|
|
37
|
+
* without migrating or validating it again, and loads no validation chunk for it.
|
|
38
|
+
* Never throws: a deck that does not validate comes back with its issues.
|
|
39
|
+
*/
|
|
40
|
+
export declare function prepareStageDeck(input: Record<string, unknown>): PreparedDeck;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { toTexuliDeck } from '@protoyard/decks';
|
|
2
|
+
import { deckThemeKey } from '@protoyard/decks/style/key';
|
|
3
|
+
import { prepareDeckV2 } from '@texuli/slides/stage-core';
|
|
4
|
+
import { validateProtoyardBlock } from "./renderers.js";
|
|
5
|
+
/**
|
|
6
|
+
* The deck DeckApp hands the `@texuli/slides` stage.
|
|
7
|
+
*
|
|
8
|
+
* - **Theme:** `style.theme` becomes Protoyard's theme key (a theme id, a
|
|
9
|
+
* palette seed as `seed-<hex>`, or the default theme) with the font pair
|
|
10
|
+
* (`stageThemeKey`), which DeckApp's `loadTheme` resolves through
|
|
11
|
+
* `@protoyard/themes`.
|
|
12
|
+
* - **Free slides** stay free slides: the stage checks their blocks with
|
|
13
|
+
* DeckApp's `validateBlock` (a typed-object check, see `renderers.ts`)
|
|
14
|
+
* and counts their steps itself. A free slide with no title takes its
|
|
15
|
+
* first heading's text, which the stage names the slide by. DeckApp's
|
|
16
|
+
* free-slide renderer paints the original `@protoyard/blocks` from
|
|
17
|
+
* `freeSlides`.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The theme key the stage loads and caches a theme by: the deck theme key,
|
|
21
|
+
* plus `-font-<pair>` when the deck picks a font pairing. The stage caches
|
|
22
|
+
* loaded themes by key, so a font pair change (Deck Studio) must change the
|
|
23
|
+
* key or the stage keeps the old fonts. Both parts are slugs, so the key is
|
|
24
|
+
* still a valid catalog theme id for the deck model.
|
|
25
|
+
*/
|
|
26
|
+
export function stageThemeKey(style) {
|
|
27
|
+
const theme = deckThemeKey(style);
|
|
28
|
+
return style?.fontPair ? `${theme}-font-${style.fontPair}` : theme;
|
|
29
|
+
}
|
|
30
|
+
/** The deck theme key inside a stage theme key, given the font pair the deck has now. */
|
|
31
|
+
export function themeKeyOf(stageKey, fontPair) {
|
|
32
|
+
const suffix = fontPair ? `-font-${fontPair}` : '';
|
|
33
|
+
return suffix && stageKey.endsWith(suffix) ? stageKey.slice(0, -suffix.length) : stageKey;
|
|
34
|
+
}
|
|
35
|
+
export function stageDeck(deck) {
|
|
36
|
+
const freeSlides = new Map();
|
|
37
|
+
const slides = deck.slides.map((slide) => {
|
|
38
|
+
if (slide.layout !== 'free')
|
|
39
|
+
return slide;
|
|
40
|
+
const free = slide;
|
|
41
|
+
freeSlides.set(free.id, free);
|
|
42
|
+
if (free.title)
|
|
43
|
+
return free;
|
|
44
|
+
// The stage names the slide by its title: keep the first heading as one when it has none.
|
|
45
|
+
const heading = free.blocks.find((block) => block.type === 'heading');
|
|
46
|
+
return heading && 'text' in heading
|
|
47
|
+
? { ...free, title: String(heading.text).slice(0, 160) }
|
|
48
|
+
: free;
|
|
49
|
+
});
|
|
50
|
+
const { paletteSeed: _seed, ...style } = deck.style ?? {};
|
|
51
|
+
return {
|
|
52
|
+
input: { ...deck, style: { ...style, theme: stageThemeKey(deck.style) }, slides },
|
|
53
|
+
freeSlides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Migrate a stage input (`stageDeck(deck).input`) to the deck model and validate it
|
|
58
|
+
* once with Protoyard's block check (`prepareDeckV2`, Texuli G83). An `ok` result is a
|
|
59
|
+
* prepared deck: at the `developer` tier the stage paints it on its first render
|
|
60
|
+
* without migrating or validating it again, and loads no validation chunk for it.
|
|
61
|
+
* Never throws: a deck that does not validate comes back with its issues.
|
|
62
|
+
*/
|
|
63
|
+
export function prepareStageDeck(input) {
|
|
64
|
+
return prepareDeckV2(toTexuliDeck(input), { validateBlock: validateProtoyardBlock });
|
|
65
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from 'texuli/badge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outline mode: a box and the JSON pointer for each top-level block of the
|
|
6
|
+
* slide on screen (a free slide), or for the slide's props (a catalog
|
|
7
|
+
* layout). Decoration only: `aria-hidden`, `pointer-events: none`, hidden in
|
|
8
|
+
* print. Boxes are measured from the rendered stage and refit on resize.
|
|
9
|
+
*/
|
|
10
|
+
let {
|
|
11
|
+
root,
|
|
12
|
+
slideId,
|
|
13
|
+
slidePointer,
|
|
14
|
+
propsPointer,
|
|
15
|
+
}: {
|
|
16
|
+
/** The deck root the stage renders in. */
|
|
17
|
+
root: HTMLElement | undefined;
|
|
18
|
+
slideId: string;
|
|
19
|
+
/** `/deck/slides/3`: block pointers are `<slidePointer>/blocks/<n>`. */
|
|
20
|
+
slidePointer: string;
|
|
21
|
+
/** The props pointer of a catalog slide, or null for a free slide. */
|
|
22
|
+
propsPointer: string | null;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
interface Box {
|
|
26
|
+
pointer: string;
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let layer = $state<HTMLDivElement | undefined>();
|
|
34
|
+
let boxes = $state<Box[]>([]);
|
|
35
|
+
|
|
36
|
+
function slideElement(host: HTMLElement, id: string): HTMLElement | null {
|
|
37
|
+
for (const node of host.querySelectorAll<HTMLElement>('[data-tx-deck-slide]')) {
|
|
38
|
+
if (node.dataset.txDeckSlide === id) return node;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function measure(): void {
|
|
44
|
+
const host = root;
|
|
45
|
+
const own = layer;
|
|
46
|
+
if (!host || !own) return;
|
|
47
|
+
const origin = own.getBoundingClientRect();
|
|
48
|
+
const slide = slideElement(host, slideId);
|
|
49
|
+
if (!slide) {
|
|
50
|
+
boxes = [];
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const box = (element: Element, pointer: string): Box => {
|
|
54
|
+
const rect = element.getBoundingClientRect();
|
|
55
|
+
return {
|
|
56
|
+
pointer,
|
|
57
|
+
x: rect.left - origin.left,
|
|
58
|
+
y: rect.top - origin.top,
|
|
59
|
+
width: rect.width,
|
|
60
|
+
height: rect.height,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
// A free slide: the promoted heading is block 0, every other block has a (box-less) item wrapper.
|
|
64
|
+
const free = slide.querySelector('[data-py-deck-free]');
|
|
65
|
+
if (!propsPointer && free) {
|
|
66
|
+
const heading = free.querySelector('[data-slot="heading"]');
|
|
67
|
+
const items = [...free.querySelectorAll<HTMLElement>('[data-tx-deck-item]')].flatMap((item) => {
|
|
68
|
+
const child = item.firstElementChild;
|
|
69
|
+
return child ? [box(child, `${slidePointer}/blocks/${item.dataset.txDeckItem}`)] : [];
|
|
70
|
+
});
|
|
71
|
+
boxes = heading ? [box(heading, `${slidePointer}/blocks/0`), ...items] : items;
|
|
72
|
+
} else {
|
|
73
|
+
boxes = [box(slide, propsPointer ?? slidePointer)];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
// Re-measure whenever the slide (or the deck root) changes, after it renders.
|
|
79
|
+
void slideId;
|
|
80
|
+
void propsPointer;
|
|
81
|
+
const host = root;
|
|
82
|
+
if (!host) return;
|
|
83
|
+
let frame = requestAnimationFrame(measure);
|
|
84
|
+
const again = () => {
|
|
85
|
+
cancelAnimationFrame(frame);
|
|
86
|
+
frame = requestAnimationFrame(measure);
|
|
87
|
+
};
|
|
88
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(again);
|
|
89
|
+
observer?.observe(host);
|
|
90
|
+
window.addEventListener('resize', again);
|
|
91
|
+
return () => {
|
|
92
|
+
cancelAnimationFrame(frame);
|
|
93
|
+
observer?.disconnect();
|
|
94
|
+
window.removeEventListener('resize', again);
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<div
|
|
100
|
+
bind:this={layer}
|
|
101
|
+
class="pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
data-py-deck-outline
|
|
104
|
+
>
|
|
105
|
+
{#each boxes as item (item.pointer)}
|
|
106
|
+
<div
|
|
107
|
+
class="absolute rounded-sm border-2 border-dashed border-primary"
|
|
108
|
+
style={`left:${item.x}px;top:${item.y}px;width:${item.width}px;height:${item.height}px`}
|
|
109
|
+
data-py-deck-outline-box={item.pointer}
|
|
110
|
+
>
|
|
111
|
+
<Badge
|
|
112
|
+
class="absolute top-0 left-0 max-w-full truncate rounded-sm px-1 font-mono text-[10px] leading-4"
|
|
113
|
+
label={item.pointer}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
{/each}
|
|
117
|
+
</div>
|