mdeck 3.1.0 → 3.3.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 +16 -0
- package/assets/base.css +24 -0
- package/assets/extensions/themes/academic/styles.css +15 -0
- package/docs/reference/authoring.md +75 -2
- package/docs/reference/extensions.md +3 -1
- package/docs/site/content/audience.md +6 -0
- package/docs/site/content/commands.md +28 -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 +6 -2
- package/src/build/bibliography.js +289 -0
- package/src/build/check.js +12 -0
- package/src/build/renderCheck.js +98 -0
- package/src/build/slidesPlugin.js +47 -7
- package/src/build/themeCheck.js +18 -6
- package/src/cli/args.js +20 -4
- package/src/cli/index.js +39 -10
- package/src/cli/themes.js +9 -3
- package/src/components/Bibliography.jsx +23 -0
- package/src/components/Poll.jsx +25 -2
- package/src/components/QrCodeTag.jsx +11 -2
- package/src/components/VideoPlayer.jsx +16 -0
- package/src/components/activity.jsx +2 -2
- package/src/components/citation.css +28 -0
- package/src/components/poll.css +12 -2
- package/src/core/citations.js +108 -0
- package/src/core/editDeck.js +43 -0
- package/src/core/ink.js +16 -0
- package/src/core/labels.js +16 -0
- package/src/core/parseSlides.js +1 -1
- package/src/core/urls.js +2 -2
- package/src/core/validateDeck.js +7 -1
- package/src/layouts/layoutApi.jsx +13 -3
- package/src/live/answer/answer.css +1 -0
- package/src/live/answer/answer.js +2 -0
- package/src/live/client.js +25 -2
- package/src/runtime/ReaderView.jsx +2 -1
- package/src/runtime/citations.js +60 -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 -0
- package/src/runtime/ink/room.js +8 -0
- package/src/runtime/ink/select.js +4 -2
- package/src/runtime/main.jsx +19 -7
- package/src/runtime/markedSetup.js +7 -0
- package/src/runtime/registry.jsx +2 -0
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,6 +48,12 @@ 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.',
|
|
50
58
|
'question.empty': 'Answers appear here.',
|
|
51
59
|
'scale.average': 'Average {n}',
|
|
@@ -80,6 +88,8 @@ export const LABELS = {
|
|
|
80
88
|
'poll.reset': 'Zurücksetzen',
|
|
81
89
|
'poll.showAnswer': 'Richtige Antwort zeigen',
|
|
82
90
|
'poll.hideAnswer': 'Richtige Antwort ausblenden',
|
|
91
|
+
'poll.showResults': 'Ergebnis zeigen',
|
|
92
|
+
'poll.hideResults': 'Ergebnis ausblenden',
|
|
83
93
|
'poll.live': 'Live',
|
|
84
94
|
'poll.offline': 'Keine Verbindung zum Server',
|
|
85
95
|
'poll.unreachable': 'Handys erreichen diesen Computer nicht. Mit {command} starten oder {setting} setzen.',
|
|
@@ -91,6 +101,12 @@ export const LABELS = {
|
|
|
91
101
|
'respond.send': 'Senden',
|
|
92
102
|
'respond.sent': 'Gesendet. Danke!',
|
|
93
103
|
'join.scan': 'Scannen und mitmachen',
|
|
104
|
+
'follow.scan': 'Scannen und Folien mitverfolgen',
|
|
105
|
+
'follow.open': 'Folien mitverfolgen',
|
|
106
|
+
'follow.live': 'Live',
|
|
107
|
+
'follow.back': 'Zur aktuellen Folie',
|
|
108
|
+
'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
|
|
109
|
+
'follow.tryHere': 'Mitverfolgen hier ausprobieren',
|
|
94
110
|
'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
|
|
95
111
|
'question.empty': 'Hier erscheinen die Antworten.',
|
|
96
112
|
'scale.average': 'Durchschnitt {n}',
|
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
|
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
|
}
|
|
@@ -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 } 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,12 @@ 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 cited = renderCitations(entries.map(([, r]) => r.content).join(separator))
|
|
192
|
+
const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
|
|
185
193
|
const pieces = processed.split(separator)
|
|
186
194
|
const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
|
|
187
|
-
|
|
195
|
+
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids)
|
|
196
|
+
return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
|
|
188
197
|
}
|
|
189
198
|
|
|
190
199
|
export function MarkdownRegion({ region, content, class: className, ...attributes }) {
|
|
@@ -194,7 +203,7 @@ export function MarkdownRegion({ region, content, class: className, ...attribute
|
|
|
194
203
|
}
|
|
195
204
|
|
|
196
205
|
// Layouts supply body content; every layout receives the same outer frame.
|
|
197
|
-
export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
|
|
206
|
+
export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, citationsHtml, children }) {
|
|
198
207
|
const layout = manifest.id === 'generic' ? meta.layout : manifest.id
|
|
199
208
|
const frame = manifest.frame ?? 'standard'
|
|
200
209
|
const title = frame === 'title'
|
|
@@ -207,6 +216,7 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
|
|
|
207
216
|
isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
|
|
208
217
|
{children}
|
|
209
218
|
<SlideFootnotes html={footnotesHtml} />
|
|
219
|
+
<SlideCitations html={citationsHtml} />
|
|
210
220
|
{frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
|
|
211
221
|
<InkLayer slideId={id} />
|
|
212
222
|
</section>
|
|
@@ -32,3 +32,4 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
|
|
|
32
32
|
.answer-text button[type=submit] { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|
|
33
33
|
.answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
|
|
34
34
|
footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
|
|
35
|
+
.answer-follow { display: block; margin: 0 20px 24px; padding: 12px 16px; text-align: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; }
|
|
@@ -109,6 +109,8 @@
|
|
|
109
109
|
? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
|
|
110
110
|
: el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
|
|
111
111
|
current?.title ? el('footer', {}, current.title) : null,
|
|
112
|
+
// The presenter's screen sends the link when other devices can follow the slides.
|
|
113
|
+
/^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
|
|
112
114
|
)
|
|
113
115
|
}
|
|
114
116
|
|
package/src/live/client.js
CHANGED
|
@@ -114,6 +114,20 @@ function joinUrl(info) {
|
|
|
114
114
|
return info.network ? new URL(`__mdeck/live/${settings.code}`, info.network).href : null
|
|
115
115
|
}
|
|
116
116
|
const localJoinUrl = () => `${serverBase()}/${settings.code}`
|
|
117
|
+
|
|
118
|
+
// The link for following the slides on another device (?view=follow): this
|
|
119
|
+
// deck's page, where other devices reach both the page and the stage room
|
|
120
|
+
// that carries the presenter's position. That is `mdeck run --network`'s
|
|
121
|
+
// network address, or a deck hosted with a `server` setting, whose stage room
|
|
122
|
+
// is on that server. null when only this computer could follow; localFollowUrl
|
|
123
|
+
// then opens the follow view here, for trying it out.
|
|
124
|
+
const relayed = () => /^\/t\/[^/]+\/$/.test(devPath(''))
|
|
125
|
+
function followUrl(info) {
|
|
126
|
+
if (relayed()) return null
|
|
127
|
+
if (import.meta.env?.DEV || proxyControls) return info.network ? new URL(`${devPath('')}?view=follow`, info.network).href : null
|
|
128
|
+
return settings.server ? `${window.location.origin}${window.location.pathname}?view=follow` : null
|
|
129
|
+
}
|
|
130
|
+
const localFollowUrl = () => `${window.location.origin}${devPath('')}?view=follow`
|
|
117
131
|
// Until the server has answered or failed, `known` is false and activities
|
|
118
132
|
// show no join code, so a quick capture never shows a passing warning.
|
|
119
133
|
const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
|
|
@@ -164,6 +178,13 @@ export function useJoinLink() {
|
|
|
164
178
|
return { joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
|
|
165
179
|
}
|
|
166
180
|
|
|
181
|
+
/** The link for following the slides, for a slide that shows it (<qrcode follow />). */
|
|
182
|
+
export function useFollowLink() {
|
|
183
|
+
const [info, setInfo] = useState({})
|
|
184
|
+
useEffect(() => { serverInfo().then(setInfo) }, [])
|
|
185
|
+
return { joinUrl: followUrl(info), localJoinUrl: localFollowUrl(), code: settings.code, ...reach(info) }
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
/** The latest answer of each device, e.g. to count votes that can be changed. */
|
|
168
189
|
export function latestByDevice(messages) {
|
|
169
190
|
const latest = new Map()
|
|
@@ -197,7 +218,7 @@ export function captureLook(doc = lookSource()) {
|
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
// The words the answer page shows, in the deck's language.
|
|
200
|
-
const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent') })
|
|
221
|
+
const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open') })
|
|
201
222
|
|
|
202
223
|
// The presenter's screen announces the activity on the current slide and
|
|
203
224
|
// repeats it every few seconds, so phones that join late and a server
|
|
@@ -236,7 +257,9 @@ async function sendCurrent() {
|
|
|
236
257
|
if (!info.canReset) return report({ ok: false, reason: presenterKey() ? 'wrong-code' : 'no-code' })
|
|
237
258
|
const look = captureLook()
|
|
238
259
|
if (look) lastLook = look
|
|
239
|
-
|
|
260
|
+
// Phones that answer can also follow the slides, where that works.
|
|
261
|
+
const follow = followUrl(info)
|
|
262
|
+
const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...(follow ? { follow } : {}) }
|
|
240
263
|
try {
|
|
241
264
|
const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
|
|
242
265
|
const body = response.ok ? await response.json().catch(() => ({})) : {}
|
|
@@ -2,6 +2,7 @@ import { readerLink } from '../core/urls.js'
|
|
|
2
2
|
import { h } from 'preact'
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
4
4
|
import { marked } from 'marked'
|
|
5
|
+
import { renderCitations } from './citations.js'
|
|
5
6
|
import { deckOutline } from '../core/outline.js'
|
|
6
7
|
import { manifests, SlideRenderer } from '../layouts/renderSlide'
|
|
7
8
|
import { loadTheme, THEME_METAS } from './themeLoader'
|
|
@@ -49,7 +50,7 @@ function ReadPage({ slide, index, total, deckConfig, width, height, scale, showN
|
|
|
49
50
|
<SlideRenderer id={slide.id} regions={slide.regions} meta={slide.meta} content={slide.content} deckConfig={deckConfig} index={index} total={total} />
|
|
50
51
|
</div>
|
|
51
52
|
</div>
|
|
52
|
-
{showNotes && notes && <div class="reader-page-notes" dangerouslySetInnerHTML={{ __html: marked.parse(notes) }} />}
|
|
53
|
+
{showNotes && notes && <div class="reader-page-notes" dangerouslySetInnerHTML={{ __html: marked.parse(renderCitations(notes).markdown) }} />}
|
|
53
54
|
</article>
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import initial from 'virtual:bibliography'
|
|
2
|
+
import { scanCitations } from '../core/citations.js'
|
|
3
|
+
import '../components/citation.css'
|
|
4
|
+
|
|
5
|
+
// The deck's formatted references (built by src/build/bibliography.js), or
|
|
6
|
+
// null without a `bibliography`. The editor receives new ones as the deck or
|
|
7
|
+
// its reference file changes.
|
|
8
|
+
let bibliography = initial
|
|
9
|
+
const listeners = new Set()
|
|
10
|
+
|
|
11
|
+
export const currentBibliography = () => bibliography
|
|
12
|
+
export function onBibliography(listener) {
|
|
13
|
+
listeners.add(listener)
|
|
14
|
+
return () => listeners.delete(listener)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (import.meta.hot) {
|
|
18
|
+
import.meta.hot.accept('virtual:bibliography', next => {
|
|
19
|
+
if (!next) return
|
|
20
|
+
bibliography = next.default
|
|
21
|
+
for (const listener of listeners) listener(bibliography)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const escape = text => String(text).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
|
26
|
+
|
|
27
|
+
// A citation the build has not formatted: one typed in the editor since the
|
|
28
|
+
// last save is put together from its works; an unknown key is marked.
|
|
29
|
+
function fallback(cluster, bib) {
|
|
30
|
+
return cluster.items.map(({ id }) => bib?.singles[id]
|
|
31
|
+
?? `<span class="citation-key${bib?.ids.includes(id) ? '' : ' citation-key--missing'}" title="${escape(bib ? `@${id} is not in the bibliography` : 'This deck has no bibliography')}">@${escape(id)}${bib?.ids.includes(id) ? '' : '?'}</span>`).join('; ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Puts each citation's formatted text in place; returns the keys the text
|
|
35
|
+
// cites, in order, for the slide's reference footer.
|
|
36
|
+
export function renderCitations(markdown, bib = bibliography) {
|
|
37
|
+
if (!markdown || !markdown.includes('@')) return { markdown, ids: [] }
|
|
38
|
+
const known = new Set(bib?.ids ?? [])
|
|
39
|
+
const clusters = scanCitations(markdown, { isKey: id => known.has(id) })
|
|
40
|
+
if (!clusters.length) return { markdown, ids: [] }
|
|
41
|
+
let out = ''
|
|
42
|
+
let at = 0
|
|
43
|
+
const ids = []
|
|
44
|
+
for (const cluster of clusters) {
|
|
45
|
+
const formatted = bib?.clusters[cluster.raw]
|
|
46
|
+
const unknown = cluster.items.filter(item => !known.has(item.id))
|
|
47
|
+
const html = formatted == null ? fallback(cluster, bib) : unknown.length ? `${formatted}; ${fallback({ items: unknown }, bib)}` : formatted
|
|
48
|
+
const keys = cluster.items.map(item => item.id)
|
|
49
|
+
out += markdown.slice(at, cluster.start) + `<cite class="citation" data-cites="${escape(keys.join(' '))}">${html}</cite>`
|
|
50
|
+
at = cluster.end
|
|
51
|
+
for (const id of keys) if (bib?.entries[id] && !ids.includes(id)) ids.push(id)
|
|
52
|
+
}
|
|
53
|
+
return { markdown: out + markdown.slice(at), ids }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The full references of `ids`, for a slide's footer.
|
|
57
|
+
export function citationsHtml(ids, bib = bibliography) {
|
|
58
|
+
const entries = ids.map(id => bib?.entries[id]).filter(Boolean)
|
|
59
|
+
return entries.length ? `<div class="csl-bib-body">${entries.join('')}</div>` : ''
|
|
60
|
+
}
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
66
|
import { iconSvg } from '../core/icons.js'
|
|
67
|
-
import { inkPaint } from '../core/ink.js'
|
|
67
|
+
import { inkPaint, snapAngle } from '../core/ink.js'
|
|
68
68
|
|
|
69
69
|
(() => {
|
|
70
70
|
const DESIGN_W_DEFAULT = 1920;
|
|
@@ -1025,8 +1025,11 @@ import { inkPaint } from '../core/ink.js'
|
|
|
1025
1025
|
this._drawStraight(drawing, drawing.points[drawing.points.length - 1]);
|
|
1026
1026
|
}
|
|
1027
1027
|
|
|
1028
|
-
|
|
1028
|
+
// The line's end snaps to steps of 15° (horizontal, vertical, 45° …)
|
|
1029
|
+
// when it is close to one, see snapAngle in core/ink.js.
|
|
1030
|
+
_drawStraight(drawing, end) {
|
|
1029
1031
|
const [x0, y0] = drawing.points[0];
|
|
1032
|
+
const [x, y] = snapAngle([x0, y0], end);
|
|
1030
1033
|
drawing.points = [[x0, y0, drawing.straight], [x, y, drawing.straight]];
|
|
1031
1034
|
drawing.restart = true;
|
|
1032
1035
|
this._drawLive(drawing.path, drawing.points, drawing.tool);
|
|
@@ -38,7 +38,9 @@ export function createEditorBridge({ parse, validate = () => [], loadTheme, setE
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Renders are serialized; while one is in flight only the newest request waits.
|
|
41
|
+
let latest = null
|
|
41
42
|
function render(source, options = {}) {
|
|
43
|
+
latest = { source, options }
|
|
42
44
|
if (running) { pending = { source, options }; return running }
|
|
43
45
|
running = run(source, options).finally(() => {
|
|
44
46
|
running = null
|
|
@@ -54,5 +56,9 @@ export function createEditorBridge({ parse, validate = () => [], loadTheme, setE
|
|
|
54
56
|
return true
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// The newest request again, with its preview settings, e.g. after the
|
|
60
|
+
// references were reformatted.
|
|
61
|
+
const refresh = () => latest ? render(latest.source, latest.options) : null
|
|
62
|
+
|
|
63
|
+
return { render, refresh, handleMessage, current: () => current }
|
|
58
64
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The follow view (?view=follow): a phone or laptop in the room shows the
|
|
2
|
+
// presenter's slide and ink, read from the stage room, and sends nothing.
|
|
3
|
+
// A follower may page back on their own and return with "Back to live";
|
|
4
|
+
// slides and steps the presenter has not shown yet stay hidden.
|
|
5
|
+
|
|
6
|
+
/** Where a follower may be: no further than the furthest the presenter has shown. */
|
|
7
|
+
export function clampToShown({ index, step }, furthest) {
|
|
8
|
+
if (!furthest) return { index: 0, step: -1 }
|
|
9
|
+
if (index > furthest.index) return { index: furthest.index, step: furthest.step }
|
|
10
|
+
if (index === furthest.index && step > furthest.step) return { index, step: furthest.step }
|
|
11
|
+
return { index, step }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The furthest of two positions, slide first, then step.
|
|
15
|
+
const further = (a, b) => !a ? b : (b.index > a.index || (b.index === a.index && b.step > a.step)) ? b : a
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The follower's state: the presenter's position, the furthest shown, and
|
|
19
|
+
* whether this device is with the presenter (`live`).
|
|
20
|
+
* presenter(position) the presenter moved: returns where to go, or null
|
|
21
|
+
* moved(position) the follower moved: returns where to go back to if
|
|
22
|
+
* they went past what was shown, else null
|
|
23
|
+
* backToLive() returns the presenter's position
|
|
24
|
+
*/
|
|
25
|
+
export function createFollower() {
|
|
26
|
+
const state = { presenter: null, furthest: null, live: true }
|
|
27
|
+
const same = (a, b) => !!a && !!b && a.index === b.index && a.step === b.step
|
|
28
|
+
return {
|
|
29
|
+
state,
|
|
30
|
+
presenter(position) {
|
|
31
|
+
state.presenter = position
|
|
32
|
+
state.furthest = further(state.furthest, position)
|
|
33
|
+
return state.live ? position : null
|
|
34
|
+
},
|
|
35
|
+
moved(position) {
|
|
36
|
+
const allowed = clampToShown(position, state.furthest)
|
|
37
|
+
state.live = state.presenter ? same(allowed, state.presenter) : true
|
|
38
|
+
return same(allowed, position) ? null : allowed
|
|
39
|
+
},
|
|
40
|
+
backToLive() {
|
|
41
|
+
state.live = true
|
|
42
|
+
return state.presenter
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BAR_CSS = `
|
|
48
|
+
.follow-bar { position: fixed; left: 50%; top: max(14px, env(safe-area-inset-top)); transform: translateX(-50%); z-index: 2147483001;
|
|
49
|
+
display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border: 1px solid #3a3a3a; border-radius: 999px;
|
|
50
|
+
background: rgba(22, 22, 22, 0.92); color: #e6e6e6; font: 14px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif; white-space: nowrap; }
|
|
51
|
+
button.follow-bar { cursor: pointer; }
|
|
52
|
+
.follow-dot { width: 8px; height: 8px; border-radius: 50%; background: #e11d48; box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.25); }
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Follow the presenter on `stage`. `onStagePosition` hears the positions the
|
|
57
|
+
* stage room carries; it must be called before the room is listened to.
|
|
58
|
+
* `words` are the labels: live, back, waiting.
|
|
59
|
+
*/
|
|
60
|
+
export function startFollowing(stage, { onStagePosition, words }) {
|
|
61
|
+
const follower = createFollower()
|
|
62
|
+
const style = document.createElement('style')
|
|
63
|
+
style.textContent = BAR_CSS
|
|
64
|
+
document.head.appendChild(style)
|
|
65
|
+
let bar = null
|
|
66
|
+
|
|
67
|
+
function update() {
|
|
68
|
+
const { presenter, live } = follower.state
|
|
69
|
+
const next = !presenter ? document.createElement('div') : live ? document.createElement('div') : document.createElement('button')
|
|
70
|
+
next.className = 'follow-bar'
|
|
71
|
+
if (!presenter) next.textContent = words.waiting
|
|
72
|
+
else if (live) { const dot = document.createElement('span'); dot.className = 'follow-dot'; next.append(dot, words.live) }
|
|
73
|
+
else { next.textContent = words.back; next.addEventListener('click', () => { go(follower.backToLive()); update() }) }
|
|
74
|
+
bar ? bar.replaceWith(next) : document.body.appendChild(next)
|
|
75
|
+
bar = next
|
|
76
|
+
}
|
|
77
|
+
// Positions set from here arrive back as 'sync' and are not the follower's own.
|
|
78
|
+
const go = position => { if (position) stage.setState({ index: position.index, step: position.step }) }
|
|
79
|
+
|
|
80
|
+
onStagePosition(position => { go(follower.presenter(position)); update() })
|
|
81
|
+
stage.addEventListener('statechange', event => {
|
|
82
|
+
if (['init', 'sync'].includes(event.detail.reason)) return
|
|
83
|
+
go(follower.moved({ index: event.detail.index, step: event.detail.step }))
|
|
84
|
+
update()
|
|
85
|
+
})
|
|
86
|
+
// Until the presenter's position arrives, the title slide.
|
|
87
|
+
go(follower.moved(stage.state))
|
|
88
|
+
update()
|
|
89
|
+
}
|
package/src/runtime/ink/bus.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// { type: 'zoom', key, slideId, scale, x, y } the part of the slide this
|
|
14
14
|
// window shows (stage zoom), at most every ~50 ms
|
|
15
15
|
// { type: 'reveal', key, room, shown } a poll's right answer shown or hidden
|
|
16
|
+
// { type: 'results', key, room, shown } a poll's results shown or hidden
|
|
16
17
|
// { type: 'op', op } a change of the saved ink (applyOp)
|
|
17
18
|
// Keys start with the sending window's id, so strokes never mix.
|
|
18
19
|
import { changeInk, renameSlide } from './store.js'
|
|
@@ -51,6 +52,8 @@ export function createInkBus(stage, transports) {
|
|
|
51
52
|
stage.liveStroke(message.key, known)
|
|
52
53
|
} else if (message.type === 'reveal') {
|
|
53
54
|
stage.dispatchEvent(new CustomEvent('pollreveal', { detail: { room: message.room, shown: !!message.shown } }))
|
|
55
|
+
} else if (message.type === 'results') {
|
|
56
|
+
stage.dispatchEvent(new CustomEvent('pollresults', { detail: { room: message.room, shown: !!message.shown } }))
|
|
54
57
|
} else if (message.type === 'zoom') {
|
|
55
58
|
if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
|
|
56
59
|
} else if (message.type === 'dot') {
|
|
@@ -103,6 +106,10 @@ export function createInkBus(stage, transports) {
|
|
|
103
106
|
stage.addEventListener('pollreveal', ({ detail }) => {
|
|
104
107
|
if (detail?.local) send({ type: 'reveal', key: `${me}:reveal`, room: detail.room, shown: !!detail.shown })
|
|
105
108
|
})
|
|
109
|
+
// A poll's results shown or hidden here: the others follow.
|
|
110
|
+
stage.addEventListener('pollresults', ({ detail }) => {
|
|
111
|
+
if (detail?.local) send({ type: 'results', key: `${me}:results`, room: detail.room, shown: !!detail.shown })
|
|
112
|
+
})
|
|
106
113
|
|
|
107
114
|
// The zoom: the latest view, not every step of a pinch.
|
|
108
115
|
let zoom = null, zoomTimer = null
|
package/src/runtime/ink/room.js
CHANGED
|
@@ -77,6 +77,14 @@ export function followStageRoom(stage) {
|
|
|
77
77
|
})
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** Hear the position other devices announce (the follow view decides what to do with it). */
|
|
81
|
+
export function onStagePosition(listener) {
|
|
82
|
+
stateListeners.add(state => {
|
|
83
|
+
if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
|
|
84
|
+
listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
/** Publish navigation made here; received positions never echo back. */
|
|
81
89
|
export function announceStagePosition(stage) {
|
|
82
90
|
const room = stageRoom()
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that the pen moves one at a time. Changes show at once in this window and
|
|
8
8
|
// become one change (undo puts the strokes back) when the pen lifts.
|
|
9
9
|
import { slideStrokes, changeInk, onInkChange } from './store.js'
|
|
10
|
-
import { strokesInLasso, strokesBox, translateStroke, hitTest } from '../../core/ink.js'
|
|
10
|
+
import { strokesInLasso, strokesBox, translateStroke, hitTest, snapAngle } from '../../core/ink.js'
|
|
11
11
|
|
|
12
12
|
const PAD = 14 // design pixels around a selection
|
|
13
13
|
const TAP = 10 // a lasso smaller than this is a tap
|
|
@@ -119,6 +119,8 @@ export function createInkSelection(stage, controller) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// The stroke with one end point moved to `point` (its pressure kept).
|
|
122
|
-
|
|
122
|
+
// The other end stays put, and the moved one snaps to steps of 15° around it.
|
|
123
|
+
function moveEnd(stroke, index, point) {
|
|
124
|
+
const [x, y] = snapAngle(stroke.points[1 - index], point)
|
|
123
125
|
return { ...stroke, points: stroke.points.map((p, i) => i === index ? [x, y, p[2]] : p) }
|
|
124
126
|
}
|
package/src/runtime/main.jsx
CHANGED
|
@@ -2,6 +2,7 @@ import { validatePageUrl } from '../core/urls.js'
|
|
|
2
2
|
import { h, render } from 'preact'
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
4
4
|
import { marked } from 'marked'
|
|
5
|
+
import { renderCitations, onBibliography } from './citations.js'
|
|
5
6
|
import deckFileContent from 'virtual:slides'
|
|
6
7
|
import { parseSlides } from '../core/parseSlides'
|
|
7
8
|
import { validateDeck } from '../core/validateDeck'
|
|
@@ -18,7 +19,8 @@ import { followActiveRooms } from '../live/follow.js'
|
|
|
18
19
|
import { createOrder } from './syncOrder.js'
|
|
19
20
|
import { fitDeck } from './fit.js'
|
|
20
21
|
import { attachInk, watchInk } from './ink/attach.js'
|
|
21
|
-
import { roomTransport, followStageRoom, announceStagePosition } from './ink/room.js'
|
|
22
|
+
import { roomTransport, followStageRoom, announceStagePosition, onStagePosition } from './ink/room.js'
|
|
23
|
+
import { startFollowing } from './follow.js'
|
|
22
24
|
import { claimPairing } from '../live/pairing.js'
|
|
23
25
|
import { strokePath } from '../core/ink.js'
|
|
24
26
|
import { inkFileName } from 'virtual:deck-ink'
|
|
@@ -33,7 +35,7 @@ import './deck-stage.js'
|
|
|
33
35
|
|
|
34
36
|
const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
|
|
35
37
|
|
|
36
|
-
// One address parameter selects the view: ?view=deck, reader, presenter or
|
|
38
|
+
// One address parameter selects the view: ?view=deck, reader, presenter, audience or follow.
|
|
37
39
|
export function requestedView(url, fallback = 'deck') {
|
|
38
40
|
return url.searchParams.get('view') ?? fallback
|
|
39
41
|
}
|
|
@@ -486,7 +488,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
486
488
|
}}
|
|
487
489
|
dangerouslySetInnerHTML={{
|
|
488
490
|
__html: note
|
|
489
|
-
? marked.parse(note)
|
|
491
|
+
? marked.parse(renderCitations(note).markdown)
|
|
490
492
|
: '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
|
|
491
493
|
}}
|
|
492
494
|
/>
|
|
@@ -694,11 +696,13 @@ async function init() {
|
|
|
694
696
|
const { slides } = parsed
|
|
695
697
|
const presenterMode = view === 'presenter'
|
|
696
698
|
const audienceMode = view === 'audience'
|
|
699
|
+
// A phone or laptop in the room following the talk: shows, never sends.
|
|
700
|
+
const followMode = view === 'follow'
|
|
697
701
|
const embedded = url.searchParams.get('embedded') === '1'
|
|
698
702
|
const readerMode = view === 'reader' && !editorMode && !embedded
|
|
699
703
|
// The deck window and the presenter's main frame draw; the audience window
|
|
700
704
|
// and the next-slide preview show what is drawn.
|
|
701
|
-
const drawHere = !audienceMode && (!embedded || url.searchParams.get('draw') === '1')
|
|
705
|
+
const drawHere = !audienceMode && !followMode && (!embedded || url.searchParams.get('draw') === '1')
|
|
702
706
|
const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
|
|
703
707
|
|
|
704
708
|
injectSpeakerNotes(slides)
|
|
@@ -748,6 +752,8 @@ async function init() {
|
|
|
748
752
|
if (event.source !== window.parent || event.origin !== window.location.origin) return
|
|
749
753
|
if (!bridge.handleMessage(event.data)) handleDeckControl(event.data?.deckControl)
|
|
750
754
|
})
|
|
755
|
+
// Saving the deck or its reference file reformats the citations.
|
|
756
|
+
onBibliography(() => bridge.refresh())
|
|
751
757
|
await bridge.render(slidesContent)
|
|
752
758
|
post({ deckEditorReady: true })
|
|
753
759
|
return
|
|
@@ -762,7 +768,7 @@ async function init() {
|
|
|
762
768
|
|
|
763
769
|
await loadTheme(deckConfig)
|
|
764
770
|
mountDeck({ deck: parsed, deckConfig })
|
|
765
|
-
if (!embedded && !audienceMode) {
|
|
771
|
+
if (!embedded && !audienceMode && !followMode) {
|
|
766
772
|
// A quiet way into the reader view for anyone who opened the file directly.
|
|
767
773
|
const entry = document.createElement('a')
|
|
768
774
|
entry.className = 'reader-entry'
|
|
@@ -801,14 +807,20 @@ async function init() {
|
|
|
801
807
|
}
|
|
802
808
|
window.addEventListener('pointerdown', onPen, true)
|
|
803
809
|
}
|
|
810
|
+
} else if (inkStage && followMode) {
|
|
811
|
+
// The presenter's slide and ink, read from the stage room; counted as a phone.
|
|
812
|
+
inkStage.setAttribute('data-follower', '')
|
|
813
|
+
startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
|
|
814
|
+
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
|
|
804
815
|
} else if (inkStage && audienceMode) {
|
|
805
816
|
// The audience window shows the presenter's zoom and a poll's answer, without their controls.
|
|
806
817
|
inkStage.setAttribute('data-follower', '')
|
|
807
818
|
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'audience' })] })
|
|
808
819
|
} else if (inkStage) watchInk(inkStage, { storageKey: inkStorageKey })
|
|
809
820
|
|
|
810
|
-
// A full deck or audience window is a presenter's screen; previews are
|
|
811
|
-
|
|
821
|
+
// A full deck or audience window is a presenter's screen; previews are
|
|
822
|
+
// embedded, and a follower only watches.
|
|
823
|
+
if (!embedded && !followMode) {
|
|
812
824
|
const stage = document.querySelector('deck-stage')
|
|
813
825
|
stage?.addEventListener('slidechange', event => announceSlide(parsed, event.detail.index, { initial: event.detail.reason === 'init' }))
|
|
814
826
|
if (stage) announceSlide(parsed, stage.index, { initial: true })
|
|
@@ -143,5 +143,12 @@ marked.use({
|
|
|
143
143
|
.replace(/>/g, '>')
|
|
144
144
|
return `<codeblock lang="${lang}"${attrs ? ' ' + attrs : ''}>${encoded}</codeblock>`
|
|
145
145
|
},
|
|
146
|
+
// A paragraph of nothing but pictures (one, or several side by side) is
|
|
147
|
+
// marked, so themes can fit it into the slide or set it as a row; a
|
|
148
|
+
// sentence with an icon in it stays a sentence.
|
|
149
|
+
paragraph({ tokens }) {
|
|
150
|
+
const pictures = tokens.some(t => t.type === 'image') && tokens.every(t => t.type === 'image' || t.type === 'br' || (t.type === 'text' && !t.raw.trim()))
|
|
151
|
+
return `<p${pictures ? ' class="pictures"' : ''}>${this.parser.parseInline(tokens)}</p>\n`
|
|
152
|
+
},
|
|
146
153
|
},
|
|
147
154
|
})
|
package/src/runtime/registry.jsx
CHANGED
|
@@ -5,6 +5,7 @@ import Poll from '../components/Poll.jsx'
|
|
|
5
5
|
import Question from '../components/Question.jsx'
|
|
6
6
|
import WordCloud from '../components/WordCloud.jsx'
|
|
7
7
|
import Scale from '../components/Scale.jsx'
|
|
8
|
+
import Bibliography from '../components/Bibliography.jsx'
|
|
8
9
|
import deckComponents from 'virtual:deck-components'
|
|
9
10
|
|
|
10
11
|
// Maps lowercase HTML tag names to Preact components.
|
|
@@ -22,5 +23,6 @@ export const registry = {
|
|
|
22
23
|
question: Question,
|
|
23
24
|
wordcloud: WordCloud,
|
|
24
25
|
scale: Scale,
|
|
26
|
+
bibliography: Bibliography,
|
|
25
27
|
...deckComponents,
|
|
26
28
|
}
|