mdeck 3.10.1 → 3.12.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 +44 -0
- package/README.md +15 -4
- package/assets/base.css +6 -1
- package/docs/reference/authoring.md +111 -9
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +6 -3
- package/docs/site/content/drawing.md +5 -5
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +49 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +67 -9
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/content/writing.md +27 -0
- package/docs/site/images/features/hotspots.webp +0 -0
- package/docs/site/images/features/languages.webp +0 -0
- package/docs/site/pages.js +1 -0
- package/examples/custom-layouts/slides.md +21 -9
- package/examples/showcase/refs.bib +21 -0
- package/examples/showcase/slides.ar.drawings.json +11 -0
- package/examples/showcase/slides.ar.md +567 -0
- package/examples/showcase/slides.de.drawings.json +11 -0
- package/examples/showcase/slides.de.md +567 -0
- package/examples/showcase/slides.de.results.json +57 -0
- package/examples/showcase/slides.en.drawings.json +11 -0
- package/examples/showcase/{slides.md → slides.en.md} +112 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +14 -2
- package/src/build/check.js +34 -7
- package/src/build/components.js +5 -3
- package/src/build/config.js +2 -0
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +70 -22
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +96 -0
- package/src/cli/args.js +6 -5
- package/src/cli/index.js +77 -21
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +9 -1
- package/src/core/labels.js +78 -0
- package/src/core/parseSlides.js +16 -6
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +11 -2
- package/src/editor/components/DeckSettings.jsx +1 -0
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/home/Home.jsx +39 -93
- package/src/home/home.css +5 -17
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +9 -0
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +18 -6
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +371 -37
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +37 -12
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +508 -286
- package/src/runtime/shared.js +77 -0
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +67 -0
- package/examples/citations/refs.bib +0 -30
- package/examples/citations/refs.yml +0 -21
- package/examples/citations/slides.md +0 -43
- package/examples/citations/slides.pdf +0 -0
- package/examples/poll/slides.md +0 -127
- package/examples/showcase/slides.drawings.json +0 -11
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The keys that work right now (helpKeys.js), over the slide in a
|
|
2
|
+
// semi-transparent layer: ? shows and hides it, Escape or a click closes it.
|
|
3
|
+
// The presenter view renders it itself; a deck window gets it from
|
|
4
|
+
// attachKeyHelp. While it is open the other keys keep working, and the list
|
|
5
|
+
// follows them (H, D, a slide with hotspots).
|
|
6
|
+
import { h, render } from 'preact'
|
|
7
|
+
import { useState, useEffect, useRef } from 'preact/hooks'
|
|
8
|
+
import { keyHelp, isHelpKey } from './helpKeys.js'
|
|
9
|
+
|
|
10
|
+
const typing = target => target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')
|
|
11
|
+
export const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '')
|
|
12
|
+
|
|
13
|
+
/** Open state for the help: ? toggles it, Escape closes it before anything else sees Escape. */
|
|
14
|
+
export function useKeyHelp() {
|
|
15
|
+
const [open, setOpen] = useState(false)
|
|
16
|
+
const openRef = useRef(false)
|
|
17
|
+
openRef.current = open
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const onKey = event => {
|
|
20
|
+
if (typing(event.target)) return
|
|
21
|
+
if (isHelpKey(event)) {
|
|
22
|
+
event.preventDefault()
|
|
23
|
+
event.stopPropagation()
|
|
24
|
+
setOpen(shown => !shown)
|
|
25
|
+
} else if (event.key === 'Escape' && openRef.current) {
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
event.stopPropagation()
|
|
28
|
+
setOpen(false)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Capturing, so that Escape closes the help instead of zooming out or ending a drawing.
|
|
32
|
+
window.addEventListener('keydown', onKey, true)
|
|
33
|
+
return () => window.removeEventListener('keydown', onKey, true)
|
|
34
|
+
}, [])
|
|
35
|
+
return [open, setOpen]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const S = {
|
|
39
|
+
layer: { position: 'fixed', inset: 0, zIndex: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', boxSizing: 'border-box', background: 'rgba(8,8,8,0.45)', fontFamily: 'ui-sans-serif, system-ui, sans-serif', direction: 'ltr' },
|
|
40
|
+
panel: { width: 'min(100%, 980px)', maxHeight: '100%', overflow: 'auto', boxSizing: 'border-box', padding: '22px 26px 24px', borderRadius: '14px', background: 'rgba(20,20,20,0.82)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 20px 60px rgba(0,0,0,0.45)', color: '#e8e8e8' },
|
|
41
|
+
head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '16px', marginBottom: '16px' },
|
|
42
|
+
heading: { margin: 0, fontSize: '16px', fontWeight: 600 },
|
|
43
|
+
hint: { fontSize: '12px', color: '#8a8a8a' },
|
|
44
|
+
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(270px, 1fr))', gap: '20px 36px' },
|
|
45
|
+
title: { margin: '0 0 8px', fontSize: '11px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#8a8a8a' },
|
|
46
|
+
// One grid per group, so its keys share a column as wide as the widest.
|
|
47
|
+
list: { margin: 0, padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'auto 1fr', alignItems: 'baseline', gap: '8px 14px', fontSize: '13.5px', lineHeight: 1.35 },
|
|
48
|
+
row: { display: 'contents' },
|
|
49
|
+
keys: { display: 'flex', flexWrap: 'wrap', gap: '4px' },
|
|
50
|
+
kbd: { display: 'inline-block', minWidth: '14px', padding: '1px 6px', borderRadius: '5px', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.16)', borderBottomWidth: '2px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '12px', color: '#f4f4f4', textAlign: 'center', whiteSpace: 'nowrap' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The layer itself: `context` is what keyHelp() takes. */
|
|
54
|
+
export function KeyHelp({ context, onClose }) {
|
|
55
|
+
const groups = keyHelp({ mac: isMac(), ...context })
|
|
56
|
+
return (
|
|
57
|
+
<div class="key-help" role="dialog" aria-label="Keyboard shortcuts" style={S.layer} onClick={onClose}>
|
|
58
|
+
<div style={S.panel} onClick={event => event.stopPropagation()}>
|
|
59
|
+
<div style={S.head}>
|
|
60
|
+
<h2 style={S.heading}>Keys</h2>
|
|
61
|
+
<span style={S.hint}>The keys that work now · ? or Esc closes</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div style={S.grid}>
|
|
64
|
+
{groups.map(group => (
|
|
65
|
+
<section key={group.title} class="key-help-group">
|
|
66
|
+
<h3 style={S.title}>{group.title}</h3>
|
|
67
|
+
<ul style={S.list}>
|
|
68
|
+
{group.keys.map(({ keys, label }) => (
|
|
69
|
+
<li key={label} style={S.row}>
|
|
70
|
+
<span style={S.keys}>{keys.map(key => <kbd key={key} style={S.kbd}>{key}</kbd>)}</span>
|
|
71
|
+
<span>{label}</span>
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
</section>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A deck window's help: what it lists follows the stage (the slide's
|
|
84
|
+
// hotspots, drawing on or off).
|
|
85
|
+
function DeckKeyHelp({ stage, canDraw }) {
|
|
86
|
+
const [open, setOpen] = useKeyHelp()
|
|
87
|
+
const [, refresh] = useState(0)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const update = () => refresh(n => n + 1)
|
|
90
|
+
const events = ['slidechange', 'statechange', 'inkmode', 'overviewchange']
|
|
91
|
+
for (const name of events) stage.addEventListener(name, update)
|
|
92
|
+
return () => { for (const name of events) stage.removeEventListener(name, update) }
|
|
93
|
+
}, [stage])
|
|
94
|
+
if (!open) return null
|
|
95
|
+
const context = {
|
|
96
|
+
view: 'deck',
|
|
97
|
+
blank: stage.blank,
|
|
98
|
+
join: stage.hasAttribute('data-joinable'),
|
|
99
|
+
joinShown: stage.join,
|
|
100
|
+
overview: stage.overview,
|
|
101
|
+
hotspots: stage.hotspotMode !== 'off',
|
|
102
|
+
inking: stage.inking,
|
|
103
|
+
canDraw,
|
|
104
|
+
fullscreen: window.top === window && !!window.mdeckFullscreen?.available(),
|
|
105
|
+
}
|
|
106
|
+
return <KeyHelp context={context} onClose={() => setOpen(false)} />
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Gives a deck window the help that ? shows. */
|
|
110
|
+
export function attachKeyHelp(stage, { canDraw = true } = {}) {
|
|
111
|
+
const host = document.createElement('div')
|
|
112
|
+
host.className = 'key-help-host'
|
|
113
|
+
document.body.appendChild(host)
|
|
114
|
+
render(h(DeckKeyHelp, { stage, canDraw }), host)
|
|
115
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The presenter view's controls, shared by its three layouts (main.jsx): the
|
|
2
|
+
// speaker layout on a computer, the slide alone on an iPad, and the phone
|
|
3
|
+
// clicker. What is used during the talk is an icon button, its key in the
|
|
4
|
+
// tooltip; what is set once lives in two popovers: who is connected (with
|
|
5
|
+
// the codes that pair an iPad or a phone) and the settings.
|
|
6
|
+
import { h } from 'preact'
|
|
7
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
8
|
+
import { S } from './chrome.jsx'
|
|
9
|
+
import { Icon } from '../components/Icon.jsx'
|
|
10
|
+
import QrCode from '../components/QrCode.jsx'
|
|
11
|
+
import { ConnectedViews } from '../components/ConnectedViews.jsx'
|
|
12
|
+
import { devicesText, phonesText } from '../live/presence.js'
|
|
13
|
+
import { devPath } from '../core/devPath.js'
|
|
14
|
+
|
|
15
|
+
export const tool = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', borderRadius: '10px', fontSize: '15px' }
|
|
16
|
+
export const toolOn = { background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }
|
|
17
|
+
const muted = { color: '#888', fontSize: '12px' }
|
|
18
|
+
|
|
19
|
+
/** An icon button; `label` is its tooltip and name, with the key in brackets. */
|
|
20
|
+
export function ToolButton({ icon, label, pressed, onClick, class: className, style, size = 18, disabled, children }) {
|
|
21
|
+
return <button type="button" class={className} title={label} aria-label={label} aria-pressed={pressed} disabled={disabled} onClick={onClick}
|
|
22
|
+
style={{ ...tool, ...(pressed ? toolOn : {}), ...(disabled ? { opacity: 0.4, cursor: 'default' } : {}), ...style }}>
|
|
23
|
+
{icon && <Icon name={icon} size={size} style={{ display: 'block' }} />}{children}
|
|
24
|
+
</button>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A button that opens a panel below (or above) it. The panel stays in the
|
|
29
|
+
* page while closed, hidden, so its controls keep their state.
|
|
30
|
+
*/
|
|
31
|
+
export function Popover({ class: className, label, trigger, alert = false, above = false, align = 'end', children }) {
|
|
32
|
+
const [open, setOpen] = useState(false)
|
|
33
|
+
const ref = useRef(null)
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open) return
|
|
36
|
+
const outside = event => { if (!ref.current?.contains(event.target)) setOpen(false) }
|
|
37
|
+
const escape = event => { if (event.key === 'Escape') setOpen(false) }
|
|
38
|
+
document.addEventListener('pointerdown', outside)
|
|
39
|
+
window.addEventListener('keydown', escape)
|
|
40
|
+
return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('keydown', escape) }
|
|
41
|
+
}, [open])
|
|
42
|
+
return <span ref={ref} class={className} style={{ position: 'relative', display: 'inline-flex' }}>
|
|
43
|
+
<button type="button" class={`${className}-toggle`} title={label} aria-label={label} aria-expanded={open} onClick={() => setOpen(now => !now)}
|
|
44
|
+
style={{ ...tool, position: 'relative', gap: '8px', ...(open ? { background: '#2a2a2a', color: '#fff' } : {}) }}>
|
|
45
|
+
{trigger}
|
|
46
|
+
{alert && <span aria-hidden="true" style={{ position: 'absolute', top: '5px', right: '5px', width: '8px', height: '8px', borderRadius: '50%', background: '#f28b82' }} />}
|
|
47
|
+
</button>
|
|
48
|
+
<div class={`${className}-panel`} role="dialog" aria-label={label} style={{
|
|
49
|
+
display: open ? 'flex' : 'none', flexDirection: 'column', gap: '14px', position: 'absolute', zIndex: 300,
|
|
50
|
+
[above ? 'bottom' : 'top']: 'calc(100% + 6px)', [align === 'end' ? 'right' : 'left']: 0,
|
|
51
|
+
width: 'min(340px, calc(100vw - 24px))', maxHeight: 'min(80dvh, 640px)', overflow: 'auto', boxSizing: 'border-box',
|
|
52
|
+
padding: '14px', background: '#161616', border: '1px solid #2e2e2e', borderRadius: '12px', boxShadow: '0 12px 40px rgba(0,0,0,0.6)',
|
|
53
|
+
color: '#ccc', fontSize: '13px', textAlign: 'start', whiteSpace: 'normal', fontVariantNumeric: 'normal',
|
|
54
|
+
}}>{children}</div>
|
|
55
|
+
</span>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function PanelSection({ title, children, class: className }) {
|
|
59
|
+
return <section class={className} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
60
|
+
{title && <span style={{ ...S.label, marginBottom: 0 }}>{title}</span>}
|
|
61
|
+
{children}
|
|
62
|
+
</section>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The slide's own controls, as the presenter's slide frame reports them
|
|
67
|
+
* (slideControls.js): a poll's lock, eye, tick and Reset, a video's play,
|
|
68
|
+
* the hotspots' numbers. `press(i)` and `zoom(key)` act on the slide.
|
|
69
|
+
*/
|
|
70
|
+
export function SlideControls({ controls, press, zoom, big = false }) {
|
|
71
|
+
if (!controls.items.length && !controls.hotspots) return null
|
|
72
|
+
const size = big ? { minWidth: '52px', height: '52px', borderRadius: '14px' } : {}
|
|
73
|
+
return <span class="presenter-slide-controls" role="group" aria-label="This slide" style={{ display: 'inline-flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
|
74
|
+
{controls.items.map((control, i) => <ToolButton key={`c${i}`} class="presenter-slide-control" icon={control.icon ?? 'check'} label={control.label} pressed={control.pressed} disabled={control.disabled} size={big ? 22 : 18} style={size} onClick={() => press(i)} />)}
|
|
75
|
+
{controls.hotspots > 0 && <>
|
|
76
|
+
{Array.from({ length: controls.hotspots }, (_, i) => <ToolButton key={`h${i}`} class="presenter-slide-hotspot" label={`Zoom in on part ${i + 1} (${i + 1})`} pressed={controls.hotspot === i + 1} style={{ ...size, fontVariantNumeric: 'tabular-nums' }} onClick={() => zoom(String(i + 1))}>{i + 1}</ToolButton>)}
|
|
77
|
+
{controls.hotspot != null && <ToolButton class="presenter-slide-whole" icon="fullscreen-exit" label="The whole slide (0)" size={big ? 22 : 18} style={size} onClick={() => zoom('0')} />}
|
|
78
|
+
</>}
|
|
79
|
+
</span>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The launch page's view of this talk: whether devices can pair, from this
|
|
83
|
+
// computer under `mdeck run` only.
|
|
84
|
+
function useHomeInfo() {
|
|
85
|
+
const [info, setInfo] = useState(null)
|
|
86
|
+
const load = () => fetch(devPath('__mdeck/home/info'), { cache: 'no-store' }).then(response => response.ok ? response.json() : null).then(setInfo, () => setInfo(null))
|
|
87
|
+
useEffect(() => { if (import.meta.env.DEV) load() }, [])
|
|
88
|
+
return [info, load]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const PAIR = {
|
|
92
|
+
slide: { icon: 'tablet', label: 'iPad', scan: "Scan with the iPad's camera: the presenter view opens there, to draw on and go through the slides." },
|
|
93
|
+
remote: { icon: 'phone', label: 'Phone clicker', scan: "Scan with the phone's camera: a clicker opens there, with your notes, the time and big buttons." },
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Where the phones answer: the join link, a dot that is green while all is
|
|
97
|
+
// well, and words only for what needs the presenter (from the launch page's
|
|
98
|
+
// check of the server).
|
|
99
|
+
function Polls({ live, recheck }) {
|
|
100
|
+
const [copied, setCopied] = useState(false)
|
|
101
|
+
const copy = () => navigator.clipboard?.writeText(live.joinUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500) }, () => {})
|
|
102
|
+
const problem = live.server && !live.reachable ? `The poll server does not answer${live.error ? ` (${live.error})` : ''}.`
|
|
103
|
+
: !live.joinUrl ? <>Phones cannot reach this computer: start with <code>--network</code>, or set <code>server</code> in the deck.</>
|
|
104
|
+
: live.server && live.key && !live.keyAccepted ? 'The poll server does not accept this key: phones do not follow.'
|
|
105
|
+
: null
|
|
106
|
+
// A server on an older mdeck answers, but lacks what is new (reactions came with 3.12).
|
|
107
|
+
const outdated = !problem && live.server && live.serverOutdated
|
|
108
|
+
return <PanelSection title="Polls" class="presenter-polls">
|
|
109
|
+
{live.joinUrl && <span style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 }}>
|
|
110
|
+
<span aria-hidden="true" style={{ flexShrink: 0, width: '8px', height: '8px', borderRadius: '50%', background: problem ? '#f28b82' : outdated ? '#f5a524' : '#81c995' }} />
|
|
111
|
+
<a href={live.joinUrl} target="_blank" rel="noopener" title="The join link for phones" style={{ color: '#ccc', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{live.joinUrl.replace(/^https?:\/\//, '')}</a>
|
|
112
|
+
<ToolButton class="presenter-copy-join" icon={copied ? 'check' : 'copy'} label={copied ? 'Copied' : 'Copy the join link'} size={15} style={{ minWidth: '30px', height: '30px', padding: 0, flexShrink: 0 }} onClick={copy} />
|
|
113
|
+
</span>}
|
|
114
|
+
{outdated && <span class="presenter-server-outdated" style={{ color: '#f5a524' }}>
|
|
115
|
+
Your poll server runs {live.serverVersion ? `mdeck ${live.serverVersion}` : 'an older mdeck'}, this computer {live.version}: what is new, such as reactions, may not reach the phones. Update it with <code>npm install -g mdeck</code> and restart <code>mdeck server</code>.
|
|
116
|
+
</span>}
|
|
117
|
+
{problem && <span style={{ display: 'flex', gap: '8px', alignItems: 'center', color: '#f28b82' }}>
|
|
118
|
+
<span style={{ flex: 1 }}>{problem}</span>
|
|
119
|
+
<ToolButton class="presenter-recheck" icon="reset" label="Check again" size={15} style={{ minWidth: '30px', height: '30px', padding: 0, flexShrink: 0 }} onClick={recheck} />
|
|
120
|
+
</span>}
|
|
121
|
+
</PanelSection>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const VIEW_NAME = { presenter: 'Presenter view', audience: 'Audience window', deck: 'Deck window' }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Who is connected, behind the icons that show it: the views and devices,
|
|
128
|
+
* the audience window, and the codes that pair an iPad or a phone.
|
|
129
|
+
*/
|
|
130
|
+
export function Connections({ presence, phones, problem }) {
|
|
131
|
+
const [info, reload] = useHomeInfo()
|
|
132
|
+
const [code, setCode] = useState(null)
|
|
133
|
+
const [error, setError] = useState(null)
|
|
134
|
+
const action = body => fetch(devPath('__mdeck/home/action'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
135
|
+
.then(async response => { const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error ?? response.status); return result })
|
|
136
|
+
const pair = layout => action({ action: 'pair', layout }).then(result => { setCode({ url: result.url, layout }); setError(null); reload() }, failure => setError(failure.message))
|
|
137
|
+
const unpair = () => action({ action: 'unpair' }).then(() => { setCode(null); reload() }, failure => setError(failure.message))
|
|
138
|
+
const views = presence?.views ?? []
|
|
139
|
+
const kinds = Object.keys(VIEW_NAME).filter(view => views.some(entry => entry.view === view))
|
|
140
|
+
const trigger = kinds.length || phones
|
|
141
|
+
? <ConnectedViews presence={presence ?? { views: [], phones: 0 }} phones={phones} style={{ color: 'inherit' }} />
|
|
142
|
+
: <Icon name="people" size={18} style={{ display: 'block' }} />
|
|
143
|
+
return <Popover class="presenter-connections" label="Connected screens and devices" trigger={trigger} alert={!!problem}>
|
|
144
|
+
{problem && <p role="status" style={{ margin: 0, color: '#f28b82' }}>{problem}</p>}
|
|
145
|
+
<PanelSection title="Connected">
|
|
146
|
+
{kinds.length === 0 && !(presence?.phones > 0) && <span style={muted}>Only this view.</span>}
|
|
147
|
+
{kinds.map(view => <span key={view}>{VIEW_NAME[view]} on {devicesText(views.filter(entry => entry.view === view))}</span>)}
|
|
148
|
+
{phones && presence?.phones > 0 && <span>{phonesText(presence.phones)}</span>}
|
|
149
|
+
</PanelSection>
|
|
150
|
+
{info?.live && phones && <Polls live={info.live} recheck={reload} />}
|
|
151
|
+
{info?.pairing && <PanelSection title="Pair a device" class="presenter-pairing">
|
|
152
|
+
{!info.pairing.available
|
|
153
|
+
? <span style={muted}>Start <code>mdeck run</code> with <code>--network</code> so an iPad or a phone in the same network can reach it, or with <code>--server</code>.</span>
|
|
154
|
+
: <>
|
|
155
|
+
<span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
156
|
+
{Object.entries(PAIR).map(([layout, { icon, label }]) => <button key={layout} type="button" class={`presenter-pair presenter-pair--${layout}`} aria-pressed={code?.layout === layout} style={{ ...S.btn, ...(code?.layout === layout ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => pair(layout)}><Icon name={icon} size={16} />{label}</button>)}
|
|
157
|
+
{info.pairing.devices > 0 && <button type="button" class="presenter-unpair" style={{ ...S.btn, color: '#888' }} onClick={unpair}>Unpair {info.pairing.devices === 1 ? 'the device' : `${info.pairing.devices} devices`}</button>}
|
|
158
|
+
</span>
|
|
159
|
+
{code && <span style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-start', '--qr-ink': '#000', '--qr-bg': '#fff' }}>
|
|
160
|
+
<span style={{ background: '#fff', padding: '8px', borderRadius: '8px', lineHeight: 0 }}><QrCode url={code.url} size="168" /></span>
|
|
161
|
+
<span>{PAIR[code.layout].scan}</span>
|
|
162
|
+
<span style={muted}>The code works once, for ten minutes.</span>
|
|
163
|
+
</span>}
|
|
164
|
+
{error && <span style={{ color: '#f28b82' }}>{error}</span>}
|
|
165
|
+
</>}
|
|
166
|
+
</PanelSection>}
|
|
167
|
+
</Popover>
|
|
168
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import { listenToReactions } from '../live/client.js'
|
|
4
|
+
|
|
5
|
+
// The reactions phones send (src/core/reactions.js), on the presenter's
|
|
6
|
+
// screen only: each one floats up for a moment, and a tally keeps what came
|
|
7
|
+
// in the last minute, so that four 🐢 still say "slower" after they faded.
|
|
8
|
+
|
|
9
|
+
const FLOAT_MS = 2600
|
|
10
|
+
const TALLY_MS = 60000
|
|
11
|
+
|
|
12
|
+
const CSS = `
|
|
13
|
+
.reactions { position: fixed; z-index: 17; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; pointer-events: none; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
14
|
+
.reactions-float { position: relative; width: 160px; height: 220px; }
|
|
15
|
+
.reaction-bubble { position: absolute; bottom: 0; font-size: 34px; line-height: 1; animation: reaction-up ${FLOAT_MS}ms ease-out forwards; filter: drop-shadow(0 2px 4px rgba(0,0,0,.45)); }
|
|
16
|
+
.reaction-bubble.is-word { font-size: 15px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: rgba(17,17,17,.9); color: #f0f0f0; border: 1px solid #3a3a3a; }
|
|
17
|
+
@keyframes reaction-up { 0% { transform: translate(0, 0) scale(.6); opacity: 0; } 12% { transform: translate(var(--drift), -20px) scale(1.1); opacity: 1; } 75% { opacity: 1; } 100% { transform: translate(calc(var(--drift) * -1), -200px) scale(1); opacity: 0; } }
|
|
18
|
+
.reactions-tally { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-radius: 999px; background: rgba(17,17,17,.88); border: 1px solid #2a2a2a; color: #ccc; font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
19
|
+
.reactions-tally span { padding: 0 4px; }
|
|
20
|
+
@media (prefers-reduced-motion: reduce) { .reaction-bubble { animation: none; opacity: .9; } }
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
const isEmoji = value => /\p{Extended_Pictographic}/u.test(value) && [...value].length <= 4
|
|
24
|
+
|
|
25
|
+
/** The reactions overlay; `style` places it (fixed by default, at the slide's lower left). */
|
|
26
|
+
export function Reactions({ style }) {
|
|
27
|
+
const [bubbles, setBubbles] = useState([])
|
|
28
|
+
const [recent, setRecent] = useState([])
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const css = document.createElement('style')
|
|
31
|
+
css.textContent = CSS
|
|
32
|
+
document.head.appendChild(css)
|
|
33
|
+
const stop = listenToReactions(({ value, n }) => {
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
const bubble = { key: `${n}:${now}`, value, x: 10 + Math.random() * 100, drift: `${Math.round(Math.random() * 24 - 12)}px` }
|
|
36
|
+
// At most a screenful at once: a room that taps all together is still one cloud.
|
|
37
|
+
setBubbles(list => [...list.slice(-24), bubble])
|
|
38
|
+
setTimeout(() => setBubbles(list => list.filter(item => item !== bubble)), FLOAT_MS)
|
|
39
|
+
setRecent(list => [...list.filter(item => now - item.at < TALLY_MS), { value, at: now }])
|
|
40
|
+
})
|
|
41
|
+
// The tally forgets what is older than a minute.
|
|
42
|
+
const timer = setInterval(() => setRecent(list => { const now = Date.now(); const kept = list.filter(item => now - item.at < TALLY_MS); return kept.length === list.length ? list : kept }), 5000)
|
|
43
|
+
return () => { stop(); clearInterval(timer); css.remove() }
|
|
44
|
+
}, [])
|
|
45
|
+
const tally = new Map()
|
|
46
|
+
for (const { value } of recent) tally.set(value, (tally.get(value) ?? 0) + 1)
|
|
47
|
+
if (!bubbles.length && !tally.size) return null
|
|
48
|
+
return <div class="reactions" aria-hidden="true" style={{ left: '16px', bottom: '16px', ...style }}>
|
|
49
|
+
<div class="reactions-float">
|
|
50
|
+
{bubbles.map(bubble => <span key={bubble.key} class={`reaction-bubble${isEmoji(bubble.value) ? '' : ' is-word'}`} style={{ left: `${bubble.x}px`, '--drift': bubble.drift }}>{bubble.value}</span>)}
|
|
51
|
+
</div>
|
|
52
|
+
{tally.size > 0 && <div class="reactions-tally" title="Reactions in the last minute">
|
|
53
|
+
{[...tally].sort((a, b) => b[1] - a[1]).map(([value, count]) => <span key={value}>{value} {count}</span>)}
|
|
54
|
+
</div>}
|
|
55
|
+
</div>
|
|
56
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The end of a rehearsal: each slide's planned time, the time it got, and
|
|
2
|
+
// the time to save, which starts at the rehearsed time rounded to 15 s and
|
|
3
|
+
// can be moved in 15 s steps, put back to the plan, or taken out. Saving
|
|
4
|
+
// writes `time:` into the slides' settings through `mdeck run`
|
|
5
|
+
// (build/timingPlugin.js); a built deck can only copy the times.
|
|
6
|
+
import { h } from 'preact'
|
|
7
|
+
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
8
|
+
import { S } from './chrome.jsx'
|
|
9
|
+
import { Icon } from '../components/Icon.jsx'
|
|
10
|
+
import { slideTitle } from '../core/outline.js'
|
|
11
|
+
import { devPath } from '../core/devPath.js'
|
|
12
|
+
import { pairHeaders } from '../live/pairing.js'
|
|
13
|
+
import { formatTime, formatClock, roundTime, initialTimes, changedTimes, TIME_STEP } from '../core/timing.js'
|
|
14
|
+
|
|
15
|
+
const ENDPOINT = devPath('__mdeck/timing')
|
|
16
|
+
|
|
17
|
+
// Whether this page can save into the deck: only `mdeck run` answers.
|
|
18
|
+
async function canSaveTimes() {
|
|
19
|
+
try { return (await fetch(ENDPOINT, { cache: 'no-store', headers: pairHeaders() })).ok } catch { return false }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dash = <span style={{ color: '#555' }}>—</span>
|
|
23
|
+
const sum = values => values.reduce((total, t) => total + (t ?? 0), 0)
|
|
24
|
+
|
|
25
|
+
export function RehearsalSummary({ slides, plan, spent, onSaved, onDiscard, onClose }) {
|
|
26
|
+
const own = plan.own
|
|
27
|
+
const [next, setNext] = useState(() => initialTimes(slides, spent, own))
|
|
28
|
+
const [canSave, setCanSave] = useState(null)
|
|
29
|
+
const [status, setStatus] = useState(null)
|
|
30
|
+
useEffect(() => { canSaveTimes().then(setCanSave) }, [])
|
|
31
|
+
const changes = useMemo(() => changedTimes(slides, own, next), [next])
|
|
32
|
+
const changeCount = Object.keys(changes).length
|
|
33
|
+
const setAt = (i, value) => setNext(times => times.map((t, j) => j === i ? value : t))
|
|
34
|
+
const step = (i, by) => setAt(i, Math.max(TIME_STEP, (next[i] ?? roundTime(spent[slides[i].id] ?? 0)) + by))
|
|
35
|
+
|
|
36
|
+
async function save() {
|
|
37
|
+
setStatus({ busy: true })
|
|
38
|
+
try {
|
|
39
|
+
const reply = await fetch(ENDPOINT, { method: 'POST', headers: pairHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ ids: slides.map(slide => slide.id), times: changes }) })
|
|
40
|
+
const body = await reply.json().catch(() => ({}))
|
|
41
|
+
if (!reply.ok) return setStatus({ error: body.error ?? `The deck was not saved (${reply.status})` })
|
|
42
|
+
onSaved(changes, body.changed ?? changeCount)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
setStatus({ error: `The deck was not saved: ${error.message}` })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copy() {
|
|
49
|
+
const text = slides.map((slide, i) => `${i + 1}. ${slideTitle(slide, i, null)}: ${next[i] == null ? '—' : `time: ${formatTime(next[i])}`}`).join('\n')
|
|
50
|
+
navigator.clipboard?.writeText(text).then(() => setStatus({ note: 'Copied the times.' }), () => setStatus({ error: 'The browser did not let this page copy.' }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onRowKey(event, i) {
|
|
54
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
event.stopPropagation()
|
|
57
|
+
step(i, event.key === 'ArrowUp' ? TIME_STEP : -TIME_STEP)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const small = { ...S.btn, padding: 0, width: '26px', height: '26px' }
|
|
62
|
+
const cell = { padding: '6px 8px', borderBottom: '1px solid #222', verticalAlign: 'middle' }
|
|
63
|
+
const head = { ...cell, ...S.label, display: 'table-cell', marginBottom: 0, textAlign: 'start', position: 'sticky', top: 0, background: '#161616' }
|
|
64
|
+
const num = { ...cell, textAlign: 'end', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div class="rehearsal-summary" role="dialog" aria-modal="true" aria-label="Rehearsal" style={{ position: 'fixed', inset: 0, zIndex: 210, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', color: '#ccc' }}
|
|
68
|
+
onKeyDown={event => { event.stopPropagation(); if (event.key === 'Escape') onClose() }}>
|
|
69
|
+
<div style={{ background: '#161616', border: '1px solid #2a2a2a', borderRadius: '12px', padding: '20px 24px', width: 'min(94vw, 760px)', maxHeight: '88vh', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
|
70
|
+
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px' }}>
|
|
71
|
+
<span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '15px' }}>Rehearsal</span>
|
|
72
|
+
<span style={{ color: '#888' }}>New times start at the rehearsed time, rounded to {TIME_STEP} s. ↑ ↓ change the focused one.</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div style={{ overflow: 'auto', minHeight: 0 }}>
|
|
75
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
76
|
+
<thead>
|
|
77
|
+
<tr>
|
|
78
|
+
<th style={head}>Slide</th>
|
|
79
|
+
<th style={{ ...head, textAlign: 'end' }}>Planned</th>
|
|
80
|
+
<th style={{ ...head, textAlign: 'end' }}>Rehearsed</th>
|
|
81
|
+
<th style={{ ...head, textAlign: 'center' }}>New time</th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
{slides.map((slide, i) => {
|
|
86
|
+
const changed = next[i] !== own[i]
|
|
87
|
+
return (
|
|
88
|
+
<tr key={`${slide.id}#${i}`} class="rehearsal-row" tabIndex={0} onKeyDown={event => onRowKey(event, i)} style={{ outline: 'none' }}>
|
|
89
|
+
<td style={{ ...cell, maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
90
|
+
<span style={{ color: '#666', marginInlineEnd: '8px', fontVariantNumeric: 'tabular-nums' }}>{i + 1}</span>{slideTitle(slide, i, null)}
|
|
91
|
+
</td>
|
|
92
|
+
<td style={{ ...num, color: '#888' }}>{own[i] == null ? dash : formatTime(own[i])}</td>
|
|
93
|
+
<td style={{ ...num, color: '#888' }}>{spent[slide.id] >= 1 ? formatClock(spent[slide.id]) : dash}</td>
|
|
94
|
+
<td style={{ ...cell, whiteSpace: 'nowrap' }}>
|
|
95
|
+
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
|
|
96
|
+
<button style={small} title={`${TIME_STEP} s less`} aria-label={`${TIME_STEP} seconds less`} onClick={() => step(i, -TIME_STEP)}><Icon name="remove" size={14} /></button>
|
|
97
|
+
<span class="rehearsal-time" style={{ display: 'inline-block', minWidth: '56px', textAlign: 'center', fontVariantNumeric: 'tabular-nums', color: changed ? '#f0f0f0' : '#888', fontWeight: changed ? 600 : 400 }}>{next[i] == null ? dash : formatTime(next[i])}</span>
|
|
98
|
+
<button style={small} title={`${TIME_STEP} s more`} aria-label={`${TIME_STEP} seconds more`} onClick={() => step(i, TIME_STEP)}><Icon name="add" size={14} /></button>
|
|
99
|
+
<button style={{ ...small, visibility: changed ? 'visible' : 'hidden' }} title="Keep the planned time" aria-label="Keep the planned time" onClick={() => setAt(i, own[i])}><Icon name="undo" size={14} /></button>
|
|
100
|
+
<button style={{ ...small, visibility: next[i] == null ? 'hidden' : 'visible' }} title="No time for this slide" aria-label="No time for this slide" onClick={() => setAt(i, null)}><Icon name="close" size={14} /></button>
|
|
101
|
+
</span>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</tbody>
|
|
107
|
+
<tfoot>
|
|
108
|
+
<tr>
|
|
109
|
+
<td style={{ ...cell, color: '#888' }}>Total{plan.budget ? ` (deck time ${formatClock(plan.budget)})` : ''}</td>
|
|
110
|
+
<td style={num}>{formatClock(sum(own))}</td>
|
|
111
|
+
<td style={num}>{formatClock(sum(Object.values(spent)))}</td>
|
|
112
|
+
<td style={{ ...num, textAlign: 'center', color: plan.budget && sum(next) > plan.budget ? '#f28b82' : '#f0f0f0' }}>{formatClock(sum(next))}</td>
|
|
113
|
+
</tr>
|
|
114
|
+
</tfoot>
|
|
115
|
+
</table>
|
|
116
|
+
</div>
|
|
117
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
|
118
|
+
<span role="status" style={{ flex: 1, color: status?.error ? '#f28b82' : '#888' }}>
|
|
119
|
+
{status?.error ?? status?.note ?? (canSave === false ? 'Saving into the deck needs mdeck run; copy the times instead.' : `${changeCount} slide${changeCount === 1 ? '' : 's'} change.`)}
|
|
120
|
+
</span>
|
|
121
|
+
<button style={{ ...S.btn, color: '#888' }} title="Go on rehearsing (Esc)" onClick={onClose}>Back</button>
|
|
122
|
+
<button style={{ ...S.btn, color: '#888' }} title="End the rehearsal without saving" onClick={onDiscard}>Discard</button>
|
|
123
|
+
<button style={S.btn} onClick={copy}><Icon name="copy" size={14} />Copy</button>
|
|
124
|
+
<button class="rehearsal-save" style={{ ...S.btn, background: '#1c2a3a', borderColor: '#2f4a66', opacity: canSave && changeCount && !status?.busy ? 1 : 0.5 }} disabled={!canSave || !changeCount || status?.busy} onClick={save}>Save to deck</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `mdeck/component`: what a deck component may use from mdeck. Everything
|
|
2
|
+
// here is a promise kept across versions; anything else a component reaches
|
|
3
|
+
// (the page outside its slide, the stage, the presenter view) is not, and may
|
|
4
|
+
// change without notice. See "Deck-local components" in the authoring
|
|
5
|
+
// reference.
|
|
6
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
7
|
+
import { getShared, setShared, subscribeShared, currentView } from './shared.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A value shared by every slide and every window of this talk, under `name`.
|
|
11
|
+
* Returns [value, setValue]; `initial` stands in until someone sets one.
|
|
12
|
+
* Values must survive JSON. The latest change wins.
|
|
13
|
+
*/
|
|
14
|
+
export function useShared(name, initial = null) {
|
|
15
|
+
const [value, setValue] = useState(() => getShared(name))
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setValue(getShared(name))
|
|
18
|
+
return subscribeShared(name, setValue)
|
|
19
|
+
}, [name])
|
|
20
|
+
const current = () => getShared(name) === undefined ? initial : getShared(name)
|
|
21
|
+
return [value === undefined ? initial : value, next => setShared(name, typeof next === 'function' ? next(current()) : next)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The kind of window this is: presenter, audience, deck, follow, preview or reader. */
|
|
25
|
+
export function useView() {
|
|
26
|
+
return currentView()
|
|
27
|
+
}
|