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,132 @@
|
|
|
1
|
+
// Drawing on a deck: turns the stage's ink events into changes of the ink,
|
|
2
|
+
// with undo and redo of this device's own changes; `publish(op)` passes each
|
|
3
|
+
// change on to the other windows (bus.js). Every change goes to
|
|
4
|
+
// `save(op)` when a server keeps the ink file; otherwise it is kept in this
|
|
5
|
+
// browser (localStorage) and the file can be downloaded.
|
|
6
|
+
import { currentInk, changeInk, slideStrokes } from './store.js'
|
|
7
|
+
import { simplify, hitTest, serializeInk } from '../../core/ink.js'
|
|
8
|
+
import { inkFileName } from 'virtual:deck-ink'
|
|
9
|
+
|
|
10
|
+
function deviceId() {
|
|
11
|
+
try {
|
|
12
|
+
let id = localStorage.getItem('mdeck-ink-device')
|
|
13
|
+
if (!id) localStorage.setItem('mdeck-ink-device', id = Math.random().toString(36).slice(2, 8))
|
|
14
|
+
return id
|
|
15
|
+
} catch { return (deviceId.fallback ??= Math.random().toString(36).slice(2, 8)) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const readLog = key => { try { return JSON.parse(localStorage.getItem(key) ?? '[]') } catch { return [] } }
|
|
19
|
+
const writeLog = (key, log) => { try { localStorage.setItem(key, JSON.stringify(log)); return true } catch { return false } }
|
|
20
|
+
|
|
21
|
+
export function createInkController({ storageKey, save = null, publish = () => {} } = {}) {
|
|
22
|
+
const device = deviceId()
|
|
23
|
+
let counter = 0
|
|
24
|
+
const undoStack = [], redoStack = []
|
|
25
|
+
const listeners = new Set()
|
|
26
|
+
const notify = () => { for (const listener of listeners) listener(state()) }
|
|
27
|
+
let saving = save
|
|
28
|
+
|
|
29
|
+
// Changes made in this browser without a server, from earlier visits too.
|
|
30
|
+
let log = saving ? [] : readLog(storageKey)
|
|
31
|
+
for (const op of log) changeInk(op)
|
|
32
|
+
|
|
33
|
+
const state = () => ({ canUndo: undoStack.length > 0, canRedo: redoStack.length > 0, unsaved: !saving && log.length > 0, saving: !!saving })
|
|
34
|
+
|
|
35
|
+
function send(ops) {
|
|
36
|
+
// Another window of this deck may have added to the log meanwhile.
|
|
37
|
+
if (!saving) log = readLog(storageKey)
|
|
38
|
+
for (const op of ops) {
|
|
39
|
+
changeInk(op)
|
|
40
|
+
publish(op)
|
|
41
|
+
if (saving) saving(op)
|
|
42
|
+
else log.push(op)
|
|
43
|
+
}
|
|
44
|
+
if (!saving) writeLog(storageKey, log)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A history entry is the list of changes that undoes it. Changes with the
|
|
48
|
+
// same `gesture` (one stroke of the eraser) are one entry.
|
|
49
|
+
function commit(ops, inverse, gesture = null) {
|
|
50
|
+
if (!ops.length) return
|
|
51
|
+
send(ops)
|
|
52
|
+
const last = undoStack[undoStack.length - 1]
|
|
53
|
+
if (gesture != null && last?.gesture === gesture) {
|
|
54
|
+
last.ops.push(...ops)
|
|
55
|
+
last.inverse.unshift(...inverse)
|
|
56
|
+
} else undoStack.push({ ops, inverse, gesture })
|
|
57
|
+
redoStack.length = 0
|
|
58
|
+
notify()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
device,
|
|
63
|
+
subscribe(listener) { listeners.add(listener); listener(state()); return () => listeners.delete(listener) },
|
|
64
|
+
/** Changes kept in this browser, not saved anywhere yet. */
|
|
65
|
+
localChanges: () => log.slice(),
|
|
66
|
+
/** Tells the other windows about a change already made here (a slide's new id). */
|
|
67
|
+
share(op) { publish(op) },
|
|
68
|
+
/** Sends a change without adding it to the undo history. */
|
|
69
|
+
replay(op) { send([op]) },
|
|
70
|
+
/** From here on, changes go to `save(op)` and the local copy is dropped. */
|
|
71
|
+
useServer(saveOp) {
|
|
72
|
+
saving = saveOp
|
|
73
|
+
log = []
|
|
74
|
+
try { localStorage.removeItem(storageKey) } catch {}
|
|
75
|
+
notify()
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
addStroke({ slideId, tool, color, size, points }) {
|
|
79
|
+
const stroke = { id: `${device}:${Date.now().toString(36)}${(counter++).toString(36)}`, tool, color, size, points: simplify(points, 0.5) }
|
|
80
|
+
commit([{ type: 'add', slideId, stroke }], [{ type: 'remove', slideId, ids: [stroke.id] }])
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
erase({ slideId, point, radius, gesture = null }) {
|
|
84
|
+
const hits = slideStrokes(slideId).filter(stroke => hitTest(stroke, point, radius))
|
|
85
|
+
if (!hits.length) return
|
|
86
|
+
commit([{ type: 'remove', slideId, ids: hits.map(stroke => stroke.id) }], hits.map(stroke => ({ type: 'add', slideId, stroke })), gesture == null ? null : `erase:${gesture}`)
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** Puts changed copies of strokes (the same ids) in place of the originals: moving a selection. */
|
|
90
|
+
replaceStrokes({ slideId, before, after }) {
|
|
91
|
+
if (!after.length) return
|
|
92
|
+
commit(after.map(stroke => ({ type: 'add', slideId, stroke })), before.map(stroke => ({ type: 'add', slideId, stroke })))
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
removeStrokes({ slideId, strokes }) {
|
|
96
|
+
if (!strokes.length) return
|
|
97
|
+
commit([{ type: 'remove', slideId, ids: strokes.map(stroke => stroke.id) }], strokes.map(stroke => ({ type: 'add', slideId, stroke })))
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
clearSlide(slideId) {
|
|
101
|
+
const strokes = slideStrokes(slideId)
|
|
102
|
+
if (!strokes.length) return
|
|
103
|
+
commit([{ type: 'clear', slideId, ids: strokes.map(stroke => stroke.id) }], strokes.map(stroke => ({ type: 'add', slideId, stroke })))
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
undo() {
|
|
107
|
+
const entry = undoStack.pop()
|
|
108
|
+
if (!entry) return
|
|
109
|
+
send(entry.inverse)
|
|
110
|
+
redoStack.push(entry)
|
|
111
|
+
notify()
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
redo() {
|
|
115
|
+
const entry = redoStack.pop()
|
|
116
|
+
if (!entry) return
|
|
117
|
+
send(entry.ops)
|
|
118
|
+
undoStack.push(entry)
|
|
119
|
+
notify()
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** Saves the ink as its file, for putting it next to the deck. */
|
|
123
|
+
download() {
|
|
124
|
+
const blob = new Blob([serializeInk(currentInk())], { type: 'application/json' })
|
|
125
|
+
const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: inkFileName })
|
|
126
|
+
document.body.appendChild(link)
|
|
127
|
+
link.click()
|
|
128
|
+
link.remove()
|
|
129
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 1000)
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The talk across devices, through the deck's stage room on the server
|
|
2
|
+
// (built into `mdeck run`, or `mdeck server`): an iPad draws and steers, the
|
|
3
|
+
// laptop's audience window on the projector follows, and navigation on the
|
|
4
|
+
// laptop reaches the iPad too.
|
|
5
|
+
//
|
|
6
|
+
// Main slide frames and audience windows share one connection for ink and
|
|
7
|
+
// position. Next-slide previews do not connect, since browsers allow few
|
|
8
|
+
// connections per server.
|
|
9
|
+
import { stageRoom } from '../../live/client.js'
|
|
10
|
+
import { presenceQuery } from '../../live/presence.js'
|
|
11
|
+
|
|
12
|
+
const BATCH_MS = 80
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An ink bus transport through the stage room; `listen` only when `receive`.
|
|
16
|
+
* `view` counts this window as connected; `onPresence` hears who else is.
|
|
17
|
+
*/
|
|
18
|
+
export function roomTransport({ receive = false, view = null, onPresence = null } = {}) {
|
|
19
|
+
const room = stageRoom()
|
|
20
|
+
let queue = [], timer = null, allowed = null, listeners = 1, source = null
|
|
21
|
+
|
|
22
|
+
async function flush() {
|
|
23
|
+
timer = null
|
|
24
|
+
if (!queue.length) return
|
|
25
|
+
const messages = queue.splice(0, 100)
|
|
26
|
+
if (allowed === null) {
|
|
27
|
+
const info = await room.info()
|
|
28
|
+
allowed = info.reachable ? !!info.canReset : null
|
|
29
|
+
if (!allowed) { queue = []; return }
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${room.url}/ink`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ messages }) })
|
|
33
|
+
if (response.status === 403) { allowed = false; queue = []; return }
|
|
34
|
+
if (response.ok) listeners = (await response.json()).listeners ?? 1
|
|
35
|
+
} catch {}
|
|
36
|
+
if (queue.length) timer = setTimeout(flush, BATCH_MS)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
send(message) {
|
|
41
|
+
if (allowed === false) return
|
|
42
|
+
// Nobody on another device watches: strokes in progress can stay here.
|
|
43
|
+
// Strokes in progress, the laser's dot and the zoom only matter to
|
|
44
|
+
// someone watching.
|
|
45
|
+
const live = message.type === 'segment' || message.type === 'dot' || message.type === 'zoom'
|
|
46
|
+
if (live && listeners === 0) return
|
|
47
|
+
queue.push(message)
|
|
48
|
+
timer ??= setTimeout(flush, live ? BATCH_MS : 0)
|
|
49
|
+
},
|
|
50
|
+
listen(handler) {
|
|
51
|
+
if (!receive || typeof EventSource === 'undefined') return
|
|
52
|
+
source = new EventSource(`${room.url}/events?${presenceQuery({ view, listen: !!onPresence })}`)
|
|
53
|
+
source.addEventListener('snapshot', event => {
|
|
54
|
+
const snapshot = JSON.parse(event.data)
|
|
55
|
+
for (const message of snapshot.ink ?? []) handler(message)
|
|
56
|
+
if (snapshot.presence) onPresence?.(snapshot.presence)
|
|
57
|
+
})
|
|
58
|
+
if (onPresence) source.addEventListener('presence', event => onPresence(JSON.parse(event.data)))
|
|
59
|
+
source.addEventListener('ink', event => { for (const message of JSON.parse(event.data).messages) handler(message) })
|
|
60
|
+
for (const listener of stateListeners) source.addEventListener('snapshot', event => listener(JSON.parse(event.data).state))
|
|
61
|
+
for (const listener of stateListeners) source.addEventListener('state', event => listener(JSON.parse(event.data).state))
|
|
62
|
+
},
|
|
63
|
+
close() { source?.close() },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Position followers, attached before the transport listens.
|
|
68
|
+
const stateListeners = new Set()
|
|
69
|
+
|
|
70
|
+
/** Follow navigation from another device in either kind of main slide window. */
|
|
71
|
+
export function followStageRoom(stage) {
|
|
72
|
+
stateListeners.add(state => {
|
|
73
|
+
if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
|
|
74
|
+
const current = stage.state
|
|
75
|
+
if (current?.index === state.index && current?.step === state.step) return
|
|
76
|
+
stage.setState({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Publish navigation made here; received positions never echo back. */
|
|
81
|
+
export function announceStagePosition(stage) {
|
|
82
|
+
const room = stageRoom()
|
|
83
|
+
let allowed = null, last = null
|
|
84
|
+
async function post(event) {
|
|
85
|
+
const { index, step, slideId } = stage.state
|
|
86
|
+
const key = `${slideId}:${index}:${step}`
|
|
87
|
+
if (event?.detail.reason === 'sync') { last = key; return }
|
|
88
|
+
if (key === last || allowed === false) return
|
|
89
|
+
// Opening a window must not move the projector; only changes count.
|
|
90
|
+
const at = !event || event.detail.reason === 'init' ? 0 : Date.now()
|
|
91
|
+
last = key
|
|
92
|
+
if (allowed === null) {
|
|
93
|
+
const info = await room.info()
|
|
94
|
+
allowed = info.reachable && !!info.canReset
|
|
95
|
+
if (!allowed) return
|
|
96
|
+
}
|
|
97
|
+
fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, at, screen: room.screen } }) }).catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
stage.addEventListener('statechange', post)
|
|
100
|
+
post()
|
|
101
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Selecting and moving drawn strokes. The select tool's lasso (`inkselect`,
|
|
2
|
+
// { phase, slideId, point, points } in design pixels) selects the strokes at
|
|
3
|
+
// least half inside it, its tap the topmost stroke under it; a finger's tap
|
|
4
|
+
// while drawing (`inktap`) selects the stroke under the finger. A selection
|
|
5
|
+
// then takes the pen that lands on it, whatever the tool: inside its box the
|
|
6
|
+
// pen moves it, and a straight line (two points) shows handles at its ends
|
|
7
|
+
// that the pen moves one at a time. Changes show at once in this window and
|
|
8
|
+
// become one change (undo puts the strokes back) when the pen lifts.
|
|
9
|
+
import { slideStrokes, changeInk, onInkChange } from './store.js'
|
|
10
|
+
import { strokesInLasso, strokesBox, translateStroke, hitTest } from '../../core/ink.js'
|
|
11
|
+
|
|
12
|
+
const PAD = 14 // design pixels around a selection
|
|
13
|
+
const TAP = 10 // a lasso smaller than this is a tap
|
|
14
|
+
const HANDLE = 40 // design pixels around an end point that grab it
|
|
15
|
+
|
|
16
|
+
export function createInkSelection(stage, controller) {
|
|
17
|
+
let slideId = null, ids = new Set(), drag = null
|
|
18
|
+
const listeners = new Set()
|
|
19
|
+
const selected = () => slideStrokes(slideId).filter(stroke => ids.has(stroke.id))
|
|
20
|
+
// The end points of a lone straight line.
|
|
21
|
+
const handles = () => { const strokes = selected(); return strokes.length === 1 && strokes[0].points.length === 2 ? strokes[0].points : [] }
|
|
22
|
+
|
|
23
|
+
function show(lasso = null) {
|
|
24
|
+
const strokes = slideId ? selected() : []
|
|
25
|
+
stage.showSelection({ box: strokes.length ? strokesBox(strokes, PAD) : null, lasso, handles: drag?.handle == null ? handles() : [] })
|
|
26
|
+
}
|
|
27
|
+
function select(id, strokes) {
|
|
28
|
+
slideId = id
|
|
29
|
+
ids = new Set(strokes.map(stroke => stroke.id))
|
|
30
|
+
for (const listener of listeners) listener(ids.size)
|
|
31
|
+
show()
|
|
32
|
+
}
|
|
33
|
+
const handleAt = point => handles().findIndex(([x, y]) => Math.hypot(point[0] - x, point[1] - y) <= HANDLE)
|
|
34
|
+
const insideBox = point => {
|
|
35
|
+
const box = ids.size ? strokesBox(selected(), PAD) : null
|
|
36
|
+
return !!box && point[0] >= box[0] && point[0] <= box[2] && point[1] >= box[1] && point[1] <= box[3]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The stage asks before it lets the pen draw.
|
|
40
|
+
stage.inkSelectionHit = point => !!ids.size && stage._inkSlideId?.() === slideId && (handleAt(point) >= 0 || insideBox(point))
|
|
41
|
+
stage.inkSelectionClear = () => clear()
|
|
42
|
+
|
|
43
|
+
stage.addEventListener('inktap', ({ detail }) => {
|
|
44
|
+
const hit = [...slideStrokes(detail.slideId)].reverse().find(stroke => hitTest(stroke, detail.point, detail.radius ?? 22))
|
|
45
|
+
select(detail.slideId, hit ? [hit] : [])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
stage.addEventListener('inkselect', ({ detail }) => {
|
|
49
|
+
const { phase, point, points } = detail
|
|
50
|
+
if (phase === 'down') {
|
|
51
|
+
const handle = detail.slideId === slideId ? handleAt(point) : -1
|
|
52
|
+
if (handle >= 0) {
|
|
53
|
+
drag = { handle, original: selected()[0] }
|
|
54
|
+
} else if (detail.slideId === slideId && insideBox(point)) {
|
|
55
|
+
drag = { from: point, originals: selected() }
|
|
56
|
+
} else {
|
|
57
|
+
drag = null
|
|
58
|
+
select(detail.slideId, [])
|
|
59
|
+
}
|
|
60
|
+
show()
|
|
61
|
+
} else if (phase === 'move') {
|
|
62
|
+
if (drag?.handle != null) {
|
|
63
|
+
changeInk({ type: 'add', slideId, stroke: moveEnd(drag.original, drag.handle, point) })
|
|
64
|
+
show()
|
|
65
|
+
} else if (drag) {
|
|
66
|
+
const dx = point[0] - drag.from[0], dy = point[1] - drag.from[1]
|
|
67
|
+
for (const stroke of drag.originals) changeInk({ type: 'add', slideId, stroke: translateStroke(stroke, dx, dy) })
|
|
68
|
+
show()
|
|
69
|
+
} else show(points)
|
|
70
|
+
} else if (phase === 'up') {
|
|
71
|
+
if (drag?.handle != null) {
|
|
72
|
+
const { original, handle } = drag
|
|
73
|
+
drag = null
|
|
74
|
+
const moved = moveEnd(original, handle, point)
|
|
75
|
+
if (moved.points[handle][0] !== original.points[handle][0] || moved.points[handle][1] !== original.points[handle][1]) {
|
|
76
|
+
controller.replaceStrokes({ slideId, before: [original], after: [moved] })
|
|
77
|
+
}
|
|
78
|
+
show()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (drag) {
|
|
82
|
+
const dx = point[0] - drag.from[0], dy = point[1] - drag.from[1]
|
|
83
|
+
const { originals } = drag
|
|
84
|
+
drag = null
|
|
85
|
+
if (dx || dy) controller.replaceStrokes({ slideId, before: originals, after: originals.map(stroke => translateStroke(stroke, dx, dy)) })
|
|
86
|
+
show()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const strokes = slideStrokes(detail.slideId)
|
|
90
|
+
const span = strokesBox([{ points, size: 0 }])
|
|
91
|
+
const tap = span[2] - span[0] < TAP && span[3] - span[1] < TAP
|
|
92
|
+
select(detail.slideId, tap ? [...strokes].reverse().filter(stroke => hitTest(stroke, point, 12)).slice(0, 1) : strokesInLasso(strokes, points))
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Strokes that went away (eraser, undo, another window) leave the selection.
|
|
97
|
+
onInkChange(() => {
|
|
98
|
+
if (!ids.size || drag) return
|
|
99
|
+
const present = new Set(selected().map(stroke => stroke.id))
|
|
100
|
+
if (present.size !== ids.size) select(slideId, selected())
|
|
101
|
+
else show()
|
|
102
|
+
})
|
|
103
|
+
function clear() { drag = null; if (ids.size || slideId) select(null, []) }
|
|
104
|
+
stage.addEventListener('slidechange', clear)
|
|
105
|
+
stage.addEventListener('inkmode', event => { if (!event.detail.inking) clear() })
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
/** Calls `listener(count)` whenever the selection changes. */
|
|
109
|
+
subscribe(listener) { listeners.add(listener); listener(ids.size); return () => listeners.delete(listener) },
|
|
110
|
+
clear,
|
|
111
|
+
count: () => ids.size,
|
|
112
|
+
remove() {
|
|
113
|
+
const strokes = selected()
|
|
114
|
+
if (!strokes.length) return
|
|
115
|
+
controller.removeStrokes({ slideId, strokes })
|
|
116
|
+
select(slideId, [])
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The stroke with one end point moved to `point` (its pressure kept).
|
|
122
|
+
function moveEnd(stroke, index, [x, y]) {
|
|
123
|
+
return { ...stroke, points: stroke.points.map((p, i) => i === index ? [x, y, p[2]] : p) }
|
|
124
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Saving ink through `mdeck run` (src/build/inkPlugin.js). When the page was
|
|
2
|
+
// served by a dev server that lets it save, the controller's changes go
|
|
3
|
+
// there, batched, and changes from other windows arrive as `mdeck:ink`.
|
|
4
|
+
// Anywhere else (a built deck, a device that may not save) this returns false
|
|
5
|
+
// and the ink stays in this browser.
|
|
6
|
+
import { replaceInk, renameSlide } from './store.js'
|
|
7
|
+
import { pairHeaders } from '../../live/pairing.js'
|
|
8
|
+
import { devPath } from '../../core/devPath.js'
|
|
9
|
+
|
|
10
|
+
const BASE = devPath('__mdeck/ink')
|
|
11
|
+
|
|
12
|
+
// A paired iPad sends its token; this computer needs none.
|
|
13
|
+
export async function connectInkServer(controller, { headers = () => pairHeaders() } = {}) {
|
|
14
|
+
let response
|
|
15
|
+
try { response = await fetch(BASE, { cache: 'no-store', headers: headers() }) } catch { return false }
|
|
16
|
+
if (!response.ok) return false
|
|
17
|
+
let { ink, deckHash } = await response.json()
|
|
18
|
+
|
|
19
|
+
// Changes made in this browser before the server was there go to it first.
|
|
20
|
+
const earlier = controller.localChanges()
|
|
21
|
+
replaceInk(ink)
|
|
22
|
+
let queue = [], sending = false, stale = false
|
|
23
|
+
|
|
24
|
+
async function refresh() {
|
|
25
|
+
if (sending || queue.length) { stale = true; return }
|
|
26
|
+
try {
|
|
27
|
+
const fresh = await (await fetch(BASE, { cache: 'no-store', headers: headers() })).json()
|
|
28
|
+
if (sending || queue.length) { stale = true; return }
|
|
29
|
+
deckHash = fresh.deckHash
|
|
30
|
+
replaceInk(fresh.ink)
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function flush() {
|
|
35
|
+
if (sending || !queue.length) return
|
|
36
|
+
sending = true
|
|
37
|
+
const ops = queue
|
|
38
|
+
queue = []
|
|
39
|
+
try {
|
|
40
|
+
const reply = await fetch(`${BASE}/ops`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers() }, body: JSON.stringify({ deckHash, ops }) })
|
|
41
|
+
const body = await reply.json().catch(() => ({}))
|
|
42
|
+
if (reply.ok) {
|
|
43
|
+
deckHash = body.deckHash
|
|
44
|
+
// A slide got its id with this stroke: the audience window and other
|
|
45
|
+
// devices must use it too, or later strokes, the laser and the zoom
|
|
46
|
+
// would name a slide they do not know.
|
|
47
|
+
for (const { from, to } of body.renamed ?? []) { renameSlide(from, to); controller.share({ type: 'rename', from, to }) }
|
|
48
|
+
} else {
|
|
49
|
+
// The deck changed under this page: take the server's ink.
|
|
50
|
+
console.warn('mdeck: ink not saved:', body.error ?? reply.status)
|
|
51
|
+
stale = true
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn('mdeck: ink not saved:', error.message)
|
|
55
|
+
queue = [...ops, ...queue]
|
|
56
|
+
} finally {
|
|
57
|
+
sending = false
|
|
58
|
+
}
|
|
59
|
+
if (queue.length) flush()
|
|
60
|
+
else if (stale) { stale = false; refresh() }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const save = op => {
|
|
64
|
+
queue.push(op)
|
|
65
|
+
queueMicrotask(flush)
|
|
66
|
+
}
|
|
67
|
+
controller.useServer(save)
|
|
68
|
+
for (const op of earlier) controller.replay(op)
|
|
69
|
+
|
|
70
|
+
import.meta.hot?.on('mdeck:ink', ({ renamed = [] } = {}) => {
|
|
71
|
+
for (const { from, to } of renamed) renameSlide(from, to)
|
|
72
|
+
refresh()
|
|
73
|
+
})
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The deck's ink in the browser: what the ink file held when the page was
|
|
2
|
+
// built, plus changes made since. Components subscribe to one slide.
|
|
3
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
4
|
+
import bundled from 'virtual:deck-ink'
|
|
5
|
+
import { applyOp } from '../../core/ink.js'
|
|
6
|
+
|
|
7
|
+
let ink = bundled
|
|
8
|
+
const listeners = new Set()
|
|
9
|
+
// Slides that got an id while the page was open: the rendered slides still
|
|
10
|
+
// know their old id (slide-3), the ink uses the new one.
|
|
11
|
+
const aliases = new Map()
|
|
12
|
+
export const resolveSlideId = slideId => aliases.get(slideId) ?? slideId
|
|
13
|
+
|
|
14
|
+
export const currentInk = () => ink
|
|
15
|
+
export const slideStrokes = slideId => ink.slides[resolveSlideId(slideId)] ?? []
|
|
16
|
+
|
|
17
|
+
/** Replaces all ink, for example after the server sent the saved file. */
|
|
18
|
+
export function replaceInk(next) {
|
|
19
|
+
ink = next
|
|
20
|
+
for (const listener of listeners) listener(ink)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Applies one change (see applyOp in core/ink.js) and tells subscribers. */
|
|
24
|
+
export function changeInk(op) {
|
|
25
|
+
const next = applyOp(ink, op.slideId ? { ...op, slideId: resolveSlideId(op.slideId) } : op)
|
|
26
|
+
if (next !== ink) replaceInk(next)
|
|
27
|
+
return next
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A slide got a new id: its ink and the rendered slide follow. */
|
|
31
|
+
export function renameSlide(from, to) {
|
|
32
|
+
if (!from || !to || from === to) return
|
|
33
|
+
aliases.set(from, to)
|
|
34
|
+
for (const [old, current] of aliases) if (current === from) aliases.set(old, to)
|
|
35
|
+
for (const section of document.querySelectorAll(`[data-slide-id="${CSS.escape(from)}"]`)) section.dataset.slideId = to
|
|
36
|
+
changeInk({ type: 'rename', from, to })
|
|
37
|
+
replaceInk(ink)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Calls `listener(ink)` after every change; returns the way to stop. */
|
|
41
|
+
export function onInkChange(listener) {
|
|
42
|
+
listeners.add(listener)
|
|
43
|
+
return () => listeners.delete(listener)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useSlideInk(slideId) {
|
|
47
|
+
const [strokes, setStrokes] = useState(() => slideStrokes(slideId))
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const listener = () => setStrokes(slideStrokes(slideId))
|
|
50
|
+
listeners.add(listener)
|
|
51
|
+
listener()
|
|
52
|
+
return () => listeners.delete(listener)
|
|
53
|
+
}, [slideId])
|
|
54
|
+
return strokes
|
|
55
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* The floating ink toolbar, in the dark chrome of the other tools. It sits
|
|
2
|
+
above the stage and its ink layers, and the stage leaves its input alone. */
|
|
3
|
+
.ink-toolbar {
|
|
4
|
+
position: fixed; left: 0; right: 0; bottom: max(16px, env(safe-area-inset-bottom)); margin: 0 auto; width: fit-content;
|
|
5
|
+
z-index: 2147483000;
|
|
6
|
+
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
|
|
7
|
+
max-width: calc(100vw - 24px);
|
|
8
|
+
padding: 6px; border: 1px solid #3a3a3a; border-radius: 12px;
|
|
9
|
+
background: rgba(22, 22, 22, 0.94); color: #e6e6e6;
|
|
10
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
|
11
|
+
font: 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
12
|
+
/* Taps only: a pinch here must not zoom the page, and the toolbar with it. */
|
|
13
|
+
touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
|
14
|
+
}
|
|
15
|
+
/* Folded: one button in the bottom right corner, with the tool's colour. */
|
|
16
|
+
.ink-toolbar.is-collapsed {
|
|
17
|
+
left: auto; right: max(16px, env(safe-area-inset-right)); margin: 0;
|
|
18
|
+
padding: 4px; border-radius: 50%;
|
|
19
|
+
}
|
|
20
|
+
.ink-toolbar.is-collapsed .ink-btn { border-radius: 50%; position: relative; }
|
|
21
|
+
.ink-expand[style*="--tool-color"]::after {
|
|
22
|
+
content: ''; position: absolute; right: 5px; bottom: 5px; width: 10px; height: 10px; border-radius: 50%;
|
|
23
|
+
background: var(--tool-color); box-shadow: 0 0 0 1.5px rgba(22, 22, 22, 0.94);
|
|
24
|
+
}
|
|
25
|
+
.ink-group { display: flex; align-items: center; gap: 4px; padding: 0 4px; }
|
|
26
|
+
.ink-group + .ink-group { border-left: 1px solid #333; }
|
|
27
|
+
.ink-btn, .ink-color, .ink-size {
|
|
28
|
+
display: grid; place-items: center; width: 40px; height: 40px; padding: 0;
|
|
29
|
+
border: 1px solid transparent; border-radius: 8px; background: none; color: inherit; cursor: pointer;
|
|
30
|
+
-webkit-tap-highlight-color: transparent;
|
|
31
|
+
}
|
|
32
|
+
.ink-btn:hover:not(:disabled), .ink-size:hover { background: #262626; }
|
|
33
|
+
.ink-btn.is-active, .ink-size.is-active { background: #27436a; border-color: #3d6094; color: #fff; }
|
|
34
|
+
.ink-btn:disabled { opacity: 0.35; cursor: default; }
|
|
35
|
+
.ink-color::after { content: ''; width: 22px; height: 22px; border-radius: 50%; background: var(--swatch); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); }
|
|
36
|
+
.ink-color.is-active { border-color: #e6e6e6; }
|
|
37
|
+
.ink-size span { display: block; border-radius: 50%; background: currentColor; }
|
|
38
|
+
/* Narrow windows, such as the presenter view's slide beside the notes: a
|
|
39
|
+
little more compact, so the toolbar stays on one line. */
|
|
40
|
+
@media (max-width: 1000px) { .ink-btn, .ink-color, .ink-size { width: 34px; height: 34px; } .ink-group { gap: 2px; padding: 0 3px; } .ink-toolbar { gap: 4px; padding: 5px; } }
|
|
41
|
+
/* Fingers need bigger targets than a mouse. */
|
|
42
|
+
@media (pointer: coarse) { .ink-btn, .ink-color, .ink-size { width: 44px; height: 44px; } }
|
|
43
|
+
/* The small confirmation popover over the clear button. */
|
|
44
|
+
.ink-anchor { position: relative; display: flex; }
|
|
45
|
+
.ink-pop {
|
|
46
|
+
position: absolute; bottom: calc(100% + 12px); left: 50%; transform: translateX(calc(-50% + var(--shift, 0px)));
|
|
47
|
+
display: flex; align-items: center; gap: 6px; white-space: nowrap;
|
|
48
|
+
padding: 6px 6px 6px 12px; border: 1px solid #3a3a3a; border-radius: 10px;
|
|
49
|
+
background: #1c1c1c; color: #e6e6e6; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
|
50
|
+
animation: ink-pop-in 100ms ease-out;
|
|
51
|
+
}
|
|
52
|
+
/* A caret pointing at the button; it stays on the button when the popover is shifted. */
|
|
53
|
+
.ink-pop::after {
|
|
54
|
+
content: ''; position: absolute; top: 100%; left: calc(50% - var(--shift, 0px)); width: 9px; height: 9px;
|
|
55
|
+
margin: -5px 0 0 -5px; transform: rotate(45deg);
|
|
56
|
+
background: #1c1c1c; border: solid #3a3a3a; border-width: 0 1px 1px 0;
|
|
57
|
+
}
|
|
58
|
+
.ink-pop-text { padding-right: 4px; font-size: 13px; color: #cfcfcf; }
|
|
59
|
+
.ink-pop-btn {
|
|
60
|
+
height: 30px; padding: 0 12px; border: 1px solid #3a3a3a; border-radius: 7px;
|
|
61
|
+
background: #262626; color: inherit; font: 500 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif; cursor: pointer;
|
|
62
|
+
-webkit-tap-highlight-color: transparent;
|
|
63
|
+
}
|
|
64
|
+
.ink-pop-btn:hover { background: #303030; }
|
|
65
|
+
.ink-pop-btn.is-danger { background: #e11d48; border-color: #e11d48; color: #fff; }
|
|
66
|
+
.ink-pop-btn.is-danger:hover { background: #f43f5e; border-color: #f43f5e; }
|
|
67
|
+
.ink-pop-btn:focus-visible { outline: 2px solid #6ea0e8; outline-offset: 1px; }
|
|
68
|
+
@keyframes ink-pop-in { from { opacity: 0; margin-bottom: -4px; } }
|
|
69
|
+
@media (pointer: coarse) { .ink-pop-btn { height: 40px; padding: 0 16px; } .ink-pop-text { font-size: 14px; } }
|
|
70
|
+
@media (prefers-reduced-motion: reduce) { .ink-pop { animation: none; } }
|
|
71
|
+
/* While drawing, touches neither pan, zoom the page, rubber-band nor select
|
|
72
|
+
text: the pencil's strokes must not move the page (or, in the presenter
|
|
73
|
+
view, the page around the slide). Two fingers zoom the slide itself (the
|
|
74
|
+
stage's zoom), and taps still reach buttons and the stage. */
|
|
75
|
+
html.is-inking, html.is-inking body {
|
|
76
|
+
overscroll-behavior: none; overflow: hidden; touch-action: none;
|
|
77
|
+
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
|
78
|
+
}
|
|
79
|
+
@media print { .ink-toolbar { display: none !important; } }
|