@codename/deck 0.0.0-stage → 0.1.1

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/src/blocks.js ADDED
@@ -0,0 +1,99 @@
1
+ // The drawers a deck opened on its own uses for ```echarts and ```mermaid, each library loaded
2
+ // from `base` (a CDN by default) the first time a slide needs it, never before. A chart is data,
3
+ // read with JSON.parse; it takes the deck's colours and type, light or dark, and is drawn as SVG.
4
+ // A diagram is drawn by Pele from Mermaid's language as SVG, then shown as a picture, so nothing it
5
+ // makes runs or joins the page; its words are its picture's text for whoever can't see it. A host (Jean) gives
6
+ // its own drawers instead.
7
+
8
+ /** @typedef {{ base: string, root: HTMLElement, versions?: { echarts?: string, pele?: string } }} Where */
9
+
10
+ /** @type {Map<string, Promise<any>>} */
11
+ const loading = new Map()
12
+
13
+ /** A classic script, loaded once: from a file:// page, modules from elsewhere are not always let in. @param {string} src @param {string} global */
14
+ function script(src, global) {
15
+ if (!loading.has(src)) loading.set(src, new Promise((resolve, reject) => {
16
+ const tag = document.createElement('script')
17
+ tag.src = src
18
+ tag.onload = () => resolve(/** @type {any} */ (globalThis)[global])
19
+ tag.onerror = reject
20
+ document.head.append(tag)
21
+ }))
22
+ return /** @type {Promise<any>} */ (loading.get(src))
23
+ }
24
+
25
+ /**
26
+ * A library's file: from a CDN's npm mirror, at its version (`https://…/npm/name@1.2.3/file`), or from a
27
+ * node_modules folder, which holds one version (`…/node_modules/name/file`).
28
+ * @param {string} base @param {string} name @param {string} version @param {string} file
29
+ */
30
+ export const libraryAt = (base, name, version, file) => (/node_modules\/$/.test(base) ? `${base}${name}/${file}` : `${base}${name}@${version}/${file}`)
31
+
32
+ /** @param {Where} where */
33
+ export function defaultBlocks({ base, root, versions = {} }) {
34
+ const { echarts: chartsAt = '6.1.0', pele: diagramsAt = '1.10.0' } = versions
35
+ /** @type {Set<{ chart: any, option: any, box: HTMLElement }>} */
36
+ const charts = new Set()
37
+ /** @type {any} */
38
+ let colours = null
39
+ root.addEventListener('deck-scheme', (event) => {
40
+ colours = /** @type {CustomEvent} */ (event).detail.colours
41
+ for (const one of charts) one.chart.setOption(themed(one.option, one.box), true)
42
+ })
43
+ // Its type sized with the slide's, its category axis read from the right in a language written so.
44
+ const themed = (/** @type {any} */ option, /** @type {HTMLElement} */ box) => {
45
+ const size = parseFloat(getComputedStyle(box).fontSize) || 16
46
+ const rtl = getComputedStyle(box).direction === 'rtl'
47
+ const small = Math.round(size * 0.6)
48
+ const axis = (/** @type {any} */ given, /** @type {boolean} */ across) => given === undefined ? undefined : [given].flat().map((one) => ({
49
+ axisLabel: { color: colours?.muted, fontSize: small }, axisLine: { lineStyle: { color: colours?.line } }, splitLine: { lineStyle: { color: colours?.line } },
50
+ nameTextStyle: { color: colours?.muted, fontSize: small }, ...(rtl && across && one.type === 'category' ? { inverse: true } : {}), ...(rtl && !across ? { position: 'right' } : {}), ...one,
51
+ }))
52
+ return {
53
+ color: colours?.series, backgroundColor: 'transparent', animation: !matchMedia('(prefers-reduced-motion: reduce)').matches,
54
+ textStyle: { fontFamily: getComputedStyle(root).getPropertyValue('--deck-body') || 'system-ui', color: colours?.ink, fontSize: small },
55
+ ...option,
56
+ title: option.title ? { ...option.title, textStyle: { color: colours?.ink, fontSize: Math.round(size * 0.8), ...option.title?.textStyle } } : undefined,
57
+ legend: option.legend ? { itemWidth: small * 1.6, itemHeight: small, ...option.legend, textStyle: { color: colours?.ink, fontSize: small, ...option.legend?.textStyle } } : undefined,
58
+ grid: { containLabel: true, left: small, right: small, top: option.title ? size * 1.8 : small, bottom: option.legend ? size * 1.6 : small, ...option.grid },
59
+ xAxis: axis(option.xAxis, true), yAxis: axis(option.yAxis, false), toolbox: undefined,
60
+ series: [option.series ?? []].flat().map((one) => (one.type === 'pie' ? { label: { color: colours?.ink, fontSize: small }, ...one } : one)),
61
+ }
62
+ }
63
+ return {
64
+ /** @param {HTMLElement} box @param {string} source */
65
+ async echarts(box, source) {
66
+ const option = JSON.parse(source)
67
+ const echarts = await script(libraryAt(base, 'echarts', chartsAt, 'dist/echarts.min.js'), 'echarts')
68
+ const place = document.createElement('div')
69
+ place.className = 'deck-chart'
70
+ place.setAttribute('role', 'img')
71
+ place.setAttribute('aria-label', String(option.title?.text ?? ''))
72
+ box.append(place)
73
+ const chart = echarts.init(place, null, { renderer: 'svg', locale: document.documentElement.lang?.startsWith('ja') ? 'JA' : 'EN' })
74
+ const one = { chart, option, box }
75
+ charts.add(one)
76
+ chart.setOption(themed(option, box))
77
+ new ResizeObserver(() => { chart.resize(); chart.setOption(themed(option, box), true) }).observe(place)
78
+ Object.assign(box, { chart })
79
+ },
80
+ /**
81
+ * A diagram written in Mermaid's language, drawn by Pele (Obsidian's, MIT): every kind Mermaid
82
+ * 12.1 has, no DOM needed, an SVG with no script or HTML in it. Shown as a picture, its colours
83
+ * the deck's, set as Pele's variables inside it. @param {HTMLElement} box @param {string} source
84
+ */
85
+ async mermaid(box, source) {
86
+ const pele = await script(libraryAt(base, 'pele', diagramsAt, 'dist/pele.global.js'), 'pele')
87
+ const font = getComputedStyle(root).getPropertyValue('--deck-body') || 'system-ui, sans-serif'
88
+ const { svg } = pele.render(source, { fontFamily: font, links: false, images: false, autoDirection: true, maxWidth: Math.max(320, box.clientWidth * 1.4) })
89
+ const look = `--pele-bg:transparent;--pele-fg:${colours?.ink};--pele-muted:${colours?.muted};--pele-line:${colours?.muted};--pele-surface:${colours?.soft};--pele-surface-alt:${colours?.line};--pele-border:${colours?.primary};--pele-accent:${colours?.primary};--pele-font:${font.replace(/[<>"]/g, '')}`
90
+ const picture = document.createElement('img')
91
+ picture.alt = source
92
+ picture.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg.replace(/^(<svg\b[^>]*>)/, `$1<style>svg{${look}}</style>`))}`
93
+ const place = document.createElement('div')
94
+ place.className = 'deck-diagram'
95
+ place.append(picture)
96
+ box.append(place)
97
+ },
98
+ }
99
+ }
package/src/css.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ // The stylesheet, bundled as text by build.mjs.
2
+ declare module '*.css' {
3
+ const text: string
4
+ export default text
5
+ }
package/src/deck.css ADDED
@@ -0,0 +1,164 @@
1
+ /* deck: a slide is a 16:9 box, a size container, whose canvas sizes its type and space with the
2
+ slide's width (cqi), so it wraps the same at any size; its colours and faces are the look's tokens as custom properties; logical properties
3
+ only, so a deck in Arabic mirrors. The others are laid out behind the one shown, unseen. */
4
+ .deck { --deck-paper: #fffdf8; --deck-ink: #1d1b16; --deck-muted: #6b675e; --deck-line: #e4e0d6; --deck-primary: #8a6100; --deck-fill: #f7c531; --deck-on-fill: #1d1b16; --deck-soft: #fdf1c9;
5
+ --deck-heading: system-ui, sans-serif; --deck-body: system-ui, sans-serif; --deck-radius: 10px;
6
+ position: relative; display: grid; gap: 0.5rem; color: var(--deck-ink); font-family: var(--deck-body); }
7
+ .deck *, .deck *::before, .deck *::after { box-sizing: border-box; }
8
+ .deck-stage { position: relative; view-transition-name: deck-stage; }
9
+ .deck-slide {
10
+ container-type: inline-size; position: relative; aspect-ratio: 16 / 9; overflow: hidden; inline-size: 100%;
11
+ background: var(--deck-paper); color: var(--deck-ink); border-radius: var(--deck-radius); box-shadow: 0 0 0 1px var(--deck-line);
12
+ }
13
+ .deck-canvas { position: absolute; inset: 0; display: grid; padding: 6cqi 7cqi 7cqi; gap: 0 4cqi; font-size: 2.6cqi; line-height: 1.4; }
14
+ .deck-slide:not(.is-shown) { position: absolute; inset-block-start: 0; inset-inline-start: 0; visibility: hidden; }
15
+ .deck-slot { --deck-fit: 1; min-block-size: 0; min-inline-size: 0; display: flex; flex-direction: column; justify-content: flex-start; font-size: calc(1em * var(--deck-fit)); overflow: hidden; }
16
+ .deck-slot > :first-child { margin-block-start: 0; }
17
+ .deck-slot > :last-child { margin-block-end: 0; }
18
+ .deck-slide :is(h1, h2, h3, h4, .deck-h) { font-family: var(--deck-heading); font-weight: 700; line-height: 1.15; margin: 0 0 0.55em; text-wrap: balance; letter-spacing: -0.01em; }
19
+ .deck-slide :is(h1, .deck-h1) { font-size: 2.1em; }
20
+ .deck-slide :is(h2, .deck-h2) { font-size: 1.6em; }
21
+ .deck-slide :is(h3, .deck-h3) { font-size: 1.25em; color: var(--deck-primary); }
22
+ .deck-slide :is(p, ul, ol, blockquote, pre, .deck-table, .deck-block) { margin-block: 0 0.7em; }
23
+ .deck-slide :is(ul, ol) { padding-inline-start: 1.2em; }
24
+ .deck-slide li { margin-block-end: 0.3em; }
25
+ .deck-slide li::marker { color: var(--deck-primary); }
26
+ .deck-slide .deck-tasks { list-style: none; padding-inline-start: 0; }
27
+ .deck-slide .deck-tasks input { font-size: inherit; inline-size: 0.8em; block-size: 0.8em; margin: 0; margin-inline-end: 0.5em; vertical-align: -0.05em; accent-color: var(--deck-primary); }
28
+ .deck-slide strong { color: var(--deck-primary); }
29
+ .deck-slide a { color: var(--deck-primary); }
30
+ .deck-slide code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.85em; background: var(--deck-soft); padding: 0.05em 0.3em; border-radius: 0.2em; }
31
+ .deck-slide pre { background: color-mix(in srgb, var(--deck-ink) 6%, var(--deck-paper)); padding: 0.8em 1em; border-radius: calc(var(--deck-radius) * 0.6); overflow: hidden; font-size: 0.75em; line-height: 1.45; text-align: start; }
32
+ .deck-slide pre code { background: none; padding: 0; font-size: 1em; }
33
+ .deck-slide pre.has-marks .deck-line:not(.is-marked) { opacity: 0.45; }
34
+ .deck-slide blockquote { border-inline-start: 0.25em solid var(--deck-fill); padding-inline-start: 0.9em; margin-inline: 0; font-style: italic; }
35
+ .deck-slide img { max-inline-size: 100%; max-block-size: 100%; object-fit: contain; }
36
+ .deck-slide .deck-picture { flex: 1; min-block-size: 0; display: grid; place-items: center; }
37
+ .deck-slide .deck-picture img { block-size: 100%; }
38
+ .deck-table { overflow: hidden; }
39
+ .deck-slide table { border-collapse: collapse; inline-size: 100%; font-size: 0.85em; }
40
+ .deck-slide :is(th, td) { padding: 0.35em 0.7em; border-block-end: 1px solid var(--deck-line); text-align: start; }
41
+ .deck-slide th { border-block-end: 2px solid var(--deck-primary); font-weight: 700; }
42
+ .deck-slide [data-align="right"] { text-align: end; font-variant-numeric: tabular-nums; }
43
+ .deck-slide [data-align="center"] { text-align: center; }
44
+ .deck-block { position: relative; flex: 1 1 auto; min-block-size: 12cqi; overflow: hidden; }
45
+ .deck-block .deck-chart, .deck-block .deck-diagram { position: absolute; inset: 0; display: grid; place-items: center; }
46
+ .deck-block .deck-diagram img { max-inline-size: 100%; max-block-size: 100%; }
47
+ .deck-block .deck-block-source { display: none; }
48
+ .deck-block.is-failed .deck-block-source { display: block; }
49
+ .deck-number { position: absolute; inset-block-end: 2.6cqi; inset-inline-end: 3cqi; font-size: 1.5cqi; color: var(--deck-muted); font-variant-numeric: tabular-nums; }
50
+
51
+ /* Layouts from their kit's definition: the paper they are on, where their words sit, how big their heading is. */
52
+ .deck-slide[data-paper="fill"] { background: var(--deck-fill); color: var(--deck-on-fill); }
53
+ .deck-slide[data-paper="ink"] { background: var(--deck-ink); color: var(--deck-paper); }
54
+ .deck-slide[data-paper="soft"] { background: var(--deck-soft); }
55
+ .deck-slide:is([data-paper="fill"], [data-paper="ink"]) :is(strong, a, h3, .deck-h3) { color: inherit; }
56
+ .deck-slide[data-justify="center"] .deck-slot { justify-content: center; }
57
+ .deck-slide[data-justify="end"] .deck-slot { justify-content: flex-end; }
58
+ .deck-slide[data-align="center"] .deck-slot { align-items: center; text-align: center; }
59
+ .deck-slide[data-align="end"] .deck-slot { align-items: flex-end; text-align: end; }
60
+ .deck-slide[data-title="large"] :is(h1, h2, .deck-h1, .deck-h2) { font-size: 2.4em; }
61
+ .deck-slide[data-title="huge"] :is(h1, .deck-h1) { font-size: 2.9em; }
62
+ .deck-slide[data-title="huge"] :is(h2, .deck-h2) { font-size: 2.4em; }
63
+ /* What the usual layouts add to their definition: Slidev's looks for them. */
64
+ .deck-layout-section .deck-slot::before { content: ""; display: block; inline-size: 3em; block-size: 0.3em; background: var(--deck-fill); margin-block-end: 0.8em; border-radius: 0.15em; }
65
+ .deck-layout-statement :is(h1, h2, p, .deck-h1, .deck-h2) { font-size: 2.4em; font-family: var(--deck-heading); font-weight: 700; line-height: 1.2; }
66
+ .deck-slide.deck-layout-fact :is(h1, .deck-h1) { font-size: 5em; color: var(--deck-primary); margin-block-end: 0.15em; }
67
+ .deck-layout-quote blockquote { font-size: 1.7em; border: 0; padding: 0; font-family: var(--deck-heading); }
68
+ /* Scripts without italics keep their upright letters. */
69
+ .deck :is(blockquote, em):is(:lang(ar), :lang(fa), :lang(ur), :lang(he), :lang(ja), :lang(zh), :lang(ko), :lang(hi), :lang(th)) { font-style: normal; }
70
+ .deck-layout-quote blockquote::before { content: "\201C"; display: block; font-size: 2.4em; line-height: 0.6; color: var(--deck-primary); }
71
+ .deck-layout-full .deck-canvas { padding: 0; }
72
+ .deck-slide.has-image .deck-image { background: var(--deck-image) center / cover no-repeat, var(--deck-soft); }
73
+ .deck-layout-image-left.has-image .deck-canvas, .deck-layout-image-right.has-image .deck-canvas { padding: 0; grid-template: "image s-default" 1fr / 1fr 1fr !important; gap: 0; }
74
+ .deck-layout-image-right.has-image .deck-canvas { grid-template-areas: "s-default image" !important; }
75
+ :is(.deck-layout-image-left, .deck-layout-image-right).has-image .deck-image { grid-area: image; }
76
+ :is(.deck-layout-image-left, .deck-layout-image-right).has-image .deck-slot { padding: 6cqi 5cqi; justify-content: center; }
77
+ :is(.deck-layout-image, .deck-layout-cover).has-image .deck-image { position: absolute; inset: 0; }
78
+ :is(.deck-layout-image, .deck-layout-cover).has-image .deck-slot { position: relative; color: #fff; text-shadow: 0 0.05em 0.4em rgb(0 0 0 / 0.6); }
79
+ .deck-layout-cover.has-image .deck-image::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, rgb(0 0 0 / 0.65), rgb(0 0 0 / 0.1)); }
80
+ .deck-class-invert { background: var(--deck-ink); color: var(--deck-paper); }
81
+ .deck-class-text-center .deck-slot { text-align: center; align-items: center; }
82
+
83
+ /* Steps: what a click shows next, there but unseen until then. */
84
+ .deck-step { transition: opacity 0.3s, translate 0.3s; }
85
+ .deck-step:not(.is-on) { opacity: 0; visibility: hidden; translate: 0 0.3em; }
86
+ .deck.is-editing .deck-step:not(.is-on), .deck.is-overview .deck-step:not(.is-on), .deck-print .deck-step { opacity: 1; visibility: visible; translate: none; }
87
+
88
+ /* Its bar. */
89
+ .deck-bar { display: flex; align-items: center; gap: 0.25rem; font: 0.85rem/1 system-ui, sans-serif; color: var(--deck-muted); }
90
+ .deck-bar button { display: inline-grid; place-items: center; min-inline-size: 2.25rem; min-block-size: 2.25rem; padding: 0 0.5rem; border: 0; border-radius: 0.5rem; background: none; color: var(--deck-ink); font: inherit; cursor: pointer; }
91
+ .deck-bar button:hover { background: color-mix(in srgb, var(--deck-ink) 8%, transparent); }
92
+ .deck-bar button:focus-visible { outline: 2px solid var(--deck-primary); outline-offset: 1px; }
93
+ .deck-bar button:disabled { opacity: 0.35; cursor: default; }
94
+ .deck-bar button[aria-pressed="true"] { background: color-mix(in srgb, var(--deck-primary) 18%, transparent); }
95
+ .deck-bar svg { inline-size: 1.15rem; block-size: 1.15rem; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
96
+ .deck-bar [dir="rtl"] .deck-flip, .deck[dir="rtl"] .deck-flip { scale: -1 1; }
97
+ .deck-where { font-variant-numeric: tabular-nums; min-inline-size: 4.5em; text-align: center; }
98
+ .deck-bar .deck-gap { flex: 1; }
99
+
100
+ /* Presented: the stage fills the screen, black around it. */
101
+ .deck-stage:fullscreen { display: grid; place-items: center; background: #000; }
102
+ .deck-stage:fullscreen .deck-slide { inline-size: min(100vw, 100vh * 16 / 9); border-radius: 0; box-shadow: none; }
103
+ .deck-stage:fullscreen .deck-slide:not(.is-shown) { inset-block-start: 50%; translate: 0 -50%; }
104
+
105
+ /* The presenter's view: this slide, the next, the notes and the time. */
106
+ .deck-presenter { display: none; }
107
+ .deck.is-presenting { grid-template: "stage next" auto "stage notes" 1fr "bar bar" auto / 3fr 2fr; min-block-size: 70vh; }
108
+ .deck.is-presenting .deck-stage { grid-area: stage; align-self: start; }
109
+ .deck.is-presenting .deck-presenter { display: contents; }
110
+ .deck.is-presenting .deck-bar { grid-area: bar; }
111
+ .deck-next { grid-area: next; display: grid; gap: 0.3rem; font: 0.8rem system-ui, sans-serif; color: var(--deck-muted); }
112
+ .deck-next .deck-slide { position: relative; visibility: visible; }
113
+ .deck-notes { grid-area: notes; overflow: auto; padding: 0.8rem 1rem; border-radius: 0.6rem; background: color-mix(in srgb, var(--deck-ink) 5%, transparent); font: 1.1rem/1.5 system-ui, sans-serif; white-space: pre-wrap; }
114
+ .deck-clock { font-variant-numeric: tabular-nums; font-size: 1.2rem; color: var(--deck-ink); }
115
+
116
+ /* All the slides at once. */
117
+ .deck.is-overview .deck-stage { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0.8rem; view-transition-name: none; }
118
+ .deck.is-overview .deck-slide { position: relative; visibility: visible; cursor: pointer; }
119
+ .deck.is-overview .deck-slide.is-shown { box-shadow: 0 0 0 3px var(--deck-primary); }
120
+
121
+ /* Changed by hand: the Markdown beside the slides, and words changed on a slide in place. */
122
+ .deck.is-editing { grid-template: "source stage" auto "source bar" 1fr / minmax(18rem, 2fr) 3fr; align-items: start; }
123
+ .deck.is-editing .deck-stage { grid-area: stage; }
124
+ .deck.is-editing .deck-bar { grid-area: bar; }
125
+ .deck-source { display: none; }
126
+ .deck.is-editing .deck-source { display: block; grid-area: source; inline-size: 100%; block-size: min(80vh, 48rem); resize: vertical; padding: 0.8rem; border: 1px solid var(--deck-line); border-radius: 0.6rem; background: var(--deck-paper); color: var(--deck-ink); font: 0.85rem/1.55 ui-monospace, Menlo, Consolas, monospace; tab-size: 2; }
127
+ .deck.is-editing [data-edit] { cursor: text; border-radius: 0.15em; }
128
+ .deck.is-editing [data-edit]:hover { box-shadow: 0 0 0 0.12em color-mix(in srgb, var(--deck-primary) 45%, transparent); }
129
+ .deck [data-edit][contenteditable] { outline: 0.12em solid var(--deck-primary); outline-offset: 0.1em; }
130
+ .deck-slide[data-overflow] { box-shadow: 0 0 0 3px #c92a2a; }
131
+ @media (max-width: 52rem) { .deck.is-editing, .deck.is-presenting { grid-template: none; display: grid; } .deck.is-editing .deck-source { block-size: 40vh; } .deck.is-presenting .deck-presenter { display: grid; } }
132
+
133
+ /* Transitions between slides, as Slidev names them; none for those who'd rather not. */
134
+ ::view-transition-group(deck-stage) { animation-duration: 0.45s; animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); }
135
+ html[data-deck-transition~="fade"]::view-transition-old(deck-stage) { animation: 0.35s ease both deck-out-fade; }
136
+ html[data-deck-transition~="fade"]::view-transition-new(deck-stage) { animation: 0.35s ease both deck-in-fade; }
137
+ html[data-deck-transition~="slide-left"]::view-transition-old(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-out-start; }
138
+ html[data-deck-transition~="slide-left"]::view-transition-new(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-in-end; }
139
+ html[data-deck-transition~="slide-right"]::view-transition-old(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-out-end; }
140
+ html[data-deck-transition~="slide-right"]::view-transition-new(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-in-start; }
141
+ html[data-deck-transition~="slide-up"]::view-transition-old(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-out-up; }
142
+ html[data-deck-transition~="slide-up"]::view-transition-new(deck-stage) { animation: 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both deck-in-up; }
143
+ html[data-deck-transition~="morph"]::view-transition-old(deck-stage) { animation: 0.3s ease both deck-out-fade; }
144
+ html[data-deck-transition~="morph"]::view-transition-new(deck-stage) { animation: 0.3s ease both deck-in-fade; }
145
+ html[data-deck-transition~="morph"]::view-transition-group(*) { animation-duration: 0.6s; }
146
+ @keyframes deck-out-fade { to { opacity: 0; } }
147
+ @keyframes deck-in-fade { from { opacity: 0; } }
148
+ @keyframes deck-out-start { to { translate: -30% 0; opacity: 0; } }
149
+ @keyframes deck-in-end { from { translate: 30% 0; opacity: 0; } }
150
+ @keyframes deck-out-end { to { translate: 30% 0; opacity: 0; } }
151
+ @keyframes deck-in-start { from { translate: -30% 0; opacity: 0; } }
152
+ @keyframes deck-out-up { to { translate: 0 -20%; opacity: 0; } }
153
+ @keyframes deck-in-up { from { translate: 0 20%; opacity: 0; } }
154
+ @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } .deck-step { transition: none; } }
155
+ @media (forced-colors: active) { .deck-slide { border: 1px solid CanvasText; } .deck-step:not(.is-on) { visibility: hidden; } }
156
+
157
+ /* On paper: every slide, one a page, every step shown, no bar. */
158
+ @media print {
159
+ @page { size: 13.333in 7.5in; margin: 0; }
160
+ .deck-bar, .deck-source, .deck-presenter { display: none !important; }
161
+ .deck, .deck.is-editing, .deck.is-presenting { display: block; }
162
+ .deck-slide, .deck-slide:not(.is-shown) { position: relative; visibility: visible; break-after: page; border-radius: 0; box-shadow: none; inline-size: 100vw; }
163
+ .deck-step:not(.is-on) { opacity: 1; visibility: visible; translate: none; }
164
+ }
package/src/deck.js ADDED
@@ -0,0 +1,359 @@
1
+ // A deck on a page: its slides drawn into the element given, paged with the bar, the keys a
2
+ // presenter's clicker sends, a click or a swipe; each step shown in turn; moved between with the
3
+ // transition its settings name, through the View Transitions API; presented full screen, with
4
+ // the presenter's view (this slide, the next, the notes, the time) and all slides at once; light
5
+ // or dark; changed as Markdown beside the slides, or in place on a slide, live. Host-agnostic:
6
+ // the host gives the drawers of ```echarts and ```mermaid, how a picture's address resolves, its
7
+ // words, how a slide's Markdown is read (its own marked, with its extensions), and what to do when
8
+ // the deck changes or is saved.
9
+ import { parseDeck } from './parse.js'
10
+ import { renderSlide, h } from './render.js'
11
+ import { tokensOf, schemeOf, VOICES } from './look.js'
12
+ import { mergeKits } from './kit.js'
13
+ import { stringsFor } from './strings.js'
14
+ import { Lexer } from 'marked'
15
+
16
+ /** @typedef {import('./parse.js').Deck} Deck */
17
+ /**
18
+ * @typedef {{
19
+ * source: string, lang?: string, dir?: string, kit?: Record<string, any>, scheme?: 'light' | 'dark' | 'auto',
20
+ * blocks?: Record<string, import('./render.js').Drawer>, resolve?: (src: string) => string | null,
21
+ * strings?: Record<string, string>, headings?: 'real' | 'aria', editable?: boolean, lex?: (markdown: string) => any[],
22
+ * onChange?: (source: string) => void, onSave?: (source: string) => void, onExport?: (deck: DeckView) => void,
23
+ * }} Options
24
+ */
25
+
26
+ const RTL = /^(ar|he|fa|ur|ps|sd|ug|yi|dv|ckb)\b/i
27
+ const ICONS = {
28
+ back: 'M15 5l-7 7 7 7', next: 'M9 5l7 7-7 7', present: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
29
+ presenter: 'M3 5h12v9H3zM18 8h3M18 12h3M6 19h6', grid: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
30
+ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', scheme: 'M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z', export: 'M12 4v11M7 10l5 5 5-5M5 20h14',
31
+ }
32
+
33
+ /** @param {string} name */
34
+ function icon(name) {
35
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
36
+ svg.setAttribute('viewBox', '0 0 24 24')
37
+ svg.setAttribute('aria-hidden', 'true')
38
+ if (name === 'back' || name === 'next') svg.setAttribute('class', 'deck-flip')
39
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
40
+ path.setAttribute('d', ICONS[/** @type {keyof typeof ICONS} */ (name)])
41
+ svg.append(path)
42
+ return svg
43
+ }
44
+
45
+ /** @typedef {ReturnType<typeof createDeck>} DeckView */
46
+
47
+ /** @param {HTMLElement} root @param {Options} options */
48
+ export function createDeck(root, options) {
49
+ let source = options.source.replace(/\r\n?/g, '\n')
50
+ let deck = parseDeck(source)
51
+ let at = 0, stepAt = 0
52
+ /** @type {{ element: HTMLElement, steps: number }[]} */
53
+ let drawn = []
54
+ const lang = String(options.lang ?? deck.settings.lang ?? document.documentElement.lang ?? 'en') || 'en'
55
+ const dir = String(options.dir ?? deck.settings.dir ?? (RTL.test(lang) ? 'rtl' : 'ltr'))
56
+ const words = { ...stringsFor(lang), ...options.strings }
57
+ const number = new Intl.NumberFormat(lang).format
58
+ const dark = matchMedia('(prefers-color-scheme: dark)')
59
+ let scheme = /** @type {'light' | 'dark'} */ ('light')
60
+ const started = Date.now()
61
+
62
+ root.classList.add('deck')
63
+ root.lang = lang
64
+ root.dir = dir
65
+ const stage = h('div', { class: 'deck-stage', tabindex: 0, role: 'region', 'aria-roledescription': words.carousel, 'aria-label': words.deck })
66
+ const where = h('span', { class: 'deck-where', 'aria-live': 'polite' })
67
+ const button = (/** @type {string} */ name, /** @type {string} */ label, /** @type {() => void} */ act) => {
68
+ const b = h('button', { type: 'button', 'aria-label': label, 'data-act': name }, [icon(name)])
69
+ b.addEventListener('click', act)
70
+ return b
71
+ }
72
+ const back = button('back', words.previous, () => move(-1))
73
+ const next = button('next', words.next, () => move(1))
74
+ const editButton = button('edit', words.edit, () => toggle('is-editing'))
75
+ const presenterButton = button('presenter', words.presenter, () => toggle('is-presenting'))
76
+ const overviewButton = button('grid', words.overview, () => toggle('is-overview'))
77
+ const bar = h('div', { class: 'deck-bar' }, [back, where, next, h('span', { class: 'deck-gap' }),
78
+ overviewButton, presenterButton, options.editable === false ? null : editButton,
79
+ button('scheme', words.scheme, () => { scheme = scheme === 'dark' ? 'light' : 'dark'; paintLook() }),
80
+ options.onExport ? button('export', words.export, () => options.onExport?.(view)) : null,
81
+ document.fullscreenEnabled ? button('present', words.present, () => present()) : null])
82
+ const sourceBox = /** @type {HTMLTextAreaElement} */ (h('textarea', { class: 'deck-source', spellcheck: 'false', 'aria-label': words.source, dir: 'auto' }))
83
+ const nextBox = h('div', { class: 'deck-next' })
84
+ const notes = h('div', { class: 'deck-notes', dir: 'auto', 'aria-label': words.notes })
85
+ const clock = h('span', { class: 'deck-clock' })
86
+ const presenter = h('div', { class: 'deck-presenter' }, [nextBox, notes])
87
+ root.replaceChildren(sourceBox, stage, presenter, bar)
88
+ bar.insertBefore(clock, bar.querySelector('.deck-gap'))
89
+
90
+ // The kit: deck's own, under the host's (an organisation's, a project's), under the file's own.
91
+ let kitProblems = /** @type {string[]} */ ([])
92
+ const kitOf = () => {
93
+ const merged = mergeKits(options.kit, deck.settings.kit)
94
+ kitProblems = merged.problems
95
+ return merged.kit
96
+ }
97
+
98
+ function paintLook() {
99
+ context.kit = kitOf()
100
+ const tokens = tokensOf(deck.settings, context.kit.tokens)
101
+ const colours = schemeOf(tokens, scheme)
102
+ const stack = (/** @type {string[]} */ faces) => faces.map((f) => (/^[\w-]+$/.test(f) ? f : `"${f.replace(/["\\]/g, '')}"`)).join(', ')
103
+ const voice = tokens.fonts ? { heading: stack(tokens.fonts.heading), body: stack(tokens.fonts.body) } : VOICES[tokens.voice]
104
+ for (const [name, value] of Object.entries({
105
+ paper: colours.paper, ink: colours.ink, muted: colours.muted, line: colours.line, primary: colours.primary,
106
+ fill: tokens.primary, 'on-fill': colours.onPrimary, soft: colours.soft, heading: voice.heading, body: voice.body, radius: `${tokens.radius}px`,
107
+ ...Object.fromEntries(colours.series.map((colour, n) => [`series-${n + 1}`, colour])),
108
+ })) root.style.setProperty(`--deck-${name}`, value)
109
+ root.dataset.scheme = scheme
110
+ root.dispatchEvent(new CustomEvent('deck-scheme', { detail: { scheme, colours, tokens } }))
111
+ }
112
+ const chooseScheme = () => {
113
+ const asked = options.scheme ?? deck.settings.colorSchema ?? 'auto'
114
+ scheme = asked === 'dark' || (asked === 'auto' && dark.matches) ? 'dark' : 'light'
115
+ }
116
+
117
+ /** @type {import('./render.js').Context} */
118
+ const context = {
119
+ blocks: options.blocks ?? {}, resolve: options.resolve ?? ((src) => src), headings: options.headings ?? 'real', number: (n) => number(n), total: 0, kit: mergeKits().kit,
120
+ lex: options.lex ?? ((markdown) => new Lexer({ gfm: true }).lex(markdown)), slide: words.slide,
121
+ }
122
+
123
+ function draw() {
124
+ context.total = deck.slides.length
125
+ drawn = deck.slides.map((slide) => renderSlide(slide, context))
126
+ for (const [i, { element }] of drawn.entries()) {
127
+ element.setAttribute('aria-label', words.where.replace('{n}', number(i + 1)).replace('{count}', number(drawn.length)))
128
+ element.addEventListener('click', () => { if (root.classList.contains('is-overview')) { toggle('is-overview'); go(i, 0) } })
129
+ }
130
+ stage.replaceChildren(...drawn.map(({ element }) => element))
131
+ at = Math.min(at, drawn.length - 1)
132
+ show()
133
+ requestAnimationFrame(() => fitAll())
134
+ }
135
+
136
+ /** Each slot's type shrunk, down to six tenths, until it holds what it says; a slide still over is marked. */
137
+ function fitAll() {
138
+ for (const { element } of drawn) fit(element)
139
+ }
140
+ /** @param {HTMLElement} slide */
141
+ function fit(slide) {
142
+ let over = false
143
+ for (const slot of /** @type {NodeListOf<HTMLElement>} */ (slide.querySelectorAll('.deck-slot'))) {
144
+ let scale = 1
145
+ slot.style.setProperty('--deck-fit', '1')
146
+ while (slot.scrollHeight > slot.clientHeight + 1 && scale > 0.6) slot.style.setProperty('--deck-fit', String((scale = Math.round((scale - 0.05) * 100) / 100)))
147
+ if (slot.scrollHeight > slot.clientHeight + 1) over = true
148
+ }
149
+ if (over) slide.dataset.overflow = 'true'
150
+ else delete slide.dataset.overflow
151
+ }
152
+
153
+ function show() {
154
+ for (const [i, { element, steps }] of drawn.entries()) {
155
+ element.classList.toggle('is-shown', i === at)
156
+ element.inert = i !== at && !root.classList.contains('is-overview')
157
+ for (const one of element.querySelectorAll('.deck-step')) one.classList.toggle('is-on', i < at || (i === at && Number(one.getAttribute('data-step')) <= stepAt) || steps === 0)
158
+ }
159
+ where.textContent = `${number(at + 1)} / ${number(drawn.length)}`
160
+ back.toggleAttribute('disabled', at === 0 && stepAt === 0)
161
+ next.toggleAttribute('disabled', at >= drawn.length - 1 && stepAt >= (drawn[at]?.steps ?? 0))
162
+ notes.textContent = deck.slides[at]?.notes || words.noNotes
163
+ const following = deck.slides[at + 1]
164
+ nextBox.replaceChildren(h('span', {}, [following ? words.upNext : words.last]), ...(following ? [renderSlide(following, context).element] : []))
165
+ root.dispatchEvent(new CustomEvent('deck-move', { detail: { at, step: stepAt } }))
166
+ }
167
+
168
+ /** A click on, or the next key: the next step, else the next slide. @param {number} by */
169
+ function move(by) {
170
+ const steps = drawn[at]?.steps ?? 0
171
+ if (by > 0 && stepAt < steps) { stepAt++; show(); return }
172
+ if (by < 0 && stepAt > 0) { stepAt--; show(); return }
173
+ const to = at + by
174
+ if (to < 0 || to >= drawn.length) return
175
+ go(to, by > 0 ? 0 : drawn[to].steps)
176
+ }
177
+
178
+ /** @param {number} to @param {number} [toStep] */
179
+ function go(to, toStep = 0) {
180
+ if (to === at) { stepAt = toStep; show(); return }
181
+ const forward = to > at
182
+ const settings = deck.slides[forward ? to : at]?.settings ?? {}
183
+ const named = String(settings.transition ?? deck.settings.transition ?? 'none')
184
+ // Slidev's `a | b`: one going forward, the other going back.
185
+ const [ahead, behind] = named.split('|').map((part) => part.trim())
186
+ let kind = forward ? ahead : behind ?? ahead
187
+ if (dir === 'rtl') kind = kind.replace(/-(left|right)$/, (_, side) => (side === 'left' ? '-right' : '-left'))
188
+ if (kind === 'view-transition') kind = 'morph'
189
+ const update = () => { at = to; stepAt = toStep; if (kind === 'morph') name(drawn[to].element); show() }
190
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
191
+ if (!document.startViewTransition || kind === 'none' || reduced || root.classList.contains('is-overview')) return update()
192
+ if (kind === 'morph') name(drawn[at].element, drawn[to].element)
193
+ document.documentElement.dataset.deckTransition = kind
194
+ const transition = document.startViewTransition(update)
195
+ // A transition skipped by the next one, as a clicker pressed twice does, is no error.
196
+ transition.ready.catch(() => {})
197
+ transition.finished.finally(() => {
198
+ delete document.documentElement.dataset.deckTransition
199
+ for (const { element } of drawn) for (const one of element.querySelectorAll('[data-morph]')) { /** @type {HTMLElement} */ (one).style.removeProperty('view-transition-name'); one.removeAttribute('data-morph') }
200
+ })
201
+ }
202
+
203
+ /**
204
+ * Morph, as Keynote's Magic Move and PowerPoint's Morph do it, with nothing to write: what two
205
+ * slides share (a heading's words, a list item's, a picture, a chart) gets one name on each,
206
+ * so the browser moves it from where it was to where it goes. Called with the slide shown and
207
+ * the next, then with the next alone once it shows. @param {HTMLElement} slide @param {HTMLElement} [toward]
208
+ */
209
+ function name(slide, toward) {
210
+ const key = (/** @type {Element} */ el) => `${el.tagName}:${el.tagName === 'IMG' ? el.getAttribute('src') : (el.textContent ?? '').trim().slice(0, 80)}`
211
+ const parts = (/** @type {HTMLElement} */ s) => /** @type {HTMLElement[]} */ ([...s.querySelectorAll('h1, h2, h3, h4, .deck-h, li, p, img, .deck-block, blockquote, .deck-image')])
212
+ if (toward) {
213
+ const there = new Map(parts(toward).map((el) => [key(el), el]))
214
+ let n = 0
215
+ for (const el of parts(slide)) {
216
+ const match = there.get(key(el))
217
+ if (!match || match.dataset.morph || el.closest('[data-morph]')) continue
218
+ const id = `deck-m${n++}`
219
+ el.dataset.morph = match.dataset.morph = id
220
+ el.style.setProperty('view-transition-name', id)
221
+ }
222
+ return
223
+ }
224
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (slide.querySelectorAll('[data-morph]'))) {
225
+ const id = /** @type {string} */ (el.dataset.morph)
226
+ for (const old of /** @type {NodeListOf<HTMLElement>} */ (stage.querySelectorAll(`[data-morph="${id}"]`))) if (old !== el) old.style.removeProperty('view-transition-name')
227
+ el.style.setProperty('view-transition-name', id)
228
+ }
229
+ }
230
+
231
+ /** @param {string} mode */
232
+ function toggle(mode) {
233
+ for (const other of ['is-editing', 'is-presenting', 'is-overview']) if (other !== mode) root.classList.remove(other)
234
+ root.classList.toggle(mode)
235
+ editButton.setAttribute('aria-pressed', String(root.classList.contains('is-editing')))
236
+ presenterButton.setAttribute('aria-pressed', String(root.classList.contains('is-presenting')))
237
+ overviewButton.setAttribute('aria-pressed', String(root.classList.contains('is-overview')))
238
+ if (root.classList.contains('is-editing')) { sourceBox.value = source; sourceBox.focus() }
239
+ show()
240
+ requestAnimationFrame(() => fitAll())
241
+ }
242
+
243
+ function present() {
244
+ stage.requestFullscreen?.().then(() => stage.focus(), () => {})
245
+ }
246
+
247
+ /** The source changed, by the Markdown or on a slide: read again, drawn again, where the user is. @param {string} text */
248
+ function setSource(text) {
249
+ source = text.replace(/\r\n?/g, '\n')
250
+ deck = parseDeck(source)
251
+ paintLook()
252
+ draw()
253
+ options.onChange?.(source)
254
+ }
255
+
256
+ // Changed as Markdown: drawn again as the user types, the slide under the caret shown.
257
+ let pending = 0
258
+ sourceBox.addEventListener('input', (event) => {
259
+ if (/** @type {InputEvent} */ (event).isComposing) return
260
+ clearTimeout(pending)
261
+ pending = setTimeout(() => setSource(sourceBox.value), 120)
262
+ })
263
+ const followCaret = () => {
264
+ const caret = sourceBox.selectionStart
265
+ const found = deck.slides.findIndex((slide) => caret >= slide.start && caret <= slide.end + 1)
266
+ if (found >= 0 && found !== at) { at = found; stepAt = drawn[found]?.steps ?? 0; show() }
267
+ }
268
+ sourceBox.addEventListener('keyup', followCaret)
269
+ sourceBox.addEventListener('click', followCaret)
270
+
271
+ // Changed in place: a double click on words makes them editable; Enter or leaving keeps them.
272
+ stage.addEventListener('dblclick', (event) => {
273
+ if (options.editable === false || document.fullscreenElement) return
274
+ const target = /** @type {HTMLElement | null} */ (/** @type {Element} */ (event.target).closest('[data-edit]'))
275
+ if (!target) return
276
+ const [from, to] = /** @type {string} */ (target.dataset.edit).split('-').map(Number)
277
+ target.contentEditable = 'true'
278
+ target.focus()
279
+ const done = (/** @type {boolean} */ keep) => {
280
+ target.removeEventListener('keydown', key)
281
+ target.removeEventListener('blur', blur)
282
+ target.contentEditable = 'false'
283
+ const words = markdownOf(target)
284
+ if (keep && words !== source.slice(from, to)) setSource(source.slice(0, from) + words + source.slice(to))
285
+ else draw()
286
+ }
287
+ const key = (/** @type {KeyboardEvent} */ e) => {
288
+ if (e.isComposing) return
289
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); done(true) }
290
+ if (e.key === 'Escape') { e.preventDefault(); done(false) }
291
+ }
292
+ const blur = () => done(true)
293
+ target.addEventListener('keydown', key)
294
+ target.addEventListener('blur', blur)
295
+ })
296
+
297
+ // The keys a clicker sends, and the deck's own.
298
+ root.addEventListener('keydown', (event) => {
299
+ const target = /** @type {HTMLElement} */ (event.target)
300
+ if (target.isContentEditable || target === sourceBox || (target.tagName === 'BUTTON' && (event.key === ' ' || event.key === 'Enter'))) {
301
+ if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
302
+ return
303
+ }
304
+ const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight', backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
305
+ if (event.altKey || event.ctrlKey || event.metaKey) {
306
+ if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
307
+ return
308
+ }
309
+ const acts = /** @type {Record<string, () => void>} */ ({
310
+ [forward]: () => move(1), [backward]: () => move(-1), ArrowDown: () => move(1), ArrowUp: () => move(-1), PageDown: () => move(1), PageUp: () => move(-1), ' ': () => move(1),
311
+ Home: () => go(0), End: () => go(drawn.length - 1, drawn.at(-1)?.steps ?? 0), f: present, p: () => toggle('is-presenting'), o: () => toggle('is-overview'), e: () => toggle('is-editing'),
312
+ })
313
+ const act = acts[event.key]
314
+ if (!act) return
315
+ event.preventDefault()
316
+ act()
317
+ })
318
+ stage.addEventListener('click', (event) => {
319
+ if (document.fullscreenElement && !(/** @type {Element} */ (event.target).closest('a, button, input'))) move(1)
320
+ })
321
+ let touch = 0
322
+ stage.addEventListener('touchstart', (event) => { touch = event.touches[0].clientX }, { passive: true })
323
+ stage.addEventListener('touchend', (event) => {
324
+ const dx = event.changedTouches[0].clientX - touch
325
+ if (Math.abs(dx) > 50) move((dx < 0) === (dir !== 'rtl') ? 1 : -1)
326
+ })
327
+ dark.addEventListener('change', () => { chooseScheme(); paintLook() })
328
+ const tick = setInterval(() => {
329
+ const s = Math.floor((Date.now() - started) / 1000)
330
+ clock.textContent = root.classList.contains('is-presenting') ? `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : ''
331
+ }, 1000)
332
+
333
+ chooseScheme()
334
+ paintLook()
335
+ draw()
336
+ document.fonts?.ready.then(() => fitAll())
337
+
338
+ const view = {
339
+ root, stage, get source() { return source }, get deck() { return deck }, get at() { return at }, get scheme() { return scheme }, get kit() { return context.kit }, get kitProblems() { return kitProblems }, lang, dir,
340
+ slides: () => drawn.map(({ element }) => element), setSource, go, toggle, present, fitAll,
341
+ setScheme: (/** @type {'light' | 'dark'} */ value) => { scheme = value; paintLook() },
342
+ destroy: () => clearInterval(tick),
343
+ }
344
+ return view
345
+ }
346
+
347
+ /** The Markdown of words changed in place: bold, italics, code and links kept as Markdown writes them. @param {Node} node @returns {string} */
348
+ export function markdownOf(node) {
349
+ let out = ''
350
+ for (const child of node.childNodes) {
351
+ if (child.nodeType === Node.TEXT_NODE) out += (child.textContent ?? '').replace(/([*_`[\]\\])/g, '\\$1')
352
+ else if (child instanceof HTMLElement) {
353
+ const inner = markdownOf(child)
354
+ out += ({ STRONG: `**${inner}**`, B: `**${inner}**`, EM: `*${inner}*`, I: `*${inner}*`, CODE: `\`${child.textContent}\``, DEL: `~~${inner}~~`, BR: ' \n',
355
+ A: `[${inner}](${child.getAttribute('href') ?? ''})` })[child.tagName] ?? inner
356
+ }
357
+ }
358
+ return out.replace(/ /g, ' ').replace(/\n+$/, '')
359
+ }