mdeck 2.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 +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- package/src/runtime/themeLoader.js +68 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mdeck design</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="design-root"></div>
|
|
10
|
+
<script type="module" src="./design.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mdeck editor</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="editor-root"></div>
|
|
10
|
+
<script type="module" src="./editor.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Editor-mode rendering loop for the deck runtime, kept free of DOM access so
|
|
2
|
+
// it can be tested in Node. The editor pushes whole sources; the bridge
|
|
3
|
+
// parses, reloads the theme only when deck settings changed, mounts, and
|
|
4
|
+
// reports diagnostics back. Nothing here throws on a broken deck.
|
|
5
|
+
|
|
6
|
+
export function createEditorBridge({ parse, validate = () => [], loadTheme, setExtensionOverrides = () => {}, setCalloutLabels = () => {}, applyOverrides = config => config, mount, post = () => {}, fallbackTheme = 'neue' }) {
|
|
7
|
+
let appliedConfig = null
|
|
8
|
+
let themeConfig = null
|
|
9
|
+
let current = { deck: null, deckConfig: null }
|
|
10
|
+
let running = null
|
|
11
|
+
let pending = null
|
|
12
|
+
|
|
13
|
+
// `config` merges over the deck's own settings (e.g. to preview a palette);
|
|
14
|
+
// `overrides` carries unsaved theme and palette manifests.
|
|
15
|
+
async function run(source, { selection = null, config = null, overrides = null } = {}) {
|
|
16
|
+
const deck = parse(source)
|
|
17
|
+
const runtime = []
|
|
18
|
+
const requested = applyOverrides({ ...deck.deckConfig, ...(config ?? {}) })
|
|
19
|
+
const key = JSON.stringify({ requested, overrides })
|
|
20
|
+
if (key !== appliedConfig) {
|
|
21
|
+
setExtensionOverrides(overrides ?? {})
|
|
22
|
+
setCalloutLabels(requested)
|
|
23
|
+
let config = requested
|
|
24
|
+
try {
|
|
25
|
+
await loadTheme(config)
|
|
26
|
+
} catch (error) {
|
|
27
|
+
runtime.push({ severity: 'error', code: 'theme-load', message: error.message, line: 1, column: 1 })
|
|
28
|
+
config = { ...requested, theme: fallbackTheme }
|
|
29
|
+
try { await loadTheme(config) } catch {}
|
|
30
|
+
}
|
|
31
|
+
appliedConfig = key
|
|
32
|
+
themeConfig = config
|
|
33
|
+
}
|
|
34
|
+
let error = null
|
|
35
|
+
try { mount({ deck, deckConfig: themeConfig, selection }) } catch (caught) { error = caught?.message ?? String(caught) }
|
|
36
|
+
current = { deck, deckConfig: themeConfig }
|
|
37
|
+
post({ deckRendered: { slideCount: deck.slides.length, diagnostics: [...deck.diagnostics, ...validate(deck), ...runtime], error } })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Renders are serialized; while one is in flight only the newest request waits.
|
|
41
|
+
function render(source, options = {}) {
|
|
42
|
+
if (running) { pending = { source, options }; return running }
|
|
43
|
+
running = run(source, options).finally(() => {
|
|
44
|
+
running = null
|
|
45
|
+
if (pending) { const next = pending; pending = null; render(next.source, next.options) }
|
|
46
|
+
})
|
|
47
|
+
return running
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleMessage(data) {
|
|
51
|
+
if (!data || typeof data !== 'object' || !data.deckSource) return false
|
|
52
|
+
const { source, selection, config, overrides } = data.deckSource
|
|
53
|
+
if (typeof source === 'string') render(source, { selection: selection ?? null, config: config ?? null, overrides: overrides ?? null })
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { render, handleMessage, current: () => current }
|
|
58
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Content that does not fit its place on a slide. Code blocks are the usual
|
|
2
|
+
// cause: first their type gets smaller, down to a size still readable from
|
|
3
|
+
// the back of a room; only then do they get a smaller height, at least a few
|
|
4
|
+
// lines, and scroll inside (code-block.css). The output of run code is part
|
|
5
|
+
// of the same fit: when it appears, the code gives way, and if that is not
|
|
6
|
+
// enough, the output scrolls too. Whatever still overflows marks the slide with
|
|
7
|
+
// `data-overflow`, and the deck says so in the console, so a too-full slide
|
|
8
|
+
// is found while writing rather than on the projector.
|
|
9
|
+
|
|
10
|
+
const MIN_LINES = 4
|
|
11
|
+
// Output of run code keeps at least this many lines when it has to scroll.
|
|
12
|
+
const MIN_OUTPUT_LINES = 2
|
|
13
|
+
// The smallest code type, in slide pixels (a 1920 × 1080 slide).
|
|
14
|
+
const MIN_CODE_SIZE = 22
|
|
15
|
+
|
|
16
|
+
// The nearest box between the element and the slide whose content is taller
|
|
17
|
+
// than the box itself, and by how much.
|
|
18
|
+
function overflowAround(element, slide) {
|
|
19
|
+
for (let box = element.parentElement; box && box !== slide.parentElement; box = box.parentElement) {
|
|
20
|
+
const extra = box.scrollHeight - box.clientHeight
|
|
21
|
+
if (extra > 1) return { box, extra }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The element that scrolls: the highlighted layer of editable code (the text
|
|
27
|
+
// field above it fills the same box), or the code itself.
|
|
28
|
+
const scroller = wrapper => wrapper.querySelector('.code-editable-highlight') ?? wrapper.querySelector('pre.code-block')
|
|
29
|
+
|
|
30
|
+
function lineHeight(element) {
|
|
31
|
+
const style = getComputedStyle(element)
|
|
32
|
+
return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 30
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The fade at the lower edge goes once the code is scrolled to its end. For
|
|
36
|
+
// editable code the text field scrolls and the highlighted layer follows.
|
|
37
|
+
function watchEnd(element, scrolling = element) {
|
|
38
|
+
const update = () => element.classList.toggle('is-at-end', scrolling.scrollTop + scrolling.clientHeight >= scrolling.scrollHeight - 4)
|
|
39
|
+
if (!scrolling.dataset.fitWatched) {
|
|
40
|
+
scrolling.dataset.fitWatched = ''
|
|
41
|
+
scrolling.addEventListener('scroll', update, { passive: true })
|
|
42
|
+
}
|
|
43
|
+
update()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Smaller type first: the text of the code takes the height, the padding
|
|
47
|
+
// stays, so the size needed follows from how much is missing. The editable
|
|
48
|
+
// text field above highlighted code gets the same size, so the cursor stays
|
|
49
|
+
// on its letters.
|
|
50
|
+
function shrinkType(wrapper, element, slide) {
|
|
51
|
+
const codes = [...wrapper.querySelectorAll('.code-block')]
|
|
52
|
+
for (let step = 0; step < 4; step++) {
|
|
53
|
+
const around = overflowAround(wrapper, slide)
|
|
54
|
+
if (!around) return
|
|
55
|
+
const size = parseFloat(getComputedStyle(element).fontSize)
|
|
56
|
+
if (!(size > MIN_CODE_SIZE)) return
|
|
57
|
+
const style = getComputedStyle(element)
|
|
58
|
+
const text = element.scrollHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
|
59
|
+
if (text <= 0) return
|
|
60
|
+
const next = Math.max(MIN_CODE_SIZE, Math.floor(size * Math.max(0, text - around.extra - 2) / text))
|
|
61
|
+
if (next >= size) return
|
|
62
|
+
for (const code of codes) code.style.fontSize = `${next}px`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fits one slide's code blocks into the space they have; returns whether the slide still overflows. */
|
|
67
|
+
export function fitSlide(slide) {
|
|
68
|
+
if (!slide) return false
|
|
69
|
+
const blocks = [...slide.querySelectorAll('.code-block-wrapper')].map(wrapper => ({ wrapper, element: scroller(wrapper) })).filter(block => block.element)
|
|
70
|
+
// Start from the natural size, so a slide that got more room fits again.
|
|
71
|
+
for (const { wrapper, element } of blocks) {
|
|
72
|
+
for (const code of wrapper.querySelectorAll('.code-block')) code.style.fontSize = ''
|
|
73
|
+
element.style.maxHeight = ''
|
|
74
|
+
const output = wrapper.querySelector('.code-output')
|
|
75
|
+
if (output) { output.style.maxHeight = ''; output.style.fontSize = ''; output.classList.remove('is-capped') }
|
|
76
|
+
element.classList.remove('is-capped')
|
|
77
|
+
wrapper.querySelector('.code-editable-container')?.classList.remove('is-capped')
|
|
78
|
+
}
|
|
79
|
+
// The tallest code gives way first.
|
|
80
|
+
blocks.sort((a, b) => b.element.offsetHeight - a.element.offsetHeight)
|
|
81
|
+
for (const { wrapper, element } of blocks) {
|
|
82
|
+
shrinkType(wrapper, element, slide)
|
|
83
|
+
const smallest = lineHeight(element) * MIN_LINES
|
|
84
|
+
// Measured again after each step: a column's scrollHeight leaves out
|
|
85
|
+
// some margins, so one step can fall short.
|
|
86
|
+
for (let step = 0; step < 4; step++) {
|
|
87
|
+
const around = overflowAround(wrapper, slide)
|
|
88
|
+
if (!around) break
|
|
89
|
+
const height = Math.max(smallest, element.offsetHeight - around.extra - 2)
|
|
90
|
+
if (height >= element.offsetHeight) break
|
|
91
|
+
element.style.maxHeight = `${Math.floor(height)}px`
|
|
92
|
+
element.classList.add('is-capped')
|
|
93
|
+
wrapper.querySelector('.code-editable-container')?.classList.add('is-capped')
|
|
94
|
+
}
|
|
95
|
+
if (element.classList.contains('is-capped')) watchEnd(element, wrapper.querySelector('.code-editable-input') ?? element)
|
|
96
|
+
}
|
|
97
|
+
// Output in the code's type size; with the code at its smallest height and
|
|
98
|
+
// the slide still too full, the output scrolls as well.
|
|
99
|
+
for (const { wrapper, element } of blocks) {
|
|
100
|
+
const output = wrapper.querySelector('.code-output')
|
|
101
|
+
if (!output) continue
|
|
102
|
+
output.style.fontSize = element.style.fontSize
|
|
103
|
+
const around = overflowAround(output, slide)
|
|
104
|
+
if (around) {
|
|
105
|
+
const height = Math.max(lineHeight(output) * MIN_OUTPUT_LINES, output.offsetHeight - around.extra - 2)
|
|
106
|
+
if (height < output.offsetHeight) output.style.maxHeight = `${Math.floor(height)}px`
|
|
107
|
+
}
|
|
108
|
+
if (output.scrollHeight > output.clientHeight + 2) {
|
|
109
|
+
output.classList.add('is-capped')
|
|
110
|
+
watchEnd(output)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const body = slide.querySelector('.slide-body')
|
|
114
|
+
const columns = [...slide.querySelectorAll('.slide-body, .split-left, .split-right, .text-pane')]
|
|
115
|
+
const overflowing = columns.some(box => box.scrollHeight - box.clientHeight > 2) || (body && body.scrollHeight > body.clientHeight + 2)
|
|
116
|
+
slide.toggleAttribute('data-overflow', overflowing)
|
|
117
|
+
return overflowing
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keeps the deck's slides fitted: the slide on screen when it is shown, all
|
|
122
|
+
* of them once the fonts are there and when the size changes. Reports each
|
|
123
|
+
* slide that is still too full once.
|
|
124
|
+
*/
|
|
125
|
+
export function fitDeck(stage, { report = true } = {}) {
|
|
126
|
+
if (!stage) return () => {}
|
|
127
|
+
const reported = new Set()
|
|
128
|
+
const fit = slide => {
|
|
129
|
+
if (!slide || !slide.offsetHeight) return
|
|
130
|
+
if (fitSlide(slide) && report) {
|
|
131
|
+
const id = slide.dataset.slideId ?? ''
|
|
132
|
+
if (!reported.has(id)) {
|
|
133
|
+
reported.add(id)
|
|
134
|
+
console.warn(`mdeck: slide ${id} has more content than fits; it is cut off at the bottom.`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const active = () => stage.querySelector(':scope > [data-deck-active]')
|
|
139
|
+
const fitActive = () => requestAnimationFrame(() => fit(active()))
|
|
140
|
+
stage.addEventListener('slidechange', fitActive)
|
|
141
|
+
// A slide's content changed size, as when run code shows its output.
|
|
142
|
+
const refit = event => requestAnimationFrame(() => fit(event.target.closest?.('.slide') ?? active()))
|
|
143
|
+
stage.addEventListener('mdeck:refit', refit)
|
|
144
|
+
document.fonts?.ready.then(fitActive)
|
|
145
|
+
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(fitActive) : null
|
|
146
|
+
observer?.observe(stage)
|
|
147
|
+
fitActive()
|
|
148
|
+
return () => { stage.removeEventListener('slidechange', fitActive); stage.removeEventListener('mdeck:refit', refit); observer?.disconnect() }
|
|
149
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="dark" />
|
|
7
|
+
<title>mdeck</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="home-root"></div>
|
|
11
|
+
<script type="module" src="./home.jsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<!-- Added to an iPad's Home Screen, the deck opens without Safari's bars. -->
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
9
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
10
|
+
<title>Deck</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<script type="module" src="./main.jsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useMemo } from 'preact/hooks'
|
|
3
|
+
import { strokePath, toolOpacity, inkPaint, inkOrder } from '../../core/ink.js'
|
|
4
|
+
import { useSlideInk, currentInk } from './store.js'
|
|
5
|
+
|
|
6
|
+
// The saved ink of one slide, drawn above its content. It sits inside the
|
|
7
|
+
// slide's <section>, so it scales with the stage, prints, and appears in the
|
|
8
|
+
// reader's Read mode and in PDFs. It never takes pointer input.
|
|
9
|
+
export function InkLayer({ slideId }) {
|
|
10
|
+
const strokes = useSlideInk(slideId)
|
|
11
|
+
const paths = useMemo(() => inkOrder(strokes).map(stroke => ({ id: stroke.id, d: strokePath(stroke), color: stroke.color, opacity: toolOpacity(stroke.tool) })), [strokes])
|
|
12
|
+
if (!paths.length) return null
|
|
13
|
+
const { width, height } = currentInk()
|
|
14
|
+
return <svg class="slide-ink" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden="true">
|
|
15
|
+
{paths.map(path => <path key={path.id} d={path.d} style={{ fill: inkPaint(path.color) }} fill-opacity={path.opacity} />)}
|
|
16
|
+
</svg>
|
|
17
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { Icon } from '../../components/Icon.jsx'
|
|
4
|
+
import { inkPaint } from '../../core/ink.js'
|
|
5
|
+
import './toolbar.css'
|
|
6
|
+
|
|
7
|
+
// The floating ink toolbar: tools, colours and sizes of the pen and the
|
|
8
|
+
// highlighter (each keeps its own; one pen colour is the theme's accent),
|
|
9
|
+
// deleting a selection, undo/redo, clear slide, hide saved ink, drawing with a
|
|
10
|
+
// finger, and the drawings file. Swipes and the deck's own controls and keys
|
|
11
|
+
// turn the slides. It folds into one button in the bottom right corner,
|
|
12
|
+
// showing the current tool, and drawing goes on while it is folded. It drives
|
|
13
|
+
// a <deck-stage> (inkTool, inkFinger, data-ink-hidden), an ink controller and
|
|
14
|
+
// the selection.
|
|
15
|
+
|
|
16
|
+
// "accent" is stored as the word and drawn in the theme's --accent, so the
|
|
17
|
+
// drawing follows the palette (inkPaint in core/ink.js).
|
|
18
|
+
export const INK_COLORS = ['#e11d48', '#2563eb', '#16a34a', 'accent', '#111111']
|
|
19
|
+
export const HIGHLIGHTER_COLORS = ['#ffeb3b', '#76ff03', '#ff4081', '#40c4ff', '#ff9100']
|
|
20
|
+
const COLORS = { pen: INK_COLORS, highlighter: HIGHLIGHTER_COLORS }
|
|
21
|
+
const COLOR_NAMES = { '#e11d48': 'Red', '#2563eb': 'Blue', '#16a34a': 'Green', accent: 'Accent', '#111111': 'Text colour',
|
|
22
|
+
'#ffeb3b': 'Yellow', '#76ff03': 'Green', '#ff4081': 'Pink', '#40c4ff': 'Blue', '#ff9100': 'Orange' }
|
|
23
|
+
const SIZES = { pen: [3, 6, 12], highlighter: [18, 30, 48] }
|
|
24
|
+
// Each tool's icon has the tool's name.
|
|
25
|
+
const TOOLS = [
|
|
26
|
+
['pen', 'Pen'],
|
|
27
|
+
['highlighter', 'Highlighter'],
|
|
28
|
+
['select', 'Select and move'],
|
|
29
|
+
['laser', 'Laser pointer'],
|
|
30
|
+
['eraser', 'Eraser'],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
// A small "are you sure" popover above the button that opened it. It does not
|
|
34
|
+
// dim anything. While open it takes the keys, so the deck behind it does not
|
|
35
|
+
// turn slides; Escape or a tap elsewhere cancels, and focus stays inside.
|
|
36
|
+
function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel }) {
|
|
37
|
+
const cancelRef = useRef(null)
|
|
38
|
+
const popRef = useRef(null)
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const pop = popRef.current
|
|
41
|
+
// Keep it on screen when the button sits near an edge of a narrow window.
|
|
42
|
+
const rect = pop.getBoundingClientRect()
|
|
43
|
+
const shift = Math.max(8 - rect.left, Math.min(0, innerWidth - 8 - rect.right))
|
|
44
|
+
pop.style.setProperty('--shift', `${Math.round(shift)}px`)
|
|
45
|
+
const opener = document.activeElement
|
|
46
|
+
cancelRef.current?.focus()
|
|
47
|
+
const onKey = event => {
|
|
48
|
+
event.stopPropagation()
|
|
49
|
+
if (event.key === 'Escape') { event.preventDefault(); onCancel() }
|
|
50
|
+
else if (event.key === 'Tab') {
|
|
51
|
+
const items = [...pop.querySelectorAll('button')]
|
|
52
|
+
const at = items.indexOf(document.activeElement)
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
items[(at + (event.shiftKey ? items.length - 1 : 1)) % items.length].focus()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const onDown = event => { if (!pop.parentElement.contains(event.target)) onCancel() }
|
|
58
|
+
window.addEventListener('keydown', onKey, true)
|
|
59
|
+
window.addEventListener('pointerdown', onDown, true)
|
|
60
|
+
return () => {
|
|
61
|
+
window.removeEventListener('keydown', onKey, true)
|
|
62
|
+
window.removeEventListener('pointerdown', onDown, true)
|
|
63
|
+
opener?.focus?.()
|
|
64
|
+
}
|
|
65
|
+
}, [])
|
|
66
|
+
return <div class="ink-pop" ref={popRef} role="alertdialog" aria-label={message}>
|
|
67
|
+
<span class="ink-pop-text">{message}</span>
|
|
68
|
+
<button type="button" class="ink-pop-btn" ref={cancelRef} onClick={onCancel}>Cancel</button>
|
|
69
|
+
<button type="button" class="ink-pop-btn is-danger" onClick={onConfirm}>{confirmLabel}</button>
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function InkToolbar({ stage, controller, selection, onDone }) {
|
|
74
|
+
const [tool, setTool] = useState(TOOLS.some(([id]) => id === stage.inkTool?.tool) ? stage.inkTool.tool : 'pen')
|
|
75
|
+
const [style, setStyle] = useState(() => ({ pen: { color: INK_COLORS[0], sizeIndex: 1 }, highlighter: { color: HIGHLIGHTER_COLORS[0], sizeIndex: 1 }, ...stage._inkStyle }))
|
|
76
|
+
const [selected, setSelected] = useState(0)
|
|
77
|
+
const [collapsed, setCollapsed] = useState(!!stage._inkCollapsed)
|
|
78
|
+
const [hidden, setHidden] = useState(stage.hasAttribute('data-ink-hidden'))
|
|
79
|
+
const [finger, setFinger] = useState(!!stage.inkFinger)
|
|
80
|
+
const [clearing, setClearing] = useState(null) // the slide id awaiting confirmation
|
|
81
|
+
const [history, setHistory] = useState({ canUndo: false, canRedo: false, unsaved: false })
|
|
82
|
+
|
|
83
|
+
useEffect(() => controller.subscribe(setHistory), [controller])
|
|
84
|
+
useEffect(() => selection?.subscribe(setSelected), [selection])
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const { color, sizeIndex } = style[tool] ?? style.pen
|
|
87
|
+
stage.inkTool = { tool, color, size: (SIZES[tool] ?? SIZES.pen)[sizeIndex] }
|
|
88
|
+
// Remembered while the page is open, when drawing starts again.
|
|
89
|
+
stage._inkStyle = style
|
|
90
|
+
stage.setAttribute('data-ink-tool', tool)
|
|
91
|
+
if (tool !== 'select') selection?.clear()
|
|
92
|
+
}, [tool, style])
|
|
93
|
+
const setOwn = change => setStyle(all => ({ ...all, [tool]: { ...all[tool], ...change } }))
|
|
94
|
+
useEffect(() => { stage._inkCollapsed = collapsed }, [collapsed])
|
|
95
|
+
useEffect(() => { stage.toggleAttribute('data-ink-hidden', hidden) }, [hidden])
|
|
96
|
+
useEffect(() => { stage.inkFinger = finger }, [finger])
|
|
97
|
+
|
|
98
|
+
const slideId = () => stage._inkSlideId?.()
|
|
99
|
+
const button = (label, icon, action, { active = false, disabled = false } = {}) =>
|
|
100
|
+
<button type="button" class={`ink-btn${active ? ' is-active' : ''}`} title={label} aria-label={label} aria-pressed={active} disabled={disabled} onClick={action}><Icon name={icon} size={20} /></button>
|
|
101
|
+
|
|
102
|
+
const writing = !!COLORS[tool]
|
|
103
|
+
if (collapsed) {
|
|
104
|
+
const [, label] = TOOLS.find(([id]) => id === tool)
|
|
105
|
+
return <div class="ink-toolbar is-collapsed" role="toolbar" aria-label="Ink" data-ink-ui>
|
|
106
|
+
<button type="button" class="ink-btn ink-expand" title={`${label}: show the toolbar`} aria-label={`${label}: show the toolbar`} style={writing ? { '--tool-color': inkPaint(style[tool].color) } : {}} onClick={() => setCollapsed(false)}><Icon name={tool} size={20} /></button>
|
|
107
|
+
</div>
|
|
108
|
+
}
|
|
109
|
+
return <div class="ink-toolbar" role="toolbar" aria-label="Ink" data-ink-ui>
|
|
110
|
+
<div class="ink-group">
|
|
111
|
+
{TOOLS.map(([id, label]) => button(label, id, () => setTool(id), { active: tool === id }))}
|
|
112
|
+
</div>
|
|
113
|
+
{writing && <div class="ink-group">
|
|
114
|
+
{COLORS[tool].map(value => <button type="button" key={value} class={`ink-color${value === style[tool].color ? ' is-active' : ''}`} style={{ '--swatch': inkPaint(value) }} title={COLOR_NAMES[value] ?? value} aria-label={`Colour ${COLOR_NAMES[value] ?? value}`} aria-pressed={value === style[tool].color} onClick={() => setOwn({ color: value })} />)}
|
|
115
|
+
</div>}
|
|
116
|
+
{writing && <div class="ink-group">
|
|
117
|
+
{[0, 1, 2].map(i => <button type="button" key={i} class={`ink-size${i === style[tool].sizeIndex ? ' is-active' : ''}`} title={['Thin', 'Medium', 'Thick'][i]} aria-label={['Thin', 'Medium', 'Thick'][i]} aria-pressed={i === style[tool].sizeIndex} onClick={() => setOwn({ sizeIndex: i })}><span style={{ width: `${6 + i * 5}px`, height: `${6 + i * 5}px` }} /></button>)}
|
|
118
|
+
</div>}
|
|
119
|
+
{selected > 0 && <div class="ink-group">
|
|
120
|
+
{button('Delete selection (Delete)', 'trash', () => selection.remove())}
|
|
121
|
+
</div>}
|
|
122
|
+
<div class="ink-group">
|
|
123
|
+
{button('Undo', 'undo', () => controller.undo(), { disabled: !history.canUndo })}
|
|
124
|
+
{button('Redo', 'redo', () => controller.redo(), { disabled: !history.canRedo })}
|
|
125
|
+
<span class="ink-anchor">
|
|
126
|
+
{button('Clear this slide', 'trash', () => { const id = slideId(); setClearing(open => open || !id ? null : id) })}
|
|
127
|
+
{clearing && <ConfirmPopover
|
|
128
|
+
message="Clear this slide?"
|
|
129
|
+
confirmLabel="Clear"
|
|
130
|
+
onCancel={() => setClearing(null)}
|
|
131
|
+
onConfirm={() => { controller.clearSlide(clearing); setClearing(null) }}
|
|
132
|
+
/>}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="ink-group">
|
|
136
|
+
{button(hidden ? 'Show saved ink' : 'Hide saved ink', hidden ? 'eye-off' : 'eye', () => setHidden(!hidden), { active: hidden })}
|
|
137
|
+
{button(finger ? 'Fingers draw (on)' : 'Fingers draw (off: fingers swipe and select)', 'finger', () => setFinger(!finger), { active: finger })}
|
|
138
|
+
{!history.saving && button(history.unsaved ? 'Download the drawings file (changes are only in this browser)' : 'Download the drawings file', 'download', () => controller.download(), { active: history.unsaved })}
|
|
139
|
+
</div>
|
|
140
|
+
<div class="ink-group">
|
|
141
|
+
{button('Fold the toolbar', 'fold', () => setCollapsed(true))}
|
|
142
|
+
{button('Stop drawing (D)', 'close', onDone)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Drawing on a deck window: connects a <deck-stage> with an ink controller
|
|
2
|
+
// and the floating toolbar. D starts and ends drawing, Escape ends it,
|
|
3
|
+
// Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes, Delete removes the selection.
|
|
4
|
+
import { h, render } from 'preact'
|
|
5
|
+
import { strokePath } from '../../core/ink.js'
|
|
6
|
+
import { createInkController, readLog } from './controller.js'
|
|
7
|
+
import { changeInk, renameSlide } from './store.js'
|
|
8
|
+
import { createInkBus, broadcastTransport } from './bus.js'
|
|
9
|
+
import { InkToolbar } from './Toolbar.jsx'
|
|
10
|
+
import { connectInkServer } from './server.js'
|
|
11
|
+
import { createInkSelection } from './select.js'
|
|
12
|
+
|
|
13
|
+
const typing = target => target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')
|
|
14
|
+
|
|
15
|
+
const channelName = storageKey => `mdeck-ink-live:${storageKey}`
|
|
16
|
+
|
|
17
|
+
/** A window that only shows the ink others draw, such as the audience window. */
|
|
18
|
+
export function watchInk(stage, { storageKey, transports = [] } = {}) {
|
|
19
|
+
stage.inkRenderer = strokePath
|
|
20
|
+
for (const op of readLog(storageKey)) changeInk(op)
|
|
21
|
+
// Served by `mdeck dev`: slides that got an id while this window was open.
|
|
22
|
+
import.meta.hot?.on('mdeck:ink', ({ renamed = [] } = {}) => { for (const { from, to } of renamed) renameSlide(from, to) })
|
|
23
|
+
return createInkBus(stage, [broadcastTransport(channelName(storageKey)), ...transports])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachInk(stage, { storageKey, save = null, keys = true, transports = [], onMode = () => {} } = {}) {
|
|
27
|
+
stage.inkRenderer = strokePath
|
|
28
|
+
const bus = createInkBus(stage, [broadcastTransport(channelName(storageKey)), ...transports])
|
|
29
|
+
const controller = createInkController({ storageKey, save, publish: op => bus.op(op) })
|
|
30
|
+
// Saving through `mdeck run` when it lets this page; otherwise in this browser.
|
|
31
|
+
if (!save) connectInkServer(controller)
|
|
32
|
+
stage.addEventListener('inkstroke', event => { controller.addStroke(event.detail); bus.strokeDone(event.detail) })
|
|
33
|
+
stage.addEventListener('inklaser', event => bus.laserDone(event.detail))
|
|
34
|
+
stage.addEventListener('inkerase', event => controller.erase(event.detail))
|
|
35
|
+
const selection = createInkSelection(stage, controller)
|
|
36
|
+
|
|
37
|
+
const host = document.createElement('div')
|
|
38
|
+
host.className = 'ink-toolbar-host'
|
|
39
|
+
document.body.appendChild(host)
|
|
40
|
+
const done = () => { stage.inking = false }
|
|
41
|
+
const showToolbar = on => render(on ? h(InkToolbar, { stage, controller, selection, onDone: done }) : null, host)
|
|
42
|
+
stage.setAttribute('data-ink-enabled', '')
|
|
43
|
+
stage.addEventListener('inkmode', event => {
|
|
44
|
+
showToolbar(event.detail.inking)
|
|
45
|
+
document.documentElement.classList.toggle('is-inking', event.detail.inking)
|
|
46
|
+
onMode(event.detail.inking)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (keys) {
|
|
50
|
+
window.addEventListener('keydown', event => {
|
|
51
|
+
if (typing(event.target)) return
|
|
52
|
+
const mod = event.metaKey || event.ctrlKey
|
|
53
|
+
if (!mod && !event.altKey && (event.key === 'd' || event.key === 'D')) {
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
stage.inking = !stage.inking
|
|
56
|
+
} else if (event.key === 'Escape' && stage.inking) {
|
|
57
|
+
stage.inking = false
|
|
58
|
+
} else if ((event.key === 'Delete' || event.key === 'Backspace') && stage.inking && selection.count() > 0) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
selection.remove()
|
|
61
|
+
} else if (mod && (event.key === 'z' || event.key === 'Z') && stage.inking) {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
if (event.shiftKey) controller.redo()
|
|
64
|
+
else controller.undo()
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return controller
|
|
69
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Live ink between the windows of one talk: the presenter's view, the
|
|
2
|
+
// audience window, other deck windows. Strokes show while they are drawn,
|
|
3
|
+
// and every change of the ink (a finished stroke, the eraser, undo) reaches
|
|
4
|
+
// the others right away, whether or not a server saves it.
|
|
5
|
+
//
|
|
6
|
+
// Messages, the same on every transport:
|
|
7
|
+
// { type: 'segment', key, slideId, tool, color, size, from, points }
|
|
8
|
+
// points `from` index on, every ~60 ms while drawing
|
|
9
|
+
// { type: 'end', key, fade, stroke? } the stroke is finished; a laser
|
|
10
|
+
// trail (tool 'laser', never saved) retracts on its own
|
|
11
|
+
// { type: 'dot', key, slideId, point } the laser's dot (null: hidden), as
|
|
12
|
+
// a hovering pen moves, at most every ~50 ms
|
|
13
|
+
// { type: 'zoom', key, slideId, scale, x, y } the part of the slide this
|
|
14
|
+
// window shows (stage zoom), at most every ~50 ms
|
|
15
|
+
// { type: 'reveal', key, room, shown } a poll's right answer shown or hidden
|
|
16
|
+
// { type: 'op', op } a change of the saved ink (applyOp)
|
|
17
|
+
// Keys start with the sending window's id, so strokes never mix.
|
|
18
|
+
import { changeInk, renameSlide } from './store.js'
|
|
19
|
+
|
|
20
|
+
const SEGMENT_MS = 60
|
|
21
|
+
const DOT_MS = 50
|
|
22
|
+
|
|
23
|
+
export function broadcastTransport(name) {
|
|
24
|
+
if (typeof BroadcastChannel === 'undefined') return null
|
|
25
|
+
const channel = new BroadcastChannel(name)
|
|
26
|
+
return {
|
|
27
|
+
send: message => channel.postMessage(message),
|
|
28
|
+
listen: handler => { channel.onmessage = ({ data }) => handler(data) },
|
|
29
|
+
close: () => channel.close(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createInkBus(stage, transports) {
|
|
34
|
+
transports = transports.filter(Boolean)
|
|
35
|
+
const me = Math.random().toString(36).slice(2, 8)
|
|
36
|
+
const send = message => { for (const transport of transports) transport.send(message) }
|
|
37
|
+
|
|
38
|
+
// Strokes from others, by key: the points so far.
|
|
39
|
+
const incoming = new Map()
|
|
40
|
+
function receive(message) {
|
|
41
|
+
if (!message || typeof message !== 'object' || message.key?.startsWith(`${me}:`)) return
|
|
42
|
+
if (message.type === 'op') {
|
|
43
|
+
if (message.op?.type === 'rename') renameSlide(message.op.from, message.op.to)
|
|
44
|
+
else changeInk(message.op)
|
|
45
|
+
}
|
|
46
|
+
else if (message.type === 'segment') {
|
|
47
|
+
const known = incoming.get(message.key) ?? { ...message, points: [] }
|
|
48
|
+
// A lost segment leaves a gap until the end message repairs it.
|
|
49
|
+
if (message.from <= known.points.length) known.points = [...known.points.slice(0, message.from), ...message.points]
|
|
50
|
+
incoming.set(message.key, known)
|
|
51
|
+
stage.liveStroke(message.key, known)
|
|
52
|
+
} else if (message.type === 'reveal') {
|
|
53
|
+
stage.dispatchEvent(new CustomEvent('pollreveal', { detail: { room: message.room, shown: !!message.shown } }))
|
|
54
|
+
} else if (message.type === 'zoom') {
|
|
55
|
+
if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
|
|
56
|
+
} else if (message.type === 'dot') {
|
|
57
|
+
stage.laserDot(message.key, message.slideId, message.point)
|
|
58
|
+
} else if (message.type === 'end') {
|
|
59
|
+
if (message.stroke) stage.liveStroke(message.key, message.stroke)
|
|
60
|
+
incoming.delete(message.key)
|
|
61
|
+
stage.endLiveStroke(message.key, { fade: !!message.fade })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const transport of transports) transport.listen(receive)
|
|
65
|
+
|
|
66
|
+
// Own strokes in progress, sent in small pieces.
|
|
67
|
+
const outgoing = new Map()
|
|
68
|
+
function flush(key) {
|
|
69
|
+
const entry = outgoing.get(key)
|
|
70
|
+
if (!entry) return
|
|
71
|
+
clearTimeout(entry.timer)
|
|
72
|
+
entry.timer = null
|
|
73
|
+
const { detail, sent } = entry
|
|
74
|
+
if (detail.points.length > sent) {
|
|
75
|
+
send({ type: 'segment', key: `${me}:${key}`, slideId: detail.slideId, tool: detail.tool, color: detail.color, size: detail.size, from: sent, points: detail.points.slice(sent) })
|
|
76
|
+
entry.sent = detail.points.length
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
stage.addEventListener('inkprogress', ({ detail }) => {
|
|
80
|
+
let entry = outgoing.get(detail.key)
|
|
81
|
+
if (!entry) outgoing.set(detail.key, entry = { detail, sent: 0, timer: null })
|
|
82
|
+
entry.detail = detail
|
|
83
|
+
// A straightened stroke replaces its points: send them again from the start.
|
|
84
|
+
if (detail.restart) entry.sent = 0
|
|
85
|
+
entry.timer ??= setTimeout(() => flush(detail.key), entry.sent ? SEGMENT_MS : 0)
|
|
86
|
+
})
|
|
87
|
+
const finish = (detail, extra = {}) => {
|
|
88
|
+
flush(detail.key)
|
|
89
|
+
outgoing.delete(detail.key)
|
|
90
|
+
send({ type: 'end', key: `${me}:${detail.key}`, ...extra })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The laser's dot: the latest position, not every one.
|
|
94
|
+
let dot = null, dotTimer = null
|
|
95
|
+
const sendDot = () => { dotTimer = null; if (dot) send({ type: 'dot', key: `${me}:dot`, ...dot }) }
|
|
96
|
+
stage.addEventListener('inklaserdot', ({ detail }) => {
|
|
97
|
+
dot = detail
|
|
98
|
+
if (!detail.point) { clearTimeout(dotTimer); dotTimer = null; sendDot(); return }
|
|
99
|
+
dotTimer ??= setTimeout(sendDot, DOT_MS)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// A poll's right answer, shown here: the others show it too.
|
|
103
|
+
stage.addEventListener('pollreveal', ({ detail }) => {
|
|
104
|
+
if (detail?.local) send({ type: 'reveal', key: `${me}:reveal`, room: detail.room, shown: !!detail.shown })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// The zoom: the latest view, not every step of a pinch.
|
|
108
|
+
let zoom = null, zoomTimer = null
|
|
109
|
+
const sendZoom = () => { zoomTimer = null; if (zoom) send({ type: 'zoom', key: `${me}:zoom`, ...zoom }) }
|
|
110
|
+
stage.addEventListener('zoomchange', ({ detail }) => {
|
|
111
|
+
if (detail.reason === 'remote') return
|
|
112
|
+
const { slideId, scale, x, y } = detail
|
|
113
|
+
zoom = { slideId, scale, x, y }
|
|
114
|
+
zoomTimer ??= setTimeout(sendZoom, scale > 1 ? DOT_MS : 0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
/** A change made in this window: the others apply it too. */
|
|
119
|
+
op(op) { send({ type: 'op', key: `${me}:op`, op }) },
|
|
120
|
+
/** The stroke with this key became saved ink (its op was sent first). */
|
|
121
|
+
strokeDone(detail) { finish(detail) },
|
|
122
|
+
/** A laser trail ended: the others let it retract, like this window. */
|
|
123
|
+
laserDone(detail) { finish(detail) },
|
|
124
|
+
close() { for (const transport of transports) transport.close?.() },
|
|
125
|
+
}
|
|
126
|
+
}
|