@codename/deck 0.3.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/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,8 +42,7 @@ 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 }
@@ -58,7 +63,7 @@ export function renderSlide(slide, context) {
58
63
  canvas.style.setProperty('grid-template-rows', definition.rows.join(' '))
59
64
  section.append(canvas)
60
65
  const image = slide.settings.image ?? slide.settings.background
61
- 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)) {
62
67
  const url = context.resolve(image)
63
68
  if (url) {
64
69
  const picture = h('div', { class: 'deck-image', role: 'img', 'aria-label': String(slide.settings.alt ?? '') || undefined })
@@ -69,10 +74,13 @@ export function renderSlide(slide, context) {
69
74
  }
70
75
  for (const name of names) {
71
76
  const slot = slots.get(name)
72
- 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) : [])
73
78
  element.style.setProperty('grid-area', `s-${name}`)
74
79
  canvas.append(element)
75
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)
76
84
  // Slots a layout doesn't have still show, under the rest, rather than being lost.
77
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)))
78
86
  if (definition.numbered && slide.settings.paginate !== false) section.append(h('span', { class: 'deck-number', 'aria-hidden': 'true' }, [context.number(slide.index + 1)]))
@@ -87,15 +95,50 @@ export function renderSlide(slide, context) {
87
95
  return { element: section, steps: counter.step }
88
96
  }
89
97
 
90
- /** @param {Slot} slot @param {Context} context @param {{ step: number }} counter @returns {Node[]} */
91
- 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') {
92
103
  const tokens = context.lex(slot.text)
93
104
  /** @type {Node[]} */
94
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)
95
120
  let at = slot.start, mode = /** @type {'' | 'click' | 'clicks'} */ ('')
96
- for (const token of tokens) {
121
+ for (const [i, token] of tokens.entries()) {
97
122
  const start = at
98
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
+ }
99
142
  // Pandoc's fences: `::: incremental` shows its list a point a click, as <v-clicks> does; the others, nothing.
100
143
  const fences = token.type === 'paragraph' ? token.raw.trim().split('\n').map((/** @type {string} */ line) => line.trim()) : []
101
144
  if (fences.length && fences.every((/** @type {string} */ line) => /^:{3,}/.test(line))) {
@@ -107,7 +150,7 @@ function blocks(slot, context, counter) {
107
150
  }
108
151
  if (token.type === 'html') {
109
152
  const tag = /^\s*<(\/?)(v-clicks?|br)\b[^>]*>\s*$/i.exec(token.raw)
110
- if (tag && tag[2] === 'br') out.push(h('br'))
153
+ if (tag && tag[2] === 'br') sink.push(h('br'))
111
154
  else if (tag) mode = tag[1] ? '' : /** @type {'click' | 'clicks'} */ (tag[2].toLowerCase() === 'v-clicks' ? 'clicks' : 'click')
112
155
  else if (/<li\b/i.test(token.raw)) {
113
156
  // A list written as HTML, as brains write Slidev's steps (<li v-click>): its items' words, each
@@ -120,30 +163,79 @@ function blocks(slot, context, counter) {
120
163
  if (/\bv-click\b/.test(attrs) || mode === 'clicks') step(li, ++counter.step)
121
164
  list.append(li)
122
165
  }
123
- if (list.childElementCount) out.push(list)
166
+ if (list.childElementCount) sink.push(list)
124
167
  } else {
125
168
  const words = token.raw.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
126
- if (words) out.push(h('p', {}, [words]))
169
+ if (words) sink.push(h('p', {}, [words]))
127
170
  }
128
171
  continue
129
172
  }
130
173
  const element = block(token, start, context, mode === 'clicks' ? counter : null)
131
174
  if (!element) continue
132
175
  if (mode === 'click' || (mode === 'clicks' && token.type !== 'list')) step(element, ++counter.step)
133
- out.push(element)
176
+ sink.push(element)
134
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))
135
181
  return out
136
182
  }
137
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
+
138
230
  /** @param {Element} element @param {number} n */
139
- 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)) }
140
232
 
141
233
  /**
142
234
  * @param {any} token @param {number} start where its raw starts in the source
143
235
  * @param {Context} context @param {{ step: number } | null} stepped list items each a step
144
236
  * @returns {HTMLElement | null}
145
237
  */
146
- function block(token, start, context, stepped) {
238
+ export function block(token, start, context, stepped) {
147
239
  switch (token.type) {
148
240
  case 'heading': {
149
241
  const head = /^\s*#{1,6}[ \t]+/.exec(token.raw)
@@ -185,7 +277,7 @@ function block(token, start, context, stepped) {
185
277
  if (drawer) {
186
278
  const box = h('div', { class: `deck-block deck-${lang}`, 'data-source': token.text })
187
279
  box.append(h('pre', { class: 'deck-block-source', dir: 'ltr' }, [h('code', {}, [token.text])]))
188
- 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')))
189
281
  return box
190
282
  }
191
283
  const marked = lines(info[2] ?? '')
@@ -214,6 +306,17 @@ function children(tokens, start, raw, context) {
214
306
  })
215
307
  }
216
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
+
217
320
  /** Line numbers as Slidev marks them, `{2,4-5}`; `|` steps are read as all their lines at once. @param {string} text */
218
321
  function lines(text) {
219
322
  const marked = new Set()
@@ -225,7 +328,7 @@ function lines(text) {
225
328
  }
226
329
 
227
330
  /** @param {number} from @param {number} to */
228
- const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
331
+ export const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
229
332
 
230
333
  /** @param {any[]} tokens @param {Context} context @returns {Node[]} */
231
334
  export function inline(tokens = [], context) {
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // deck's version, which a linked file names in its renderer's address. Kept equal to package.json's by a test.
2
- export const VERSION = '0.3.0'
2
+ export const VERSION = '0.4.0'