mdeck 3.2.0 → 3.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/CHANGELOG.md +27 -0
- package/assets/base.css +23 -2
- package/assets/extensions/themes/academic/styles.css +33 -5
- package/assets/extensions/themes/aurora/styles.css +32 -6
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/docs/reference/authoring.md +89 -6
- package/docs/site/content/audience.md +41 -4
- package/docs/site/content/commands.md +30 -4
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/more-content.md +22 -0
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/troubleshooting.md +1 -1
- package/docs/site/content/views.md +3 -1
- package/examples/citations/refs.bib +30 -0
- package/examples/citations/refs.yml +21 -0
- package/examples/citations/slides.md +43 -0
- package/examples/citations/slides.pdf +0 -0
- package/package.json +5 -1
- package/skills/write-slides/SKILL.md +7 -3
- package/src/build/bibliography.js +307 -0
- package/src/build/check.js +12 -0
- package/src/build/renderCheck.js +98 -0
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +70 -7
- package/src/build/themeCheck.js +8 -5
- package/src/cli/args.js +22 -5
- package/src/cli/index.js +54 -12
- package/src/components/Bibliography.jsx +23 -0
- package/src/components/Numeric.jsx +59 -0
- package/src/components/Poll.jsx +59 -33
- package/src/components/QrCodeTag.jsx +11 -2
- package/src/components/Question.jsx +10 -6
- package/src/components/Scale.jsx +13 -8
- package/src/components/VideoPlayer.jsx +16 -0
- package/src/components/WordCloud.jsx +10 -6
- package/src/components/activity.jsx +90 -9
- package/src/components/citation.css +28 -0
- package/src/components/inlineText.js +40 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +27 -7
- package/src/core/citations.js +108 -0
- package/src/core/editDeck.js +43 -0
- package/src/core/icons.js +2 -0
- package/src/core/ink.js +16 -0
- package/src/core/labels.js +48 -0
- package/src/core/parseSlides.js +1 -1
- package/src/core/results.js +64 -0
- package/src/core/urls.js +2 -2
- package/src/core/validateDeck.js +7 -1
- package/src/editor/sampleDeck.js +34 -8
- package/src/layouts/layoutApi.jsx +14 -3
- package/src/live/answer/answer.css +20 -0
- package/src/live/answer/answer.js +149 -24
- package/src/live/client.js +75 -7
- package/src/live/roomTag.js +6 -2
- package/src/live/rooms.js +6 -1
- package/src/runtime/ReaderView.jsx +2 -1
- package/src/runtime/citations.js +70 -0
- package/src/runtime/deck-stage.js +5 -2
- package/src/runtime/editorBridge.js +7 -1
- package/src/runtime/follow.js +89 -0
- package/src/runtime/ink/bus.js +7 -6
- package/src/runtime/ink/room.js +8 -0
- package/src/runtime/ink/select.js +4 -2
- package/src/runtime/main.jsx +22 -8
- package/src/runtime/registry.jsx +4 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Numbers typed on a phone for <numeric>. The answer page has its own copy
|
|
2
|
+
// of parseNumber (src/live/answer/answer.js), as it has no build step.
|
|
3
|
+
|
|
4
|
+
/** 0.5, 0,5, −3, 1e-3 or 1/2 as a number; null for anything else. */
|
|
5
|
+
export function parseNumber(text) {
|
|
6
|
+
const plain = String(text ?? '').trim().replace(/\s+/g, '').replace(/−/g, '-').replace(/(\d),(\d)/g, '$1.$2')
|
|
7
|
+
const parts = plain.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(?:\/((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?$/i)
|
|
8
|
+
if (!parts) return null
|
|
9
|
+
const value = parts[2] ? Number(parts[1]) / Number(parts[2]) : Number(parts[1])
|
|
10
|
+
return Number.isFinite(value) ? value : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The answers grouped by value, most frequent first: [{ value, text, n }].
|
|
15
|
+
* `text` is how most people typed it (0.5, or 1/2). Values that differ only
|
|
16
|
+
* in the last digits of a float (1/3 and 0.333333333333) count as one.
|
|
17
|
+
*/
|
|
18
|
+
export function groupNumbers(texts) {
|
|
19
|
+
const groups = new Map()
|
|
20
|
+
for (const text of texts) {
|
|
21
|
+
const value = parseNumber(text)
|
|
22
|
+
if (value == null) continue
|
|
23
|
+
const key = Number(value.toPrecision(12))
|
|
24
|
+
const group = groups.get(key) ?? { value, n: 0, spellings: new Map() }
|
|
25
|
+
const spelling = String(text).trim()
|
|
26
|
+
group.n++
|
|
27
|
+
group.spellings.set(spelling, (group.spellings.get(spelling) ?? 0) + 1)
|
|
28
|
+
groups.set(key, group)
|
|
29
|
+
}
|
|
30
|
+
return [...groups.values()]
|
|
31
|
+
.map(({ value, n, spellings }) => ({ value, n, text: [...spellings].sort((a, b) => b[1] - a[1])[0][0] }))
|
|
32
|
+
.sort((a, b) => b.n - a.n || a.value - b.value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `value` is the answer, give or take `tolerance` (0: equal, as far as floats go). */
|
|
36
|
+
export function isRightNumber(value, answer, tolerance = 0) {
|
|
37
|
+
if (value == null || answer == null) return false
|
|
38
|
+
return Math.abs(value - answer) <= Math.max(tolerance, 1e-9 * Math.max(1, Math.abs(answer)))
|
|
39
|
+
}
|
package/src/components/poll.css
CHANGED
|
@@ -5,23 +5,37 @@
|
|
|
5
5
|
.poll-bars { display: grid; gap: 20px; }
|
|
6
6
|
.poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
|
|
7
7
|
.poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
8
|
+
/* buttons="letters|numbers": the key phones show, before the option. */
|
|
9
|
+
.poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-right: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
|
|
10
|
+
/* A picture as an option: a thumbnail beside its bar. */
|
|
11
|
+
.poll-label:has(img) { white-space: normal; }
|
|
12
|
+
.poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
|
|
13
|
+
.poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
8
14
|
.poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
|
|
9
15
|
.poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
|
|
10
16
|
.poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
11
17
|
.poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
12
18
|
.poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
|
|
13
19
|
.poll-dot.is-live { background: #16a34a; opacity: 1; }
|
|
14
|
-
.poll-
|
|
20
|
+
.poll-spacer { flex: 1; }
|
|
21
|
+
.poll-reset { padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
15
22
|
.poll-reset:disabled { opacity: .4; cursor: default; }
|
|
16
23
|
/* The audience sees the results, not the presenter's controls. */
|
|
17
24
|
deck-stage[data-follower] .poll-reset { display: none; }
|
|
18
25
|
/* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
|
|
19
|
-
|
|
20
|
-
.poll-
|
|
26
|
+
/* The presenter's controls: close (lock), results (eye), right answer (tick). */
|
|
27
|
+
.poll-control { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
|
|
28
|
+
.poll-control:hover, .poll-control:focus-visible { opacity: 1; }
|
|
29
|
+
.poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
|
|
30
|
+
deck-stage[data-follower] .poll-control { display: none; }
|
|
31
|
+
/* Closed: no more answers count. */
|
|
32
|
+
.poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
|
|
33
|
+
/* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
|
|
34
|
+
.poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
|
|
35
|
+
.poll.is-results-hidden .poll-count { opacity: .35; }
|
|
36
|
+
deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
|
|
21
37
|
.poll-solve svg { width: 24px; height: 24px; }
|
|
22
|
-
.poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
|
|
23
38
|
.poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
|
|
24
|
-
deck-stage[data-follower] .poll-solve { display: none; }
|
|
25
39
|
.poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
|
|
26
40
|
.poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
|
|
27
41
|
.poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
|
|
@@ -67,15 +81,21 @@ deck-stage[data-follower] .poll-solve { display: none; }
|
|
|
67
81
|
.poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
|
|
68
82
|
.poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
|
|
69
83
|
.poll-join-slide .poll-link { font-size: 28px; }
|
|
70
|
-
@media print { .poll-reset, .poll-dot, .poll-
|
|
84
|
+
@media print { .poll-reset, .poll-dot, .poll-control { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
|
|
71
85
|
|
|
72
86
|
/* A record of the talk (no room server: a PDF, a file sent to readers): the
|
|
73
87
|
question and its options, without counts, the live dot or a join code. */
|
|
74
88
|
.poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
|
|
89
|
+
.poll.is-static .poll-close { display: none; }
|
|
75
90
|
.poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
|
|
76
91
|
.poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
|
|
77
92
|
|
|
78
93
|
/* On paper the join code and the presenter's controls have no use. */
|
|
79
94
|
@media print {
|
|
80
|
-
.poll-join, .poll-reset, .poll-
|
|
95
|
+
.poll-join, .poll-reset, .poll-dot, .poll-control { display: none !important; }
|
|
81
96
|
}
|
|
97
|
+
|
|
98
|
+
/* <numeric>: the most frequent answers; Other for the rest. */
|
|
99
|
+
.poll-row.is-other .poll-label { color: var(--muted, #666); font-style: italic; }
|
|
100
|
+
.poll--numeric .poll-label { font-variant-numeric: tabular-nums; }
|
|
101
|
+
.poll .numeric-answer { margin: 24px 0 0; font-weight: 600; color: var(--poll-correct, #16a34a); }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { sourceLines, fenceState } from './source.js'
|
|
2
|
+
|
|
3
|
+
// Pandoc's citation syntax, shared by the build (which formats the deck's
|
|
4
|
+
// citations with citeproc) and the runtime (which puts the formatted text in
|
|
5
|
+
// place): `[@smith2020]`, `[see @smith2020, p. 12; @doe2019]`, `[-@smith2020]`
|
|
6
|
+
// and narrative `@smith2020` (`Smith (2020)`), optionally `@smith2020 [p. 12]`.
|
|
7
|
+
// A narrative `@key` counts only when `isKey` knows it, so e-mail addresses and
|
|
8
|
+
// handles stay text. Fenced and inline code are left alone.
|
|
9
|
+
|
|
10
|
+
const KEY = String.raw`[\w][\w:.#$%&+?<>~/-]*`
|
|
11
|
+
// Not a link (`](`, `][ref]`); a footnote may follow (`[@a][^1]`).
|
|
12
|
+
const NOT_LINK = String.raw`(?!\(|\[(?!\^))`
|
|
13
|
+
const BRACKETED = new RegExp(String.raw`\[([^\[\]\n]*?-?@[\w][^\[\]\n]*?)\]${NOT_LINK}`, 'g')
|
|
14
|
+
// The key starts a word: `[info@fhnw.ch]` is an address, not a citation.
|
|
15
|
+
const ITEM = new RegExp(String.raw`^((?:.*?\s)?)(-?)@(${KEY})(.*)$`, 's')
|
|
16
|
+
const NARRATIVE = new RegExp(String.raw`(?<![\w@\[\]\`])@(${KEY})(?:\s\[([^\[\]\n]+)\]${NOT_LINK})?`, 'g')
|
|
17
|
+
|
|
18
|
+
// Locator terms readers write after a comma, mapped to CSL labels.
|
|
19
|
+
const LOCATORS = [
|
|
20
|
+
[/^(pp?\.?|pages?|S\.|Seiten?)\s*/i, 'page'],
|
|
21
|
+
[/^(chaps?\.?|ch\.|chapters?|Kap\.)\s*/i, 'chapter'],
|
|
22
|
+
[/^(secs?\.?|sections?|§§?)\s*/i, 'section'],
|
|
23
|
+
[/^(figs?\.?|figures?|Abb\.)\s*/i, 'figure'],
|
|
24
|
+
[/^(vols?\.?|volumes?|Bd\.)\s*/i, 'volume'],
|
|
25
|
+
[/^(paras?\.?|paragraphs?|¶¶?)\s*/i, 'paragraph'],
|
|
26
|
+
[/^(nos?\.?|numbers?|Nr\.)\s*/i, 'issue'],
|
|
27
|
+
[/^(ll?\.|lines?)\s*/i, 'line'],
|
|
28
|
+
[/^(eqs?\.?|equations?)\s*/i, 'equation'],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
// Keys end before trailing punctuation: `@smith2020.` cites `smith2020`.
|
|
32
|
+
const trimKey = key => key.replace(/[:.#$%&+?<>~/-]+$/, '')
|
|
33
|
+
|
|
34
|
+
// `, p. 12, emphasis added` → locator `12`, label `page`, suffix `, emphasis added`.
|
|
35
|
+
export function parseLocator(text) {
|
|
36
|
+
const rest = text.replace(/^\s*,?\s*/, '')
|
|
37
|
+
if (!rest) return {}
|
|
38
|
+
for (const [pattern, label] of LOCATORS) {
|
|
39
|
+
const term = rest.match(pattern)
|
|
40
|
+
if (!term) continue
|
|
41
|
+
const value = rest.slice(term[0].length).match(/^[^\s,;]+(?:\s*[-–]\s*[^\s,;]+)?/)
|
|
42
|
+
if (value) return withSuffix({ label, locator: value[0] }, rest.slice(term[0].length + value[0].length))
|
|
43
|
+
}
|
|
44
|
+
const number = rest.match(/^\d[\w]*(?:\s*[-–]\s*\w+)?/)
|
|
45
|
+
if (number) return withSuffix({ label: 'page', locator: number[0] }, rest.slice(number[0].length))
|
|
46
|
+
return { suffix: text.trim() }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const withSuffix = (item, suffix) => suffix.trim() ? { ...item, suffix: suffix.trim() } : item
|
|
50
|
+
|
|
51
|
+
function parseItem(part) {
|
|
52
|
+
const match = part.match(ITEM)
|
|
53
|
+
if (!match) return null
|
|
54
|
+
const id = trimKey(match[3])
|
|
55
|
+
const after = match[3].slice(id.length) + match[4]
|
|
56
|
+
const item = { id, ...parseLocator(after) }
|
|
57
|
+
if (match[1].trim()) item.prefix = match[1].trim()
|
|
58
|
+
if (match[2]) item.suppressAuthor = true
|
|
59
|
+
return item
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ranges of `code` and link targets in a line, which hold no citations.
|
|
63
|
+
function codeSpans(text) {
|
|
64
|
+
const spans = []
|
|
65
|
+
const pattern = /(`+)[\s\S]*?\1|\]\([^)\s]*(?:\s[^)]*)?\)/g
|
|
66
|
+
for (let match; (match = pattern.exec(text));) spans.push([match.index, match.index + match[0].length])
|
|
67
|
+
return spans
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Every citation in `markdown`, in order: `{ start, end, raw, items, narrative }`.
|
|
71
|
+
// `raw` is the text as written, the key the build's formatted output is filed under.
|
|
72
|
+
export function scanCitations(markdown, { isKey = () => true } = {}) {
|
|
73
|
+
const found = []
|
|
74
|
+
let fence = null
|
|
75
|
+
for (const line of sourceLines(markdown)) {
|
|
76
|
+
const before = fence
|
|
77
|
+
fence = fenceState(line.text, fence)
|
|
78
|
+
if (before || fence || !line.text.includes('@')) continue
|
|
79
|
+
const code = codeSpans(line.text)
|
|
80
|
+
const taken = []
|
|
81
|
+
const free = (start, end) => ![...code, ...taken].some(([a, b]) => start < b && end > a)
|
|
82
|
+
for (const match of line.text.matchAll(BRACKETED)) {
|
|
83
|
+
const end = match.index + match[0].length
|
|
84
|
+
// `[@key]: …` at the start of a line defines a link reference.
|
|
85
|
+
const definition = line.text[end] === ':' && !line.text.slice(0, match.index).trim()
|
|
86
|
+
if (line.text[match.index - 1] === '!' || definition || !free(match.index, end)) continue
|
|
87
|
+
const items = match[1].split(';').map(parseItem)
|
|
88
|
+
if (items.some(item => !item)) continue
|
|
89
|
+
taken.push([match.index, end])
|
|
90
|
+
found.push({ start: line.start + match.index, end: line.start + end, raw: match[0], items, narrative: false })
|
|
91
|
+
}
|
|
92
|
+
for (const match of line.text.matchAll(NARRATIVE)) {
|
|
93
|
+
const id = trimKey(match[1])
|
|
94
|
+
const keyEnd = match.index + 1 + id.length
|
|
95
|
+
const end = match[2] != null ? match.index + match[0].length : keyEnd
|
|
96
|
+
if (!isKey(id) || !free(match.index, end)) continue
|
|
97
|
+
taken.push([match.index, end])
|
|
98
|
+
const item = { id, ...(match[2] != null ? parseLocator(match[2]) : {}) }
|
|
99
|
+
found.push({ start: line.start + match.index, end: line.start + end, raw: line.text.slice(match.index, end), items: [item], narrative: true })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return found.sort((a, b) => a.start - b.start)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The cited keys, each once, in order of first citation.
|
|
106
|
+
export function citedKeys(clusters) {
|
|
107
|
+
return [...new Set(clusters.flatMap(cluster => cluster.items.map(item => item.id)))]
|
|
108
|
+
}
|
package/src/core/editDeck.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// identical; callers reparse the result.
|
|
5
5
|
import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
|
|
6
6
|
import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
|
|
7
|
+
import { maskCode } from '../live/roomTag.js'
|
|
7
8
|
|
|
8
9
|
const REGION_RE = /^[a-z][a-z0-9-]*$/
|
|
9
10
|
|
|
@@ -228,3 +229,45 @@ export function stripNotes(source) {
|
|
|
228
229
|
.map(block => blockRemoval(deck, block)))
|
|
229
230
|
return edit(deck, edits)
|
|
230
231
|
}
|
|
232
|
+
|
|
233
|
+
// The live activities, and the code to join them (<qrcode join />): a slide
|
|
234
|
+
// with one of them is left out of shared builds. <qrcode follow /> and other
|
|
235
|
+
// QR codes stay.
|
|
236
|
+
const ACTIVITY_RE = /<(poll|question|wordcloud|scale|numeric)\b/i
|
|
237
|
+
const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
|
|
238
|
+
const joins = text => { const m = JOIN_RE.exec(text); return !!m && !/^(false|no|off|0)$/i.test(m[1] ?? m[2] ?? m[3] ?? '') }
|
|
239
|
+
export function hasActivity(slide) {
|
|
240
|
+
return [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
|
|
241
|
+
.some(text => { const masked = maskCode(text); return ACTIVITY_RE.test(masked) || joins(masked) })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
|
|
245
|
+
// <scale>, <numeric>) for shared builds: a reader cannot vote, and the results belong to
|
|
246
|
+
// the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
|
|
247
|
+
// to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
|
|
248
|
+
// that are gone, so saved drawings can follow their slides.
|
|
249
|
+
export function stripActivities(source) {
|
|
250
|
+
const deck = parseSlides(source)
|
|
251
|
+
const dropped = deck.slides.filter(hasActivity)
|
|
252
|
+
const same = () => ({ source, ids: Object.fromEntries(deck.slides.map(slide => [slide.id, slide.id])), removed: [] })
|
|
253
|
+
// A deck of nothing but activities stays as it is rather than becoming empty.
|
|
254
|
+
if (!dropped.length || dropped.length === deck.slides.length) return same()
|
|
255
|
+
const ranges = dropped.map(slide => {
|
|
256
|
+
const bounds = slideBounds(deck, slide.id)
|
|
257
|
+
const next = deck.slides[deck.slides.indexOf(slide) + 1]
|
|
258
|
+
return bounds.delimiterStart != null
|
|
259
|
+
? { start: bounds.delimiterStart, end: bounds.end }
|
|
260
|
+
: { start: bounds.start, end: next ? next.source.start : deck.source.length }
|
|
261
|
+
}).sort((a, b) => a.start - b.start)
|
|
262
|
+
// Neighbouring slides can share a delimiter: join ranges that touch.
|
|
263
|
+
const merged = []
|
|
264
|
+
for (const range of ranges) {
|
|
265
|
+
const last = merged.at(-1)
|
|
266
|
+
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
|
|
267
|
+
else merged.push({ ...range })
|
|
268
|
+
}
|
|
269
|
+
const next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
|
|
270
|
+
const after = parseSlides(next).slides
|
|
271
|
+
const kept = deck.slides.filter(slide => !dropped.includes(slide))
|
|
272
|
+
return { source: next, ids: Object.fromEntries(kept.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
|
|
273
|
+
}
|
package/src/core/icons.js
CHANGED
|
@@ -41,6 +41,8 @@ export const ICONS = {
|
|
|
41
41
|
trash: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', // trash-2
|
|
42
42
|
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', // eye
|
|
43
43
|
'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', // eye-off
|
|
44
|
+
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', // lock
|
|
45
|
+
'lock-open': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>', // lock-open
|
|
44
46
|
finger: '<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', // pointer
|
|
45
47
|
pen: '<path d="M13 21h8"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>', // pen-line
|
|
46
48
|
highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
|
package/src/core/ink.js
CHANGED
|
@@ -188,6 +188,22 @@ export const inkPaint = color => PEN_PAINT[color] ?? color
|
|
|
188
188
|
/** Strokes in drawing order: highlighter below the pen, as on paper. */
|
|
189
189
|
export const inkOrder = strokes => [...strokes.filter(s => s.tool === 'highlighter'), ...strokes.filter(s => s.tool !== 'highlighter')]
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The end of a straight line from `from` to `to`, turned onto the nearest
|
|
193
|
+
* multiple of `step` degrees when it lies within `tolerance` of it (0°, 15°,
|
|
194
|
+
* 30° … by default); its length stays. Further off, the end stays as drawn.
|
|
195
|
+
*/
|
|
196
|
+
export function snapAngle([x0, y0], [x, y], { step = 15, tolerance = 4 } = {}) {
|
|
197
|
+
const dx = x - x0, dy = y - y0
|
|
198
|
+
const length = Math.hypot(dx, dy)
|
|
199
|
+
if (!length) return [x, y]
|
|
200
|
+
const angle = Math.atan2(dy, dx) * 180 / Math.PI
|
|
201
|
+
const snapped = Math.round(angle / step) * step
|
|
202
|
+
if (Math.abs(angle - snapped) > tolerance) return [x, y]
|
|
203
|
+
const rad = snapped * Math.PI / 180
|
|
204
|
+
return [Math.round((x0 + Math.cos(rad) * length) * 10) / 10, Math.round((y0 + Math.sin(rad) * length) * 10) / 10]
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
/** The stroke moved by dx, dy design pixels; the same id, so it replaces the original. */
|
|
192
208
|
export const translateStroke = (stroke, dx, dy) => ({ ...stroke, points: stroke.points.map(([x, y, p]) => [x + dx, y + dy, p]) })
|
|
193
209
|
|
package/src/core/labels.js
CHANGED
|
@@ -35,6 +35,8 @@ export const LABELS = {
|
|
|
35
35
|
'poll.reset': 'Reset',
|
|
36
36
|
'poll.showAnswer': 'Show the right answer',
|
|
37
37
|
'poll.hideAnswer': 'Hide the right answer',
|
|
38
|
+
'poll.showResults': 'Show the results',
|
|
39
|
+
'poll.hideResults': 'Hide the results',
|
|
38
40
|
'poll.live': 'Live',
|
|
39
41
|
'poll.offline': 'Not connected to the server',
|
|
40
42
|
'poll.unreachable': 'Phones cannot reach this computer. Start with {command}, or set {setting}.',
|
|
@@ -46,7 +48,29 @@ export const LABELS = {
|
|
|
46
48
|
'respond.send': 'Send',
|
|
47
49
|
'respond.sent': 'Sent. Thank you!',
|
|
48
50
|
'join.scan': 'Scan to join',
|
|
51
|
+
'follow.scan': 'Scan to follow the slides',
|
|
52
|
+
'follow.open': 'Follow the slides',
|
|
53
|
+
'follow.live': 'Live',
|
|
54
|
+
'follow.back': 'Back to live',
|
|
55
|
+
'follow.waiting': 'The talk has not started yet.',
|
|
56
|
+
'follow.tryHere': 'Try following here',
|
|
49
57
|
'join.static': 'During the talk, phones joined here to answer.',
|
|
58
|
+
'poll.answeredOf': '{n} of {of} answered',
|
|
59
|
+
'poll.close': 'Close: later answers do not count',
|
|
60
|
+
'poll.open': 'Open again',
|
|
61
|
+
'poll.closed': 'Closed',
|
|
62
|
+
'poll.several': 'Several answers possible.',
|
|
63
|
+
'poll.pickSeveral': 'Tap all that apply.',
|
|
64
|
+
'poll.thanksSeveral': 'Thanks! You chose {choice}. Tap to change.',
|
|
65
|
+
'respond.closed': 'Closed: no more answers.',
|
|
66
|
+
'respond.right': 'Right!',
|
|
67
|
+
'respond.wrong': 'Not quite. The right answer: {answer}',
|
|
68
|
+
'respond.solution': 'The right answer: {answer}',
|
|
69
|
+
'numeric.placeholder': 'A number',
|
|
70
|
+
'numeric.invalid': 'Enter a number, such as 0.5 or 1/2.',
|
|
71
|
+
'numeric.sent': 'Sent: {value}. Send another to change.',
|
|
72
|
+
'numeric.other': 'Other',
|
|
73
|
+
'numeric.right': '{n} right',
|
|
50
74
|
'question.empty': 'Answers appear here.',
|
|
51
75
|
'scale.average': 'Average {n}',
|
|
52
76
|
},
|
|
@@ -80,6 +104,8 @@ export const LABELS = {
|
|
|
80
104
|
'poll.reset': 'Zurücksetzen',
|
|
81
105
|
'poll.showAnswer': 'Richtige Antwort zeigen',
|
|
82
106
|
'poll.hideAnswer': 'Richtige Antwort ausblenden',
|
|
107
|
+
'poll.showResults': 'Ergebnis zeigen',
|
|
108
|
+
'poll.hideResults': 'Ergebnis ausblenden',
|
|
83
109
|
'poll.live': 'Live',
|
|
84
110
|
'poll.offline': 'Keine Verbindung zum Server',
|
|
85
111
|
'poll.unreachable': 'Handys erreichen diesen Computer nicht. Mit {command} starten oder {setting} setzen.',
|
|
@@ -91,7 +117,29 @@ export const LABELS = {
|
|
|
91
117
|
'respond.send': 'Senden',
|
|
92
118
|
'respond.sent': 'Gesendet. Danke!',
|
|
93
119
|
'join.scan': 'Scannen und mitmachen',
|
|
120
|
+
'follow.scan': 'Scannen und Folien mitverfolgen',
|
|
121
|
+
'follow.open': 'Folien mitverfolgen',
|
|
122
|
+
'follow.live': 'Live',
|
|
123
|
+
'follow.back': 'Zur aktuellen Folie',
|
|
124
|
+
'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
|
|
125
|
+
'follow.tryHere': 'Mitverfolgen hier ausprobieren',
|
|
94
126
|
'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
|
|
127
|
+
'poll.answeredOf': '{n} von {of} haben geantwortet',
|
|
128
|
+
'poll.close': 'Schliessen: spätere Antworten zählen nicht',
|
|
129
|
+
'poll.open': 'Wieder öffnen',
|
|
130
|
+
'poll.closed': 'Geschlossen',
|
|
131
|
+
'poll.several': 'Mehrere Antworten möglich.',
|
|
132
|
+
'poll.pickSeveral': 'Alle zutreffenden antippen.',
|
|
133
|
+
'poll.thanksSeveral': 'Danke! Gewählt: {choice}. Zum Ändern antippen.',
|
|
134
|
+
'respond.closed': 'Geschlossen: keine weiteren Antworten.',
|
|
135
|
+
'respond.right': 'Richtig!',
|
|
136
|
+
'respond.wrong': 'Leider nicht. Richtig ist: {answer}',
|
|
137
|
+
'respond.solution': 'Richtig ist: {answer}',
|
|
138
|
+
'numeric.placeholder': 'Eine Zahl',
|
|
139
|
+
'numeric.invalid': 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.',
|
|
140
|
+
'numeric.sent': 'Gesendet: {value}. Zum Ändern eine andere senden.',
|
|
141
|
+
'numeric.other': 'Andere',
|
|
142
|
+
'numeric.right': '{n} richtig',
|
|
95
143
|
'question.empty': 'Hier erscheinen die Antworten.',
|
|
96
144
|
'scale.average': 'Durchschnitt {n}',
|
|
97
145
|
},
|
package/src/core/parseSlides.js
CHANGED
|
@@ -2,7 +2,7 @@ import yaml from 'js-yaml'
|
|
|
2
2
|
import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
|
|
3
3
|
export { sourceLines, fenceState } from './source.js'
|
|
4
4
|
|
|
5
|
-
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session'.split(' '))
|
|
5
|
+
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite'.split(' '))
|
|
6
6
|
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.split(' '))
|
|
7
7
|
|
|
8
8
|
// Names from before 2.0. They are still recognised, so that an old deck gets a
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The answers given during a talk, kept beside the deck as
|
|
2
|
+
// <deck>.results.json, so they outlast the room server (which forgets them)
|
|
3
|
+
// and show in builds, PDFs and files sent to readers.
|
|
4
|
+
//
|
|
5
|
+
// { "version": 1, "rooms": { "<room>": { "savedAt": "…", "closed": false,
|
|
6
|
+
// "answers": [{ "phone": 1, "at": "…", "value": … }] } } }
|
|
7
|
+
//
|
|
8
|
+
// Phones are numbered per room in the order they first answered; their own
|
|
9
|
+
// ids never leave the room server. `answers` are those that counted: once an
|
|
10
|
+
// activity was closed, the ones before.
|
|
11
|
+
|
|
12
|
+
export const resultsFileFor = deckPath => String(deckPath).replace(/\.md$/i, '') + '.results.json'
|
|
13
|
+
|
|
14
|
+
const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
15
|
+
|
|
16
|
+
/** One room's answers as kept: room messages ({ n, at, from, data }) with numbered phones. */
|
|
17
|
+
export function keptAnswers(messages) {
|
|
18
|
+
const phones = new Map()
|
|
19
|
+
return messages.map(message => {
|
|
20
|
+
const from = message.from ?? `#${message.n}`
|
|
21
|
+
if (!phones.has(from)) phones.set(from, phones.size + 1)
|
|
22
|
+
return { phone: phones.get(from), at: message.at ?? null, value: message.data?.value ?? null }
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Kept answers as room messages again, for the slides that show them. */
|
|
27
|
+
export const answersAsMessages = answers => answers.map((answer, i) => ({ n: i + 1, at: answer.at, from: `phone-${answer.phone}`, data: { value: answer.value } }))
|
|
28
|
+
|
|
29
|
+
/** A results file as read, with anything unexpected left out. */
|
|
30
|
+
export function normalizeResults(data) {
|
|
31
|
+
const rooms = {}
|
|
32
|
+
for (const [room, entry] of Object.entries(data?.rooms ?? {})) {
|
|
33
|
+
if (!ROOM_RE.test(room) || !Array.isArray(entry?.answers)) continue
|
|
34
|
+
rooms[room] = {
|
|
35
|
+
savedAt: typeof entry.savedAt === 'string' ? entry.savedAt : null,
|
|
36
|
+
closed: !!entry.closed,
|
|
37
|
+
answers: entry.answers.filter(answer => answer && Number.isInteger(answer.phone)).map(({ phone, at, value }) => ({ phone, at: typeof at === 'string' ? at : null, value: value ?? null })),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { version: 1, rooms }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One room's answers in, or (none) out; returns the new results. */
|
|
44
|
+
export function withRoom(results, room, { answers, closed = false, savedAt = new Date().toISOString() }) {
|
|
45
|
+
if (!ROOM_RE.test(room)) throw Object.assign(new Error('Room names use letters, digits, dots, hyphens and underscores'), { status: 400 })
|
|
46
|
+
const rooms = { ...results.rooms }
|
|
47
|
+
if (Array.isArray(answers) && answers.length) rooms[room] = { savedAt, closed: !!closed, answers }
|
|
48
|
+
else delete rooms[room]
|
|
49
|
+
return normalizeResults({ rooms })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const serializeResults = results => JSON.stringify(results, null, 2) + '\n'
|
|
53
|
+
|
|
54
|
+
const csvCell = value => {
|
|
55
|
+
const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
|
|
56
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** All answers as CSV: room, phone, time, answer (several picks joined by |). */
|
|
60
|
+
export function resultsCsv(results) {
|
|
61
|
+
const rows = [['room', 'phone', 'at', 'answer']]
|
|
62
|
+
for (const [room, entry] of Object.entries(results.rooms)) for (const answer of entry.answers) rows.push([room, answer.phone, answer.at, answer.value])
|
|
63
|
+
return rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
|
|
64
|
+
}
|
package/src/core/urls.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience'])
|
|
1
|
+
const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience', 'follow'])
|
|
2
2
|
const OLD_VIEWS = { d: 'deck', s: 'reader', p: 'presenter', a: 'audience', share: 'reader' }
|
|
3
3
|
|
|
4
4
|
// Old URLs must be corrected explicitly: guessing a view can expose notes or
|
|
@@ -21,7 +21,7 @@ export function validatePageUrl(url) {
|
|
|
21
21
|
for (const secret of ['serverkey', 'livekey', 'pair']) corrected.searchParams.delete(secret)
|
|
22
22
|
if (replacements.length) return { message: `This URL uses settings from before API 2.0: ${replacements.join(', ')}. Open the updated address below. If you supplied a server key, add it again as ?serverkey=… on your own computer.`, corrected: corrected.href }
|
|
23
23
|
const view = url.searchParams.get('view')
|
|
24
|
-
if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter or
|
|
24
|
+
if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter, audience or follow.` }
|
|
25
25
|
return null
|
|
26
26
|
}
|
|
27
27
|
|
package/src/core/validateDeck.js
CHANGED
|
@@ -38,14 +38,20 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
38
38
|
else if (typeof value !== 'string') add('invalid-config', `labels.${key} must be text`, deck.configSource?.start)
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
// References: bibliography.js reads the files and reports what it cannot use.
|
|
42
|
+
if (config.bibliography != null && ![config.bibliography].flat().every(file => typeof file === 'string' && file.trim())) add('invalid-config', 'bibliography must be a file name such as refs.bib, or a list of them', deck.configSource?.start)
|
|
43
|
+
if (config.csl != null && (typeof config.csl !== 'string' || !config.csl.trim())) add('invalid-config', 'csl must be a style name (apa, vancouver, harvard1) or a .csl file', deck.configSource?.start)
|
|
44
|
+
if (config.nocite != null && (!Array.isArray(config.nocite) || config.nocite.some(key => typeof key !== 'string'))) add('invalid-config', "nocite must be a list of reference keys, or ['*'] for all", deck.configSource?.start)
|
|
41
45
|
if (config.server != null && (typeof config.server !== 'string' || !/^https?:\/\//.test(config.server))) add('invalid-config', 'server must be an http:// or https:// address', deck.configSource?.start)
|
|
42
46
|
if (isPlainObject(config.session)) {
|
|
43
47
|
for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
|
|
44
48
|
if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
|
|
45
49
|
}
|
|
46
50
|
const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
|
|
51
|
+
const switches = ['citations']
|
|
47
52
|
if (isPlainObject(config.show)) {
|
|
48
|
-
for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key)) add('invalid-config', `show.${key} is not a setting; available: ${Object.keys(enums).join(', ')}`, deck.configSource?.start, 'warning')
|
|
53
|
+
for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key) && !switches.includes(key)) add('invalid-config', `show.${key} is not a setting; available: ${[...Object.keys(enums), ...switches].join(', ')}`, deck.configSource?.start, 'warning')
|
|
54
|
+
for (const key of switches) if (config.show[key] != null && typeof config.show[key] !== 'boolean') add('invalid-config', `show.${key} must be true or false`, deck.configSource?.start)
|
|
49
55
|
for (const [key, values] of Object.entries(enums)) {
|
|
50
56
|
if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
|
|
51
57
|
}
|
package/src/editor/sampleDeck.js
CHANGED
|
@@ -6,6 +6,26 @@
|
|
|
6
6
|
// over hills under a sun, for every layout that shows a picture.
|
|
7
7
|
export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
|
|
8
8
|
|
|
9
|
+
// The references the sample deck cites. Builds find them in a file beside the
|
|
10
|
+
// deck; the dev and edit servers read them from here where there is none.
|
|
11
|
+
export const SAMPLE_REFERENCES_FILE = 'sample-references.bib'
|
|
12
|
+
export const SAMPLE_REFERENCES = `@article{doe2024,
|
|
13
|
+
author = {Doe, Jane and Roe, Richard},
|
|
14
|
+
title = {Measuring how slides are read},
|
|
15
|
+
journal = {Journal of Sample Studies},
|
|
16
|
+
year = {2024},
|
|
17
|
+
volume = {12},
|
|
18
|
+
pages = {1--10}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@book{roe2023,
|
|
22
|
+
author = {Roe, Richard},
|
|
23
|
+
title = {Quotations and Their Sources},
|
|
24
|
+
publisher = {Example Press},
|
|
25
|
+
year = {2023}
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
|
|
9
29
|
// `picture` is where the slides find the picture: a file beside the deck, or
|
|
10
30
|
// a data URL where there is no such file.
|
|
11
31
|
export function sampleDeck(picture = './picture.svg') {
|
|
@@ -15,6 +35,7 @@ meta:
|
|
|
15
35
|
author: "A. Author"
|
|
16
36
|
organization: "Institute"
|
|
17
37
|
date: "2026-10-06"
|
|
38
|
+
bibliography: ${SAMPLE_REFERENCES_FILE}
|
|
18
39
|
---
|
|
19
40
|
|
|
20
41
|
---
|
|
@@ -51,10 +72,10 @@ alt: "Shapes"
|
|
|
51
72
|
---
|
|
52
73
|
# Lists, emphasis and code
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
1. A step with *emphasis* and **strong** words
|
|
76
|
+
2. A step with \`inline code\` and a [link](https://example.com)
|
|
77
|
+
- A nested point
|
|
78
|
+
- Another nested point
|
|
58
79
|
|
|
59
80
|
\`\`\`js
|
|
60
81
|
const total = values.reduce((sum, value) => sum + value, 0)
|
|
@@ -69,7 +90,7 @@ const total = values.reduce((sum, value) => sum + value, 0)
|
|
|
69
90
|
| Treatment | 5.1 | 0.6 |
|
|
70
91
|
| Follow-up | 5.0 | 0.7 |
|
|
71
92
|
|
|
72
|
-
The difference is $\\Delta = 0.9
|
|
93
|
+
The difference is $\\Delta = 0.9$ [@doe2024, p. 4], with a footnote.[^1]
|
|
73
94
|
|
|
74
95
|
[^1]: A source for the claim.
|
|
75
96
|
|
|
@@ -115,9 +136,9 @@ $$
|
|
|
115
136
|
|
|
116
137
|
+++
|
|
117
138
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
> A quotation set apart from the text.
|
|
140
|
+
|
|
141
|
+
As @roe2023 notes, a quote needs its source.
|
|
121
142
|
:::
|
|
122
143
|
|
|
123
144
|
---
|
|
@@ -158,6 +179,11 @@ alt: "Shapes"
|
|
|
158
179
|
overlay: true
|
|
159
180
|
---
|
|
160
181
|
# A picture that fills the slide.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
# References
|
|
185
|
+
|
|
186
|
+
<bibliography />
|
|
161
187
|
`.replaceAll('./picture.svg', picture)
|
|
162
188
|
}
|
|
163
189
|
|
|
@@ -5,6 +5,7 @@ import { marked, Parser } from 'marked'
|
|
|
5
5
|
import { registry } from '../runtime/registry'
|
|
6
6
|
import { InkLayer } from '../runtime/ink/InkLayer.jsx'
|
|
7
7
|
import themedSvgs from 'virtual:deck-svgs'
|
|
8
|
+
import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
|
|
8
9
|
|
|
9
10
|
// ─── Content extraction ────────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -170,6 +171,12 @@ function SlideFootnotes({ html }) {
|
|
|
170
171
|
return <div class="slide-footnotes" dangerouslySetInnerHTML={{ __html: html }} />
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// The full references of the works this slide cites (show.citations: false hides them).
|
|
175
|
+
function SlideCitations({ html }) {
|
|
176
|
+
if (!html) return null
|
|
177
|
+
return <div class="slide-citations" dangerouslySetInnerHTML={{ __html: html }} />
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
function slideNum(index) {
|
|
174
181
|
return String(index + 1).padStart(2, '0')
|
|
175
182
|
}
|
|
@@ -181,10 +188,13 @@ export function prepareSlide(slide) {
|
|
|
181
188
|
.sort((a, b) => position(a[1]) - position(b[1]))
|
|
182
189
|
let separator = '\u0000mdeck-region\u0000'
|
|
183
190
|
while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
|
|
184
|
-
const
|
|
191
|
+
const bib = bibliographyOf(slide.deckConfig)
|
|
192
|
+
const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
|
|
193
|
+
const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
|
|
185
194
|
const pieces = processed.split(separator)
|
|
186
195
|
const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
|
|
187
|
-
|
|
196
|
+
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
|
|
197
|
+
return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
export function MarkdownRegion({ region, content, class: className, ...attributes }) {
|
|
@@ -194,7 +204,7 @@ export function MarkdownRegion({ region, content, class: className, ...attribute
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
// Layouts supply body content; every layout receives the same outer frame.
|
|
197
|
-
export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
|
|
207
|
+
export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, citationsHtml, children }) {
|
|
198
208
|
const layout = manifest.id === 'generic' ? meta.layout : manifest.id
|
|
199
209
|
const frame = manifest.frame ?? 'standard'
|
|
200
210
|
const title = frame === 'title'
|
|
@@ -207,6 +217,7 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
|
|
|
207
217
|
isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
|
|
208
218
|
{children}
|
|
209
219
|
<SlideFootnotes html={footnotesHtml} />
|
|
220
|
+
<SlideCitations html={citationsHtml} />
|
|
210
221
|
{frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
|
|
211
222
|
<InkLayer slideId={id} />
|
|
212
223
|
</section>
|