@codename/deck 0.2.0 → 0.4.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/README.md +4 -4
- package/dist/deck.bundle.mjs +95 -89
- package/dist/deck.bundle.mjs.map +4 -4
- package/dist/deck.css +118 -0
- package/dist/deck.js +215 -91
- package/dist/deck.js.map +4 -4
- package/dist/deck.mjs +43 -37
- package/dist/deck.mjs.map +4 -4
- package/dist/template.mjs +3 -1
- package/dist/template.mjs.map +4 -4
- package/dist/types/blocks.d.ts +6 -4
- package/dist/types/figures.d.ts +68 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/kit.d.ts +27 -4
- package/dist/types/look.d.ts +6 -2
- package/dist/types/mindmap.d.ts +69 -0
- package/dist/types/pptx.d.ts +3 -1
- package/dist/types/render.d.ts +21 -3
- package/dist/types/template.d.ts +35 -0
- package/dist/types/version.d.ts +1 -1
- package/docs/format.md +85 -4
- package/docs/host.md +4 -4
- package/docs/kits.md +21 -4
- package/package.json +1 -1
- package/src/blocks.js +26 -9
- package/src/deck.css +118 -0
- package/src/deck.js +13 -3
- package/src/figures.js +255 -0
- package/src/index.js +1 -1
- package/src/kit.js +59 -15
- package/src/look.js +22 -16
- package/src/mindmap.js +208 -0
- package/src/pptx.js +157 -26
- package/src/render.js +126 -20
- package/src/template.js +451 -26
- package/src/version.js +1 -1
package/src/kit.js
CHANGED
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
// deck's own kit holds the usual layouts; a host's (an organisation's, a project's) and the deck
|
|
4
4
|
// file's own `kit:` go over it, in that order. A layout is a grid: its slots by name, the areas
|
|
5
5
|
// they take, its tracks, how its words sit, what paper it is on, how big its logo shows, and the
|
|
6
|
-
// PowerPoint layout it is nearest to
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// PowerPoint layout it is nearest to, whether its lists are drawn as a mind map or a figure (cards, numbers, a timeline,
|
|
7
|
+
// chevrons, a programme, a pyramid, a bento), whether its `##` parts face each other or make a matrix, whether it shows
|
|
8
|
+
// the deck's contents, and the art a template draws behind it, layers among the kit's pictures; a kit may turn one of
|
|
9
|
+
// the usual layouts off. Its tokens may hold a logo, a picture inside
|
|
10
|
+
// the kit and never an address, as its pictures are. Everything is checked against a small grammar;
|
|
11
|
+
// what fails is dropped, with a reason. Pure.
|
|
9
12
|
|
|
10
13
|
/** @typedef {{ required?: boolean, max_words?: number, max_chars?: number, items?: [number, number], item_max_words?: number, heading_max_words?: number, kind?: string }} Budget */
|
|
11
|
-
/** @typedef {
|
|
12
|
-
/** @typedef {{
|
|
14
|
+
/** @typedef {'bullets' | 'mindmap' | 'steps' | 'metrics' | 'timeline' | 'process' | 'agenda' | 'pyramid' | 'bento'} List */
|
|
15
|
+
/** @typedef {{ label?: Record<string, string> | string, intent?: string, use_when?: string, avoid_when?: string, slots: string[], budgets: Record<string, Budget>, areas: string[][], columns: string[], rows: string[], align: 'start' | 'center' | 'end', justify: 'start' | 'center' | 'end', paper: 'paper' | 'fill' | 'ink' | 'soft', title: 'normal' | 'large' | 'huge', logo: 'big' | 'small' | 'none', lists: List, parts: 'none' | 'versus' | 'matrix', contents: boolean, pptx: string, numbered: boolean, art?: string[] }} Layout */
|
|
16
|
+
/** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout>, pictures: Record<string, string> }} Kit */
|
|
13
17
|
|
|
14
18
|
const NAME = /^[a-z][a-z0-9-]{0,31}$/
|
|
15
19
|
const TRACK = /^(auto|min-content|max-content|\d{1,2}(\.\d)?fr|\d{1,3}%)$/
|
|
16
20
|
const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
|
|
17
|
-
const ENUMS = /** @type {const} */ ({
|
|
21
|
+
const ENUMS = /** @type {const} */ ({
|
|
22
|
+
align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'], logo: ['small', 'big', 'none'],
|
|
23
|
+
lists: ['bullets', 'mindmap', 'steps', 'metrics', 'timeline', 'process', 'agenda', 'pyramid', 'bento'], parts: ['none', 'versus', 'matrix'] })
|
|
18
24
|
/** The corners a logo sits in, as `logoAt` names them, the first by default. */
|
|
19
25
|
export const CORNERS = ['top-end', 'top-start', 'bottom-end', 'bottom-start']
|
|
20
26
|
|
|
@@ -24,21 +30,33 @@ export const CORNERS = ['top-end', 'top-start', 'bottom-end', 'bottom-start']
|
|
|
24
30
|
*/
|
|
25
31
|
export const logoOf = (value) => (typeof value === 'string' && value.length <= 700_000 && /^data:image\/(png|jpeg|gif|webp|svg\+xml);base64,[a-z0-9+/]+=*$/i.test(value) ? value : null)
|
|
26
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A picture of a kit's, a layer of a layout's art drawn behind its words over the whole slide: a picture inside the kit as a
|
|
35
|
+
* logo is, or an SVG written out (`data:image/svg+xml,…`), of 3 MB at most, never an address to fetch; else null. @param {unknown} value
|
|
36
|
+
*/
|
|
37
|
+
export const artOf = (value) => (typeof value === 'string' && value.length <= 3_145_728 && /^data:image\/(?:(?:png|jpeg|gif|webp|svg\+xml);base64,[a-z0-9+/]+=*|svg\+xml,[\w.!~*'()%/+=-]*)$/i.test(value) ? value : null)
|
|
38
|
+
|
|
27
39
|
/** The names brains give the usual layouts, for the ones deck has. */
|
|
28
40
|
export const ALIASES = /** @type {Record<string, string>} */ ({
|
|
29
41
|
title: 'cover', 'title-slide': 'cover', 'two-columns': 'two-cols', 'two-column': 'two-cols', columns: 'two-cols', comparison: 'two-cols-header',
|
|
30
|
-
'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end',
|
|
42
|
+
'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end', 'mind-map': 'mindmap', map: 'mindmap',
|
|
43
|
+
toc: 'summary', contents: 'summary', 'table-of-contents': 'summary', outline: 'summary', 'sub-section': 'subsection', 'subsection-header': 'subsection',
|
|
44
|
+
cards: 'steps', kpi: 'metrics', kpis: 'metrics', stats: 'metrics', numbers: 'metrics', roadmap: 'timeline', milestones: 'timeline', chevrons: 'process',
|
|
45
|
+
schedule: 'agenda', programme: 'agenda', program: 'agenda', hierarchy: 'pyramid', mosaic: 'bento', vs: 'versus', 'face-off': 'versus', quadrant: 'matrix', quadrants: 'matrix', '2x2': 'matrix',
|
|
31
46
|
})
|
|
32
47
|
|
|
33
48
|
/** deck's own kit: the usual layouts, the ones Slidev names and the ones PowerPoint has, as data. */
|
|
34
49
|
export const STANDARD = /** @type {Kit} */ ({
|
|
35
50
|
name: 'standard',
|
|
36
51
|
tokens: {},
|
|
52
|
+
pictures: {},
|
|
37
53
|
layouts: Object.fromEntries(Object.entries(/** @type {Record<string, any>} */ ({
|
|
38
54
|
default: { label: { en: 'Title and content', fr: 'Titre et contenu' }, pptx: 'Title and Content' },
|
|
39
55
|
cover: { label: { en: 'Cover', fr: 'Couverture' }, justify: 'center', paper: 'fill', title: 'huge', logo: 'big', pptx: 'Title Slide', numbered: false },
|
|
40
56
|
intro: { label: { en: 'Introduction', fr: 'Introduction' }, justify: 'center', paper: 'fill', title: 'large', logo: 'big', pptx: 'Title Slide', numbered: false },
|
|
41
57
|
section: { label: { en: 'Section', fr: 'Section' }, justify: 'center', paper: 'ink', title: 'huge', pptx: 'Section Header', numbered: false },
|
|
58
|
+
subsection: { label: { en: 'Subsection', fr: 'Sous-section' }, justify: 'center', paper: 'soft', title: 'large', pptx: 'Section Header', numbered: false },
|
|
59
|
+
summary: { label: { en: 'Contents', fr: 'Sommaire' }, contents: true, pptx: 'Title and Content' },
|
|
42
60
|
center: { label: { en: 'Centred', fr: 'Centré' }, justify: 'center', align: 'center', pptx: 'Title Only' },
|
|
43
61
|
statement: { label: { en: 'Statement', fr: 'Affirmation' }, justify: 'center', align: 'center', title: 'large', pptx: 'Title Only' },
|
|
44
62
|
fact: { label: { en: 'Big number', fr: 'Grand chiffre' }, justify: 'center', align: 'center', title: 'huge', pptx: 'Title Only' },
|
|
@@ -52,6 +70,16 @@ export const STANDARD = /** @type {Kit} */ ({
|
|
|
52
70
|
'image-left': { label: { en: 'Picture left', fr: 'Image à gauche' }, pptx: 'Picture with Caption', justify: 'center' },
|
|
53
71
|
'image-right': { label: { en: 'Picture right', fr: 'Image à droite' }, pptx: 'Picture with Caption', justify: 'center' },
|
|
54
72
|
image: { label: { en: 'Picture', fr: 'Image' }, logo: 'none', pptx: 'Blank', numbered: false },
|
|
73
|
+
mindmap: { label: { en: 'Mind map', fr: 'Carte mentale' }, lists: 'mindmap', pptx: 'Title Only' },
|
|
74
|
+
steps: { label: { en: 'Steps', fr: 'Étapes' }, lists: 'steps', pptx: 'Title Only' },
|
|
75
|
+
metrics: { label: { en: 'Key figures', fr: 'Chiffres clés' }, lists: 'metrics', pptx: 'Title Only' },
|
|
76
|
+
timeline: { label: { en: 'Timeline', fr: 'Frise' }, lists: 'timeline', pptx: 'Title Only' },
|
|
77
|
+
process: { label: { en: 'Process', fr: 'Processus' }, lists: 'process', pptx: 'Title Only' },
|
|
78
|
+
agenda: { label: { en: 'Agenda', fr: 'Programme' }, lists: 'agenda', pptx: 'Title Only' },
|
|
79
|
+
pyramid: { label: { en: 'Pyramid', fr: 'Pyramide' }, lists: 'pyramid', pptx: 'Title Only' },
|
|
80
|
+
bento: { label: { en: 'Bento', fr: 'Mosaïque' }, lists: 'bento', pptx: 'Title Only' },
|
|
81
|
+
versus: { label: { en: 'Versus', fr: 'Face à face' }, parts: 'versus', pptx: 'Title Only' },
|
|
82
|
+
matrix: { label: { en: 'Matrix', fr: 'Matrice' }, parts: 'matrix', pptx: 'Title Only' },
|
|
55
83
|
})).map(([name, given]) => [name, /** @type {Layout} */ (layoutOf(given).layout)])),
|
|
56
84
|
})
|
|
57
85
|
|
|
@@ -98,12 +126,14 @@ export function layoutOf(given) {
|
|
|
98
126
|
return value
|
|
99
127
|
}
|
|
100
128
|
const label = typeof given.label === 'string' || (given.label && typeof given.label === 'object') ? given.label : undefined
|
|
129
|
+
const art = Array.isArray(given.art) && given.art.length <= 8 && given.art.every((/** @type {unknown} */ one) => typeof one === 'string' && NAME.test(one)) ? given.art : undefined
|
|
130
|
+
if (given.art !== undefined && !art) problems.push('art is not 8 names at most')
|
|
101
131
|
const words = (/** @type {any} */ v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)
|
|
102
132
|
return {
|
|
103
133
|
layout: /** @type {Layout} */ ({
|
|
104
134
|
label, intent: words(given.intent), use_when: words(given.use_when), avoid_when: words(given.avoid_when), slots, budgets, areas: rows, columns, rows: rowTracks,
|
|
105
|
-
align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'),
|
|
106
|
-
pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false,
|
|
135
|
+
align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'), lists: pick('lists', 'bullets'), parts: pick('parts', 'none'), contents: given.contents === true,
|
|
136
|
+
pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false, art,
|
|
107
137
|
}),
|
|
108
138
|
problems,
|
|
109
139
|
}
|
|
@@ -123,14 +153,15 @@ function clean(given) {
|
|
|
123
153
|
/**
|
|
124
154
|
* Design tokens as the W3C's Design Tokens format (DTCG 2025.10) writes them, read into a kit's:
|
|
125
155
|
* `color.light` and `color.dark` groups (bg, surface, fg, muted, accent) or flat colours, `font.sans`
|
|
126
|
-
* and the like; each `$value` a
|
|
156
|
+
* and the like; each `$value` a colour string or a colour object with its `hex` and `alpha`. Tokens already flat
|
|
127
157
|
* pass through. @param {any} given @returns {Record<string, any>}
|
|
128
158
|
*/
|
|
129
159
|
export function tokensFrom(given) {
|
|
130
160
|
if (!given || typeof given !== 'object') return {}
|
|
131
161
|
if (!JSON.stringify(given).includes('"$value"')) return given
|
|
132
162
|
const value = (/** @type {any} */ t) => (t && typeof t === 'object' && '$value' in t ? t.$value : t)
|
|
133
|
-
|
|
163
|
+
// A colour object's alpha kept, as #rrggbbaa.
|
|
164
|
+
const colour = (/** @type {any} */ t) => { const v = value(t); return typeof v === 'string' ? v : typeof v?.hex === 'string' ? `${v.hex}${typeof v.alpha === 'number' && v.alpha < 1 ? Math.round(Math.max(0, v.alpha) * 255).toString(16).padStart(2, '0') : ''}` : undefined }
|
|
134
165
|
const face = (/** @type {any} */ t) => { const v = value(t); return Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : undefined }
|
|
135
166
|
const NAMES = /** @type {const} */ ({ bg: 'paper', background: 'paper', paper: 'paper', fg: 'ink', text: 'ink', ink: 'ink', accent: 'primary', primary: 'primary', muted: 'muted', surface: 'soft', soft: 'soft' })
|
|
136
167
|
const group = (/** @type {any} */ g) => Object.fromEntries(Object.entries(g ?? {}).filter(([k]) => k in NAMES).map(([k, t]) => [NAMES[/** @type {keyof typeof NAMES} */ (k)], colour(t)]).filter(([, v]) => v))
|
|
@@ -146,13 +177,13 @@ export function tokensFrom(given) {
|
|
|
146
177
|
}
|
|
147
178
|
|
|
148
179
|
/**
|
|
149
|
-
* Kits merged, the later over the earlier: their tokens, their dark ones within theirs,
|
|
150
|
-
* checked; a layout given as false is taken away, but the default. A logo that isn't a picture inside the
|
|
151
|
-
* @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
|
|
180
|
+
* Kits merged, the later over the earlier: their tokens, their dark ones within theirs, their layouts by name and their
|
|
181
|
+
* pictures, each checked; a layout given as false is taken away, but the default. A logo that isn't a picture inside the
|
|
182
|
+
* kit is dropped. @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
|
|
152
183
|
*/
|
|
153
184
|
export function mergeKits(...kits) {
|
|
154
185
|
/** @type {Kit} */
|
|
155
|
-
const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts } }
|
|
186
|
+
const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts }, pictures: {} }
|
|
156
187
|
const problems = []
|
|
157
188
|
for (const given of kits) {
|
|
158
189
|
if (!given || typeof given !== 'object') continue
|
|
@@ -161,6 +192,10 @@ export function mergeKits(...kits) {
|
|
|
161
192
|
const next = tokensFrom(given.tokens), dark = next.dark && typeof next.dark === 'object' ? { ...kit.tokens.dark, ...next.dark } : kit.tokens.dark
|
|
162
193
|
kit.tokens = { ...kit.tokens, ...next, ...(dark && { dark }) }
|
|
163
194
|
}
|
|
195
|
+
for (const [name, picture] of Object.entries(given.pictures ?? {})) {
|
|
196
|
+
if (NAME.test(name) && artOf(picture)) kit.pictures[name] = picture
|
|
197
|
+
else problems.push(`picture ${name}: not a picture inside the kit`)
|
|
198
|
+
}
|
|
164
199
|
for (const [name, definition] of Object.entries(given.layouts ?? given.types ?? {})) {
|
|
165
200
|
if (!NAME.test(name)) { problems.push(`layout ${name}: not a-z, 0-9 and -`); continue }
|
|
166
201
|
if (definition === false) { if (name !== 'default') delete kit.layouts[name]; continue }
|
|
@@ -182,6 +217,15 @@ export function labelOf(layout, name, lang) {
|
|
|
182
217
|
return layout.label?.[lang] ?? layout.label?.[lang.split('-')[0]] ?? layout.label?.en ?? name
|
|
183
218
|
}
|
|
184
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The layout a slide is drawn with: the one it names, by its name or a name brains give it, else the one its content
|
|
222
|
+
* suits. @param {{ index: number, settings: Record<string, any>, slots: { name: string, text: string }[] }} slide @param {Kit} kit
|
|
223
|
+
*/
|
|
224
|
+
export function layoutNameOf(slide, kit) {
|
|
225
|
+
const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
|
|
226
|
+
return kit.layouts[asked] ? asked : kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
|
|
227
|
+
}
|
|
228
|
+
|
|
185
229
|
/**
|
|
186
230
|
* The layout a slide that names none is given, from what it holds, as Pandoc's PowerPoint writer
|
|
187
231
|
* chooses: the first slide is the cover; a heading alone, a section; slots beyond the first, the
|
package/src/look.js
CHANGED
|
@@ -41,12 +41,12 @@ export function tokensOf(settings, project = {}) {
|
|
|
41
41
|
function clean(given) {
|
|
42
42
|
/** @type {Partial<Tokens>} */
|
|
43
43
|
const kept = {}
|
|
44
|
-
for (const key of /** @type {const} */ (['paper', 'ink', 'primary', 'muted', 'soft'])) if (typeof given[key] === 'string' &&
|
|
45
|
-
if (given.dark && typeof given.dark === 'object') kept.dark = Object.fromEntries(Object.entries(given.dark).filter(([, v]) => typeof v === 'string' &&
|
|
44
|
+
for (const key of /** @type {const} */ (['paper', 'ink', 'primary', 'muted', 'soft'])) if (typeof given[key] === 'string' && rgbOf(given[key])) kept[key] = given[key]
|
|
45
|
+
if (given.dark && typeof given.dark === 'object') kept.dark = Object.fromEntries(Object.entries(given.dark).filter(([, v]) => typeof v === 'string' && rgbOf(v)))
|
|
46
46
|
if (given.fonts && Array.isArray(given.fonts.heading) && Array.isArray(given.fonts.body)) kept.fonts = { heading: given.fonts.heading.map(String), body: given.fonts.body.map(String) }
|
|
47
47
|
if (typeof given.voice === 'string') kept.voice = given.voice
|
|
48
48
|
if (typeof given.radius === 'number') kept.radius = given.radius
|
|
49
|
-
if (Array.isArray(given.series) && given.series.length && given.series.every((/** @type {unknown} */ c) => typeof c === 'string' &&
|
|
49
|
+
if (Array.isArray(given.series) && given.series.length && given.series.every((/** @type {unknown} */ c) => typeof c === 'string' && rgbOf(c))) kept.series = given.series.slice(0, 6)
|
|
50
50
|
return kept
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -55,30 +55,36 @@ function clean(given) {
|
|
|
55
55
|
* @param {Tokens} tokens @param {'light' | 'dark'} scheme @returns {Scheme}
|
|
56
56
|
*/
|
|
57
57
|
export function schemeOf(tokens, scheme) {
|
|
58
|
-
|
|
58
|
+
// A see-through colour is seen over the paper it is on.
|
|
59
|
+
const light = rgbOf(tokens.paper) ?? [255, 253, 248], dark = rgbOf(tokens.ink, light) ?? [29, 27, 22]
|
|
59
60
|
// A kit's own dark colours where it gives them (a design system's dark mode), else worked out.
|
|
60
61
|
const own = scheme === 'dark' ? tokens.dark ?? {} : {}
|
|
61
|
-
const paper = round(
|
|
62
|
-
const ink = round(
|
|
63
|
-
const primary =
|
|
62
|
+
const paper = round(rgbOf(own.paper ?? '', scheme === 'dark' ? [0, 0, 0] : [255, 255, 255]) ?? (scheme === 'dark' ? mix(dark, [0, 0, 0], 0.35) : light))
|
|
63
|
+
const ink = round(rgbOf(own.ink ?? '', paper) ?? (scheme === 'dark' ? mix(light, [255, 255, 255], 0.2) : dark))
|
|
64
|
+
const primary = rgbOf(own.primary ?? tokens.primary, paper) ?? [247, 197, 49]
|
|
64
65
|
// The primary as a fill keeps its own colour; as words or marks it is made to read on the paper.
|
|
65
66
|
const text = readable(primary, paper, 4.5)
|
|
66
67
|
const onPrimary = contrast(ink, primary) >= contrast(paper, primary) ? ink : paper
|
|
67
68
|
// A chart's colours: the kit's own (a template's accents), else the primary turned round the wheel; each made to read at 3:1.
|
|
68
|
-
const given = (tokens.series ?? []).map(
|
|
69
|
+
const given = (tokens.series ?? []).map((c) => rgbOf(c, paper)).filter((c) => c !== null)
|
|
69
70
|
const series = [0, 1, 2, 3, 4, 5].map((n) => readable(/** @type {number[]} */ (given[n % (given.length || 1)] ?? rotate(primary, n * 57)), paper, 3))
|
|
70
71
|
return {
|
|
71
|
-
paper: css(paper), ink: css(ink), muted: css(readable(round(
|
|
72
|
-
primary: css(text), onPrimary: css(onPrimary), soft: css(
|
|
72
|
+
paper: css(paper), ink: css(ink), muted: css(readable(round(rgbOf(own.muted ?? (scheme === 'light' ? tokens.muted ?? '' : ''), paper) ?? mix(ink, paper, 0.38)), paper, 4.5)), line: css(mix(ink, paper, 0.82)),
|
|
73
|
+
primary: css(text), onPrimary: css(onPrimary), soft: css(rgbOf(own.soft ?? (scheme === 'light' ? tokens.soft ?? '' : ''), paper) ?? mix(primary, paper, 0.78)), series: series.map(css),
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
/**
|
|
78
|
+
* A colour as deck reads one, as red, green and blue: `#rgb` or `#rrggbb` (`#rgba`, `#rrggbbaa` with an alpha), `rgb()` or
|
|
79
|
+
* `rgba()` as CSS writes them, by commas or spaces, the alpha after a comma or a slash; one see-through is seen over `under`,
|
|
80
|
+
* white unless said. @param {string} value @param {number[]} [under] @returns {[number, number, number] | null}
|
|
81
|
+
*/
|
|
82
|
+
export function rgbOf(value, under = [255, 255, 255]) {
|
|
83
|
+
const text = String(value).trim(), digits = /^#?([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(text)?.[1], css = /^rgba?\(([^)]*)\)$/i.exec(text)?.[1].split(/[\s,/]+/).filter(Boolean)
|
|
84
|
+
const share = (/** @type {string} */ part, /** @type {number} */ whole) => (part.endsWith('%') ? (parseFloat(part) / 100) * whole : Number(part))
|
|
85
|
+
const [r, g, b, a = 1] = digits ? (digits.length < 5 ? [...digits].map((c) => c + c) : /** @type {string[]} */ (digits.match(/../g))).map((h, i) => parseInt(h, 16) / (i > 2 ? 255 : 1))
|
|
86
|
+
: css?.length === 3 || css?.length === 4 ? css.map((part, i) => share(part, i > 2 ? 1 : 255)) : []
|
|
87
|
+
return r !== undefined && [r, g, b].every((c) => c >= 0 && c <= 255) && a >= 0 && a <= 1 ? round(mix(under, [r, g, b], a)) : null
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** @param {number[]} rgb */
|
package/src/mindmap.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// A mind map, from lists as a slide holds them: each item a node, its indentation its branching,
|
|
2
|
+
// every list on the slide merged into one tree, as items of the same words at the same place are
|
|
3
|
+
// one node, under one root (the item the lists share, else the slide's heading). Laid out as maps
|
|
4
|
+
// are drawn by hand: the root in the middle, its branches shared out to either side, the first on
|
|
5
|
+
// the side a line ends, clockwise, each branch a tidy tree going outwards. Pure, but for
|
|
6
|
+
// `watchMap`, which measures a map's words on its slide and places them, again whenever their size
|
|
7
|
+
// or its box's changes, in em of the map's own type, so it holds at any size.
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ text: string, start: number, end: number, children: Item[], blocks?: { lang: string, text: string }[], step?: boolean, element?: HTMLElement }} Item */
|
|
10
|
+
/** @typedef {{ w: number, h: number, children: Box[], x?: number, y?: number, side?: number, span?: number, inner?: number }} Box */
|
|
11
|
+
/** @typedef {{ branch: number, depth: number, x1: number, y1: number, x2: number, y2: number }} Line */
|
|
12
|
+
|
|
13
|
+
const ITEM = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+(?:\[[ xX]\][ \t]+)?|$)/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A list's items as a tree, by their indentation, as written: each item's words where they are in the source, from
|
|
17
|
+
* `start` (where the list's own text starts), a line that is no item going on with the item above it, a fenced block
|
|
18
|
+
* under an item (a chart, a diagram) that item's to draw.
|
|
19
|
+
* @param {string} raw @param {number} start @returns {Item[]}
|
|
20
|
+
*/
|
|
21
|
+
export function itemsOf(raw, start) {
|
|
22
|
+
/** @type {Item[]} */
|
|
23
|
+
const top = []
|
|
24
|
+
/** @type {{ indent: number, item: Item }[]} */
|
|
25
|
+
const open = []
|
|
26
|
+
let at = start
|
|
27
|
+
/** @type {{ fence: string, indent: number, lang: string, lines: string[] } | null} */
|
|
28
|
+
let fenced = null
|
|
29
|
+
for (const line of raw.split('\n')) {
|
|
30
|
+
const from = at
|
|
31
|
+
at += line.length + 1
|
|
32
|
+
if (fenced) {
|
|
33
|
+
if (line.trimStart().startsWith(fenced.fence) && !line.trim().slice(fenced.fence.length).trim()) {
|
|
34
|
+
const item = /** @type {{ item: Item }} */ (open.at(-1)).item
|
|
35
|
+
;(item.blocks ??= []).push({ lang: fenced.lang, text: fenced.lines.join('\n') })
|
|
36
|
+
fenced = null
|
|
37
|
+
} else fenced.lines.push(line.slice(Math.min(fenced.indent, /^[ \t]*/.exec(line)?.[0].length ?? 0)))
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
const fence = /^([ \t]*)(`{3,}|~{3,})(.*)$/.exec(line)
|
|
41
|
+
if (fence && open.length) { fenced = { fence: fence[2], indent: fence[1].length, lang: fence[3].trim(), lines: [] }; continue }
|
|
42
|
+
const marker = ITEM.exec(line)
|
|
43
|
+
if (marker) {
|
|
44
|
+
const indent = marker[1].replace(/\t/g, ' ').length
|
|
45
|
+
const item = { text: line.slice(marker[0].length).trim(), start: from + marker[0].length, end: from + line.trimEnd().length, children: [] }
|
|
46
|
+
while (open.length && /** @type {{ indent: number }} */ (open.at(-1)).indent >= indent) open.pop()
|
|
47
|
+
;(open.at(-1)?.item.children ?? top).push(item)
|
|
48
|
+
open.push({ indent, item })
|
|
49
|
+
} else if (line.trim() && open.length) {
|
|
50
|
+
const item = /** @type {{ item: Item }} */ (open.at(-1)).item
|
|
51
|
+
item.text = `${item.text} ${line.trim()}`.trim()
|
|
52
|
+
item.end = from + line.trimEnd().length
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return top
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An item's words as they are compared: no emphasis, no case, one space. @param {string} text */
|
|
59
|
+
export const keyOf = (text) => text.normalize('NFC').replace(/[*_`~]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Items merged: of siblings with the same words, the first keeps the others' children, at every level; a step if any
|
|
63
|
+
* was one. Items without words stay apart. @param {Item[]} items @returns {Item[]}
|
|
64
|
+
*/
|
|
65
|
+
export function mergeItems(items) {
|
|
66
|
+
/** @type {Item[]} */
|
|
67
|
+
const out = []
|
|
68
|
+
/** @type {Map<string, Item>} */
|
|
69
|
+
const seen = new Map()
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
const key = keyOf(item.text), same = key ? seen.get(key) : undefined
|
|
72
|
+
if (same) { same.children.push(...item.children); same.step = same.step || item.step; continue }
|
|
73
|
+
const copy = { ...item, children: [...item.children] }
|
|
74
|
+
if (key) seen.set(key, copy)
|
|
75
|
+
out.push(copy)
|
|
76
|
+
}
|
|
77
|
+
for (const item of out) item.children = mergeItems(item.children)
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A map laid out, its boxes' sizes given (in em): the root's centre at the middle, its branches shared out to the end
|
|
83
|
+
* side (top to bottom) and the start side (bottom to top) so the two are as tall as they can be alike, each child a gap
|
|
84
|
+
* beyond its parent, siblings stacked and centred on it, spaced out further (up to four times) where the map would be
|
|
85
|
+
* flatter than its box (`aspect`, its width over its height). Each box gets its `x` and `y` (its top start corner, from the
|
|
86
|
+
* map's top start corner, mirrored if `rtl`) and its `side` (1 right of the root, -1 left); gives the map's size and its
|
|
87
|
+
* lines, each from a parent's side to its child's.
|
|
88
|
+
* @param {Box} root @param {{ rtl?: boolean, aspect?: number }} [how] @returns {{ width: number, height: number, lines: Line[] }}
|
|
89
|
+
*/
|
|
90
|
+
export function layoutMap(root, { rtl = false, aspect = 0 } = {}) {
|
|
91
|
+
let laid = layoutAt(root, rtl, 1)
|
|
92
|
+
if (aspect > 0 && laid.width / laid.height > aspect && layoutAt(root, rtl, 4).height < laid.width / aspect) laid = layoutAt(root, rtl, 4)
|
|
93
|
+
else if (aspect > 0 && laid.width / laid.height > aspect) {
|
|
94
|
+
let low = 1, high = 4
|
|
95
|
+
for (let n = 0; n < 12; n++) { const mid = (low + high) / 2; if (layoutAt(root, rtl, mid).height < laid.width / aspect) low = mid; else high = mid }
|
|
96
|
+
laid = layoutAt(root, rtl, low)
|
|
97
|
+
}
|
|
98
|
+
return laid
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The map laid out with its siblings' spaces so many times the usual. @param {Box} root @param {boolean} rtl @param {number} spread */
|
|
102
|
+
function layoutAt(root, rtl, spread) {
|
|
103
|
+
const gap = (/** @type {number} */ depth) => (depth <= 1 ? 2.4 : 1.5)
|
|
104
|
+
const space = (/** @type {number} */ depth) => (depth <= 1 ? 0.8 * spread : 0.3 * Math.sqrt(spread))
|
|
105
|
+
const measure = (/** @type {Box} */ box, /** @type {number} */ depth) => {
|
|
106
|
+
box.inner = box.children.reduce((sum, child) => sum + measure(child, depth + 1), 0) + space(depth + 1) * Math.max(0, box.children.length - 1)
|
|
107
|
+
box.span = Math.max(box.h, box.inner)
|
|
108
|
+
return box.span
|
|
109
|
+
}
|
|
110
|
+
/** @type {Line[]} */
|
|
111
|
+
const lines = []
|
|
112
|
+
// A box at its near edge `x`, in a band from `top`, going out to `side` (1 the end, -1 the start).
|
|
113
|
+
const place = (/** @type {Box} */ box, /** @type {number} */ x, /** @type {number} */ top, /** @type {number} */ side, /** @type {number} */ depth, /** @type {number} */ branch) => {
|
|
114
|
+
const middle = top + /** @type {number} */ (box.span) / 2
|
|
115
|
+
box.x = side > 0 ? x : x - box.w
|
|
116
|
+
box.side = rtl ? -side : side
|
|
117
|
+
box.y = middle - box.h / 2
|
|
118
|
+
let y = middle - /** @type {number} */ (box.inner) / 2
|
|
119
|
+
const far = x + side * box.w
|
|
120
|
+
for (const child of box.children) {
|
|
121
|
+
const near = far + side * gap(depth + 1)
|
|
122
|
+
place(child, near, y, side, depth + 1, branch)
|
|
123
|
+
lines.push({ branch, depth: depth + 1, x1: far, y1: middle, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
|
|
124
|
+
y += /** @type {number} */ (child.span) + space(depth + 1)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const child of root.children) measure(child, 1)
|
|
128
|
+
root.span = root.h
|
|
129
|
+
root.side = 0
|
|
130
|
+
root.x = -root.w / 2
|
|
131
|
+
root.y = -root.h / 2
|
|
132
|
+
// The branches to the end side, then the rest to the start: the split that makes the two sides nearest in height.
|
|
133
|
+
const tall = (/** @type {Box[]} */ some) => some.reduce((sum, b) => sum + /** @type {number} */ (b.span), 0) + space(1) * Math.max(0, some.length - 1)
|
|
134
|
+
const count = root.children.length
|
|
135
|
+
let split = count
|
|
136
|
+
for (let k = Math.ceil(count / 2); k <= count; k++) if (Math.max(tall(root.children.slice(0, k)), tall(root.children.slice(k))) < Math.max(tall(root.children.slice(0, split)), tall(root.children.slice(split)))) split = k
|
|
137
|
+
for (const [side, some] of /** @type {[number, Box[]][]} */ ([[1, root.children.slice(0, split)], [-1, root.children.slice(split).reverse()]])) {
|
|
138
|
+
let y = -tall(some) / 2
|
|
139
|
+
for (const child of some) {
|
|
140
|
+
const near = side * (root.w / 2 + gap(1))
|
|
141
|
+
place(child, near, y, side, 1, root.children.indexOf(child))
|
|
142
|
+
lines.push({ branch: root.children.indexOf(child), depth: 1, x1: side * root.w / 2, y1: 0, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
|
|
143
|
+
y += /** @type {number} */ (child.span) + space(1)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// From the map's top start corner.
|
|
147
|
+
/** @type {Box[]} */
|
|
148
|
+
const all = []
|
|
149
|
+
const walk = (/** @type {Box} */ box) => { all.push(box); box.children.forEach(walk) }
|
|
150
|
+
walk(root)
|
|
151
|
+
const left = Math.min(...all.map((b) => /** @type {number} */ (b.x))), right = Math.max(...all.map((b) => /** @type {number} */ (b.x) + b.w))
|
|
152
|
+
const top = Math.min(...all.map((b) => /** @type {number} */ (b.y))), bottom = Math.max(...all.map((b) => /** @type {number} */ (b.y) + b.h))
|
|
153
|
+
const width = right - left
|
|
154
|
+
const x = (/** @type {number} */ v) => (rtl ? width - (v - left) : v - left)
|
|
155
|
+
for (const box of all) { box.x = rtl ? width - (/** @type {number} */ (box.x) - left) - box.w : /** @type {number} */ (box.x) - left; box.y = /** @type {number} */ (box.y) - top }
|
|
156
|
+
for (const line of lines) Object.assign(line, { x1: x(line.x1), x2: x(line.x2), y1: line.y1 - top, y2: line.y2 - top })
|
|
157
|
+
return { width, height: bottom - top, lines }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A line as a curve leaving its parent and reaching its child level. @param {Line} line */
|
|
161
|
+
export const curveOf = ({ x1, y1, x2, y2 }) => `M${x1.toFixed(3)} ${y1.toFixed(3)}C${((x1 + x2) / 2).toFixed(3)} ${y1.toFixed(3)} ${((x1 + x2) / 2).toFixed(3)} ${y2.toFixed(3)} ${x2.toFixed(3)} ${y2.toFixed(3)}`
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A map drawn by render.js (`.deck-mindmap`, its plane, its lines' SVG with a group a branch, its tree as lists, each
|
|
165
|
+
* item's words a `.deck-map-node`) placed once it is on a page, and again as its words or its box change size: its
|
|
166
|
+
* words where the layout puts them, its lines, the whole shrunk to its box. @param {HTMLElement} map
|
|
167
|
+
*/
|
|
168
|
+
export function watchMap(map) {
|
|
169
|
+
const plane = /** @type {HTMLElement} */ (map.querySelector('.deck-map-plane'))
|
|
170
|
+
const svg = /** @type {SVGSVGElement} */ (plane.querySelector('.deck-map-lines'))
|
|
171
|
+
const root = /** @type {HTMLElement} */ (plane.querySelector('.deck-map-tree > li'))
|
|
172
|
+
const place = () => {
|
|
173
|
+
if (!map.clientWidth || !map.clientHeight) return
|
|
174
|
+
const em = parseFloat(getComputedStyle(plane).fontSize) || 16
|
|
175
|
+
/** @type {[HTMLElement, Box][]} */
|
|
176
|
+
const placed = []
|
|
177
|
+
const boxOf = (/** @type {Element} */ li) => {
|
|
178
|
+
const node = /** @type {HTMLElement} */ (li.firstElementChild)
|
|
179
|
+
const list = li.lastElementChild?.tagName === 'UL' ? [...li.lastElementChild.children] : []
|
|
180
|
+
/** @type {Box} */
|
|
181
|
+
const box = { w: node.offsetWidth / em, h: node.offsetHeight / em, children: list.map(boxOf) }
|
|
182
|
+
placed.push([node, box])
|
|
183
|
+
return box
|
|
184
|
+
}
|
|
185
|
+
const { width, height, lines } = layoutMap(boxOf(root), { rtl: getComputedStyle(map).direction === 'rtl', aspect: map.clientWidth / map.clientHeight })
|
|
186
|
+
// As shares of the plane: a node's own em is its own type's.
|
|
187
|
+
for (const [node, box] of placed) {
|
|
188
|
+
node.style.setProperty('left', `${(/** @type {number} */ (box.x) / width) * 100}%`)
|
|
189
|
+
node.style.setProperty('top', `${(/** @type {number} */ (box.y) / height) * 100}%`)
|
|
190
|
+
node.setAttribute('data-side', box.side === 1 ? 'right' : box.side === -1 ? 'left' : 'middle')
|
|
191
|
+
}
|
|
192
|
+
plane.style.setProperty('width', `${width}em`)
|
|
193
|
+
plane.style.setProperty('height', `${height}em`)
|
|
194
|
+
svg.setAttribute('viewBox', `0 0 ${width.toFixed(3)} ${height.toFixed(3)}`)
|
|
195
|
+
const groups = [...svg.children]
|
|
196
|
+
for (const group of groups) group.replaceChildren()
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
199
|
+
path.setAttribute('d', curveOf(line))
|
|
200
|
+
path.setAttribute('data-depth', String(line.depth))
|
|
201
|
+
groups[line.branch]?.append(path)
|
|
202
|
+
}
|
|
203
|
+
plane.style.setProperty('scale', String(Math.min(1, map.clientWidth / (width * em), map.clientHeight / (height * em))))
|
|
204
|
+
}
|
|
205
|
+
const watcher = new ResizeObserver(place)
|
|
206
|
+
watcher.observe(map)
|
|
207
|
+
for (const node of plane.querySelectorAll('.deck-map-node')) watcher.observe(node)
|
|
208
|
+
}
|