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/cli/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { loadRegistry, manifestsOf, serializeRegistry } from '../extensions/disc
|
|
|
12
12
|
import { BACKUP_DIR } from '../build/editorPlugin.js'
|
|
13
13
|
import { createEditorServer, createDesignServer } from '../build/editorServer.js'
|
|
14
14
|
import { homePlugin } from '../build/homePlugin.js'
|
|
15
|
-
import { checkDeck } from '../build/check.js'
|
|
15
|
+
import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
|
|
16
16
|
import { livePlugin, startLiveServer } from '../live/server.js'
|
|
17
17
|
import { inkPlugin } from '../build/inkPlugin.js'
|
|
18
18
|
import { createPairing, pairingPlugin } from '../build/pairing.js'
|
|
@@ -24,8 +24,8 @@ import { ManifestError, KINDS } from '../extensions/manifest.js'
|
|
|
24
24
|
|
|
25
25
|
import { frameworkRoot } from '../paths.js'
|
|
26
26
|
import { parseSlides } from '../core/parseSlides.js'
|
|
27
|
-
import { stripNotes as stripNotesFrom } from '../core/editDeck.js'
|
|
28
|
-
import { parseArgs, validateServerOrigin } from './args.js'
|
|
27
|
+
import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
|
|
28
|
+
import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
|
|
29
29
|
import { assertDrawings } from '../build/drawings.js'
|
|
30
30
|
import { createRelayAccess } from '../build/relayAccess.js'
|
|
31
31
|
import { sessionCode } from '../live/code.js'
|
|
@@ -178,12 +178,13 @@ async function runNewWizard() {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false } = {}) {
|
|
181
|
+
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
|
|
182
182
|
const absSlides = resolve(slidesPath)
|
|
183
183
|
const deckDir = dirname(absSlides)
|
|
184
184
|
const absOutDir = resolve(outDir)
|
|
185
185
|
const raw = readFileSync(absSlides, 'utf-8')
|
|
186
|
-
|
|
186
|
+
let markdown = stripNotes ? stripNotesFrom(raw) : raw
|
|
187
|
+
if (stripActivities) markdown = stripActivitiesFrom(markdown).source
|
|
187
188
|
|
|
188
189
|
for (const ref of collectLocalAssetRefs(markdown)) {
|
|
189
190
|
const source = resolve(deckDir, ref)
|
|
@@ -320,12 +321,13 @@ const HELP = `
|
|
|
320
321
|
|
|
321
322
|
${c.dim}Make something to hand out${c.reset}
|
|
322
323
|
${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
|
|
323
|
-
--reader reader view, notes removed unless --notes
|
|
324
|
+
--reader reader view, notes removed unless --notes, polls kept as a record unless --no-polls
|
|
324
325
|
--single-file everything inlined into one file instead
|
|
325
326
|
--launchers add macOS/Linux and Windows launchers (folders only)
|
|
326
327
|
${c.green}mdeck send${c.reset} [slides.md] [-o talk.html] One file to send: the reader view, speaker notes removed,
|
|
327
328
|
with a PDF inside
|
|
328
329
|
--notes keep the speaker notes in the file
|
|
330
|
+
--no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
|
|
329
331
|
--no-pdf do not render the PDF inside
|
|
330
332
|
${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
|
|
331
333
|
--no-drawings for build, send and pdf: leave out <slides>.drawings.json
|
|
@@ -334,6 +336,11 @@ const HELP = `
|
|
|
334
336
|
${c.dim}Check and look things up${c.reset}
|
|
335
337
|
${c.green}mdeck migrate${c.reset} [slides.md] [--dry-run] Update settings, local layouts and drawings to API 2.0
|
|
336
338
|
${c.green}mdeck check${c.reset} [slides.md] [--strict] Validate source and local assets
|
|
339
|
+
--render also show every slide in Chrome, all steps revealed: content that
|
|
340
|
+
does not fit, text cut off or hard to read, errors on the page
|
|
341
|
+
${c.green}mdeck snapshot${c.reset} [slides.md] Pictures of slides as PNG files (default: .mdeck-snapshots/)
|
|
342
|
+
--slide <numbers> only these slides: 3, 3,5 or 2-4
|
|
343
|
+
--dark | --light in this appearance instead of the deck's own -o <folder>
|
|
337
344
|
${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
|
|
338
345
|
List built-in and deck-local layouts, themes and palettes
|
|
339
346
|
${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
|
|
@@ -463,11 +470,29 @@ if (command === 'new') {
|
|
|
463
470
|
const input = requireInput('check')
|
|
464
471
|
const registry = registryFor(input)
|
|
465
472
|
const { diagnostics } = checkDeck(input, registry)
|
|
473
|
+
diagnostics.push(...await bibliographyDiagnostics(input))
|
|
474
|
+
if (hasFlag('--render') && !diagnostics.some(d => d.severity === 'error')) {
|
|
475
|
+
tip('Building the deck and looking at every slide in Chrome…')
|
|
476
|
+
try {
|
|
477
|
+
const { renderCheck } = await import('../build/renderCheck.js')
|
|
478
|
+
diagnostics.push(...await renderCheck(input))
|
|
479
|
+
} catch (error) { diagnostics.push({ severity: 'error', code: 'render', message: error.message, line: 1 }) }
|
|
480
|
+
}
|
|
466
481
|
if (diagnostics.length) console.log(formatDiagnostics(diagnostics, input))
|
|
467
482
|
const warned = diagnostics.length || registry.warnings.length
|
|
468
483
|
if (diagnostics.some(d => d.severity === 'error') || (warned && hasFlag('--strict'))) process.exitCode = 1
|
|
469
484
|
else ok(`Checked ${input}${warned ? ' (with warnings)' : ''}`)
|
|
470
485
|
|
|
486
|
+
} else if (command === 'snapshot') {
|
|
487
|
+
const input = requireInput('snapshot')
|
|
488
|
+
try {
|
|
489
|
+
const slides = parseSlideNumbers(parsed.options['--slide'] ?? '')
|
|
490
|
+
const outDir = resolve(outputOption() ?? resolve(dirname(resolve(input)), '.mdeck-snapshots'))
|
|
491
|
+
const appearance = hasFlag('--dark') ? 'dark' : hasFlag('--light') ? 'light' : ''
|
|
492
|
+
const { snapshot } = await import('../build/renderCheck.js')
|
|
493
|
+
for (const file of await snapshot(input, { slides, outDir, appearance })) console.log(relative(process.cwd(), file))
|
|
494
|
+
} catch (error) { err(error.message); process.exitCode = 1 }
|
|
495
|
+
|
|
471
496
|
} else if (command === 'list') {
|
|
472
497
|
const KIND_WORDS = { layouts: 'layout', themes: 'theme', palettes: 'palette' }
|
|
473
498
|
const [first, ...others] = positionals()
|
|
@@ -578,7 +603,8 @@ if (command === 'new') {
|
|
|
578
603
|
ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
|
|
579
604
|
tip(`Presenter: ${new URL('?view=presenter', local).href}`)
|
|
580
605
|
tip(`Audience: ${new URL('?view=audience', local).href}`)
|
|
581
|
-
|
|
606
|
+
// Phones in the room follow the talk (the reader view would show slides ahead).
|
|
607
|
+
if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
|
|
582
608
|
console.log()
|
|
583
609
|
if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
|
|
584
610
|
ok(`Watching ${c.cyan}${abs}${c.reset}`)
|
|
@@ -645,6 +671,8 @@ if (command === 'new') {
|
|
|
645
671
|
const launchers = !sending && hasFlag('--launchers')
|
|
646
672
|
const reader = sending || hasFlag('--reader')
|
|
647
673
|
const stripNotes = reader && !hasFlag('--notes')
|
|
674
|
+
// Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
|
|
675
|
+
const stripActivities = reader && hasFlag('--no-polls')
|
|
648
676
|
const wantPdf = sending && !hasFlag('--no-pdf')
|
|
649
677
|
|
|
650
678
|
if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
|
|
@@ -659,7 +687,7 @@ if (command === 'new') {
|
|
|
659
687
|
try {
|
|
660
688
|
await build({
|
|
661
689
|
...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
|
|
662
|
-
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
|
|
690
|
+
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
|
|
663
691
|
build: {
|
|
664
692
|
outDir,
|
|
665
693
|
emptyOutDir: !tempDir,
|
|
@@ -677,7 +705,7 @@ if (command === 'new') {
|
|
|
677
705
|
await mkdir(finalOutDir, { recursive: true })
|
|
678
706
|
|
|
679
707
|
if (!selfContained) {
|
|
680
|
-
await copyLocalAssets(input, finalOutDir, { stripNotes })
|
|
708
|
+
await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
|
|
681
709
|
if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
|
|
682
710
|
}
|
|
683
711
|
|
|
@@ -701,6 +729,7 @@ if (command === 'new') {
|
|
|
701
729
|
} finally { await rm(pdfDir, { recursive: true, force: true }) }
|
|
702
730
|
}
|
|
703
731
|
if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
|
|
732
|
+
if (stripActivities && stripActivitiesFrom(readFileSync(resolve(input), 'utf-8')).removed.length) tip('Slides with polls were left out (--no-polls).')
|
|
704
733
|
ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
|
|
705
734
|
|
|
706
735
|
// ── skill ─────────────────────────────────────────────────────────────────────
|
|
@@ -730,7 +759,7 @@ if (command === 'new') {
|
|
|
730
759
|
try {
|
|
731
760
|
await build({
|
|
732
761
|
...baseConfig(input, { selfContained: true }),
|
|
733
|
-
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
|
|
762
|
+
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
|
|
734
763
|
build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
|
|
735
764
|
logLevel: 'warn',
|
|
736
765
|
})
|
package/src/cli/themes.js
CHANGED
|
@@ -25,11 +25,14 @@ const usage = kind => `search [words] | install <${kind}> | remove <${kind}> | u
|
|
|
25
25
|
function looksOf(registry, catalogue) {
|
|
26
26
|
const builtIn = new Set([...catalogue.themes, ...catalogue.palettes].filter(entry => entry.builtIn).map(entry => entry.id + (catalogue.themes.includes(entry) ? ':t' : ':p')))
|
|
27
27
|
const palettes = manifestsOf(registry, 'palette')
|
|
28
|
+
// Each theme's icon for the gallery's list (see renderPreviews).
|
|
29
|
+
const icons = new Map(catalogue.themes.map(entry => [entry.id, entry.icon]))
|
|
28
30
|
const colours = tokens => Object.fromEntries(['--bg', '--surface', '--ink', '--accent', '--accent-2'].map(key => [key, tokens?.[key] ?? null]))
|
|
29
31
|
return {
|
|
30
32
|
mdeck: VERSION,
|
|
31
33
|
themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', builtIn: builtIn.has(theme.id + ':t'),
|
|
32
|
-
palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id)
|
|
34
|
+
palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id),
|
|
35
|
+
...(icons.get(theme.id) ? { icon: icons.get(theme.id) } : {}) })),
|
|
33
36
|
palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', builtIn: builtIn.has(palette.id + ':p'),
|
|
34
37
|
...(palette.theme ? { theme: palette.theme } : {}), light: colours(palette.light), dark: colours(palette.dark) })),
|
|
35
38
|
}
|
|
@@ -148,8 +151,11 @@ export async function runPackages({ kind, positionals, flag, output, ui: { ok, e
|
|
|
148
151
|
ok(`Checked ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes, light and dark, on every kind of slide`)
|
|
149
152
|
}
|
|
150
153
|
// Themes on the title slide, palettes on the chapter slide (the third).
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
// Themes also get an icon of their title slide for lists; a palette's
|
|
155
|
+
// colours are its icon.
|
|
156
|
+
await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.key}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1,
|
|
157
|
+
...(look.palette ? {} : { icon: resolve(out, 'previews', `${look.key}-icon.webp`) }) })), { extensions })
|
|
158
|
+
for (const entry of catalogue.themes) Object.assign(entry, { preview: `previews/themes/${entry.id}.webp`, icon: `previews/themes/${entry.id}-icon.webp` })
|
|
153
159
|
for (const entry of catalogue.palettes) entry.preview = `previews/palettes/${entry.id}.webp`
|
|
154
160
|
writeFileSync(resolve(out, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
|
|
155
161
|
// For the repository's theme browser: the sample deck with every theme
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
2
|
+
import { currentBibliography, onBibliography } from '../runtime/citations.js'
|
|
3
|
+
|
|
4
|
+
// <bibliography /> on a slide: the deck's reference list, the works it cites
|
|
5
|
+
// plus `nocite`, in the citation style's order. A long list goes over several
|
|
6
|
+
// slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
|
|
7
|
+
|
|
8
|
+
function slice(order, part) {
|
|
9
|
+
const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
|
|
10
|
+
if (!match) return order
|
|
11
|
+
const [index, count] = [Number(match[1]), Number(match[2])]
|
|
12
|
+
if (!count || index < 1 || index > count) return order
|
|
13
|
+
const size = Math.ceil(order.length / count)
|
|
14
|
+
return order.slice((index - 1) * size, index * size)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Bibliography({ part }) {
|
|
18
|
+
const [bib, setBib] = useState(currentBibliography)
|
|
19
|
+
useEffect(() => onBibliography(setBib), [])
|
|
20
|
+
if (!bib) return <p class="bibliography-hint">Name a reference file in the deck settings, e.g. <code>bibliography: refs.bib</code>.</p>
|
|
21
|
+
const html = slice(bib.order, part).map(id => bib.entries[id]).join('')
|
|
22
|
+
return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
|
|
23
|
+
}
|
package/src/components/Poll.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'
|
|
|
3
3
|
import { useRoom, latestByDevice } from '../live/client.js'
|
|
4
4
|
import { t } from '../core/labels.js'
|
|
5
5
|
import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
|
|
6
|
+
import { Icon } from './Icon.jsx'
|
|
6
7
|
import './poll.css'
|
|
7
8
|
|
|
8
9
|
// <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
|
|
@@ -16,13 +17,20 @@ import './poll.css'
|
|
|
16
17
|
// with a tick outlines its label, bar and count in green. Showing it is an
|
|
17
18
|
// event on the stage (`pollreveal`, { room, shown }), which the ink bus
|
|
18
19
|
// passes to the audience window and other devices.
|
|
20
|
+
//
|
|
21
|
+
// A button with an eye shows or hides the results (bars and counts), the
|
|
22
|
+
// same way (`pollresults`, { room, shown }). `results="hidden"` starts with
|
|
23
|
+
// them hidden, e.g. for peer instruction. Hidden, the audience window shows no
|
|
24
|
+
// bars; the presenter's own screens show them faint, so the presenter can
|
|
25
|
+
// still judge the votes.
|
|
19
26
|
|
|
20
27
|
const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
|
|
21
28
|
|
|
22
|
-
export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '' }) {
|
|
29
|
+
export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '' }) {
|
|
23
30
|
const choices = choicesOf(options)
|
|
24
31
|
const correct = choicesOf(answer).filter(choice => choices.includes(choice))
|
|
25
32
|
const [shown, setShown] = useState(false)
|
|
33
|
+
const [resultsShown, setResultsShown] = useState(results !== 'hidden')
|
|
26
34
|
const ref = useRef()
|
|
27
35
|
const stage = () => ref.current?.closest('deck-stage')
|
|
28
36
|
useEffect(() => {
|
|
@@ -32,18 +40,30 @@ export default function Poll({ room = 'poll', question = '', options = '', qr, a
|
|
|
32
40
|
target.addEventListener('pollreveal', onReveal)
|
|
33
41
|
return () => target.removeEventListener('pollreveal', onReveal)
|
|
34
42
|
}, [room, correct.length])
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const target = stage()
|
|
45
|
+
if (!target) return
|
|
46
|
+
const onResults = event => { if (event.detail?.room === room) setResultsShown(!!event.detail.shown) }
|
|
47
|
+
target.addEventListener('pollresults', onResults)
|
|
48
|
+
return () => target.removeEventListener('pollresults', onResults)
|
|
49
|
+
}, [room])
|
|
35
50
|
const toggle = () => {
|
|
36
51
|
const next = !shown
|
|
37
52
|
setShown(next)
|
|
38
53
|
stage()?.dispatchEvent(new CustomEvent('pollreveal', { detail: { room, shown: next, local: true } }))
|
|
39
54
|
}
|
|
55
|
+
const toggleResults = () => {
|
|
56
|
+
const next = !resultsShown
|
|
57
|
+
setResultsShown(next)
|
|
58
|
+
stage()?.dispatchEvent(new CustomEvent('pollresults', { detail: { room, shown: next, local: true } }))
|
|
59
|
+
}
|
|
40
60
|
const live = useRoom(room)
|
|
41
61
|
const votes = latestByDevice(live.messages).map(message => message.data?.value).filter(vote => choices.includes(vote))
|
|
42
62
|
const counts = choices.map(choice => votes.filter(vote => vote === choice).length)
|
|
43
63
|
const max = Math.max(1, ...counts)
|
|
44
64
|
const code = wantsQr(qr)
|
|
45
65
|
const still = live.offline && !votes.length
|
|
46
|
-
return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}`} ref={ref}>
|
|
66
|
+
return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}${resultsShown ? '' : ' is-results-hidden'}`} ref={ref}>
|
|
47
67
|
<div class="poll-main">
|
|
48
68
|
{question && <p class="poll-question">{question}</p>}
|
|
49
69
|
<div class="poll-bars" role="list">
|
|
@@ -54,6 +74,9 @@ export default function Poll({ room = 'poll', question = '', options = '', qr, a
|
|
|
54
74
|
</div>)}
|
|
55
75
|
</div>
|
|
56
76
|
<ActivityFooter count={votes.length} {...live}>
|
|
77
|
+
<button type="button" class={`poll-results${resultsShown ? '' : ' is-off'}`} title={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-label={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-pressed={!resultsShown} onClick={toggleResults}>
|
|
78
|
+
<Icon name={resultsShown ? 'eye' : 'eye-off'} size={24} class="poll-results-icon" />
|
|
79
|
+
</button>
|
|
57
80
|
{correct.length > 0 && <button type="button" class={`poll-solve${shown ? ' is-on' : ''}`} title={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-label={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-pressed={shown} onClick={toggle}>
|
|
58
81
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>
|
|
59
82
|
</button>}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import QrCode from './QrCode.jsx'
|
|
3
|
-
import { useJoinLink } from '../live/client.js'
|
|
3
|
+
import { useJoinLink, useFollowLink } from '../live/client.js'
|
|
4
4
|
import { JoinCode } from './activity.jsx'
|
|
5
5
|
import { t } from '../core/labels.js'
|
|
6
6
|
import './poll.css'
|
|
@@ -9,6 +9,7 @@ import './poll.css'
|
|
|
9
9
|
// <qrcode join />: the deck's join link for audience questions, with the
|
|
10
10
|
// short link below it, so a slide early in the talk can invite everyone once
|
|
11
11
|
// and the questions after it can leave out their own code (`qr="false"`).
|
|
12
|
+
// <qrcode follow />: the link for following the slides on a phone or laptop.
|
|
12
13
|
|
|
13
14
|
function JoinQr({ size }) {
|
|
14
15
|
const link = useJoinLink()
|
|
@@ -18,10 +19,18 @@ function JoinQr({ size }) {
|
|
|
18
19
|
</div>
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function FollowQr({ size }) {
|
|
23
|
+
const link = useFollowLink()
|
|
24
|
+
return <div class="poll-join-slide">
|
|
25
|
+
<JoinCode {...link} size={Number(size) || 420} label={t('follow.scan')} tryHere={t('follow.tryHere')} />
|
|
26
|
+
</div>
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
// A bare `join` attribute arrives as "", so only "false" and the like turn it off.
|
|
22
30
|
const isOn = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
|
|
23
31
|
|
|
24
|
-
export default function QrCodeTag({ url, join, size }) {
|
|
32
|
+
export default function QrCodeTag({ url, join, follow, size }) {
|
|
25
33
|
if (isOn(join)) return <JoinQr size={size} />
|
|
34
|
+
if (isOn(follow)) return <FollowQr size={size} />
|
|
26
35
|
return <QrCode url={url} size={size ?? '200'} />
|
|
27
36
|
}
|
|
@@ -77,6 +77,22 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
|
|
|
77
77
|
return () => stage.removeEventListener('slidechange', onSlideChange)
|
|
78
78
|
}, [play, url])
|
|
79
79
|
|
|
80
|
+
// Web video click mode: slides stay mounted, so a playing video would go on
|
|
81
|
+
// (with sound) on the next slide. Leaving the slide unloads the player;
|
|
82
|
+
// coming back shows it ready to play again.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (play !== 'click' || !url) return
|
|
85
|
+
const stage = document.querySelector('deck-stage')
|
|
86
|
+
if (!stage) return
|
|
87
|
+
const embedUrl = getEmbedUrl(url, false)
|
|
88
|
+
function onSlideChange(e) {
|
|
89
|
+
const active = e.detail.slide?.contains(containerRef.current)
|
|
90
|
+
setIframeSrc(current => active ? (current ?? embedUrl) : null)
|
|
91
|
+
}
|
|
92
|
+
stage.addEventListener('slidechange', onSlideChange)
|
|
93
|
+
return () => stage.removeEventListener('slidechange', onSlideChange)
|
|
94
|
+
}, [play, url])
|
|
95
|
+
|
|
80
96
|
const fixedAspect = typeof aspect === 'string' && aspect.trim() !== ''
|
|
81
97
|
|
|
82
98
|
return (
|
|
@@ -12,9 +12,9 @@ export const shortLink = url => url.replace(/^https?:\/\//, '')
|
|
|
12
12
|
export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
|
|
13
13
|
|
|
14
14
|
/** The join code with the short link, or how to make phones reach the server. */
|
|
15
|
-
export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan') }) {
|
|
15
|
+
export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
|
|
16
16
|
if (!qr || offline || (!known && !joinUrl)) return null
|
|
17
|
-
if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{
|
|
17
|
+
if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{tryHere}</a></p>
|
|
18
18
|
return <a class="poll-join" href={joinUrl} target="_blank" rel="noopener" data-room={room}>
|
|
19
19
|
<QrCode url={joinUrl} size={String(size)} />
|
|
20
20
|
<span>{label}</span>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* ── Citations and references (src/runtime/citations.js) ────────────────── */
|
|
2
|
+
.citation { font-style: normal; }
|
|
3
|
+
.citation-key--missing {
|
|
4
|
+
color: var(--accent);
|
|
5
|
+
text-decoration: underline wavy var(--accent);
|
|
6
|
+
text-underline-offset: 4px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* A slide's references sit with its footnotes, above the footer. */
|
|
10
|
+
.slide-citations {
|
|
11
|
+
font-size: var(--fs-small);
|
|
12
|
+
color: var(--muted);
|
|
13
|
+
border-top: 1px solid var(--rule);
|
|
14
|
+
padding-top: 20px;
|
|
15
|
+
margin-top: auto;
|
|
16
|
+
}
|
|
17
|
+
.slide-footnotes + .slide-citations { margin-top: 0; border-top: 0; padding-top: 8px; }
|
|
18
|
+
.slide-citations .csl-bib-body { display: flex; flex-direction: column; gap: 4px; }
|
|
19
|
+
|
|
20
|
+
/* Entries hang; numeric styles put the number in a margin column. */
|
|
21
|
+
.csl-entry { line-height: 1.4; padding-left: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
|
|
22
|
+
.csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-left: 0; text-indent: 0; }
|
|
23
|
+
.csl-left-margin { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
24
|
+
.csl-entry a { color: inherit; text-decoration: none; }
|
|
25
|
+
|
|
26
|
+
/* <bibliography />: the reference list on its slide. */
|
|
27
|
+
.bibliography { display: flex; flex-direction: column; gap: 0.6em; font-size: var(--fs-small); color: var(--ink-soft); }
|
|
28
|
+
.bibliography-hint { color: var(--muted); font-style: italic; }
|
package/src/components/poll.css
CHANGED
|
@@ -18,6 +18,15 @@ deck-stage[data-follower] .poll-reset { display: none; }
|
|
|
18
18
|
/* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
|
|
19
19
|
.poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; 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; }
|
|
20
20
|
.poll-reset + .poll-solve { margin-left: 0; }
|
|
21
|
+
.poll-results { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; 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; }
|
|
22
|
+
.poll-reset + .poll-results, .poll-results + .poll-solve { margin-left: 0; }
|
|
23
|
+
.poll-results:hover, .poll-results:focus-visible { opacity: 1; }
|
|
24
|
+
.poll-results.is-off { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
|
|
25
|
+
deck-stage[data-follower] .poll-results { display: none; }
|
|
26
|
+
/* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
|
|
27
|
+
.poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
|
|
28
|
+
.poll.is-results-hidden .poll-count { opacity: .35; }
|
|
29
|
+
deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
|
|
21
30
|
.poll-solve svg { width: 24px; height: 24px; }
|
|
22
31
|
.poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
|
|
23
32
|
.poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
|
|
@@ -67,15 +76,16 @@ deck-stage[data-follower] .poll-solve { display: none; }
|
|
|
67
76
|
.poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
|
|
68
77
|
.poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
|
|
69
78
|
.poll-join-slide .poll-link { font-size: 28px; }
|
|
70
|
-
@media print { .poll-reset, .poll-dot, .poll-solve { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
|
|
79
|
+
@media print { .poll-reset, .poll-dot, .poll-solve, .poll-results { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
|
|
71
80
|
|
|
72
81
|
/* A record of the talk (no room server: a PDF, a file sent to readers): the
|
|
73
82
|
question and its options, without counts, the live dot or a join code. */
|
|
74
83
|
.poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
|
|
84
|
+
.poll.is-static .poll-results { display: none; }
|
|
75
85
|
.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
86
|
.poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
|
|
77
87
|
|
|
78
88
|
/* On paper the join code and the presenter's controls have no use. */
|
|
79
89
|
@media print {
|
|
80
|
-
.poll-join, .poll-reset, .poll-solve, .poll-dot { display: none !important; }
|
|
90
|
+
.poll-join, .poll-reset, .poll-solve, .poll-dot, .poll-results { display: none !important; }
|
|
81
91
|
}
|
|
@@ -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)\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>) 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/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
|
|