@seriousgaming/deck 0.0.0-stage → 1.0.0-alpha.2
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 +202 -0
- package/NOTICE +1 -0
- package/README.md +20 -2
- package/lib/Deck.d.ts +49 -0
- package/lib/Deck.js +148 -0
- package/lib/Slide.d.ts +27 -0
- package/lib/Slide.js +20 -0
- package/lib/Step.d.ts +25 -0
- package/lib/Step.js +24 -0
- package/lib/brand.d.ts +39 -0
- package/lib/brand.js +75 -0
- package/lib/context.d.ts +54 -0
- package/lib/context.js +32 -0
- package/lib/controls.d.ts +18 -0
- package/lib/controls.js +15 -0
- package/lib/export/collect.d.ts +32 -0
- package/lib/export/collect.js +312 -0
- package/lib/export/index.d.ts +28 -0
- package/lib/export/index.js +92 -0
- package/lib/export/ir.d.ts +85 -0
- package/lib/export/ir.js +1 -0
- package/lib/export/pptx.d.ts +12 -0
- package/lib/export/pptx.js +125 -0
- package/lib/export/use-deck-export.d.ts +19 -0
- package/lib/export/use-deck-export.js +97 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +15 -0
- package/lib/keyboard.d.ts +18 -0
- package/lib/keyboard.js +59 -0
- package/lib/memory.d.ts +31 -0
- package/lib/memory.js +60 -0
- package/lib/navigation.d.ts +26 -0
- package/lib/navigation.js +93 -0
- package/lib/overview.d.ts +11 -0
- package/lib/overview.js +22 -0
- package/lib/primitives/chart.d.ts +40 -0
- package/lib/primitives/chart.js +116 -0
- package/lib/primitives/media.d.ts +14 -0
- package/lib/primitives/media.js +5 -0
- package/lib/primitives/table.d.ts +11 -0
- package/lib/primitives/table.js +24 -0
- package/lib/primitives/text.d.ts +33 -0
- package/lib/primitives/text.js +30 -0
- package/lib/styles.d.ts +10 -0
- package/lib/styles.js +192 -0
- package/package.json +54 -3
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DeckExport } from './ir.js';
|
|
2
|
+
/**
|
|
3
|
+
* Write a {@link DeckExport} as a PowerPoint file.
|
|
4
|
+
*
|
|
5
|
+
* The only module that knows pptxgenjs exists. It is imported here on demand,
|
|
6
|
+
* so a deck that is presented and never exported never downloads it, and
|
|
7
|
+
* replacing it means rewriting this file and nothing else.
|
|
8
|
+
*
|
|
9
|
+
* PowerPoint's wide layout is 13.333 by 7.5 inches; the slide canvas is 1920 by
|
|
10
|
+
* 1080 pixels, so a pixel is 1/144 inch and half a point.
|
|
11
|
+
*/
|
|
12
|
+
export declare function writePptx(deck: DeckExport): Promise<Blob>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a {@link DeckExport} as a PowerPoint file.
|
|
3
|
+
*
|
|
4
|
+
* The only module that knows pptxgenjs exists. It is imported here on demand,
|
|
5
|
+
* so a deck that is presented and never exported never downloads it, and
|
|
6
|
+
* replacing it means rewriting this file and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* PowerPoint's wide layout is 13.333 by 7.5 inches; the slide canvas is 1920 by
|
|
9
|
+
* 1080 pixels, so a pixel is 1/144 inch and half a point.
|
|
10
|
+
*/
|
|
11
|
+
export async function writePptx(deck) {
|
|
12
|
+
const loaded = (await import('pptxgenjs'));
|
|
13
|
+
const PptxGenJSClass = 'default' in loaded.default ? loaded.default.default : loaded.default;
|
|
14
|
+
const pres = new PptxGenJSClass();
|
|
15
|
+
pres.layout = 'LAYOUT_WIDE';
|
|
16
|
+
pres.title = deck.title;
|
|
17
|
+
pres.rtlMode = deck.dir === 'rtl';
|
|
18
|
+
const inch = (value) => value / (deck.width / 13.333);
|
|
19
|
+
for (const slideExport of deck.slides) {
|
|
20
|
+
const slide = pres.addSlide();
|
|
21
|
+
slide.background = { data: stripDataPrefix(slideExport.background) };
|
|
22
|
+
for (const element of slideExport.elements) {
|
|
23
|
+
switch (element.kind) {
|
|
24
|
+
case 'text':
|
|
25
|
+
addText(slide, element, inch, deck.dir);
|
|
26
|
+
break;
|
|
27
|
+
case 'table':
|
|
28
|
+
addTable(slide, element, inch, deck.dir);
|
|
29
|
+
break;
|
|
30
|
+
case 'image':
|
|
31
|
+
addImage(slide, element, inch);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (slideExport.notes)
|
|
36
|
+
slide.addNotes(slideExport.notes);
|
|
37
|
+
}
|
|
38
|
+
return (await pres.write({ outputType: 'blob' }));
|
|
39
|
+
}
|
|
40
|
+
/** Pixels on the 1920-wide canvas to points. */
|
|
41
|
+
const pt = (pixels) => Math.round(pixels * 0.5 * 10) / 10;
|
|
42
|
+
/** pptxgenjs wants `image/png;base64,...`, without the `data:` scheme. */
|
|
43
|
+
function stripDataPrefix(dataUrl) {
|
|
44
|
+
return dataUrl.replace(/^data:/, '');
|
|
45
|
+
}
|
|
46
|
+
function position(box, inch) {
|
|
47
|
+
return { x: inch(box.x), y: inch(box.y), w: inch(box.w), h: inch(box.h) };
|
|
48
|
+
}
|
|
49
|
+
function textRuns(paragraphs, dir) {
|
|
50
|
+
const runs = [];
|
|
51
|
+
paragraphs.forEach((paragraph, p) => {
|
|
52
|
+
paragraph.runs.forEach((run, r) => {
|
|
53
|
+
const isLast = r === paragraph.runs.length - 1;
|
|
54
|
+
runs.push({
|
|
55
|
+
text: run.text,
|
|
56
|
+
options: {
|
|
57
|
+
fontFace: run.font,
|
|
58
|
+
fontSize: pt(run.size),
|
|
59
|
+
color: run.color,
|
|
60
|
+
bold: run.bold,
|
|
61
|
+
italic: run.italic,
|
|
62
|
+
underline: run.underline ? { style: 'sng' } : undefined,
|
|
63
|
+
align: paragraph.align,
|
|
64
|
+
rtlMode: dir === 'rtl',
|
|
65
|
+
bullet: paragraph.bullet === 'number'
|
|
66
|
+
? { type: 'number', indent: pt(run.size * 1.2) }
|
|
67
|
+
: paragraph.bullet === 'disc'
|
|
68
|
+
? { indent: pt(run.size * 1.2) }
|
|
69
|
+
: false,
|
|
70
|
+
indentLevel: paragraph.bullet ? paragraph.level : undefined,
|
|
71
|
+
lineSpacing: paragraph.lineHeight ? pt(paragraph.lineHeight) : undefined,
|
|
72
|
+
paraSpaceAfter: paragraph.spaceAfter ? pt(paragraph.spaceAfter) : undefined,
|
|
73
|
+
breakLine: isLast && p < paragraphs.length - 1,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return runs;
|
|
79
|
+
}
|
|
80
|
+
function addText(slide, element, inch, dir) {
|
|
81
|
+
// A little slack on the width: PowerPoint lays text out a hair wider than a
|
|
82
|
+
// browser, and a heading that wraps one word early looks broken.
|
|
83
|
+
const box = { ...element.box, w: element.box.w * 1.04 };
|
|
84
|
+
if (dir === 'rtl')
|
|
85
|
+
box.x -= element.box.w * 0.04;
|
|
86
|
+
slide.addText(textRuns(element.paragraphs, dir), {
|
|
87
|
+
...position(box, inch),
|
|
88
|
+
margin: 0,
|
|
89
|
+
valign: 'top',
|
|
90
|
+
fit: 'none',
|
|
91
|
+
wrap: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function addTable(slide, element, inch, dir) {
|
|
95
|
+
const rows = element.rows.map((row) => row.cells.map((cell) => ({
|
|
96
|
+
text: textRuns(cell.paragraphs, dir),
|
|
97
|
+
options: {
|
|
98
|
+
fill: cell.fill ? { color: cell.fill } : undefined,
|
|
99
|
+
border: cell.border
|
|
100
|
+
? [
|
|
101
|
+
{ type: 'none' },
|
|
102
|
+
{ type: 'none' },
|
|
103
|
+
{ type: 'solid', color: cell.border.color, pt: pt(cell.border.width) },
|
|
104
|
+
{ type: 'none' },
|
|
105
|
+
]
|
|
106
|
+
: { type: 'none' },
|
|
107
|
+
valign: 'middle',
|
|
108
|
+
margin: [0.06, 0.1, 0.06, 0.1],
|
|
109
|
+
},
|
|
110
|
+
})));
|
|
111
|
+
slide.addTable(rows, {
|
|
112
|
+
x: inch(element.box.x),
|
|
113
|
+
y: inch(element.box.y),
|
|
114
|
+
w: inch(element.box.w),
|
|
115
|
+
colW: element.columnWidths.map(inch),
|
|
116
|
+
rowH: element.rows.map((row) => inch(row.height)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function addImage(slide, element, inch) {
|
|
120
|
+
slide.addImage({
|
|
121
|
+
data: stripDataPrefix(element.src),
|
|
122
|
+
...position(element.box, inch),
|
|
123
|
+
altText: element.alt,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ExportFormat = 'pdf' | 'pptx' | 'png';
|
|
2
|
+
interface ExportOptions {
|
|
3
|
+
slideIndex: number;
|
|
4
|
+
title: string;
|
|
5
|
+
dir: 'ltr' | 'rtl';
|
|
6
|
+
goTo: (index: number, step?: number) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Export, and the static render it works from: every slide still and fully
|
|
10
|
+
* built, in a stack of its own, while an export or a print is under way.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useDeckExport({ slideIndex, title, dir, goTo }: ExportOptions): {
|
|
13
|
+
staticRender: boolean;
|
|
14
|
+
stackRef: import("react").RefObject<HTMLDivElement | null>;
|
|
15
|
+
busy: string | null;
|
|
16
|
+
runExport: (format: ExportFormat) => Promise<void>;
|
|
17
|
+
registerNotesFor: (index: number) => (notes: string | undefined) => void;
|
|
18
|
+
};
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { flushSync } from 'react-dom';
|
|
3
|
+
import { describeDeck, download, fileNameFor, rasteriseSlide, writePptx } from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Export, and the static render it works from: every slide still and fully
|
|
6
|
+
* built, in a stack of its own, while an export or a print is under way.
|
|
7
|
+
*/
|
|
8
|
+
export function useDeckExport({ slideIndex, title, dir, goTo }) {
|
|
9
|
+
const [staticRender, setStaticRender] = useState(false);
|
|
10
|
+
const [busy, setBusy] = useState(null);
|
|
11
|
+
const stackRef = useRef(null);
|
|
12
|
+
const notesRef = useRef(new Map());
|
|
13
|
+
const registerNotesFor = useCallback((index) => (notes) => {
|
|
14
|
+
notesRef.current.set(index, notes);
|
|
15
|
+
}, []);
|
|
16
|
+
// The browser's own print shortcut gets the same static stack the PDF
|
|
17
|
+
// control does; flushSync because print layout happens before React would
|
|
18
|
+
// otherwise render.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const before = () => flushSync(() => setStaticRender(true));
|
|
21
|
+
const after = () => setStaticRender(false);
|
|
22
|
+
window.addEventListener('beforeprint', before);
|
|
23
|
+
window.addEventListener('afterprint', after);
|
|
24
|
+
return () => {
|
|
25
|
+
window.removeEventListener('beforeprint', before);
|
|
26
|
+
window.removeEventListener('afterprint', after);
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
/** Render every slide still and fully built, wait for it to settle, then run `work`. */
|
|
30
|
+
const withStaticSlides = useCallback(async (work) => {
|
|
31
|
+
flushSync(() => setStaticRender(true));
|
|
32
|
+
try {
|
|
33
|
+
await settle(stackRef.current);
|
|
34
|
+
const rendered = [
|
|
35
|
+
...(stackRef.current?.querySelectorAll('.deck-slide') ?? []),
|
|
36
|
+
];
|
|
37
|
+
return await work(rendered);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
setStaticRender(false);
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
43
|
+
const describe = useCallback((rendered, onProgress) => describeDeck(rendered.map((element, index) => ({
|
|
44
|
+
element,
|
|
45
|
+
notes: notesRef.current.get(index),
|
|
46
|
+
})), { title, dir }, onProgress), [title, dir]);
|
|
47
|
+
const runExport = useCallback(async (format) => {
|
|
48
|
+
if (format === 'pdf') {
|
|
49
|
+
flushSync(() => setStaticRender(true));
|
|
50
|
+
await settle(stackRef.current);
|
|
51
|
+
window.print();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setBusy(format === 'pptx' ? 'Building PowerPoint…' : 'Rendering slide…');
|
|
55
|
+
try {
|
|
56
|
+
await withStaticSlides(async (rendered) => {
|
|
57
|
+
if (format === 'png') {
|
|
58
|
+
const png = await rasteriseSlide(rendered[slideIndex], { scale: 1 });
|
|
59
|
+
download(png, fileNameFor(title, 'png', ` - slide ${slideIndex + 1}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const described = await describe(rendered, (done, total) => setBusy(`Building PowerPoint… ${done} of ${total}`));
|
|
63
|
+
download(await writePptx(described), fileNameFor(title, 'pptx'));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error('Deck export failed', error);
|
|
68
|
+
setBusy(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
setBusy(null);
|
|
73
|
+
}
|
|
74
|
+
}, [withStaticSlides, describe, slideIndex, title]);
|
|
75
|
+
// Exposed for automation (end-to-end tests, a host's own export button):
|
|
76
|
+
// the same paths the controls take, with no menu to click through.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const handle = {
|
|
79
|
+
export: runExport,
|
|
80
|
+
describe: () => withStaticSlides((rendered) => describe(rendered)),
|
|
81
|
+
goTo,
|
|
82
|
+
};
|
|
83
|
+
window.__deck = handle;
|
|
84
|
+
return () => {
|
|
85
|
+
delete window.__deck;
|
|
86
|
+
};
|
|
87
|
+
}, [runExport, withStaticSlides, describe, goTo]);
|
|
88
|
+
return { staticRender, stackRef, busy, runExport, registerNotesFor };
|
|
89
|
+
}
|
|
90
|
+
/** Wait for fonts and pictures in a freshly rendered subtree, and two frames for layout. */
|
|
91
|
+
async function settle(root) {
|
|
92
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
93
|
+
await document.fonts?.ready;
|
|
94
|
+
if (!root)
|
|
95
|
+
return;
|
|
96
|
+
await Promise.all([...root.querySelectorAll('img')].map((image) => image.complete ? undefined : image.decode().catch(() => undefined)));
|
|
97
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { Deck, type DeckProps, type ExportFormat } from './Deck.js';
|
|
2
|
+
export { Slide, type SlideProps, type Transition } from './Slide.js';
|
|
3
|
+
export { Step, useStepVisible, type StepProps, type StepAnimation } from './Step.js';
|
|
4
|
+
export { ALL_STEPS, SLIDE_HEIGHT, SLIDE_WIDTH, useBrand, useDeck, useSlide, type DeckState, type RenderMode, type SlideState, } from './context.js';
|
|
5
|
+
export { defaultBrand, readableOn, resolveBrand, type Brand } from './brand.js';
|
|
6
|
+
export { Bullets, Heading, Text, type BulletsProps, type HeadingProps, type TextProps, } from './primitives/text.js';
|
|
7
|
+
export { Image, type ImageProps } from './primitives/media.js';
|
|
8
|
+
export { Table, type TableProps } from './primitives/table.js';
|
|
9
|
+
export { Chart, type ChartProps, type ChartSeriesInput, type ValueFormat, } from './primitives/chart.js';
|
|
10
|
+
/**
|
|
11
|
+
* Marks any element of your own as native PowerPoint text, so it exports as an
|
|
12
|
+
* editable text box rather than as part of the slide's picture:
|
|
13
|
+
* `<div {...nativeText}>...</div>`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const nativeText: {
|
|
16
|
+
readonly 'data-deck-native': "text";
|
|
17
|
+
};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { Deck } from './Deck.js';
|
|
2
|
+
export { Slide } from './Slide.js';
|
|
3
|
+
export { Step, useStepVisible } from './Step.js';
|
|
4
|
+
export { ALL_STEPS, SLIDE_HEIGHT, SLIDE_WIDTH, useBrand, useDeck, useSlide, } from './context.js';
|
|
5
|
+
export { defaultBrand, readableOn, resolveBrand } from './brand.js';
|
|
6
|
+
export { Bullets, Heading, Text, } from './primitives/text.js';
|
|
7
|
+
export { Image } from './primitives/media.js';
|
|
8
|
+
export { Table } from './primitives/table.js';
|
|
9
|
+
export { Chart, } from './primitives/chart.js';
|
|
10
|
+
/**
|
|
11
|
+
* Marks any element of your own as native PowerPoint text, so it exports as an
|
|
12
|
+
* editable text box rather than as part of the slide's picture:
|
|
13
|
+
* `<div {...nativeText}>...</div>`.
|
|
14
|
+
*/
|
|
15
|
+
export const nativeText = { 'data-deck-native': 'text' };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface KeyActions {
|
|
2
|
+
dir: 'ltr' | 'rtl';
|
|
3
|
+
next: () => void;
|
|
4
|
+
prev: () => void;
|
|
5
|
+
first: () => void;
|
|
6
|
+
last: () => void;
|
|
7
|
+
toggleFullscreen: () => void;
|
|
8
|
+
toggleOverview: () => void;
|
|
9
|
+
dismiss: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Right, down, space, page down and enter advance (a presenter's clicker sends
|
|
13
|
+
* page down); left, up, page up and backspace go back; home and end jump; F
|
|
14
|
+
* toggles fullscreen; O toggles the overview. In a right-to-left deck the left
|
|
15
|
+
* and right arrows swap.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useDeckKeys({ dir, next, prev, first, last, toggleFullscreen, toggleOverview, dismiss, }: KeyActions): void;
|
|
18
|
+
export {};
|
package/lib/keyboard.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Right, down, space, page down and enter advance (a presenter's clicker sends
|
|
4
|
+
* page down); left, up, page up and backspace go back; home and end jump; F
|
|
5
|
+
* toggles fullscreen; O toggles the overview. In a right-to-left deck the left
|
|
6
|
+
* and right arrows swap.
|
|
7
|
+
*/
|
|
8
|
+
export function useDeckKeys({ dir, next, prev, first, last, toggleFullscreen, toggleOverview, dismiss, }) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const onKey = (event) => {
|
|
11
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey)
|
|
12
|
+
return;
|
|
13
|
+
const target = event.target;
|
|
14
|
+
if (target instanceof Element &&
|
|
15
|
+
target.closest('input, textarea, select, [contenteditable="true"]')) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
|
|
19
|
+
const backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
|
|
20
|
+
switch (event.key) {
|
|
21
|
+
case forward:
|
|
22
|
+
case 'ArrowDown':
|
|
23
|
+
case 'PageDown':
|
|
24
|
+
case ' ':
|
|
25
|
+
case 'Enter':
|
|
26
|
+
next();
|
|
27
|
+
break;
|
|
28
|
+
case backward:
|
|
29
|
+
case 'ArrowUp':
|
|
30
|
+
case 'PageUp':
|
|
31
|
+
case 'Backspace':
|
|
32
|
+
prev();
|
|
33
|
+
break;
|
|
34
|
+
case 'Home':
|
|
35
|
+
first();
|
|
36
|
+
break;
|
|
37
|
+
case 'End':
|
|
38
|
+
last();
|
|
39
|
+
break;
|
|
40
|
+
case 'f':
|
|
41
|
+
case 'F':
|
|
42
|
+
toggleFullscreen();
|
|
43
|
+
break;
|
|
44
|
+
case 'o':
|
|
45
|
+
case 'O':
|
|
46
|
+
toggleOverview();
|
|
47
|
+
break;
|
|
48
|
+
case 'Escape':
|
|
49
|
+
dismiss();
|
|
50
|
+
return;
|
|
51
|
+
default:
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('keydown', onKey);
|
|
57
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
58
|
+
}, [dir, next, prev, first, last, toggleFullscreen, toggleOverview, dismiss]);
|
|
59
|
+
}
|
package/lib/memory.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Where a presenter was in a deck, kept so coming back lands there.
|
|
4
|
+
*
|
|
5
|
+
* Kept by the slide's name rather than its number: a deck that varies by
|
|
6
|
+
* scenario or session state can gain or lose slides between visits, and 'slide
|
|
7
|
+
* nine' would then be the wrong slide. The number is kept too, as the fallback
|
|
8
|
+
* for a slide that has since disappeared.
|
|
9
|
+
*/
|
|
10
|
+
export interface SavedPosition {
|
|
11
|
+
slide: string | null;
|
|
12
|
+
index: number;
|
|
13
|
+
/** Steps revealed; `'all'` for a fully built slide. */
|
|
14
|
+
step: number | 'all';
|
|
15
|
+
}
|
|
16
|
+
/** A slide's name: its `id` prop, or the React key every slide in an array has. */
|
|
17
|
+
export declare function slideName(slide: ReactElement): string | null;
|
|
18
|
+
/** The slide a name or number points at, or null when it points at nothing. */
|
|
19
|
+
export declare function resolveSlide(slides: ReactElement[], target: string | number | null | undefined): number | null;
|
|
20
|
+
/**
|
|
21
|
+
* Storage can be missing or refuse access (a private window, blocked site
|
|
22
|
+
* data), so every read and write is allowed to fail. Forgetting a position is
|
|
23
|
+
* the right outcome then, and failing the deck is not.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readPosition(key: string): SavedPosition | null;
|
|
26
|
+
export declare function writePosition(key: string, position: SavedPosition): void;
|
|
27
|
+
/** Where a saved position lands in the deck as it is now. */
|
|
28
|
+
export declare function restore(slides: ReactElement[], saved: SavedPosition): {
|
|
29
|
+
slideIndex: number;
|
|
30
|
+
step: number;
|
|
31
|
+
} | null;
|
package/lib/memory.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ALL_STEPS } from './context.js';
|
|
2
|
+
const PREFIX = 'deck-position:';
|
|
3
|
+
/** A slide's name: its `id` prop, or the React key every slide in an array has. */
|
|
4
|
+
export function slideName(slide) {
|
|
5
|
+
const id = slide.props.id;
|
|
6
|
+
if (typeof id === 'string')
|
|
7
|
+
return id;
|
|
8
|
+
return slide.key === null ? null : String(slide.key);
|
|
9
|
+
}
|
|
10
|
+
/** The slide a name or number points at, or null when it points at nothing. */
|
|
11
|
+
export function resolveSlide(slides, target) {
|
|
12
|
+
if (target === null || target === undefined)
|
|
13
|
+
return null;
|
|
14
|
+
if (typeof target === 'number') {
|
|
15
|
+
return slides.length === 0 ? null : Math.min(Math.max(0, target), slides.length - 1);
|
|
16
|
+
}
|
|
17
|
+
const index = slides.findIndex((slide) => slideName(slide) === target);
|
|
18
|
+
return index === -1 ? null : index;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Storage can be missing or refuse access (a private window, blocked site
|
|
22
|
+
* data), so every read and write is allowed to fail. Forgetting a position is
|
|
23
|
+
* the right outcome then, and failing the deck is not.
|
|
24
|
+
*/
|
|
25
|
+
export function readPosition(key) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = window.localStorage.getItem(PREFIX + key);
|
|
28
|
+
if (!raw)
|
|
29
|
+
return null;
|
|
30
|
+
const saved = JSON.parse(raw);
|
|
31
|
+
if (typeof saved.index !== 'number')
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
slide: typeof saved.slide === 'string' ? saved.slide : null,
|
|
35
|
+
index: saved.index,
|
|
36
|
+
step: saved.step === 'all' || typeof saved.step === 'number' ? saved.step : 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function writePosition(key, position) {
|
|
44
|
+
try {
|
|
45
|
+
window.localStorage.setItem(PREFIX + key, JSON.stringify(position));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Not remembered, which is all failing here can cost.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Where a saved position lands in the deck as it is now. */
|
|
52
|
+
export function restore(slides, saved) {
|
|
53
|
+
const byName = saved.slide === null ? null : resolveSlide(slides, saved.slide);
|
|
54
|
+
if (byName !== null) {
|
|
55
|
+
return { slideIndex: byName, step: saved.step === 'all' ? ALL_STEPS : saved.step };
|
|
56
|
+
}
|
|
57
|
+
// The slide has gone: land near where it was, at its start.
|
|
58
|
+
const byIndex = resolveSlide(slides, saved.index);
|
|
59
|
+
return byIndex === null ? null : { slideIndex: byIndex, step: 0 };
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
export type Direction = 'forward' | 'backward';
|
|
3
|
+
export interface Position {
|
|
4
|
+
slideIndex: number;
|
|
5
|
+
step: number;
|
|
6
|
+
direction: Direction;
|
|
7
|
+
}
|
|
8
|
+
interface NavigationOptions {
|
|
9
|
+
slides: ReactElement[];
|
|
10
|
+
startAt?: string | number;
|
|
11
|
+
rememberAs?: string;
|
|
12
|
+
onSlideChange?: (slideIndex: number) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Where the presenter is, and the moves that change it: by slide, and within a
|
|
16
|
+
* slide by the steps its `<Step>`s register.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useDeckNavigation({ slides, startAt, rememberAs, onSlideChange, }: NavigationOptions): {
|
|
19
|
+
slideIndex: number;
|
|
20
|
+
position: Position;
|
|
21
|
+
goTo: (index: number, step?: number) => void;
|
|
22
|
+
next: () => void;
|
|
23
|
+
prev: () => void;
|
|
24
|
+
registerStepFor: (index: number) => (at: number) => () => void;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ALL_STEPS } from './context.js';
|
|
3
|
+
import { readPosition, resolveSlide, restore, slideName, writePosition } from './memory.js';
|
|
4
|
+
/**
|
|
5
|
+
* Where the presenter is, and the moves that change it: by slide, and within a
|
|
6
|
+
* slide by the steps its `<Step>`s register.
|
|
7
|
+
*/
|
|
8
|
+
export function useDeckNavigation({ slides, startAt, rememberAs, onSlideChange, }) {
|
|
9
|
+
const startingPosition = useCallback((key) => {
|
|
10
|
+
const saved = key === undefined ? null : readPosition(key);
|
|
11
|
+
const restored = saved ? restore(slides, saved) : null;
|
|
12
|
+
return {
|
|
13
|
+
slideIndex: restored?.slideIndex ?? resolveSlide(slides, startAt ?? 0) ?? 0,
|
|
14
|
+
step: restored?.step ?? 0,
|
|
15
|
+
direction: 'forward',
|
|
16
|
+
};
|
|
17
|
+
}, [slides, startAt]);
|
|
18
|
+
const [position, setPosition] = useState(() => startingPosition(rememberAs));
|
|
19
|
+
// A different key is a different place to come back to: a session that
|
|
20
|
+
// starts while the deck is open, say. Pick up whatever it remembers.
|
|
21
|
+
const rememberedAs = useRef(rememberAs);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (rememberedAs.current === rememberAs)
|
|
24
|
+
return;
|
|
25
|
+
rememberedAs.current = rememberAs;
|
|
26
|
+
setPosition(startingPosition(rememberAs));
|
|
27
|
+
}, [rememberAs, startingPosition]);
|
|
28
|
+
const slideIndex = clamp(position.slideIndex, 0, slides.length - 1);
|
|
29
|
+
useEffect(() => onSlideChange?.(slideIndex), [slideIndex, onSlideChange]);
|
|
30
|
+
const currentName = slides[slideIndex] ? slideName(slides[slideIndex]) : null;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (rememberAs === undefined || rememberedAs.current !== rememberAs)
|
|
33
|
+
return;
|
|
34
|
+
writePosition(rememberAs, {
|
|
35
|
+
slide: currentName,
|
|
36
|
+
index: slideIndex,
|
|
37
|
+
step: position.step === ALL_STEPS ? 'all' : position.step,
|
|
38
|
+
});
|
|
39
|
+
}, [rememberAs, currentName, slideIndex, position.step]);
|
|
40
|
+
const stepsRef = useRef(new Map());
|
|
41
|
+
const stepsOf = useCallback((index) => {
|
|
42
|
+
const counts = stepsRef.current.get(index);
|
|
43
|
+
return counts ? [...counts.keys()].sort((a, b) => a - b) : [];
|
|
44
|
+
}, []);
|
|
45
|
+
const registerStepFor = useCallback((index) => (at) => {
|
|
46
|
+
const counts = stepsRef.current.get(index) ?? new Map();
|
|
47
|
+
counts.set(at, (counts.get(at) ?? 0) + 1);
|
|
48
|
+
stepsRef.current.set(index, counts);
|
|
49
|
+
return () => {
|
|
50
|
+
const held = counts.get(at) ?? 0;
|
|
51
|
+
if (held <= 1)
|
|
52
|
+
counts.delete(at);
|
|
53
|
+
else
|
|
54
|
+
counts.set(at, held - 1);
|
|
55
|
+
};
|
|
56
|
+
}, []);
|
|
57
|
+
const goTo = useCallback((index, step = 0) => {
|
|
58
|
+
setPosition((current) => ({
|
|
59
|
+
slideIndex: clamp(index, 0, slides.length - 1),
|
|
60
|
+
step,
|
|
61
|
+
direction: index < current.slideIndex ? 'backward' : 'forward',
|
|
62
|
+
}));
|
|
63
|
+
}, [slides.length]);
|
|
64
|
+
const next = useCallback(() => {
|
|
65
|
+
setPosition((current) => {
|
|
66
|
+
const steps = stepsOf(current.slideIndex);
|
|
67
|
+
const following = steps.find((at) => at > current.step);
|
|
68
|
+
if (following !== undefined)
|
|
69
|
+
return { ...current, step: following };
|
|
70
|
+
if (current.slideIndex >= slides.length - 1)
|
|
71
|
+
return current;
|
|
72
|
+
return { slideIndex: current.slideIndex + 1, step: 0, direction: 'forward' };
|
|
73
|
+
});
|
|
74
|
+
}, [slides.length, stepsOf]);
|
|
75
|
+
const prev = useCallback(() => {
|
|
76
|
+
setPosition((current) => {
|
|
77
|
+
const steps = stepsOf(current.slideIndex);
|
|
78
|
+
if (current.step > 0 && steps.length > 0) {
|
|
79
|
+
const reached = current.step === ALL_STEPS ? steps[steps.length - 1] : current.step;
|
|
80
|
+
const earlier = [...steps].reverse().find((at) => at < reached);
|
|
81
|
+
return { ...current, step: earlier ?? 0 };
|
|
82
|
+
}
|
|
83
|
+
if (current.slideIndex === 0)
|
|
84
|
+
return current;
|
|
85
|
+
// Going back lands on the previous slide as it was left: fully built.
|
|
86
|
+
return { slideIndex: current.slideIndex - 1, step: ALL_STEPS, direction: 'backward' };
|
|
87
|
+
});
|
|
88
|
+
}, [stepsOf]);
|
|
89
|
+
return { slideIndex, position, goTo, next, prev, registerStepFor };
|
|
90
|
+
}
|
|
91
|
+
function clamp(value, min, max) {
|
|
92
|
+
return Math.min(Math.max(value, min), Math.max(min, max));
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
import { type SlideState } from './context.js';
|
|
3
|
+
interface OverviewProps {
|
|
4
|
+
slides: ReactElement[];
|
|
5
|
+
slideIndex: number;
|
|
6
|
+
staticState: (index: number) => SlideState;
|
|
7
|
+
choose: (index: number) => void;
|
|
8
|
+
}
|
|
9
|
+
/** Every slide as a thumbnail, fully built, to jump to. */
|
|
10
|
+
export declare function Overview({ slides, slideIndex, staticState, choose }: OverviewProps): import("react").JSX.Element;
|
|
11
|
+
export {};
|
package/lib/overview.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { SLIDE_WIDTH, SlideContext } from './context.js';
|
|
4
|
+
/** Every slide as a thumbnail, fully built, to jump to. */
|
|
5
|
+
export function Overview({ slides, slideIndex, staticState, choose }) {
|
|
6
|
+
return (_jsx("div", { className: "deck-overview", role: "list", children: slides.map((slide, index) => (_jsxs("button", { type: "button", className: "deck-thumb", "aria-current": index === slideIndex, "aria-label": `Slide ${index + 1}`, onClick: () => choose(index), children: [_jsx(Thumbnail, { children: _jsx(SlideContext.Provider, { value: staticState(index), children: slide }) }), _jsx("span", { className: "deck-thumb-number", children: index + 1 })] }, index))) }));
|
|
7
|
+
}
|
|
8
|
+
function Thumbnail({ children }) {
|
|
9
|
+
const ref = useRef(null);
|
|
10
|
+
const [scale, setScale] = useState(0.15);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const parent = ref.current?.parentElement;
|
|
13
|
+
if (!parent)
|
|
14
|
+
return;
|
|
15
|
+
const fit = () => setScale(parent.getBoundingClientRect().width / SLIDE_WIDTH || 0.15);
|
|
16
|
+
fit();
|
|
17
|
+
const observer = new ResizeObserver(fit);
|
|
18
|
+
observer.observe(parent);
|
|
19
|
+
return () => observer.disconnect();
|
|
20
|
+
}, []);
|
|
21
|
+
return (_jsx("div", { ref: ref, className: "deck-static", style: { transform: `scale(${scale})` }, children: children }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type CSSProperties } from 'react';
|
|
2
|
+
export interface ChartSeriesInput {
|
|
3
|
+
name: string;
|
|
4
|
+
/** One value per category; `null` for a gap. */
|
|
5
|
+
values: (number | null)[];
|
|
6
|
+
/** Any CSS hex colour. Series take the brand's colours in turn by default. */
|
|
7
|
+
color?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ValueFormat {
|
|
10
|
+
style?: 'decimal' | 'currency' | 'percent';
|
|
11
|
+
/** ISO 4217 code, for `currency`. */
|
|
12
|
+
currency?: string;
|
|
13
|
+
maximumFractionDigits?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ChartProps {
|
|
16
|
+
type: 'bar' | 'line';
|
|
17
|
+
categories: string[];
|
|
18
|
+
series: ChartSeriesInput[];
|
|
19
|
+
showValues?: boolean;
|
|
20
|
+
/** Defaults to true when there is more than one series. */
|
|
21
|
+
showLegend?: boolean;
|
|
22
|
+
valueFormat?: ValueFormat;
|
|
23
|
+
/** Locale for number formatting; the document's language by default. */
|
|
24
|
+
locale?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A bar or line chart drawn in the brand's colours.
|
|
30
|
+
*
|
|
31
|
+
* Sized by its container, so give it a width and height through `style` or
|
|
32
|
+
* layout. On screen it draws in when its slide arrives. In PowerPoint it is a
|
|
33
|
+
* picture of the chart as drawn, so the export always matches the screen.
|
|
34
|
+
*/
|
|
35
|
+
export declare function Chart({ type, categories, series, showValues, showLegend, valueFormat, locale, className, style, }: ChartProps): import("react").JSX.Element;
|
|
36
|
+
/**
|
|
37
|
+
* An axis a person would draw: a nice step (1, 2, 2.5 or 5 times a power of
|
|
38
|
+
* ten) that splits the range into about five, and bounds that are whole steps.
|
|
39
|
+
*/
|
|
40
|
+
export declare function niceRange(min: number, max: number): [number, number, number];
|