@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/src/pptx.js CHANGED
@@ -2,8 +2,11 @@
2
2
  // slide layout of its own (a master, in PptxGenJS's words) with a placeholder for its title and
3
3
  // each of its slots, so a slide added there looks like the deck's; words as PowerPoint's own text,
4
4
  // a list's levels as its levels; a table as a table; a chart of bars, lines, areas, pies, rings,
5
- // points or a radar as PowerPoint's own chart, its numbers in the file to change; anything else (a
6
- // diagram, another chart, a picture) as a picture; the speaker's notes as notes; each slide's
5
+ // points or a radar as PowerPoint's own chart, its numbers in the file to change; a mind map as
6
+ // PowerPoint's own shapes, its words in boxes joined by curves; a figure (cards, key figures, a
7
+ // timeline, chevrons, a programme, a pyramid, a bento, parts face to face or in a matrix, the
8
+ // deck's contents) as PowerPoint's own shapes and boxes of words, where the slide has them; anything else (a
9
+ // diagram, another chart, a picture) as a picture, a layout's art too, on its slide layout; the speaker's notes as notes; each slide's
7
10
  // transition as PowerPoint's (Morph where it has one, a fade where it hasn't); the look's colours
8
11
  // as the file's theme. Read from the deck as drawn, each box where the browser put it, so the file
9
12
  // looks like the slides, in the scheme shown. PptxGenJS (with its JSZip) is the host's to load.
@@ -14,19 +17,23 @@ const W = 13.333, H = 7.5
14
17
  // Words: a paragraph that is a picture alone on its line is the picture.
15
18
  const TEXT = 'h1, h2, h3, h4, h5, h6, .deck-h, p:not(.deck-picture), ul, ol, blockquote'
16
19
 
17
- /** @param {string} value rgb(…) or #… @returns {string} six hex digits */
20
+ /** @param {string} value rgb(…), color(srgb …) or #… @returns {string} six hex digits */
18
21
  function hexOf(value) {
19
22
  const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value)
20
23
  if (m) return [m[1], m[2], m[3]].map((n) => Math.round(Number(n)).toString(16).padStart(2, '0')).join('').toUpperCase()
24
+ const srgb = /color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(value)
25
+ if (srgb) return [srgb[1], srgb[2], srgb[3]].map((n) => Math.round(Number(n) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()
21
26
  return value.replace('#', '').toUpperCase().slice(0, 6) || '000000'
22
27
  }
23
28
  /** @param {string} value */
24
29
  const opaque = (value) => !/rgba\([^)]*,\s*0\)|transparent/.test(value)
30
+ /** A colour's alpha, from rgba(…, a) or color(srgb … / a), else 1. @param {string} value */
31
+ const alphaOf = (value) => { const m = /rgba\((?:[^,]*,){3}\s*([\d.]+)\s*\)|\/\s*([\d.]+)\s*\)/.exec(value); return value === 'transparent' ? 0 : Number(m?.[1] ?? m?.[2] ?? 1) }
25
32
 
26
33
  /**
27
34
  * @param {import('./deck.js').DeckView} view
28
35
  * @param {{ PptxGenJS: any, JSZip: any }} libraries
29
- * @returns {Promise<{ blob: Blob, report: { slides: number, charts: number, pictures: number, tables: number, placeholders: number } }>}
36
+ * @returns {Promise<{ blob: Blob, report: { slides: number, charts: number, pictures: number, tables: number, placeholders: number, maps: number, figures: number } }>}
30
37
  */
31
38
  export async function toPptx(view, { PptxGenJS, JSZip }) {
32
39
  const pres = new PptxGenJS()
@@ -36,7 +43,7 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
36
43
  const rootStyle = getComputedStyle(view.root)
37
44
  const firstFont = (/** @type {string} */ stack) => stack.split(',').map((f) => f.trim().replace(/^["']|["']$/g, '')).find((f) => !/^(ui-|system-ui|-apple|sans-serif|serif|monospace)/.test(f)) ?? 'Arial'
38
45
  pres.theme = { headFontFace: firstFont(rootStyle.getPropertyValue('--deck-heading')), bodyFontFace: firstFont(rootStyle.getPropertyValue('--deck-body')) }
39
- const report = { slides: 0, charts: 0, pictures: 0, tables: 0, placeholders: 0 }
46
+ const report = { slides: 0, charts: 0, pictures: 0, tables: 0, placeholders: 0, maps: 0, figures: 0 }
40
47
  const rtl = view.dir === 'rtl'
41
48
  const slides = view.slides()
42
49
  const masters = new Map()
@@ -57,6 +64,21 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
57
64
  const start = parseFloat(pad.paddingInlineStart) * sx, end = parseFloat(pad.paddingInlineEnd) * sx
58
65
  return { x: x + (rtl ? end : start), y, w: w - start - end, h: h * 1.08 }
59
66
  }
67
+ // A chart as PowerPoint's own, else as a picture; a diagram or a picture as a picture.
68
+ const figure = async (/** @type {Element} */ block, /** @type {{ x: number, y: number, w: number, h: number }} */ at) => {
69
+ if (block.matches('.deck-echarts') && /** @type {any} */ (block).chart) {
70
+ const chart = /** @type {any} */ (block).chart
71
+ if (nativeChart(pres, slide, chart, at, { pt, rtl, lang: view.lang, ink: hexOf(style.color) })) report.charts++
72
+ else { slide.addImage({ data: await pictureOf(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(chart.renderToSVGString())}`, at.w / sx * 2, at.h / sx * 2), ...at }); report.pictures++ }
73
+ return
74
+ }
75
+ const img = block.querySelector('img')
76
+ if (!img) return
77
+ // A slide's pictures are read as they come near the screen: one not read yet is read now, so that it has its size.
78
+ if (!img.complete) { img.loading = 'eager'; await img.decode().catch(() => {}) }
79
+ const data = await pictureOf(img.src, img.clientWidth * 2, img.clientHeight * 2)
80
+ if (data) { slide.addImage({ data, ...place(img), altText: img.alt }); report.pictures++ }
81
+ }
60
82
  // Where a layout centres its words, PowerPoint's box centres them too.
61
83
  const anchor = (/** @type {Element} */ slot) => (getComputedStyle(slot).justifyContent === 'center' ? 'middle' : 'top')
62
84
  const pt = (/** @type {number} */ px) => Math.round(px * sx * 72 * 10) / 10
@@ -72,6 +94,9 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
72
94
  const masterName = `${definition ? labelOf(definition, layout, view.lang) : layout}${element.classList.contains('deck-class-invert') ? ' (dark)' : ''}`
73
95
  if (!masters.has(masterName)) {
74
96
  const objects = []
97
+ // The layout's art, its layers as one picture on its paper behind the rest: a slide added in PowerPoint has it too.
98
+ const art = [...element.querySelectorAll('img.deck-art')].map((one) => /** @type {HTMLImageElement} */ (one).src), backdrop = art.length && await pictureOf(art, W * 150, H * 150, false, style.backgroundColor)
99
+ if (backdrop) { objects.push({ image: { data: backdrop, x: 0, y: 0, w: W, h: H } }); report.pictures++ }
75
100
  if (title) objects.push({ placeholder: { options: { name: 'title', type: 'title', ...across(title), valign: 'bottom', margin: 0, fontFace: pres.theme.headFontFace, color: hexOf(getComputedStyle(title).color), align: rtl ? 'right' : 'left' }, text: '' } })
76
101
  for (const slot of slots) {
77
102
  const r = place(slot)
@@ -98,6 +123,9 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
98
123
  const url = /url\("?(.*?)"?\)/.exec(getComputedStyle(background).backgroundImage)?.[1]
99
124
  const data = url && await pictureOf(url, background.clientWidth * 2, background.clientHeight * 2, true)
100
125
  if (data) { slide.addImage({ data, ...place(background) }); report.pictures++ }
126
+ // The veil over it, as a quote has: the words read on it.
127
+ const veil = getComputedStyle(background, '::after')
128
+ if (veil.content !== 'none' && alphaOf(veil.backgroundColor) > 0) slide.addShape(pres.shapes.RECTANGLE, { ...place(background), fill: { color: hexOf(veil.backgroundColor), transparency: Math.round((1 - alphaOf(veil.backgroundColor)) * 100) }, line: { type: 'none' } })
101
129
  }
102
130
  if (title && names.has('title')) {
103
131
  slide.addText(runs(title, { pt, rtl, lang: view.lang }), { placeholder: 'title' })
@@ -119,19 +147,13 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
119
147
  } else if (block.matches('.deck-table')) {
120
148
  table(slide, block, at, { pt, rtl, lang: view.lang })
121
149
  report.tables++
122
- } else if (block.matches('.deck-echarts') && /** @type {any} */ (block).chart) {
123
- const chart = /** @type {any} */ (block).chart
124
- if (nativeChart(pres, slide, chart, at, { pt, rtl, lang: view.lang, ink: hexOf(style.color) })) report.charts++
125
- else { slide.addImage({ data: await pictureOf(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(chart.renderToSVGString())}`, at.w / sx * 2, at.h / sx * 2), ...at }); report.pictures++ }
126
- } else {
127
- const img = block.querySelector('img')
128
- if (img) {
129
- // A slide's pictures are read as they come near the screen: one not read yet is read now, so that it has its size.
130
- if (!img.complete) { img.loading = 'eager'; await img.decode().catch(() => {}) }
131
- const data = await pictureOf(img.src, img.clientWidth * 2, img.clientHeight * 2)
132
- if (data) { slide.addImage({ data, ...place(img), altText: img.alt }); report.pictures++ }
133
- }
134
- }
150
+ } else if (block.matches('.deck-mindmap')) {
151
+ await mindmap(pres, slide, /** @type {HTMLElement} */ (block), place, { pt, rtl, lang: view.lang }, figure)
152
+ report.maps++
153
+ } else if (block.matches('.deck-figure')) {
154
+ await drawing(pres, slide, block, place, { pt, rtl, lang: view.lang }, figure)
155
+ report.figures++
156
+ } else await figure(block, at)
135
157
  }
136
158
  }
137
159
  const notes = view.deck.slides[i]?.notes
@@ -159,6 +181,102 @@ export async function toPptx(view, { PptxGenJS, JSZip }) {
159
181
  return { blob, report }
160
182
  }
161
183
 
184
+ /**
185
+ * A mind map as PowerPoint's own shapes, where the slide has them: each line a curve in its branch's colour, each of its
186
+ * words a box, filled and outlined as on the slide (the root, the first branches), its words as runs, a chart or a
187
+ * diagram in it as the slide's are.
188
+ * @param {any} pres @param {any} slide @param {HTMLElement} map @param {(el: Element) => { x: number, y: number, w: number, h: number }} place
189
+ * @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how
190
+ * @param {(block: Element, at: { x: number, y: number, w: number, h: number }) => Promise<void>} figure
191
+ */
192
+ async function mindmap(pres, slide, map, place, how, figure) {
193
+ const plane = /** @type {HTMLElement} */ (map.querySelector('.deck-map-plane')), svg = /** @type {SVGSVGElement} */ (map.querySelector('.deck-map-lines'))
194
+ // The plane is shrunk to its box: its type and lines are so much smaller on the slide.
195
+ const scale = plane.getBoundingClientRect().width / (plane.offsetWidth || 1)
196
+ const area = place(svg), units = Number(svg.getAttribute('viewBox')?.split(' ')[2]) || 1, inch = area.w / units
197
+ for (const path of svg.querySelectorAll('path')) {
198
+ const [x1, y1, , , , , x2, y2] = (path.getAttribute('d')?.match(/-?[\d.]+/g) ?? []).map((n, i) => (i % 2 ? area.y : area.x) + Number(n) * inch)
199
+ const x = Math.min(x1, x2), y = Math.min(y1, y2), middle = (x1 + x2) / 2 - x
200
+ const stroke = getComputedStyle(path)
201
+ slide.addShape(pres.shapes.CUSTOM_GEOMETRY, {
202
+ x, y, w: Math.max(0.01, Math.abs(x2 - x1)), h: Math.max(0.01, Math.abs(y2 - y1)), line: { color: hexOf(stroke.stroke), width: Math.round(parseFloat(stroke.strokeWidth) * inch * 72 * 10) / 10 },
203
+ points: [{ x: x1 - x, y: y1 - y }, { x: x2 - x, y: y2 - y, curve: { type: 'cubic', x1: middle, y1: y1 - y, x2: middle, y2: y2 - y } }],
204
+ })
205
+ }
206
+ const small = { ...how, pt: (/** @type {number} */ px) => how.pt(px * scale) }
207
+ for (const node of /** @type {NodeListOf<HTMLElement>} */ (map.querySelectorAll('.deck-map-node'))) {
208
+ const style = getComputedStyle(node), at = place(node)
209
+ const align = style.textAlign === 'left' || style.textAlign === 'right' ? style.textAlign : style.textAlign === 'center' ? 'center' : how.rtl ? 'right' : 'left'
210
+ // A node holding a chart: its words alone, over its box, the chart where it is.
211
+ const label = node.matches('.has-block') ? node.querySelector('.deck-map-words') : node
212
+ const words = label ? runs(label, small).map((run) => ({ ...run, options: { ...run.options, align } })) : []
213
+ const border = parseFloat(style.borderTopWidth) || 0, pad = small.pt(parseFloat(style.paddingInlineStart) || 0) / 72
214
+ const fill = alphaOf(style.backgroundColor) > 0 ? { color: hexOf(style.backgroundColor), transparency: Math.round((1 - alphaOf(style.backgroundColor)) * 100) } : undefined
215
+ // PowerPoint sets type a little wider than a browser: words with no box grow away from their line.
216
+ const wide = fill || border ? at.w * 0.04 : at.w * 0.08
217
+ const x = align === 'left' ? at.x : align === 'right' ? at.x - wide : at.x - wide / 2
218
+ slide.addText(label === node && words.length ? words : '', {
219
+ x, y: at.y, w: at.w + wide, h: at.h, margin: [pad * 72, pad * 72, 0, 0], valign: 'middle', align, wrap: true,
220
+ ...(fill || border ? { shape: pres.shapes.ROUNDED_RECTANGLE, rectRadius: Math.min(at.h / 2, small.pt(parseFloat(style.borderTopLeftRadius) || 0) / 72), fill } : {}),
221
+ ...(border ? { line: { color: hexOf(style.borderTopColor), width: Math.round(small.pt(border) * 10) / 10 } } : {}),
222
+ })
223
+ if (label === node) continue
224
+ if (label) slide.addText(words, { ...place(label), margin: 0, valign: 'middle', align: 'center', wrap: true })
225
+ for (const block of node.querySelectorAll('.deck-block')) await figure(block, place(block))
226
+ }
227
+ }
228
+
229
+ /**
230
+ * A figure as PowerPoint's own shapes, as the slide draws them: each shape (a card, a chevron, a level, a dot, a rule) its
231
+ * box, or its polygon, filled and outlined as on the slide, a side's rule a line; then each of its words a box of words
232
+ * where they are, a table as a table, a chart or a diagram as the slide's are.
233
+ * @param {any} pres @param {any} slide @param {Element} root @param {(el: Element) => { x: number, y: number, w: number, h: number }} place
234
+ * @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how
235
+ * @param {(block: Element, at: { x: number, y: number, w: number, h: number }) => Promise<void>} figure
236
+ */
237
+ async function drawing(pres, slide, root, place, how, figure) {
238
+ const inches = (/** @type {number} */ px) => how.pt(px) / 72
239
+ // In the order the slide stacks them: a medallion over the parts it sits between.
240
+ const layer = (/** @type {Element} */ el) => parseInt(getComputedStyle(el).zIndex, 10) || 0
241
+ for (const shape of [...root.querySelectorAll('.deck-shape')].sort((a, b) => layer(a) - layer(b))) {
242
+ const style = getComputedStyle(shape), at = place(shape), box = shape.getBoundingClientRect()
243
+ const fill = alphaOf(style.backgroundColor) > 0 ? { color: hexOf(style.backgroundColor), transparency: Math.round((1 - alphaOf(style.backgroundColor)) * 100) } : undefined
244
+ const sides = /** @type {const} */ (['Top', 'Right', 'Bottom', 'Left']).map((side) => ({ side, width: parseFloat(style[`border${side}Width`]) || 0, colour: style[`border${side}Color`] })).map((one) => ({ ...one, width: alphaOf(one.colour) > 0 ? one.width : 0 }))
245
+ const even = sides.every((one) => Math.abs(one.width - sides[0].width) < 0.1)
246
+ const line = even && sides[0].width > 0 ? { color: hexOf(sides[0].colour), width: Math.round(how.pt(sides[0].width) * 10) / 10 } : { type: 'none' }
247
+ const polygon = /^polygon\((.*)\)$/.exec(style.clipPath)?.[1]
248
+ if (polygon && (fill || even && sides[0].width)) {
249
+ // Its points as the slide clips it: lengths and shares of its box, calc() as CSS writes it.
250
+ const length = (/** @type {string} */ text, /** @type {number} */ size) => [...text.replace(/^calc\(|\)$/g, '').matchAll(/([+-]?)\s*([\d.]+)(%|px)?/g)].reduce((sum, [, sign, n, unit]) => sum + (sign === '-' ? -1 : 1) * (unit === '%' ? (Number(n) / 100) * size : inches(Number(n))), 0)
251
+ const points = polygon.split(/,(?![^(]*\))/).map((pair) => pair.trim().match(/calc\([^)]*\)|[-\d.]+(?:%|px)?/g) ?? []).map(([x = '0', y = '0']) => ({ x: length(x, at.w), y: length(y, at.h) }))
252
+ slide.addShape(pres.shapes.CUSTOM_GEOMETRY, { ...at, fill, line, points: [...points, { close: true }] })
253
+ } else if (fill || line.width) {
254
+ const radius = style.borderTopLeftRadius.endsWith('%') ? (Math.min(box.width, box.height) * parseFloat(style.borderTopLeftRadius)) / 100 : parseFloat(style.borderTopLeftRadius) || 0
255
+ const round = radius >= Math.min(box.width, box.height) / 2 - 0.5
256
+ const kind = round && Math.abs(box.width - box.height) < 1 ? pres.shapes.OVAL : radius > 0 ? pres.shapes.ROUNDED_RECTANGLE : pres.shapes.RECTANGLE
257
+ slide.addShape(kind, { ...at, fill, line, ...(kind === pres.shapes.ROUNDED_RECTANGLE ? { rectRadius: Math.min(at.h / 2, at.w / 2, inches(radius)) } : {}) })
258
+ }
259
+ // A rule on one side alone, as a line.
260
+ if (!even) for (const { side, width, colour } of sides) {
261
+ if (!width) continue
262
+ const thick = inches(width)
263
+ const edge = { Top: { x: at.x, y: at.y + thick / 2, w: at.w, h: 0 }, Bottom: { x: at.x, y: at.y + at.h - thick / 2, w: at.w, h: 0 }, Left: { x: at.x + thick / 2, y: at.y, w: 0, h: at.h }, Right: { x: at.x + at.w - thick / 2, y: at.y, w: 0, h: at.h } }[side]
264
+ slide.addShape(pres.shapes.LINE, { ...edge, line: { color: hexOf(colour), width: Math.round(how.pt(width) * 10) / 10 } })
265
+ }
266
+ }
267
+ for (const words of root.querySelectorAll('.deck-words')) {
268
+ if (words.parentElement?.closest('.deck-words') || !words.textContent?.trim()) continue
269
+ const style = getComputedStyle(words), at = place(words)
270
+ const align = style.textAlign === 'center' ? 'center' : style.textAlign === 'left' || style.textAlign === 'right' ? style.textAlign : (style.textAlign === 'end') !== how.rtl ? 'right' : 'left'
271
+ // PowerPoint sets type a little wider than a browser: words grow away from where they are aligned.
272
+ const wide = at.w * 0.08, x = align === 'left' ? at.x : align === 'right' ? at.x - wide : at.x - wide / 2
273
+ // Words in a shape of their own (a number in its circle) in its middle; others from their top, as their lines may be fewer.
274
+ slide.addText(runs(words, how).map((run) => ({ ...run, options: { ...run.options, align } })), { x, y: at.y, w: at.w + wide, h: at.h, margin: 0, valign: words.matches('.deck-shape') ? 'middle' : 'top', align, wrap: true })
275
+ }
276
+ for (const one of root.querySelectorAll('.deck-table')) table(slide, one, place(one), how)
277
+ for (const block of root.querySelectorAll('.deck-block')) await figure(block, place(block))
278
+ }
279
+
162
280
  /** A slot's paragraphs' spacing, from the space under its first block. @param {HTMLElement} slot */
163
281
  function spaced(slot) {
164
282
  const first = slot.firstElementChild
@@ -296,21 +414,34 @@ function transitionXml(kind, rtl) {
296
414
  return '<p:transition spd="med"><p:fade/></p:transition>'
297
415
  }
298
416
 
299
- /** A picture's data as PNG, drawn at a size; null when it can't be read (another site's, unshared). @param {string} src @param {number} w @param {number} h @param {boolean} [cover] */
300
- async function pictureOf(src, w, h, cover = false) {
417
+ /**
418
+ * A picture's data as PNG, drawn at a size, or a layout's art, its layers, as JPEG on its paper; null when it can't be read
419
+ * (another site's, unshared). @param {string | string[]} src @param {number} w @param {number} h @param {boolean} [cover] @param {string} [paper]
420
+ */
421
+ async function pictureOf(src, w, h, cover = false, paper = '') {
301
422
  try {
302
- const img = new Image()
303
- img.crossOrigin = 'anonymous'
304
- img.src = src
305
- await img.decode()
423
+ const images = await Promise.all([src].flat().map(async (one) => {
424
+ const img = new Image()
425
+ img.crossOrigin = 'anonymous'
426
+ img.src = one
427
+ await img.decode()
428
+ return img
429
+ }))
430
+ const img = images[0]
306
431
  const canvas = document.createElement('canvas')
307
432
  canvas.width = Math.max(1, Math.round(w))
308
433
  canvas.height = Math.max(1, Math.round(h))
309
434
  const ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'))
310
435
  const iw = img.naturalWidth || w, ih = img.naturalHeight || h
311
436
  const scale = cover ? Math.max(canvas.width / iw, canvas.height / ih) : Math.min(canvas.width / iw, canvas.height / ih)
312
- ctx.drawImage(img, (canvas.width - iw * scale) / 2, (canvas.height - ih * scale) / 2, iw * scale, ih * scale)
313
- return canvas.toDataURL('image/png')
437
+ if (!paper) ctx.drawImage(img, (canvas.width - iw * scale) / 2, (canvas.height - ih * scale) / 2, iw * scale, ih * scale)
438
+ else {
439
+ // Art, stretched over the slide as it is drawn there, on its paper, as a JPEG: photographs stay small.
440
+ ctx.fillStyle = paper
441
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
442
+ for (const layer of images) ctx.drawImage(layer, 0, 0, canvas.width, canvas.height)
443
+ }
444
+ return canvas.toDataURL(paper ? 'image/jpeg' : 'image/png', 0.9)
314
445
  } catch {
315
446
  return null
316
447
  }
package/src/render.js CHANGED
@@ -3,13 +3,19 @@
3
3
  // policy can draw a deck. Each block that can be changed by hand says where its words are in the
4
4
  // source (data-edit, offsets), so an edit made on the slide goes back into the Markdown. HTML a
5
5
  // deck holds is not drawn: Slidev's <v-click> and <v-clicks> become steps, <br> a line break, and
6
- // any other tag gives up its words alone. ```echarts and ```mermaid go to the host's drawers.
7
- import { STANDARD, CORNERS, inferLayout, ALIASES } from './kit.js'
6
+ // any other tag gives up its words alone. ```echarts and ```mermaid go to the host's drawers. A
7
+ // layout that draws its lists as a mind map has a slot's lists merged into one map (mindmap.js);
8
+ // one that draws them as a figure (cards, numbers, a timeline…), or sets its parts face to face or
9
+ // in a matrix, or shows the deck's contents, has them drawn so (figures.js).
10
+ import { STANDARD, CORNERS, layoutNameOf } from './kit.js'
11
+ import { itemsOf, mergeItems, keyOf, watchMap } from './mindmap.js'
12
+ import { entriesOf, boldLines, listFigure, partHeads, partsFigure, contentsFigure } from './figures.js'
13
+ import { parseYaml } from './yaml.js'
8
14
 
9
15
  /** @typedef {import('./parse.js').Slide} Slide */
10
16
  /** @typedef {import('./parse.js').Slot} Slot */
11
- /** @typedef {(element: HTMLElement, source: string) => void | Promise<void>} Drawer */
12
- /** @typedef {{ blocks: Record<string, Drawer>, resolve: (src: string) => string | null, headings: 'real' | 'aria', number: (n: number) => string, total: number, kit: import('./kit.js').Kit, lex: (markdown: string) => any[], slide: string }} Context */
17
+ /** @typedef {(element: HTMLElement, source: string, options?: Record<string, any>) => void | Promise<void>} Drawer */
18
+ /** @typedef {{ blocks: Record<string, Drawer>, resolve: (src: string) => string | null, headings: 'real' | 'aria', number: (n: number) => string, total: number, kit: import('./kit.js').Kit, lex: (markdown: string) => any[], slide: string, deck?: import('./parse.js').Deck }} Context */
13
19
 
14
20
  /**
15
21
  * @param {string} tag @param {Record<string, string | number | boolean | undefined>} [attributes]
@@ -36,18 +42,20 @@ export function safeUrl(href) {
36
42
  */
37
43
  export function renderSlide(slide, context) {
38
44
  // The layout the slide names, by its name or a name brains give it, else the one its content suits.
39
- const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
40
- const layout = context.kit.layouts[asked] ? asked : context.kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
45
+ const layout = layoutNameOf(slide, context.kit)
41
46
  const definition = context.kit.layouts[layout] ?? STANDARD.layouts.default
42
47
  const classes = String(slide.settings.class ?? '').split(/\s+/).filter((name) => ['invert', 'text-center', 'text-left'].includes(name))
43
48
  const counter = { step: 0 }
44
49
  const slots = new Map(slide.slots.map((slot) => [slot.name, slot]))
45
50
  const names = definition.slots
51
+ const art = (definition.art ?? []).map((name) => context.kit.pictures?.[name]).filter(Boolean)
46
52
  const section = h('section', {
47
- class: ['deck-slide', `deck-layout-${layout}`, ...classes.map((name) => `deck-class-${name}`)].join(' '),
53
+ class: ['deck-slide', `deck-layout-${layout}`, ...classes.map((name) => `deck-class-${name}`), ...(art.length ? ['has-art'] : [])].join(' '),
48
54
  'data-index': slide.index, 'data-layout': layout, 'data-paper': definition.paper, 'data-align': definition.align, 'data-justify': definition.justify, 'data-title': definition.title,
49
55
  'aria-roledescription': context.slide, role: 'group',
50
56
  })
57
+ // Its art, as its template draws it, layer over layer behind the rest.
58
+ for (const src of art) section.append(h('img', { class: 'deck-art', src, alt: '', 'aria-hidden': 'true' }))
51
59
  // Its grid, from the layout's definition, through the CSSOM: areas named s-<slot>, as `default` is no name CSS allows.
52
60
  const canvas = h('div', { class: 'deck-canvas' })
53
61
  canvas.style.setProperty('grid-template-areas', definition.areas.map((row) => `"${row.map((cell) => (cell === '.' ? '.' : `s-${cell}`)).join(' ')}"`).join(' '))
@@ -55,7 +63,7 @@ export function renderSlide(slide, context) {
55
63
  canvas.style.setProperty('grid-template-rows', definition.rows.join(' '))
56
64
  section.append(canvas)
57
65
  const image = slide.settings.image ?? slide.settings.background
58
- if (typeof image === 'string' && ['image-left', 'image-right', 'image', 'cover'].includes(layout)) {
66
+ if (typeof image === 'string' && ['image-left', 'image-right', 'image', 'cover', 'quote'].includes(layout)) {
59
67
  const url = context.resolve(image)
60
68
  if (url) {
61
69
  const picture = h('div', { class: 'deck-image', role: 'img', 'aria-label': String(slide.settings.alt ?? '') || undefined })
@@ -66,10 +74,13 @@ export function renderSlide(slide, context) {
66
74
  }
67
75
  for (const name of names) {
68
76
  const slot = slots.get(name)
69
- const element = h('div', { class: 'deck-slot', 'data-slot': name }, slot ? blocks(slot, context, counter) : [])
77
+ const element = h('div', { class: 'deck-slot', 'data-slot': name }, slot ? blocks(slot, context, counter, definition.lists, definition.parts) : [])
70
78
  element.style.setProperty('grid-area', `s-${name}`)
71
79
  canvas.append(element)
72
80
  }
81
+ // The deck's contents, under the words of the slot they open.
82
+ const contents = definition.contents && contentsFigure(context, slide.index, Number(slide.settings.maxDepth) || undefined)
83
+ if (contents) canvas.querySelector(`.deck-slot[data-slot="${names[0]}"]`)?.append(contents)
73
84
  // Slots a layout doesn't have still show, under the rest, rather than being lost.
74
85
  for (const slot of slide.slots) if (!names.includes(slot.name)) canvas.append(h('div', { class: 'deck-slot', 'data-slot': slot.name }, blocks(slot, context, counter)))
75
86
  if (definition.numbered && slide.settings.paginate !== false) section.append(h('span', { class: 'deck-number', 'aria-hidden': 'true' }, [context.number(slide.index + 1)]))
@@ -84,15 +95,50 @@ export function renderSlide(slide, context) {
84
95
  return { element: section, steps: counter.step }
85
96
  }
86
97
 
87
- /** @param {Slot} slot @param {Context} context @param {{ step: number }} counter @returns {Node[]} */
88
- function blocks(slot, context, counter) {
98
+ /**
99
+ * @param {Slot} slot @param {Context} context @param {{ step: number }} counter
100
+ * @param {import('./kit.js').List} [kind] how its lists are drawn @param {'none' | 'versus' | 'matrix'} [parted] how its parts are set @returns {Node[]}
101
+ */
102
+ function blocks(slot, context, counter, kind = 'bullets', parted = 'none') {
89
103
  const tokens = context.lex(slot.text)
90
104
  /** @type {Node[]} */
91
105
  const out = []
106
+ // Its parts, each from its heading, and where they go; the blocks of the part being drawn go to it.
107
+ const heads = parted === 'none' ? new Set() : partHeads(tokens, parted === 'versus' ? 2 : 4)
108
+ /** @type {Node[][]} */
109
+ const parts = []
110
+ let sink = out, partsAt = -1
111
+ const mapped = kind === 'mindmap' && !heads.size, figured = kind !== 'bullets' && kind !== 'mindmap' && !heads.size
112
+ // A figure's entries, and where it goes.
113
+ /** @type {import('./figures.js').Entry[]} */
114
+ const entries = []
115
+ let figureAt = -1
116
+ // A mind map's lists, each with its heading when one comes just before it, and where the map goes.
117
+ /** @type {{ items: import('./mindmap.js').Item[], step: boolean }[]} */
118
+ const lists = []
119
+ let mapAt = -1, label = /** @type {HTMLElement | null} */ (null)
92
120
  let at = slot.start, mode = /** @type {'' | 'click' | 'clicks'} */ ('')
93
- for (const token of tokens) {
121
+ for (const [i, token] of tokens.entries()) {
94
122
  const start = at
95
123
  at += token.raw.length
124
+ if (heads.has(i)) { if (partsAt < 0) partsAt = out.length; parts.push(sink = []) }
125
+ if (figured && (token.type === 'list' || boldLines(token))) {
126
+ if (figureAt < 0) figureAt = out.length
127
+ // A click an entry in <v-clicks>, one for them all in <v-click>, numbered where they are.
128
+ const found = entriesOf(token, start, context.lex), once = mode === 'click' ? ++counter.step : 0
129
+ for (const entry of found) entry.step = once || (mode === 'clicks' ? ++counter.step : 0)
130
+ entries.push(...found)
131
+ continue
132
+ }
133
+ if (mapped && (token.type === 'list' || (token.type === 'heading' && out.length && tokens.slice(i + 1).find((/** @type {any} */ t) => t.type !== 'space')?.type === 'list'))) {
134
+ if (mapAt < 0) mapAt = out.length
135
+ if (token.type === 'heading') { label = /** @type {HTMLElement} */ (block(token, start, context, null)); continue }
136
+ const items = itemsOf(token.raw, start)
137
+ for (const item of items) if (mode === 'clicks') item.step = true
138
+ lists.push({ items: label ? [{ text: label.textContent ?? '', start, end: start, children: items, element: label, step: mode === 'clicks' }] : items, step: mode === 'clicks' })
139
+ label = null
140
+ continue
141
+ }
96
142
  // Pandoc's fences: `::: incremental` shows its list a point a click, as <v-clicks> does; the others, nothing.
97
143
  const fences = token.type === 'paragraph' ? token.raw.trim().split('\n').map((/** @type {string} */ line) => line.trim()) : []
98
144
  if (fences.length && fences.every((/** @type {string} */ line) => /^:{3,}/.test(line))) {
@@ -104,7 +150,7 @@ function blocks(slot, context, counter) {
104
150
  }
105
151
  if (token.type === 'html') {
106
152
  const tag = /^\s*<(\/?)(v-clicks?|br)\b[^>]*>\s*$/i.exec(token.raw)
107
- if (tag && tag[2] === 'br') out.push(h('br'))
153
+ if (tag && tag[2] === 'br') sink.push(h('br'))
108
154
  else if (tag) mode = tag[1] ? '' : /** @type {'click' | 'clicks'} */ (tag[2].toLowerCase() === 'v-clicks' ? 'clicks' : 'click')
109
155
  else if (/<li\b/i.test(token.raw)) {
110
156
  // A list written as HTML, as brains write Slidev's steps (<li v-click>): its items' words, each
@@ -117,30 +163,79 @@ function blocks(slot, context, counter) {
117
163
  if (/\bv-click\b/.test(attrs) || mode === 'clicks') step(li, ++counter.step)
118
164
  list.append(li)
119
165
  }
120
- if (list.childElementCount) out.push(list)
166
+ if (list.childElementCount) sink.push(list)
121
167
  } else {
122
168
  const words = token.raw.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
123
- if (words) out.push(h('p', {}, [words]))
169
+ if (words) sink.push(h('p', {}, [words]))
124
170
  }
125
171
  continue
126
172
  }
127
173
  const element = block(token, start, context, mode === 'clicks' ? counter : null)
128
174
  if (!element) continue
129
175
  if (mode === 'click' || (mode === 'clicks' && token.type !== 'list')) step(element, ++counter.step)
130
- out.push(element)
176
+ sink.push(element)
131
177
  }
178
+ if (lists.length) mindmap(out, lists.flatMap((list) => list.items), context, counter, mapAt)
179
+ if (entries.length) out.splice(figureAt, 0, listFigure(kind, entries, context))
180
+ if (parts.length) out.splice(partsAt, 0, partsFigure(parted, parts))
132
181
  return out
133
182
  }
134
183
 
184
+ /**
185
+ * A slot's lists as one mind map, put among its other blocks where the first list was: merged, under the item they share, else under the heading the slot opens with, taken
186
+ * from the slot into the map; a heading other than that one stays the slide's. Each branch is a step when its list was
187
+ * one a click. Its words keep where they are in the source, so they change in place as a list's do; a chart or a
188
+ * diagram fenced under an item is drawn in its node, under its words.
189
+ * @param {Node[]} out the slot's other blocks @param {import('./mindmap.js').Item[]} items @param {Context} context
190
+ * @param {{ step: number }} counter @param {number} at where the map goes
191
+ */
192
+ function mindmap(out, items, context, counter, at) {
193
+ const merged = mergeItems(items)
194
+ const opening = at > 0 && out[0] instanceof HTMLElement && out[0].matches('h1, h2, h3, h4, h5, h6, .deck-h') ? out[0] : null
195
+ /** @type {import('./mindmap.js').Item} */
196
+ let root
197
+ if (merged.length === 1 && (!opening || keyOf(opening.textContent ?? '') === keyOf(merged[0].text))) root = { ...merged[0], element: opening ?? merged[0].element }
198
+ else if (merged.length === 1) root = merged[0]
199
+ else root = { text: opening?.textContent ?? '', start: 0, end: 0, children: merged, element: opening ?? undefined }
200
+ if (opening && root.element === opening) { out.shift(); at-- }
201
+ const node = (/** @type {import('./mindmap.js').Item} */ item, /** @type {number} */ depth) => {
202
+ if (item.element) { item.element.classList.add('deck-map-node'); item.element.setAttribute('data-depth', String(depth)); return item.element }
203
+ const [first] = item.text ? context.lex(item.text) : []
204
+ const words = inline(first?.tokens ?? [{ type: 'text', text: item.text }], context)
205
+ if (!item.blocks?.length) return h('span', { class: 'deck-map-node', 'data-depth': depth, dir: 'auto', 'data-edit': range(item.start, item.end) }, words)
206
+ return h('div', { class: 'deck-map-node has-block', 'data-depth': depth }, [
207
+ item.text ? h('span', { class: 'deck-map-words', dir: 'auto', 'data-edit': range(item.start, item.end) }, words) : null,
208
+ ...item.blocks.map((one) => block({ type: 'code', lang: one.lang, text: one.text, raw: one.text }, item.start, context, null)),
209
+ ])
210
+ }
211
+ /** @type {(item: import('./mindmap.js').Item, depth: number) => HTMLElement} */
212
+ const tree = (item, depth) => h('li', {}, [node(item, depth), item.children.length ? h('ul', {}, item.children.map((child) => tree(child, depth + 1))) : null])
213
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
214
+ svg.setAttribute('class', 'deck-map-lines')
215
+ svg.setAttribute('aria-hidden', 'true')
216
+ svg.setAttribute('preserveAspectRatio', 'none')
217
+ const branches = root.children.map((child, n) => {
218
+ const li = tree(child, 1), group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
219
+ for (const one of [li, group]) one.setAttribute('data-branch', String((n % 6) + 1))
220
+ if (child.step) { const k = ++counter.step; step(li, k); step(group, k) }
221
+ svg.append(group)
222
+ return li
223
+ })
224
+ const top = h('li', {}, [node(root, 0), branches.length ? h('ul', {}, branches) : null])
225
+ const map = h('div', { class: 'deck-mindmap' }, [h('div', { class: 'deck-map-plane' }, [svg, h('ul', { class: 'deck-map-tree' }, [top])])])
226
+ watchMap(map)
227
+ out.splice(at, 0, map)
228
+ }
229
+
135
230
  /** @param {Element} element @param {number} n */
136
- const step = (element, n) => { element.classList.add('deck-step'); element.setAttribute('data-step', String(n)) }
231
+ export const step = (element, n) => { element.classList.add('deck-step'); element.setAttribute('data-step', String(n)) }
137
232
 
138
233
  /**
139
234
  * @param {any} token @param {number} start where its raw starts in the source
140
235
  * @param {Context} context @param {{ step: number } | null} stepped list items each a step
141
236
  * @returns {HTMLElement | null}
142
237
  */
143
- function block(token, start, context, stepped) {
238
+ export function block(token, start, context, stepped) {
144
239
  switch (token.type) {
145
240
  case 'heading': {
146
241
  const head = /^\s*#{1,6}[ \t]+/.exec(token.raw)
@@ -182,7 +277,7 @@ function block(token, start, context, stepped) {
182
277
  if (drawer) {
183
278
  const box = h('div', { class: `deck-block deck-${lang}`, 'data-source': token.text })
184
279
  box.append(h('pre', { class: 'deck-block-source', dir: 'ltr' }, [h('code', {}, [token.text])]))
185
- queueMicrotask(() => Promise.resolve(drawer(box, token.text)).catch(() => box.classList.add('is-failed')))
280
+ queueMicrotask(() => Promise.resolve(drawer(box, token.text, optionsOf(token.lang ?? ''))).catch(() => box.classList.add('is-failed')))
186
281
  return box
187
282
  }
188
283
  const marked = lines(info[2] ?? '')
@@ -211,6 +306,17 @@ function children(tokens, start, raw, context) {
211
306
  })
212
307
  }
213
308
 
309
+ /**
310
+ * The options after a fence's language, as Slidev writes them (`mermaid {theme: 'neutral', scale: 0.8}`), read as YAML's
311
+ * flow, which JSON is too; none if they don't read. @param {string} info @returns {Record<string, any>}
312
+ */
313
+ export function optionsOf(info) {
314
+ const braces = /\{[\s\S]*\}/.exec(info)?.[0]
315
+ if (!braces) return {}
316
+ const read = parseYaml(`options: ${braces.replace(/\n/g, ' ')}`).options
317
+ return read && typeof read === 'object' && !Array.isArray(read) ? read : {}
318
+ }
319
+
214
320
  /** Line numbers as Slidev marks them, `{2,4-5}`; `|` steps are read as all their lines at once. @param {string} text */
215
321
  function lines(text) {
216
322
  const marked = new Set()
@@ -222,7 +328,7 @@ function lines(text) {
222
328
  }
223
329
 
224
330
  /** @param {number} from @param {number} to */
225
- const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
331
+ export const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
226
332
 
227
333
  /** @param {any[]} tokens @param {Context} context @returns {Node[]} */
228
334
  export function inline(tokens = [], context) {