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,1533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <deck-stage> — reusable web component for HTML decks.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* (a) speaker notes — reads <script type="application/json" id="speaker-notes">
|
|
6
|
+
* and posts {slideIndexChanged: N} to the parent window on nav.
|
|
7
|
+
* (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
|
|
8
|
+
* (c) press R to reset to slide 0 (with a tasteful keyboard hint).
|
|
9
|
+
* (d) bottom-center overlay showing slide count + hints, fades out on idle.
|
|
10
|
+
* (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
|
|
11
|
+
* scaled with `transform: scale()` to fit the viewport, letterboxed.
|
|
12
|
+
* Set the `noscale` attribute to render at authored size (1:1) — the
|
|
13
|
+
* PPTX exporter sets this so its DOM capture sees unscaled geometry.
|
|
14
|
+
* (f) print — `@media print` lays every slide out as its own page at the
|
|
15
|
+
* design size, so the browser's Print → Save as PDF produces a clean
|
|
16
|
+
* one-page-per-slide PDF with no extra setup. While printing, every
|
|
17
|
+
* step on every slide is revealed and the stage carries the
|
|
18
|
+
* `data-deck-static` attribute (see "Print mode" below).
|
|
19
|
+
*
|
|
20
|
+
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
|
|
21
|
+
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
|
|
22
|
+
* form inputs, React trees) is preserved across navigation.
|
|
23
|
+
*
|
|
24
|
+
* Lifecycle event — the component dispatches a `slidechange` CustomEvent on
|
|
25
|
+
* itself whenever the active slide changes (including the initial mount).
|
|
26
|
+
* The event bubbles and composes out of shadow DOM, so you can listen on
|
|
27
|
+
* the <deck-stage> element or on document:
|
|
28
|
+
*
|
|
29
|
+
* document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
|
|
30
|
+
* e.detail.index // new 0-based index
|
|
31
|
+
* e.detail.previousIndex // previous index, or -1 on init
|
|
32
|
+
* e.detail.total // total slide count
|
|
33
|
+
* e.detail.slide // the new active slide element
|
|
34
|
+
* e.detail.previousSlide // the prior slide element, or null on init
|
|
35
|
+
* e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api' | 'reset' | 'sync'
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* Print mode — before printing (the browser's `beforeprint`, or an explicit
|
|
39
|
+
* `stage.printing = true` from the PDF renderer) the stage reveals all steps,
|
|
40
|
+
* sets `data-deck-static` on itself and dispatches a `printchange` event with
|
|
41
|
+
* `detail.printing`. The reader's Read mode marks its slides the same way.
|
|
42
|
+
* Interactive slide code that normally waits for its slide or its steps
|
|
43
|
+
* should render its finished state inside `[data-deck-static]`:
|
|
44
|
+
*
|
|
45
|
+
* const finished = () => !!el.closest('[data-deck-static]');
|
|
46
|
+
* stage?.addEventListener('printchange', () => render(finished()));
|
|
47
|
+
*
|
|
48
|
+
* Leaving print mode restores each slide's step position.
|
|
49
|
+
*
|
|
50
|
+
* Persistence: none at the deck level. The host app keeps the current slide
|
|
51
|
+
* in its own URL (?slide=) and re-delivers it via location.hash on load, so a
|
|
52
|
+
* bare load with no hash always starts at slide 1.
|
|
53
|
+
*
|
|
54
|
+
* Usage:
|
|
55
|
+
* <deck-stage width="1920" height="1080">
|
|
56
|
+
* <section data-label="Title">...</section>
|
|
57
|
+
* <section data-label="Agenda">...</section>
|
|
58
|
+
* </deck-stage>
|
|
59
|
+
*
|
|
60
|
+
* Slides are the direct element children of <deck-stage>. Each slide is
|
|
61
|
+
* automatically tagged with:
|
|
62
|
+
* - data-screen-label="NN Label" (1-indexed, for comment flow)
|
|
63
|
+
* - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { iconSvg } from '../core/icons.js'
|
|
67
|
+
import { inkPaint } from '../core/ink.js'
|
|
68
|
+
|
|
69
|
+
(() => {
|
|
70
|
+
const DESIGN_W_DEFAULT = 1920;
|
|
71
|
+
const DESIGN_H_DEFAULT = 1080;
|
|
72
|
+
const OVERLAY_HIDE_MS = 1800;
|
|
73
|
+
// Taps: short and still; controls on the slide keep theirs.
|
|
74
|
+
const TAP_MS = 350;
|
|
75
|
+
const TAP_SLOP = 12;
|
|
76
|
+
// Swipes: mostly sideways, this far, this fast.
|
|
77
|
+
const SWIPE_MIN = 60;
|
|
78
|
+
const SWIPE_MS = 700;
|
|
79
|
+
const TAP_IGNORE = 'a, button, input, select, textarea, label, summary, iframe, video, audio, canvas, [contenteditable], [role="button"], [data-ink-ui], [data-no-tap], .overlay';
|
|
80
|
+
const CLICK_AFTER_INK_MS = 400;
|
|
81
|
+
// Holding the pen still this long turns the stroke into a straight line.
|
|
82
|
+
const STRAIGHTEN_MS = 500;
|
|
83
|
+
const STRAIGHTEN_SLOP = 6; // CSS px the pen may tremble while held
|
|
84
|
+
// Laser: a red glow with a white core; the trail lasts LASER_MS.
|
|
85
|
+
const LASER_GLOW = '#ff2a2a';
|
|
86
|
+
const LASER_CORE = '#ffffff';
|
|
87
|
+
const LASER_MS = 1200;
|
|
88
|
+
const LASER_GAP_MS = 250;
|
|
89
|
+
// Zoom: up to this many times the fitted size.
|
|
90
|
+
const ZOOM_MAX = 6;
|
|
91
|
+
const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
|
|
92
|
+
const isEmbedded = new URLSearchParams(location.search).has('embedded');
|
|
93
|
+
|
|
94
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
95
|
+
|
|
96
|
+
const stylesheet = `
|
|
97
|
+
:host {
|
|
98
|
+
position: fixed;
|
|
99
|
+
inset: 0;
|
|
100
|
+
display: block;
|
|
101
|
+
background: #000;
|
|
102
|
+
color: #fff;
|
|
103
|
+
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.stage {
|
|
108
|
+
position: absolute;
|
|
109
|
+
inset: 0;
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: center;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.canvas {
|
|
116
|
+
position: relative;
|
|
117
|
+
transform-origin: center center;
|
|
118
|
+
flex-shrink: 0;
|
|
119
|
+
background: #fff;
|
|
120
|
+
will-change: transform;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Slides live in light DOM (via <slot>) so authored CSS still applies.
|
|
124
|
+
We absolutely position each slotted child to stack them. */
|
|
125
|
+
::slotted(*) {
|
|
126
|
+
position: absolute !important;
|
|
127
|
+
inset: 0 !important;
|
|
128
|
+
width: 100% !important;
|
|
129
|
+
height: 100% !important;
|
|
130
|
+
box-sizing: border-box !important;
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
opacity: 0;
|
|
133
|
+
pointer-events: none;
|
|
134
|
+
visibility: hidden;
|
|
135
|
+
}
|
|
136
|
+
::slotted([data-deck-active]) {
|
|
137
|
+
opacity: 1;
|
|
138
|
+
pointer-events: auto;
|
|
139
|
+
visibility: visible;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Ink: the pen and the mouse are taken on the window (see "Ink input"),
|
|
143
|
+
so fingers keep reaching the slides. Only when fingers draw too, a
|
|
144
|
+
layer above the slides takes all touches. The stroke in progress is
|
|
145
|
+
drawn on .ink-live, which sits in the scaled canvas and so uses design
|
|
146
|
+
pixels; the laser on .ink-laser, in screen pixels. */
|
|
147
|
+
.ink-input {
|
|
148
|
+
position: fixed;
|
|
149
|
+
inset: 0;
|
|
150
|
+
z-index: 2147482100;
|
|
151
|
+
display: none;
|
|
152
|
+
touch-action: none;
|
|
153
|
+
-webkit-user-select: none;
|
|
154
|
+
user-select: none;
|
|
155
|
+
-webkit-touch-callout: none;
|
|
156
|
+
-webkit-tap-highlight-color: transparent;
|
|
157
|
+
cursor: crosshair;
|
|
158
|
+
}
|
|
159
|
+
:host([data-inking][data-ink-finger]) .ink-input { display: block; }
|
|
160
|
+
:host([data-inking]) { cursor: crosshair; }
|
|
161
|
+
/* The stage's own zoom replaces the browser's: no page zoom or panning
|
|
162
|
+
on the slide (fingers zoom and move the slide itself). */
|
|
163
|
+
:host([data-zoomable]) { touch-action: none; }
|
|
164
|
+
.zoom-reset {
|
|
165
|
+
position: fixed;
|
|
166
|
+
top: max(12px, env(safe-area-inset-top));
|
|
167
|
+
left: max(12px, env(safe-area-inset-left));
|
|
168
|
+
z-index: 2147483000;
|
|
169
|
+
display: none;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 6px;
|
|
172
|
+
height: 40px;
|
|
173
|
+
padding: 0 14px;
|
|
174
|
+
border: 1px solid #3a3a3a;
|
|
175
|
+
border-radius: 20px;
|
|
176
|
+
background: rgba(22, 22, 22, 0.9);
|
|
177
|
+
color: #e6e6e6;
|
|
178
|
+
font: 13px/1 system-ui, -apple-system, sans-serif;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
touch-action: manipulation;
|
|
181
|
+
-webkit-tap-highlight-color: transparent;
|
|
182
|
+
}
|
|
183
|
+
.zoom-reset svg { width: 16px; height: 16px; }
|
|
184
|
+
:host([data-zoomed]) .zoom-reset { display: inline-flex; }
|
|
185
|
+
:host([data-follower]) .zoom-reset { display: none; }
|
|
186
|
+
:host([data-inking][data-ink-tool="eraser"]) { cursor: cell; }
|
|
187
|
+
.ink-laser {
|
|
188
|
+
position: fixed;
|
|
189
|
+
inset: 0;
|
|
190
|
+
width: 100vw;
|
|
191
|
+
height: 100vh;
|
|
192
|
+
z-index: 2147482200;
|
|
193
|
+
pointer-events: none;
|
|
194
|
+
}
|
|
195
|
+
.ink-live .ink-selection { fill: rgba(77, 163, 255, 0.08); stroke: #4da3ff; stroke-width: 1.5; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
|
|
196
|
+
.ink-live .ink-lasso { fill: none; }
|
|
197
|
+
.ink-live .ink-handle { fill: #fff; stroke: #4da3ff; stroke-width: 3; vector-effect: non-scaling-stroke; }
|
|
198
|
+
.ink-live {
|
|
199
|
+
position: absolute;
|
|
200
|
+
inset: 0;
|
|
201
|
+
width: 100%;
|
|
202
|
+
height: 100%;
|
|
203
|
+
pointer-events: none;
|
|
204
|
+
overflow: visible;
|
|
205
|
+
z-index: 1;
|
|
206
|
+
}
|
|
207
|
+
.ink-live .fading { transition: opacity 0.8s ease 2.2s; opacity: 0; }
|
|
208
|
+
/* The ink toolbar replaces the overlay while drawing. */
|
|
209
|
+
:host([data-inking]) .overlay { display: none; }
|
|
210
|
+
.btn.draw, .btn.fullscreen { display: none; }
|
|
211
|
+
:host([data-ink-enabled]) .btn.draw { display: inline-flex; }
|
|
212
|
+
:host([data-fullscreen-available]) .btn.fullscreen { display: inline-flex; }
|
|
213
|
+
@media (pointer: coarse) { .overlay .btn { height: 40px; min-width: 40px; } }
|
|
214
|
+
|
|
215
|
+
.overlay {
|
|
216
|
+
position: fixed;
|
|
217
|
+
left: 50%;
|
|
218
|
+
bottom: 22px;
|
|
219
|
+
transform: translate(-50%, 6px) scale(0.92);
|
|
220
|
+
filter: blur(6px);
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 4px;
|
|
224
|
+
padding: 4px;
|
|
225
|
+
background: #000;
|
|
226
|
+
color: #fff;
|
|
227
|
+
border-radius: 999px;
|
|
228
|
+
font-size: 12px;
|
|
229
|
+
font-feature-settings: "tnum" 1;
|
|
230
|
+
letter-spacing: 0.01em;
|
|
231
|
+
opacity: 0;
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
transition: opacity 260ms ease, transform 260ms cubic-bezier(.2,.8,.2,1), filter 260ms ease;
|
|
234
|
+
transform-origin: center bottom;
|
|
235
|
+
z-index: 2147483000;
|
|
236
|
+
user-select: none;
|
|
237
|
+
}
|
|
238
|
+
.overlay[data-visible] {
|
|
239
|
+
opacity: 1;
|
|
240
|
+
pointer-events: auto;
|
|
241
|
+
transform: translate(-50%, 0) scale(1);
|
|
242
|
+
filter: blur(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.btn {
|
|
246
|
+
appearance: none;
|
|
247
|
+
-webkit-appearance: none;
|
|
248
|
+
background: transparent;
|
|
249
|
+
border: 0;
|
|
250
|
+
margin: 0;
|
|
251
|
+
padding: 0;
|
|
252
|
+
color: inherit;
|
|
253
|
+
font: inherit;
|
|
254
|
+
cursor: default;
|
|
255
|
+
display: inline-flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
justify-content: center;
|
|
258
|
+
height: 28px;
|
|
259
|
+
min-width: 28px;
|
|
260
|
+
border-radius: 999px;
|
|
261
|
+
color: rgba(255,255,255,0.72);
|
|
262
|
+
transition: background 140ms ease, color 140ms ease;
|
|
263
|
+
-webkit-tap-highlight-color: transparent;
|
|
264
|
+
}
|
|
265
|
+
.btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
|
266
|
+
.btn:active { background: rgba(255,255,255,0.18); }
|
|
267
|
+
.btn:focus { outline: none; }
|
|
268
|
+
.btn:focus-visible { outline: none; }
|
|
269
|
+
.btn::-moz-focus-inner { border: 0; }
|
|
270
|
+
.btn svg { width: 16px; height: 16px; display: block; }
|
|
271
|
+
.btn.reset {
|
|
272
|
+
font-size: 11px;
|
|
273
|
+
font-weight: 500;
|
|
274
|
+
letter-spacing: 0.02em;
|
|
275
|
+
padding: 0 10px 0 12px;
|
|
276
|
+
gap: 6px;
|
|
277
|
+
color: rgba(255,255,255,0.72);
|
|
278
|
+
}
|
|
279
|
+
.btn.reset .kbd {
|
|
280
|
+
display: inline-flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
justify-content: center;
|
|
283
|
+
min-width: 16px;
|
|
284
|
+
height: 16px;
|
|
285
|
+
padding: 0 4px;
|
|
286
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
287
|
+
font-size: 10px;
|
|
288
|
+
line-height: 1;
|
|
289
|
+
color: rgba(255,255,255,0.88);
|
|
290
|
+
background: rgba(255,255,255,0.12);
|
|
291
|
+
border-radius: 4px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.count {
|
|
295
|
+
font-variant-numeric: tabular-nums;
|
|
296
|
+
color: #fff;
|
|
297
|
+
font-weight: 500;
|
|
298
|
+
padding: 0 8px;
|
|
299
|
+
min-width: 42px;
|
|
300
|
+
text-align: center;
|
|
301
|
+
font-size: 12px;
|
|
302
|
+
}
|
|
303
|
+
.count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
|
|
304
|
+
.count .total { color: rgba(255,255,255,0.55); }
|
|
305
|
+
.step-progress { color: rgba(255,255,255,0.55); font-size: 11px; margin: 0 2px; }
|
|
306
|
+
|
|
307
|
+
.divider {
|
|
308
|
+
width: 1px;
|
|
309
|
+
height: 14px;
|
|
310
|
+
background: rgba(255,255,255,0.18);
|
|
311
|
+
margin: 0 2px;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ── Print: one page per slide, no chrome ────────────────────────────
|
|
315
|
+
The screen layout stacks every slide at inset:0 inside a scaled
|
|
316
|
+
canvas; for print we want them in document flow at the authored
|
|
317
|
+
design size so the browser paginates one slide per sheet. The
|
|
318
|
+
@page size is set from the width/height attributes via the inline
|
|
319
|
+
<style id="deck-stage-print-page"> that connectedCallback injects
|
|
320
|
+
into <head> (the @page at-rule has no effect inside shadow DOM). */
|
|
321
|
+
@media print {
|
|
322
|
+
:host {
|
|
323
|
+
position: static;
|
|
324
|
+
inset: auto;
|
|
325
|
+
background: none;
|
|
326
|
+
overflow: visible;
|
|
327
|
+
color: inherit;
|
|
328
|
+
}
|
|
329
|
+
.stage { position: static; display: block; }
|
|
330
|
+
.canvas {
|
|
331
|
+
transform: none !important;
|
|
332
|
+
width: auto !important;
|
|
333
|
+
height: auto !important;
|
|
334
|
+
background: none;
|
|
335
|
+
will-change: auto;
|
|
336
|
+
}
|
|
337
|
+
::slotted(*) {
|
|
338
|
+
position: relative !important;
|
|
339
|
+
inset: auto !important;
|
|
340
|
+
width: var(--deck-design-w) !important;
|
|
341
|
+
height: var(--deck-design-h) !important;
|
|
342
|
+
box-sizing: border-box !important;
|
|
343
|
+
opacity: 1 !important;
|
|
344
|
+
visibility: visible !important;
|
|
345
|
+
pointer-events: auto;
|
|
346
|
+
break-after: page;
|
|
347
|
+
page-break-after: always;
|
|
348
|
+
break-inside: avoid;
|
|
349
|
+
overflow: hidden;
|
|
350
|
+
}
|
|
351
|
+
::slotted(*:last-child) {
|
|
352
|
+
break-after: auto;
|
|
353
|
+
page-break-after: auto;
|
|
354
|
+
}
|
|
355
|
+
.overlay, .ink-input, .ink-live, .ink-laser, .zoom-reset { display: none !important; }
|
|
356
|
+
}
|
|
357
|
+
`;
|
|
358
|
+
|
|
359
|
+
// Full screen, with Safari's prefixed names (iPad); unavailable on an iPhone.
|
|
360
|
+
function fullscreenAvailable() {
|
|
361
|
+
return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);
|
|
362
|
+
}
|
|
363
|
+
// Older Safari (iPad) names the element and the exit differently; a miss
|
|
364
|
+
// here made the button enter full screen again instead of leaving it, and
|
|
365
|
+
// an iPad has no Escape key to get out.
|
|
366
|
+
function fullscreenElement() {
|
|
367
|
+
return document.fullscreenElement || document.webkitFullscreenElement || document.webkitCurrentFullScreenElement || null;
|
|
368
|
+
}
|
|
369
|
+
function toggleFullscreen(element = document.documentElement) {
|
|
370
|
+
if (fullscreenElement()) {
|
|
371
|
+
const exit = document.exitFullscreen || document.webkitExitFullscreen || document.webkitCancelFullScreen;
|
|
372
|
+
return exit?.call(document);
|
|
373
|
+
}
|
|
374
|
+
const request = element.requestFullscreen || element.webkitRequestFullscreen;
|
|
375
|
+
return request?.call(element);
|
|
376
|
+
}
|
|
377
|
+
/** Calls back with true or false whenever the page enters or leaves full screen; returns a function that stops it. */
|
|
378
|
+
function onFullscreenChange(listener) {
|
|
379
|
+
const handler = () => listener(!!fullscreenElement());
|
|
380
|
+
for (const name of ['fullscreenchange', 'webkitfullscreenchange']) document.addEventListener(name, handler);
|
|
381
|
+
return () => { for (const name of ['fullscreenchange', 'webkitfullscreenchange']) document.removeEventListener(name, handler); };
|
|
382
|
+
}
|
|
383
|
+
window.mdeckFullscreen = { available: fullscreenAvailable, element: fullscreenElement, toggle: toggleFullscreen, onChange: onFullscreenChange };
|
|
384
|
+
|
|
385
|
+
class DeckStage extends HTMLElement {
|
|
386
|
+
static get observedAttributes() { return ['width', 'height', 'noscale']; }
|
|
387
|
+
|
|
388
|
+
constructor() {
|
|
389
|
+
super();
|
|
390
|
+
this._root = this.attachShadow({ mode: 'open' });
|
|
391
|
+
this._index = 0;
|
|
392
|
+
this._slides = [];
|
|
393
|
+
this._notes = [];
|
|
394
|
+
this._hideTimer = null;
|
|
395
|
+
this._mouseIdleTimer = null;
|
|
396
|
+
this._stepMap = new Map();
|
|
397
|
+
|
|
398
|
+
this._onKey = this._onKey.bind(this);
|
|
399
|
+
this._onResize = this._onResize.bind(this);
|
|
400
|
+
this._onSlotChange = this._onSlotChange.bind(this);
|
|
401
|
+
this._onMouseMove = this._onMouseMove.bind(this);
|
|
402
|
+
this._onPointerDown = this._onPointerDown.bind(this);
|
|
403
|
+
this._onPointerMove = this._onPointerMove.bind(this);
|
|
404
|
+
this._onPointerUp = this._onPointerUp.bind(this);
|
|
405
|
+
this._onPointerOut = this._onPointerOut.bind(this);
|
|
406
|
+
this._onTouch = this._onTouch.bind(this);
|
|
407
|
+
this._onClickCapture = this._onClickCapture.bind(this);
|
|
408
|
+
this._laserFrame = this._laserFrame.bind(this);
|
|
409
|
+
// Drawing starts with the laser pointer: pointing is the common case.
|
|
410
|
+
this.inkTool = { tool: 'laser', color: '#e11d48', size: 6 };
|
|
411
|
+
this.inkRenderer = null;
|
|
412
|
+
this._lasers = new Map();
|
|
413
|
+
this._inkUpAt = 0;
|
|
414
|
+
this._zoom = null;
|
|
415
|
+
this._touches = new Map();
|
|
416
|
+
this._onWheel = this._onWheel.bind(this);
|
|
417
|
+
this._onGesture = this._onGesture.bind(this);
|
|
418
|
+
this._onBeforePrint = () => { this.printing = true; };
|
|
419
|
+
this._onAfterPrint = () => { this.printing = false; };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
get designWidth() {
|
|
423
|
+
return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
|
|
424
|
+
}
|
|
425
|
+
get designHeight() {
|
|
426
|
+
return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
connectedCallback() {
|
|
430
|
+
// Zooming is for the stage that is the page (the deck, the audience
|
|
431
|
+
// window, the presenter's slide frame), not one inside the reader.
|
|
432
|
+
if (this.parentElement === document.body) this.setAttribute('data-zoomable', '');
|
|
433
|
+
this._render();
|
|
434
|
+
this._loadNotes();
|
|
435
|
+
this._syncPrintPageRule();
|
|
436
|
+
window.addEventListener('keydown', this._onKey);
|
|
437
|
+
if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver(() => this._fit()); this._resizeObserver.observe(this); }
|
|
438
|
+
window.addEventListener('resize', this._onResize);
|
|
439
|
+
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
|
|
440
|
+
window.addEventListener('beforeprint', this._onBeforePrint);
|
|
441
|
+
window.addEventListener('afterprint', this._onAfterPrint);
|
|
442
|
+
// Taps and ink are read on the window, before slide content sees them.
|
|
443
|
+
for (const [type, handler] of this._windowInput()) window.addEventListener(type, handler, { capture: true, passive: false });
|
|
444
|
+
// Initial collection + layout happens via slotchange, which fires on mount.
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
disconnectedCallback() {
|
|
448
|
+
window.removeEventListener('keydown', this._onKey);
|
|
449
|
+
this._resizeObserver?.disconnect();
|
|
450
|
+
window.removeEventListener('resize', this._onResize);
|
|
451
|
+
window.removeEventListener('mousemove', this._onMouseMove);
|
|
452
|
+
window.removeEventListener('beforeprint', this._onBeforePrint);
|
|
453
|
+
window.removeEventListener('afterprint', this._onAfterPrint);
|
|
454
|
+
for (const [type, handler] of this._windowInput()) window.removeEventListener(type, handler, { capture: true });
|
|
455
|
+
cancelAnimationFrame(this._laserRaf);
|
|
456
|
+
this._laserRaf = 0;
|
|
457
|
+
if (this._hideTimer) clearTimeout(this._hideTimer);
|
|
458
|
+
if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
attributeChangedCallback() {
|
|
462
|
+
if (this._canvas) {
|
|
463
|
+
this._canvas.style.width = this.designWidth + 'px';
|
|
464
|
+
this._canvas.style.height = this.designHeight + 'px';
|
|
465
|
+
this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
|
|
466
|
+
this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
|
|
467
|
+
this._inkLive?.setAttribute('viewBox', `0 0 ${this.designWidth} ${this.designHeight}`);
|
|
468
|
+
this._fit();
|
|
469
|
+
this._syncPrintPageRule();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_render() {
|
|
474
|
+
const style = document.createElement('style');
|
|
475
|
+
style.textContent = stylesheet;
|
|
476
|
+
|
|
477
|
+
const stage = document.createElement('div');
|
|
478
|
+
stage.className = 'stage';
|
|
479
|
+
|
|
480
|
+
const canvas = document.createElement('div');
|
|
481
|
+
canvas.className = 'canvas';
|
|
482
|
+
canvas.style.width = this.designWidth + 'px';
|
|
483
|
+
canvas.style.height = this.designHeight + 'px';
|
|
484
|
+
canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
|
|
485
|
+
canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
|
|
486
|
+
|
|
487
|
+
const slot = document.createElement('slot');
|
|
488
|
+
slot.addEventListener('slotchange', this._onSlotChange);
|
|
489
|
+
canvas.appendChild(slot);
|
|
490
|
+
const inkLive = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
491
|
+
inkLive.setAttribute('class', 'ink-live');
|
|
492
|
+
inkLive.setAttribute('viewBox', `0 0 ${this.designWidth} ${this.designHeight}`);
|
|
493
|
+
inkLive.setAttribute('preserveAspectRatio', 'none');
|
|
494
|
+
canvas.appendChild(inkLive);
|
|
495
|
+
stage.appendChild(canvas);
|
|
496
|
+
|
|
497
|
+
// Only shown when fingers draw: keeps their touches from scrolling or
|
|
498
|
+
// zooming. The input itself is read on the window.
|
|
499
|
+
const inkInput = document.createElement('div');
|
|
500
|
+
inkInput.className = 'ink-input export-hidden';
|
|
501
|
+
inkInput.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
|
|
502
|
+
this._inkLive = inkLive;
|
|
503
|
+
const inkLaser = document.createElement('canvas');
|
|
504
|
+
inkLaser.className = 'ink-laser export-hidden';
|
|
505
|
+
inkLaser.setAttribute('aria-hidden', 'true');
|
|
506
|
+
this._inkLaser = inkLaser;
|
|
507
|
+
const zoomReset = document.createElement('button');
|
|
508
|
+
zoomReset.type = 'button';
|
|
509
|
+
zoomReset.className = 'zoom-reset export-hidden';
|
|
510
|
+
zoomReset.setAttribute('data-ink-ui', '');
|
|
511
|
+
zoomReset.setAttribute('aria-label', 'Show the whole slide');
|
|
512
|
+
zoomReset.title = 'Show the whole slide';
|
|
513
|
+
zoomReset.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M8 11h6M20 20l-4-4"/></svg>1:1';
|
|
514
|
+
zoomReset.addEventListener('click', () => this.setZoom(null, 'reset'));
|
|
515
|
+
|
|
516
|
+
// Overlay: compact, solid black, with clickable controls.
|
|
517
|
+
const overlay = document.createElement('div');
|
|
518
|
+
overlay.className = 'overlay export-hidden';
|
|
519
|
+
overlay.setAttribute('role', 'toolbar');
|
|
520
|
+
overlay.setAttribute('aria-label', 'Deck controls');
|
|
521
|
+
overlay.setAttribute('data-noncommentable', '');
|
|
522
|
+
overlay.innerHTML = `
|
|
523
|
+
<button class="btn prev" type="button" aria-label="Previous slide" title="Previous (←)">
|
|
524
|
+
${iconSvg('prev')}
|
|
525
|
+
</button>
|
|
526
|
+
<span class="count" aria-live="polite"><span class="current">1</span><span class="step-progress" hidden> · <span class="step-cur">0</span>/<span class="step-total">1</span></span><span class="sep">/</span><span class="total">1</span></span>
|
|
527
|
+
<button class="btn next" type="button" aria-label="Next slide" title="Next (→)">
|
|
528
|
+
${iconSvg('next')}
|
|
529
|
+
</button>
|
|
530
|
+
<span class="divider"></span>
|
|
531
|
+
<button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
|
|
532
|
+
<button class="btn draw" type="button" aria-label="Draw on the slide" title="Draw (D)">
|
|
533
|
+
${iconSvg('pen')}
|
|
534
|
+
</button>
|
|
535
|
+
<button class="btn fullscreen" type="button" aria-label="Full screen" title="Full screen (F)">
|
|
536
|
+
${iconSvg('fullscreen')}
|
|
537
|
+
</button>
|
|
538
|
+
`;
|
|
539
|
+
|
|
540
|
+
overlay.querySelector('.prev').addEventListener('click', () => this.prev('click'));
|
|
541
|
+
overlay.querySelector('.next').addEventListener('click', () => this.next('click'));
|
|
542
|
+
overlay.querySelector('.reset').addEventListener('click', () => this.reset());
|
|
543
|
+
overlay.querySelector('.draw').addEventListener('click', () => { this.inking = !this.inking; });
|
|
544
|
+
const fullscreenButton = overlay.querySelector('.fullscreen');
|
|
545
|
+
fullscreenButton.addEventListener('click', () => toggleFullscreen());
|
|
546
|
+
// The same button leaves full screen, and says so.
|
|
547
|
+
onFullscreenChange(on => {
|
|
548
|
+
fullscreenButton.innerHTML = iconSvg(on ? 'fullscreen-exit' : 'fullscreen');
|
|
549
|
+
fullscreenButton.title = on ? 'Exit full screen (F)' : 'Full screen (F)';
|
|
550
|
+
fullscreenButton.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
|
|
551
|
+
});
|
|
552
|
+
// Only a top-level window can go full screen (an iPhone cannot at all).
|
|
553
|
+
if (window.top === window && fullscreenAvailable()) this.setAttribute('data-fullscreen-available', '');
|
|
554
|
+
|
|
555
|
+
this._root.append(style, stage, inkInput, inkLaser, zoomReset, overlay);
|
|
556
|
+
this._canvas = canvas;
|
|
557
|
+
this._slot = slot;
|
|
558
|
+
this._overlay = overlay;
|
|
559
|
+
this._countEl = overlay.querySelector('.current');
|
|
560
|
+
this._totalEl = overlay.querySelector('.total');
|
|
561
|
+
this._stepProgressEl = overlay.querySelector('.step-progress');
|
|
562
|
+
this._stepCurEl = overlay.querySelector('.step-cur');
|
|
563
|
+
this._stepTotalEl = overlay.querySelector('.step-total');
|
|
564
|
+
this._overlay = overlay;
|
|
565
|
+
if (this._labels) this.setLabels(this._labels);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** @page must live in the document stylesheet — it's a no-op inside
|
|
569
|
+
* shadow DOM. Inject/update a single <head> style tag so the print
|
|
570
|
+
* sheet matches the design size and Save-as-PDF yields one slide per
|
|
571
|
+
* page with no margins. */
|
|
572
|
+
_syncPrintPageRule() {
|
|
573
|
+
const id = 'deck-stage-print-page';
|
|
574
|
+
let tag = document.getElementById(id);
|
|
575
|
+
if (!tag) {
|
|
576
|
+
tag = document.createElement('style');
|
|
577
|
+
tag.id = id;
|
|
578
|
+
document.head.appendChild(tag);
|
|
579
|
+
}
|
|
580
|
+
tag.textContent =
|
|
581
|
+
'@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
|
|
582
|
+
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
|
|
583
|
+
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_onSlotChange() {
|
|
587
|
+
this._collectSlides();
|
|
588
|
+
this._restoreIndex();
|
|
589
|
+
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
|
|
590
|
+
this._fit();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
_collectSlides() {
|
|
594
|
+
const assigned = this._slot.assignedElements({ flatten: true });
|
|
595
|
+
this._slides = assigned.filter((el) => {
|
|
596
|
+
// Skip template/style/script nodes even if someone slots them.
|
|
597
|
+
const tag = el.tagName;
|
|
598
|
+
return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
this._slides.forEach((slide, i) => {
|
|
602
|
+
const n = i + 1;
|
|
603
|
+
// Determine a label for comment flow: prefer explicit data-label,
|
|
604
|
+
// then an existing data-screen-label, then first heading, else "Slide".
|
|
605
|
+
let label = slide.getAttribute('data-label');
|
|
606
|
+
if (!label) {
|
|
607
|
+
const existing = slide.getAttribute('data-screen-label');
|
|
608
|
+
if (existing) {
|
|
609
|
+
// Strip any leading number the author may have included.
|
|
610
|
+
label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (!label) {
|
|
614
|
+
const h = slide.querySelector('h1, h2, h3, [data-title]');
|
|
615
|
+
if (h) label = (h.textContent || '').trim().slice(0, 40);
|
|
616
|
+
}
|
|
617
|
+
if (!label) label = 'Slide';
|
|
618
|
+
slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
|
|
619
|
+
|
|
620
|
+
// Validation attribute for comment flow / auto-checks.
|
|
621
|
+
if (!slide.hasAttribute('data-om-validate')) {
|
|
622
|
+
slide.setAttribute('data-om-validate', VALIDATE_ATTR);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
slide.setAttribute('data-deck-slide', String(i));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
|
|
629
|
+
if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
_loadNotes() {
|
|
633
|
+
const tag = document.getElementById('speaker-notes');
|
|
634
|
+
if (!tag) { this._notes = []; return; }
|
|
635
|
+
try {
|
|
636
|
+
const parsed = JSON.parse(tag.textContent || '[]');
|
|
637
|
+
if (Array.isArray(parsed)) this._notes = parsed;
|
|
638
|
+
} catch (e) {
|
|
639
|
+
console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
|
|
640
|
+
this._notes = [];
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
_restoreIndex() {
|
|
645
|
+
const named = this._slides.findIndex(s => '#' + encodeURIComponent(s.dataset.slideId) === location.hash);
|
|
646
|
+
if (named >= 0) { this._index = named; return; }
|
|
647
|
+
// The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
|
|
648
|
+
// the iframe src. No hash → slide 1; the deck itself keeps no position
|
|
649
|
+
// state across loads.
|
|
650
|
+
const h = (location.hash || '').match(/^#(\d+)$/);
|
|
651
|
+
if (h) {
|
|
652
|
+
const n = parseInt(h[1], 10) - 1;
|
|
653
|
+
if (n >= 0 && n < this._slides.length) this._index = n;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_applyIndex({ showOverlay = true, broadcast = true, reason = 'init', step = -1 } = {}) {
|
|
658
|
+
if (!this._slides.length) return;
|
|
659
|
+
const prev = this._prevIndex == null ? -1 : this._prevIndex;
|
|
660
|
+
const curr = this._index;
|
|
661
|
+
// Keep the iframe's own hash in sync so an in-iframe location.reload()
|
|
662
|
+
// (reload banner path in viewer-handle.ts) lands on the current slide,
|
|
663
|
+
// not the stale deep-link hash from initial load.
|
|
664
|
+
try { history.replaceState(null, '', '#' + (this._slides[curr].dataset.slideId || (curr + 1))); } catch (e) {}
|
|
665
|
+
this._slides.forEach((s, i) => {
|
|
666
|
+
if (i === curr) s.setAttribute('data-deck-active', '');
|
|
667
|
+
else s.removeAttribute('data-deck-active');
|
|
668
|
+
});
|
|
669
|
+
this._stepMap.delete(curr);
|
|
670
|
+
if (step >= 0) this._stepMap.set(curr, step);
|
|
671
|
+
this._applySteps(curr);
|
|
672
|
+
// Every slide starts whole; each window does this itself.
|
|
673
|
+
if (curr !== prev && this._zoom) this.setZoom(null, 'slide', { announce: false });
|
|
674
|
+
// The live layer sits above every slide: nothing drawn on another
|
|
675
|
+
// slide may stay on it.
|
|
676
|
+
if (curr !== prev) this._clearLive();
|
|
677
|
+
if (this._countEl) this._countEl.textContent = String(curr + 1);
|
|
678
|
+
|
|
679
|
+
if (broadcast) {
|
|
680
|
+
// (1) Legacy: postMessage bridge for presenter/speaker-note renderers.
|
|
681
|
+
const note = this._notes[curr] ?? null;
|
|
682
|
+
try { window.postMessage({ slideIndexChanged: curr, note, reason }, '*'); } catch (e) {}
|
|
683
|
+
try {
|
|
684
|
+
if (window.parent && window.parent !== window) {
|
|
685
|
+
window.parent.postMessage({ slideIndexChanged: curr, note, reason }, '*');
|
|
686
|
+
}
|
|
687
|
+
} catch (e) {}
|
|
688
|
+
|
|
689
|
+
// (2) In-page CustomEvent on the <deck-stage> element itself.
|
|
690
|
+
// Bubbles and composes out of shadow DOM so slide code can listen:
|
|
691
|
+
// document.querySelector('deck-stage').addEventListener('slidechange', e => {
|
|
692
|
+
// e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
|
|
693
|
+
// });
|
|
694
|
+
const detail = {
|
|
695
|
+
index: curr,
|
|
696
|
+
previousIndex: prev,
|
|
697
|
+
total: this._slides.length,
|
|
698
|
+
slide: this._slides[curr] || null,
|
|
699
|
+
previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
|
|
700
|
+
reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api' | 'reset' | 'sync'
|
|
701
|
+
};
|
|
702
|
+
this.dispatchEvent(new CustomEvent('slidechange', {
|
|
703
|
+
detail,
|
|
704
|
+
bubbles: true,
|
|
705
|
+
composed: true,
|
|
706
|
+
}));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this._prevIndex = curr;
|
|
710
|
+
if (broadcast) this._broadcastState(reason);
|
|
711
|
+
if (showOverlay) this._flashOverlay();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
get state() {
|
|
715
|
+
return { index: this._index, slideId: this._slides[this._index]?.dataset.slideId,
|
|
716
|
+
step: this._stepMap.get(this._index) ?? -1 };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
_broadcastState(reason) {
|
|
720
|
+
const message = { deckStateChanged: this.state, reason };
|
|
721
|
+
window.postMessage(message, '*');
|
|
722
|
+
if (window.parent !== window) window.parent.postMessage(message, '*');
|
|
723
|
+
this.dispatchEvent(new CustomEvent('statechange', { detail: { ...this.state, reason } }));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
setState(state) {
|
|
727
|
+
if (!state || !Number.isInteger(state.index) || !Number.isInteger(state.step)) return;
|
|
728
|
+
const byId = state.slideId ? this._slides.findIndex(s => s.dataset.slideId === state.slideId) : -1;
|
|
729
|
+
const index = byId >= 0 ? byId : state.index;
|
|
730
|
+
if (index < 0 || index >= this._slides.length) return;
|
|
731
|
+
const max = this._getSteps(this._slides[index]).length - 1;
|
|
732
|
+
const step = Math.max(-1, Math.min(max, state.step));
|
|
733
|
+
if (index === this._index) {
|
|
734
|
+
if (step === this.state.step) return;
|
|
735
|
+
this._stepMap.set(index, step);
|
|
736
|
+
this._applySteps(index);
|
|
737
|
+
this._broadcastState('sync');
|
|
738
|
+
} else {
|
|
739
|
+
this._index = index;
|
|
740
|
+
this._applyIndex({ reason: 'sync', step });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
_flashOverlay() {
|
|
745
|
+
if (!this._overlay || isEmbedded) return;
|
|
746
|
+
this._overlay.setAttribute('data-visible', '');
|
|
747
|
+
if (this._hideTimer) clearTimeout(this._hideTimer);
|
|
748
|
+
this._hideTimer = setTimeout(() => {
|
|
749
|
+
this._overlay.removeAttribute('data-visible');
|
|
750
|
+
}, OVERLAY_HIDE_MS);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
_fit() {
|
|
754
|
+
if (!this._canvas) return;
|
|
755
|
+
// PPTX export sets noscale so the DOM capture sees authored-size
|
|
756
|
+
// geometry — the scaled canvas is in shadow DOM, so the exporter's
|
|
757
|
+
// resetTransformSelector can't reach .canvas.style.transform directly.
|
|
758
|
+
if (this.hasAttribute('noscale')) {
|
|
759
|
+
this._canvas.style.transform = 'none';
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// Fit the host element, which is the viewport when the stage is
|
|
763
|
+
// full-screen and a panel when it is embedded in another layout.
|
|
764
|
+
const rect = this.getBoundingClientRect();
|
|
765
|
+
const vw = rect.width || window.innerWidth;
|
|
766
|
+
const vh = rect.height || window.innerHeight;
|
|
767
|
+
const s = Math.min(vw / this.designWidth, vh / this.designHeight);
|
|
768
|
+
this._fitScale = s;
|
|
769
|
+
const zoom = this._zoom;
|
|
770
|
+
if (!zoom) { this._canvas.style.transform = `scale(${s})`; return; }
|
|
771
|
+
const tx = (this.designWidth / 2 - zoom.x) * s * zoom.scale;
|
|
772
|
+
const ty = (this.designHeight / 2 - zoom.y) * s * zoom.scale;
|
|
773
|
+
this._canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${s * zoom.scale})`;
|
|
774
|
+
this._kickLaser();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
_onResize() { this._fit(); }
|
|
778
|
+
|
|
779
|
+
_onMouseMove() {
|
|
780
|
+
// Keep overlay visible while mouse moves; hide after idle.
|
|
781
|
+
this._flashOverlay();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Fingers (and a pen while not drawing): a swipe to the left goes
|
|
785
|
+
// forward, to the right back; on a zoomed slide a finger moves the view
|
|
786
|
+
// instead. While not drawing, a tap on the left or right third of the
|
|
787
|
+
// screen goes back or forward, as the tap zones did. A tap while drawing selects the stroke under it
|
|
788
|
+
// (`inktap`, { slideId, point }); the selection then takes the pen.
|
|
789
|
+
// Read from pointer events, so it works whatever pointer the device
|
|
790
|
+
// reports, and controls on the slide (buttons, links, polls, embedded
|
|
791
|
+
// frames) keep their own taps.
|
|
792
|
+
_tapStart(e) {
|
|
793
|
+
const finger = e.pointerType === 'touch' && !(this.inking && this.inkFinger);
|
|
794
|
+
const pen = e.pointerType === 'pen' && !this.inking;
|
|
795
|
+
this._tap = (finger || pen) && e.isPrimary ? { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now() } : null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
_tapEnd(e) {
|
|
799
|
+
const tap = this._tap;
|
|
800
|
+
this._tap = null;
|
|
801
|
+
if (!tap || tap.id !== e.pointerId || e.type !== 'pointerup') return;
|
|
802
|
+
const dx = e.clientX - tap.x, dy = e.clientY - tap.y, ms = performance.now() - tap.t;
|
|
803
|
+
if (e.composedPath().some(el => el.nodeType === 1 && el.matches(TAP_IGNORE))) return;
|
|
804
|
+
if (Math.abs(dx) >= SWIPE_MIN && Math.abs(dx) > Math.abs(dy) * 1.5 && ms <= SWIPE_MS) {
|
|
805
|
+
if (dx < 0) this.next('swipe');
|
|
806
|
+
else this.prev('swipe');
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (ms > TAP_MS || Math.hypot(dx, dy) > TAP_SLOP) return;
|
|
810
|
+
if (!this.inking) {
|
|
811
|
+
// Not drawing: a tap on the left third goes back, on the right third forward.
|
|
812
|
+
if (this._zoom) return;
|
|
813
|
+
const x = e.clientX / window.innerWidth;
|
|
814
|
+
if (x < 1 / 3) this.prev('tap');
|
|
815
|
+
else if (x > 2 / 3) this.next('tap');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const slideId = this._inkSlideId();
|
|
819
|
+
if (slideId) this.dispatchEvent(new CustomEvent('inktap', { detail: { slideId, point: this._inkPoint(e), radius: 22 }, bubbles: true, composed: true }));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
_onKey(e) {
|
|
823
|
+
// Ignore when the user is typing.
|
|
824
|
+
const t = e.target;
|
|
825
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
826
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
827
|
+
|
|
828
|
+
const key = e.key;
|
|
829
|
+
let handled = true;
|
|
830
|
+
|
|
831
|
+
if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
|
|
832
|
+
this.next('keyboard');
|
|
833
|
+
} else if (key === 'ArrowLeft' || key === 'PageUp') {
|
|
834
|
+
this.prev('keyboard');
|
|
835
|
+
} else if (key === 'Home') {
|
|
836
|
+
this._go(0, 'keyboard');
|
|
837
|
+
} else if (key === 'End') {
|
|
838
|
+
this._go(this._slides.length - 1, 'keyboard');
|
|
839
|
+
} else if (key === 'i' || key === 'I') {
|
|
840
|
+
// Show or hide the saved ink.
|
|
841
|
+
e.preventDefault();
|
|
842
|
+
this.toggleAttribute('data-ink-hidden');
|
|
843
|
+
} else if ((key === 'f' || key === 'F') && window.top === window && fullscreenAvailable()) {
|
|
844
|
+
toggleFullscreen();
|
|
845
|
+
} else if (key === 'r' || key === 'R') {
|
|
846
|
+
this.reset();
|
|
847
|
+
} else if (/^[0-9]$/.test(key)) {
|
|
848
|
+
// 1..9 jump to that slide; 0 jumps to 10.
|
|
849
|
+
const n = key === '0' ? 9 : parseInt(key, 10) - 1;
|
|
850
|
+
if (n < this._slides.length) this._go(n, 'keyboard');
|
|
851
|
+
} else {
|
|
852
|
+
handled = false;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (handled) {
|
|
856
|
+
e.preventDefault();
|
|
857
|
+
this._flashOverlay();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
_go(i, reason = 'api') {
|
|
862
|
+
if (!this._slides.length) return;
|
|
863
|
+
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
|
|
864
|
+
if (clamped === this._index) {
|
|
865
|
+
this._flashOverlay();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
this._index = clamped;
|
|
869
|
+
this._applyIndex({ showOverlay: true, broadcast: true, reason });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Step reveal helpers ---------------------------------------------------
|
|
873
|
+
|
|
874
|
+
_getSteps(slide) {
|
|
875
|
+
if (!slide) return [];
|
|
876
|
+
// Multiple and nested reveal blocks follow document order.
|
|
877
|
+
return [...slide.querySelectorAll('[data-step]')];
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
_applySteps(slideIndex) {
|
|
881
|
+
const slide = this._slides[slideIndex];
|
|
882
|
+
if (!slide) return;
|
|
883
|
+
const steps = this._getSteps(slide);
|
|
884
|
+
const curr = this._printing ? steps.length - 1 : (this._stepMap.get(slideIndex) ?? -1);
|
|
885
|
+
steps.forEach((el, i) =>
|
|
886
|
+
i <= curr ? el.setAttribute('data-step-visible', '') : el.removeAttribute('data-step-visible')
|
|
887
|
+
);
|
|
888
|
+
this._updateStepCount(slideIndex);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
_updateStepCount(slideIndex) {
|
|
892
|
+
if (!this._stepProgressEl) return;
|
|
893
|
+
const steps = this._getSteps(this._slides[slideIndex]);
|
|
894
|
+
if (steps.length === 0) {
|
|
895
|
+
this._stepProgressEl.hidden = true;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const curr = this._stepMap.get(slideIndex) ?? -1;
|
|
899
|
+
this._stepCurEl.textContent = String(curr + 1);
|
|
900
|
+
this._stepTotalEl.textContent = String(steps.length);
|
|
901
|
+
this._stepProgressEl.hidden = false;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Public API ------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
/** Current slide index (0-based). */
|
|
907
|
+
get index() { return this._index; }
|
|
908
|
+
/** Total slide count. */
|
|
909
|
+
get length() { return this._slides.length; }
|
|
910
|
+
/** Words on the control bar, in the deck's language:
|
|
911
|
+
* { controls, previous, next, reset, resetHint }. */
|
|
912
|
+
setLabels(labels = {}) {
|
|
913
|
+
this._labels = labels;
|
|
914
|
+
const overlay = this._overlay;
|
|
915
|
+
if (!overlay) return;
|
|
916
|
+
if (labels.controls) overlay.setAttribute('aria-label', labels.controls);
|
|
917
|
+
if (labels.previous) overlay.querySelector('.prev').setAttribute('aria-label', labels.previous);
|
|
918
|
+
if (labels.next) overlay.querySelector('.next').setAttribute('aria-label', labels.next);
|
|
919
|
+
const reset = overlay.querySelector('.reset');
|
|
920
|
+
if (labels.resetHint) reset.setAttribute('aria-label', labels.resetHint);
|
|
921
|
+
if (labels.reset) reset.firstChild.textContent = labels.reset;
|
|
922
|
+
if (labels.resetHint) reset.title = `${labels.resetHint} (R)`;
|
|
923
|
+
}
|
|
924
|
+
// Ink input ---------------------------------------------------------------
|
|
925
|
+
//
|
|
926
|
+
// While `inking` is on, the pen and the mouse draw on the current slide;
|
|
927
|
+
// fingers keep operating the deck (taps move the slides, slide content
|
|
928
|
+
// stays usable) and draw only when `inkFinger` is set. Finished strokes
|
|
929
|
+
// are announced as `inkstroke` events ({ slideId, tool, color, size,
|
|
930
|
+
// points }), in design pixels with pressure; the eraser sends `inkerase`
|
|
931
|
+
// ({ slideId, point, radius }) while it moves, all with the same
|
|
932
|
+
// `gesture` number for one stroke of the eraser. The laser is never kept:
|
|
933
|
+
// its trail retracts within a second (`inklaser` when it ends) and its dot
|
|
934
|
+
// follows a hovering pen (`inklaserdot`, { slideId, point | null }). The
|
|
935
|
+
// select tool sends `inkselect` ({ phase: 'down' | 'move' | 'up', slideId,
|
|
936
|
+
// point }) and shows what it is given with `showSelection`. `inkTool` is
|
|
937
|
+
// { tool: 'pen' | 'highlighter' | 'laser' | 'select' | 'eraser', color,
|
|
938
|
+
// size }. `inkRenderer(stroke)` may return an SVG path for the stroke in
|
|
939
|
+
// progress. While a stroke or a laser trail is drawn, `inkprogress`
|
|
940
|
+
// repeats it as it grows; `key` ties it to its end event.
|
|
941
|
+
|
|
942
|
+
get inking() { return this.hasAttribute('data-inking'); }
|
|
943
|
+
set inking(on) {
|
|
944
|
+
this.toggleAttribute('data-inking', !!on);
|
|
945
|
+
if (!on) { this._endInk(null); this.laserDot('local', null, null); this.showSelection(null); }
|
|
946
|
+
this.dispatchEvent(new CustomEvent('inkmode', { detail: { inking: !!on }, bubbles: true, composed: true }));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
get inkFinger() { return this.hasAttribute('data-ink-finger'); }
|
|
950
|
+
set inkFinger(on) { this.toggleAttribute('data-ink-finger', !!on); }
|
|
951
|
+
|
|
952
|
+
_inkSlideId() { return this._slides[this._index]?.dataset.slideId ?? null; }
|
|
953
|
+
|
|
954
|
+
_inkPoint(e) {
|
|
955
|
+
const rect = this._canvas.getBoundingClientRect();
|
|
956
|
+
const x = (e.clientX - rect.left) * this.designWidth / rect.width;
|
|
957
|
+
const y = (e.clientY - rect.top) * this.designHeight / rect.height;
|
|
958
|
+
const pressure = e.pointerType === 'pen' && e.pressure > 0 ? e.pressure : 0.5;
|
|
959
|
+
return [Math.round(x * 10) / 10, Math.round(y * 10) / 10, Math.round(pressure * 100) / 100];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
_windowInput() {
|
|
963
|
+
return [['pointerdown', this._onPointerDown], ['pointermove', this._onPointerMove], ['pointerup', this._onPointerUp],
|
|
964
|
+
['pointercancel', this._onPointerUp], ['pointerout', this._onPointerOut],
|
|
965
|
+
['touchstart', this._onTouch], ['touchmove', this._onTouch], ['touchend', this._onTouch], ['click', this._onClickCapture],
|
|
966
|
+
['wheel', this._onWheel], ['gesturestart', this._onGesture], ['gesturechange', this._onGesture]];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// The ink toolbar and the deck's controls keep their input.
|
|
970
|
+
_inInkUi(e) {
|
|
971
|
+
return e.composedPath().some(el => el.nodeType === 1 && (el.hasAttribute('data-ink-ui') || el.classList.contains('overlay')));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// While drawing: the pen, the mouse's main button, and fingers only when they draw.
|
|
975
|
+
_inkTakes(e) {
|
|
976
|
+
if (!this.inking || this._printing) return false;
|
|
977
|
+
if (e.pointerType === 'touch' && !this.inkFinger) return false;
|
|
978
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return false;
|
|
979
|
+
return !this._inInkUi(e);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
_onPointerDown(e) {
|
|
983
|
+
this._tapStart(e);
|
|
984
|
+
if (this._zoomDown(e)) return;
|
|
985
|
+
if (this._inkDrawing || !this._inkTakes(e)) return;
|
|
986
|
+
e.preventDefault();
|
|
987
|
+
e.stopPropagation();
|
|
988
|
+
let tool = { ...this.inkTool };
|
|
989
|
+
const point = this._inkPoint(e);
|
|
990
|
+
// A selection (picked with a finger, or the select tool) takes the pen
|
|
991
|
+
// that lands on it: on an end point's handle or inside its box. Landing
|
|
992
|
+
// elsewhere puts the selection away and draws as usual.
|
|
993
|
+
if (tool.tool !== 'select' && this.inkSelectionHit) {
|
|
994
|
+
if (this.inkSelectionHit(point)) tool = { ...tool, tool: 'select' };
|
|
995
|
+
else this.inkSelectionClear?.();
|
|
996
|
+
}
|
|
997
|
+
this._inkGesture = (this._inkGesture || 0) + 1;
|
|
998
|
+
const drawing = this._inkDrawing = { pointerId: e.pointerId, touch: e.pointerType === 'touch', tool, points: [point], slideId: this._inkSlideId(), gesture: this._inkGesture, key: `${this._inkGesture}` };
|
|
999
|
+
if (tool.tool === 'eraser') { this._erase(point); return; }
|
|
1000
|
+
if (tool.tool === 'select') { this._inkSelect('down', point); return; }
|
|
1001
|
+
if (tool.tool === 'laser') { this.laserTrail('local', drawing.slideId, [point]); this._inkProgress(drawing); return; }
|
|
1002
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1003
|
+
this._styleLive(path, tool);
|
|
1004
|
+
this._appendLive(path);
|
|
1005
|
+
drawing.path = path;
|
|
1006
|
+
this._drawLive(path, drawing.points, tool);
|
|
1007
|
+
this._inkProgress(drawing);
|
|
1008
|
+
this._holdStill(drawing, e);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Pen and highlighter: holding still at the end of a stroke straightens
|
|
1012
|
+
// it, from its first point to the pen. Moving on afterwards (without
|
|
1013
|
+
// lifting) moves the line's end; lifting keeps the straight line.
|
|
1014
|
+
_holdStill(drawing, e) {
|
|
1015
|
+
const hold = drawing.hold;
|
|
1016
|
+
if (hold && Math.hypot(e.clientX - hold.x, e.clientY - hold.y) <= STRAIGHTEN_SLOP) return;
|
|
1017
|
+
clearTimeout(hold?.timer);
|
|
1018
|
+
drawing.hold = { x: e.clientX, y: e.clientY, timer: setTimeout(() => this._straighten(drawing), STRAIGHTEN_MS) };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
_straighten(drawing) {
|
|
1022
|
+
if (this._inkDrawing !== drawing || drawing.straight || drawing.points.length < 3) return;
|
|
1023
|
+
const pressures = drawing.points.map(p => p[2]);
|
|
1024
|
+
drawing.straight = Math.round(pressures.reduce((a, b) => a + b, 0) / pressures.length * 100) / 100;
|
|
1025
|
+
this._drawStraight(drawing, drawing.points[drawing.points.length - 1]);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
_drawStraight(drawing, [x, y]) {
|
|
1029
|
+
const [x0, y0] = drawing.points[0];
|
|
1030
|
+
drawing.points = [[x0, y0, drawing.straight], [x, y, drawing.straight]];
|
|
1031
|
+
drawing.restart = true;
|
|
1032
|
+
this._drawLive(drawing.path, drawing.points, drawing.tool);
|
|
1033
|
+
this._inkProgress(drawing);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
_onPointerMove(e) {
|
|
1037
|
+
if (this._zoomMove(e)) return;
|
|
1038
|
+
const drawing = this._inkDrawing;
|
|
1039
|
+
if (!drawing) { this._laserHover(e); return; }
|
|
1040
|
+
if (e.pointerId !== drawing.pointerId) return;
|
|
1041
|
+
e.preventDefault();
|
|
1042
|
+
e.stopPropagation();
|
|
1043
|
+
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
|
|
1044
|
+
const added = (events.length ? events : [e]).map(ev => this._inkPoint(ev));
|
|
1045
|
+
const tool = drawing.tool.tool;
|
|
1046
|
+
if (drawing.straight) { this._drawStraight(drawing, added[added.length - 1]); return; }
|
|
1047
|
+
drawing.points.push(...added);
|
|
1048
|
+
const last = drawing.points[drawing.points.length - 1];
|
|
1049
|
+
if (tool === 'pen' || tool === 'highlighter') this._holdStill(drawing, e);
|
|
1050
|
+
if (tool === 'eraser') this._erase(last);
|
|
1051
|
+
else if (tool === 'select') this._inkSelect('move', last);
|
|
1052
|
+
else if (tool === 'laser') { this.laserTrail('local', drawing.slideId, added); this._inkProgress(drawing); }
|
|
1053
|
+
else { this._drawLive(drawing.path, drawing.points, drawing.tool); this._inkProgress(drawing); }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
_onPointerUp(e) {
|
|
1057
|
+
this._zoomUp(e);
|
|
1058
|
+
this._tapEnd(e);
|
|
1059
|
+
const drawing = this._inkDrawing;
|
|
1060
|
+
if (!drawing || e.pointerId !== drawing.pointerId) return;
|
|
1061
|
+
e.stopPropagation();
|
|
1062
|
+
this._inkUpAt = performance.now();
|
|
1063
|
+
// The stroke ends where the pen left the screen, which the last move
|
|
1064
|
+
// event does not always reach.
|
|
1065
|
+
const tool = drawing.tool.tool;
|
|
1066
|
+
if (e.type === 'pointerup' && (tool === 'pen' || tool === 'highlighter')) {
|
|
1067
|
+
const point = this._inkPoint(e);
|
|
1068
|
+
const last = drawing.points[drawing.points.length - 1];
|
|
1069
|
+
if (drawing.straight) this._drawStraight(drawing, point);
|
|
1070
|
+
else if (Math.hypot(point[0] - last[0], point[1] - last[1]) > 0.5) drawing.points.push([point[0], point[1], last[2]]);
|
|
1071
|
+
}
|
|
1072
|
+
this._endInk(drawing);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// The laser's dot follows a pen hovering over the screen (newer iPads).
|
|
1076
|
+
_laserHover(e) {
|
|
1077
|
+
if (!this.inking || this.inkTool.tool !== 'laser' || e.pointerType === 'touch' || (e.pointerType === 'mouse' && e.buttons)) return;
|
|
1078
|
+
const point = this._inInkUi(e) ? null : this._inkPoint(e);
|
|
1079
|
+
this.laserDot('local', this._inkSlideId(), point);
|
|
1080
|
+
this.dispatchEvent(new CustomEvent('inklaserdot', { detail: { slideId: this._inkSlideId(), point }, bubbles: true, composed: true }));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
_onPointerOut(e) {
|
|
1084
|
+
if (e.relatedTarget || !this._lasers.get('local')?.dot) return;
|
|
1085
|
+
this.laserDot('local', null, null);
|
|
1086
|
+
this.dispatchEvent(new CustomEvent('inklaserdot', { detail: { slideId: this._inkSlideId(), point: null }, bubbles: true, composed: true }));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// iPad Safari: a pen touch while drawing neither scrolls, zooms, selects
|
|
1090
|
+
// text nor becomes a click.
|
|
1091
|
+
_onTouch(e) {
|
|
1092
|
+
if (this.hasAttribute('data-zoomable') && e.touches.length > 1 && e.cancelable && !this._inInkUi(e)) e.preventDefault();
|
|
1093
|
+
if (!this.inking || this._inInkUi(e)) return;
|
|
1094
|
+
if (![...e.changedTouches].some(t => t.touchType === 'stylus')) return;
|
|
1095
|
+
if (e.cancelable) e.preventDefault();
|
|
1096
|
+
e.stopPropagation();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// The click that ends a stroke is not a click on the slide.
|
|
1100
|
+
_onClickCapture(e) {
|
|
1101
|
+
if (!this.inking || performance.now() - this._inkUpAt > CLICK_AFTER_INK_MS || this._inInkUi(e)) return;
|
|
1102
|
+
e.preventDefault();
|
|
1103
|
+
e.stopPropagation();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
_drawLive(path, points, tool) {
|
|
1107
|
+
const stroke = { tool: tool.tool === 'highlighter' ? 'highlighter' : 'pen', size: tool.size, points };
|
|
1108
|
+
// In progress: not yet `last`, so the outline does not close early.
|
|
1109
|
+
const d = this.inkRenderer ? this.inkRenderer(stroke, { last: false })
|
|
1110
|
+
: 'M' + points.map(p => p[0] + ' ' + p[1]).join(' L');
|
|
1111
|
+
path.setAttribute('d', d);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
_styleLive(path, { tool, color, size }) {
|
|
1115
|
+
const renderer = !!this.inkRenderer;
|
|
1116
|
+
// Pen colours follow the palette and appearance (inkPaint in core/ink.js).
|
|
1117
|
+
path.style[renderer ? 'fill' : 'stroke'] = inkPaint(color);
|
|
1118
|
+
if (!renderer) { path.setAttribute('fill', 'none'); path.setAttribute('stroke-width', size); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('stroke-linejoin', 'round'); }
|
|
1119
|
+
path.setAttribute(renderer ? 'fill-opacity' : 'stroke-opacity', tool === 'highlighter' ? '0.35' : '1');
|
|
1120
|
+
// The highlighter goes under the pen, as on paper.
|
|
1121
|
+
if (tool === 'highlighter') path.setAttribute('data-under', '');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Highlighter strokes go below the pen's, the selection stays on top.
|
|
1125
|
+
_appendLive(path) {
|
|
1126
|
+
const above = path.hasAttribute('data-under')
|
|
1127
|
+
? [...this._inkLive.children].find(el => !el.hasAttribute('data-under'))
|
|
1128
|
+
: this._inkLive.querySelector('.ink-selection-group');
|
|
1129
|
+
this._inkLive.insertBefore(path, above ?? null);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Announces the stroke in progress (`inkprogress`, the same points array
|
|
1133
|
+
// as it grows), so other windows can show it while it is drawn.
|
|
1134
|
+
_inkProgress(drawing) {
|
|
1135
|
+
const { tool, color, size } = drawing.tool;
|
|
1136
|
+
// `restart`: the points were replaced (a straightened stroke), not extended.
|
|
1137
|
+
const restart = !!drawing.restart;
|
|
1138
|
+
drawing.restart = false;
|
|
1139
|
+
this.dispatchEvent(new CustomEvent('inkprogress', { detail: { key: drawing.key, slideId: drawing.slideId, tool, color, size, points: drawing.points, restart }, bubbles: true, composed: true }));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
_endInk(drawing) {
|
|
1143
|
+
this._inkDrawing = null;
|
|
1144
|
+
clearTimeout(drawing?.hold?.timer);
|
|
1145
|
+
if (!drawing || drawing.tool.tool === 'eraser' || !drawing.slideId) { drawing?.path?.remove(); return; }
|
|
1146
|
+
const { tool, color, size } = drawing.tool;
|
|
1147
|
+
if (tool === 'select') { this._inkSelect('up', drawing.points[drawing.points.length - 1], drawing); return; }
|
|
1148
|
+
const detail = { key: drawing.key, slideId: drawing.slideId, tool, color, size, points: drawing.points };
|
|
1149
|
+
if (tool === 'laser') {
|
|
1150
|
+
// Never saved: the trail retracts on its own.
|
|
1151
|
+
this.laserDot('local', drawing.slideId, null);
|
|
1152
|
+
this.dispatchEvent(new CustomEvent('inklaser', { detail, bubbles: true, composed: true }));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
this.dispatchEvent(new CustomEvent('inkstroke', { detail, bubbles: true, composed: true }));
|
|
1156
|
+
// The saved layer draws the stroke from now on; keep this one a moment
|
|
1157
|
+
// longer so nothing flickers.
|
|
1158
|
+
const path = drawing.path;
|
|
1159
|
+
this._removeLive(path);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// The saved layer draws a finished stroke from the next frame on; keep the
|
|
1163
|
+
// live one until then so nothing flickers. A window that is hidden gets
|
|
1164
|
+
// no frames, so a timer removes it in any case.
|
|
1165
|
+
_removeLive(path) {
|
|
1166
|
+
const remove = () => path.remove();
|
|
1167
|
+
requestAnimationFrame(() => requestAnimationFrame(remove));
|
|
1168
|
+
setTimeout(remove, 250);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
_clearLive() {
|
|
1172
|
+
if (!this._inkLive) return;
|
|
1173
|
+
const own = this._inkDrawing?.path;
|
|
1174
|
+
for (const path of [...this._inkLive.querySelectorAll(':scope > path')]) if (path !== own) path.remove();
|
|
1175
|
+
for (const [key, value] of this._remoteInk ?? []) if (typeof value !== 'number') this._remoteInk.delete(key);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
_erase(point) {
|
|
1179
|
+
const slideId = this._inkDrawing?.slideId ?? this._inkSlideId();
|
|
1180
|
+
if (!slideId) return;
|
|
1181
|
+
this.dispatchEvent(new CustomEvent('inkerase', { detail: { slideId, point, radius: 14, gesture: this._inkDrawing?.gesture ?? 0 }, bubbles: true, composed: true }));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
_inkSelect(phase, point, drawing = this._inkDrawing) {
|
|
1185
|
+
const slideId = drawing?.slideId ?? this._inkSlideId();
|
|
1186
|
+
if (!slideId) return;
|
|
1187
|
+
this.dispatchEvent(new CustomEvent('inkselect', { detail: { phase, slideId, point, points: drawing?.points ?? [point] }, bubbles: true, composed: true }));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Shows a selection on the current slide, in design pixels: `box` is
|
|
1192
|
+
* [x0, y0, x1, y1], `lasso` a list of points, `handles` the end points
|
|
1193
|
+
* of a selected straight line; null clears it.
|
|
1194
|
+
*/
|
|
1195
|
+
showSelection(selection) {
|
|
1196
|
+
const { box = null, lasso = null, handles = [] } = selection ?? {};
|
|
1197
|
+
if (!this._inkLive) return;
|
|
1198
|
+
this._inkLive.querySelector('.ink-selection-group')?.remove();
|
|
1199
|
+
if (!box && !lasso?.length && !handles.length) return;
|
|
1200
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
1201
|
+
const group = document.createElementNS(ns, 'g');
|
|
1202
|
+
group.setAttribute('class', 'ink-selection-group');
|
|
1203
|
+
if (box) {
|
|
1204
|
+
const rect = document.createElementNS(ns, 'rect');
|
|
1205
|
+
rect.setAttribute('class', 'ink-selection');
|
|
1206
|
+
rect.setAttribute('x', box[0]); rect.setAttribute('y', box[1]);
|
|
1207
|
+
rect.setAttribute('width', Math.max(0, box[2] - box[0])); rect.setAttribute('height', Math.max(0, box[3] - box[1]));
|
|
1208
|
+
group.appendChild(rect);
|
|
1209
|
+
}
|
|
1210
|
+
for (const [x, y] of handles) {
|
|
1211
|
+
const handle = document.createElementNS(ns, 'circle');
|
|
1212
|
+
handle.setAttribute('class', 'ink-handle');
|
|
1213
|
+
handle.setAttribute('cx', x); handle.setAttribute('cy', y); handle.setAttribute('r', 16);
|
|
1214
|
+
group.appendChild(handle);
|
|
1215
|
+
}
|
|
1216
|
+
if (lasso?.length > 1) {
|
|
1217
|
+
const polygon = document.createElementNS(ns, 'polygon');
|
|
1218
|
+
polygon.setAttribute('class', 'ink-selection ink-lasso');
|
|
1219
|
+
polygon.setAttribute('points', lasso.map(p => `${p[0]},${p[1]}`).join(' '));
|
|
1220
|
+
group.appendChild(polygon);
|
|
1221
|
+
}
|
|
1222
|
+
this._inkLive.appendChild(group);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Zoom --------------------------------------------------------------------
|
|
1226
|
+
//
|
|
1227
|
+
// Two fingers zoom into the slide and move around it; one finger moves a
|
|
1228
|
+
// zoomed slide; Ctrl + scroll (a trackpad pinch) zooms at the pointer.
|
|
1229
|
+
// Only the slide's canvas scales: the stage's controls, the ink toolbar
|
|
1230
|
+
// and the presenter's bars keep their size, and ink and laser points stay
|
|
1231
|
+
// exact. `zoom` is { scale, x, y }: the magnification over the fitted
|
|
1232
|
+
// slide and the design point at the centre of the view. Every slide
|
|
1233
|
+
// starts whole. `zoomchange` ({ slideId, scale, x, y, reason }) lets
|
|
1234
|
+
// other windows show the same part (setZoom with reason 'remote', which
|
|
1235
|
+
// is not announced again).
|
|
1236
|
+
|
|
1237
|
+
get zoom() {
|
|
1238
|
+
return this._zoom ? { ...this._zoom } : { scale: 1, x: this.designWidth / 2, y: this.designHeight / 2 };
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
setZoom(zoom, reason = 'api', { announce = true } = {}) {
|
|
1242
|
+
const next = this._clampZoom(zoom);
|
|
1243
|
+
const same = JSON.stringify(next) === JSON.stringify(this._zoom);
|
|
1244
|
+
this._zoom = next;
|
|
1245
|
+
this.toggleAttribute('data-zoomed', !!next);
|
|
1246
|
+
this._fit();
|
|
1247
|
+
if (same || !announce) return;
|
|
1248
|
+
const { scale, x, y } = this.zoom;
|
|
1249
|
+
this.dispatchEvent(new CustomEvent('zoomchange', { detail: { slideId: this._inkSlideId(), scale, x, y, reason }, bubbles: true, composed: true }));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Keeps the view on the slide; null when it shows the whole slide.
|
|
1253
|
+
_clampZoom(zoom) {
|
|
1254
|
+
if (!zoom) return null;
|
|
1255
|
+
const scale = Math.min(ZOOM_MAX, Math.max(1, Number(zoom.scale) || 1));
|
|
1256
|
+
if (scale <= 1.01) return null;
|
|
1257
|
+
const rect = this.getBoundingClientRect();
|
|
1258
|
+
// Half the view, in design pixels; a view wider than the slide stays centred.
|
|
1259
|
+
const half = length => length / 2 / ((this._fitScale || 1) * scale);
|
|
1260
|
+
const clamp = (value, h, size) => h >= size / 2 ? size / 2 : Math.min(size - h, Math.max(h, value));
|
|
1261
|
+
const x = clamp(Number(zoom.x), half(rect.width || window.innerWidth), this.designWidth);
|
|
1262
|
+
const y = clamp(Number(zoom.y), half(rect.height || window.innerHeight), this.designHeight);
|
|
1263
|
+
return { scale: Math.round(scale * 1000) / 1000, x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// The design point under a screen point, for a given zoom.
|
|
1267
|
+
_designAt(clientX, clientY, zoom = this.zoom) {
|
|
1268
|
+
const rect = this.getBoundingClientRect();
|
|
1269
|
+
const k = (this._fitScale || 1) * zoom.scale;
|
|
1270
|
+
return [zoom.x + (clientX - rect.left - rect.width / 2) / k, zoom.y + (clientY - rect.top - rect.height / 2) / k];
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// The zoom that keeps design point `point` under screen point `client`.
|
|
1274
|
+
_zoomKeeping(point, [clientX, clientY], scale) {
|
|
1275
|
+
const rect = this.getBoundingClientRect();
|
|
1276
|
+
const k = (this._fitScale || 1) * scale;
|
|
1277
|
+
return { scale, x: point[0] - (clientX - rect.left - rect.width / 2) / k, y: point[1] - (clientY - rect.top - rect.height / 2) / k };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
_zoomDown(e) {
|
|
1281
|
+
if (e.pointerType !== 'touch' || !this.hasAttribute('data-zoomable') || this._inInkUi(e)) return false;
|
|
1282
|
+
this._touches.set(e.pointerId, [e.clientX, e.clientY]);
|
|
1283
|
+
if (this._touches.size === 2) {
|
|
1284
|
+
// A second finger: a pinch, not a tap or a finger's stroke.
|
|
1285
|
+
this._tap = null;
|
|
1286
|
+
if (this._inkDrawing?.touch) { this._inkDrawing.path?.remove(); this._inkDrawing = null; }
|
|
1287
|
+
const [a, b] = [...this._touches.values()];
|
|
1288
|
+
const middle = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1289
|
+
const start = this.zoom;
|
|
1290
|
+
this._pinch = { distance: Math.hypot(a[0] - b[0], a[1] - b[1]) || 1, point: this._designAt(...middle, start), scale: start.scale };
|
|
1291
|
+
this._pan = null;
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
if (this._touches.size === 1 && this._zoom && !(this.inking && this.inkFinger)) {
|
|
1295
|
+
this._pan = { id: e.pointerId, from: [e.clientX, e.clientY], point: this._designAt(e.clientX, e.clientY), moving: false };
|
|
1296
|
+
}
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
_zoomMove(e) {
|
|
1301
|
+
if (!this._touches.has(e.pointerId)) return false;
|
|
1302
|
+
this._touches.set(e.pointerId, [e.clientX, e.clientY]);
|
|
1303
|
+
if (this._pinch && this._touches.size >= 2) {
|
|
1304
|
+
e.preventDefault();
|
|
1305
|
+
const [a, b] = [...this._touches.values()];
|
|
1306
|
+
const scale = this._pinch.scale * Math.hypot(a[0] - b[0], a[1] - b[1]) / this._pinch.distance;
|
|
1307
|
+
this.setZoom(this._zoomKeeping(this._pinch.point, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], Math.min(ZOOM_MAX, Math.max(1, scale))), 'pinch');
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
const pan = this._pan;
|
|
1311
|
+
if (pan?.id === e.pointerId && this._zoom) {
|
|
1312
|
+
if (!pan.moving && Math.hypot(e.clientX - pan.from[0], e.clientY - pan.from[1]) <= TAP_SLOP) return false;
|
|
1313
|
+
pan.moving = true;
|
|
1314
|
+
this._tap = null;
|
|
1315
|
+
e.preventDefault();
|
|
1316
|
+
this.setZoom(this._zoomKeeping(pan.point, [e.clientX, e.clientY], this._zoom.scale), 'pan');
|
|
1317
|
+
return true;
|
|
1318
|
+
}
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
_zoomUp(e) {
|
|
1323
|
+
if (!this._touches.delete(e.pointerId)) return;
|
|
1324
|
+
if (this._touches.size < 2) this._pinch = null;
|
|
1325
|
+
if (this._pan?.id === e.pointerId || !this._touches.size) this._pan = null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Ctrl + scroll is how a trackpad pinch arrives; plain scrolling moves a zoomed slide.
|
|
1329
|
+
_onWheel(e) {
|
|
1330
|
+
if (!this.hasAttribute('data-zoomable') || this._inInkUi(e)) return;
|
|
1331
|
+
if (e.ctrlKey) {
|
|
1332
|
+
e.preventDefault();
|
|
1333
|
+
const zoom = this.zoom;
|
|
1334
|
+
const scale = Math.min(ZOOM_MAX, Math.max(1, zoom.scale * Math.exp(-e.deltaY / 100)));
|
|
1335
|
+
this.setZoom(this._zoomKeeping(this._designAt(e.clientX, e.clientY, zoom), [e.clientX, e.clientY], scale), 'wheel');
|
|
1336
|
+
} else if (this._zoom) {
|
|
1337
|
+
e.preventDefault();
|
|
1338
|
+
const k = (this._fitScale || 1) * this._zoom.scale;
|
|
1339
|
+
this.setZoom({ ...this._zoom, x: this._zoom.x + e.deltaX / k, y: this._zoom.y + e.deltaY / k }, 'wheel');
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// iPad Safari's own pinch zoom, which would enlarge every bar on the page.
|
|
1344
|
+
_onGesture(e) {
|
|
1345
|
+
if (this.hasAttribute('data-zoomable') && !this._inInkUi(e)) e.preventDefault();
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Laser -------------------------------------------------------------------
|
|
1349
|
+
//
|
|
1350
|
+
// Trails and dots by key ('local' for this window, others from the ink
|
|
1351
|
+
// bus), in design pixels. A trail's points expire after LASER_MS, so it
|
|
1352
|
+
// retracts from its tail; frames run only while some trail is left.
|
|
1353
|
+
|
|
1354
|
+
_laserEntry(key, slideId) {
|
|
1355
|
+
let entry = this._lasers.get(key);
|
|
1356
|
+
if (!entry) this._lasers.set(key, entry = { slideId, points: [], dot: null });
|
|
1357
|
+
if (slideId) entry.slideId = slideId;
|
|
1358
|
+
return entry;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** Adds points to a laser trail; its dot moves to the last one. */
|
|
1362
|
+
laserTrail(key, slideId, points) {
|
|
1363
|
+
if (!points?.length) return;
|
|
1364
|
+
const entry = this._laserEntry(key, slideId);
|
|
1365
|
+
const t = performance.now();
|
|
1366
|
+
for (const [x, y] of points) entry.points.push([x, y, t]);
|
|
1367
|
+
entry.dot = points[points.length - 1];
|
|
1368
|
+
this._kickLaser();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Moves (or with null, hides) a laser dot. */
|
|
1372
|
+
laserDot(key, slideId, point) {
|
|
1373
|
+
const entry = this._lasers.get(key);
|
|
1374
|
+
if (!point && !entry) return;
|
|
1375
|
+
this._laserEntry(key, slideId).dot = point ? [point[0], point[1]] : null;
|
|
1376
|
+
this._kickLaser();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
_kickLaser() {
|
|
1380
|
+
if (!this._laserRaf) this._laserRaf = requestAnimationFrame(this._laserFrame);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
_laserFrame() {
|
|
1384
|
+
this._laserRaf = 0;
|
|
1385
|
+
const canvas = this._inkLaser;
|
|
1386
|
+
if (!canvas) return;
|
|
1387
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1388
|
+
const width = canvas.clientWidth, height = canvas.clientHeight;
|
|
1389
|
+
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
|
|
1390
|
+
canvas.width = Math.round(width * dpr);
|
|
1391
|
+
canvas.height = Math.round(height * dpr);
|
|
1392
|
+
}
|
|
1393
|
+
const ctx = canvas.getContext('2d');
|
|
1394
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1395
|
+
ctx.clearRect(0, 0, width, height);
|
|
1396
|
+
const rect = this._canvas.getBoundingClientRect();
|
|
1397
|
+
const sx = rect.width / this.designWidth, sy = rect.height / this.designHeight;
|
|
1398
|
+
const at = ([x, y]) => [rect.left + x * sx, rect.top + y * sy];
|
|
1399
|
+
const now = performance.now();
|
|
1400
|
+
const slideId = this._inkSlideId();
|
|
1401
|
+
let running = false;
|
|
1402
|
+
ctx.lineCap = 'round';
|
|
1403
|
+
ctx.lineJoin = 'round';
|
|
1404
|
+
ctx.shadowColor = LASER_GLOW;
|
|
1405
|
+
for (const [key, entry] of this._lasers) {
|
|
1406
|
+
entry.points = entry.points.filter(p => now - p[2] < LASER_MS);
|
|
1407
|
+
if (!entry.points.length && !entry.dot) { this._lasers.delete(key); continue; }
|
|
1408
|
+
if (entry.points.length) running = true;
|
|
1409
|
+
if (entry.slideId && slideId && entry.slideId !== slideId) continue;
|
|
1410
|
+
// A red glow with a white core, the core drawn second.
|
|
1411
|
+
for (const [color, lineWidth, blur] of [[LASER_GLOW, 8, 14], [LASER_CORE, 3, 0]]) {
|
|
1412
|
+
ctx.strokeStyle = color;
|
|
1413
|
+
ctx.lineWidth = lineWidth;
|
|
1414
|
+
ctx.shadowBlur = blur;
|
|
1415
|
+
ctx.beginPath();
|
|
1416
|
+
entry.points.forEach((p, i) => {
|
|
1417
|
+
const [x, y] = at(p);
|
|
1418
|
+
if (i === 0 || p[2] - entry.points[i - 1][2] > LASER_GAP_MS) ctx.moveTo(x, y);
|
|
1419
|
+
else ctx.lineTo(x, y);
|
|
1420
|
+
});
|
|
1421
|
+
ctx.stroke();
|
|
1422
|
+
}
|
|
1423
|
+
if (entry.dot) {
|
|
1424
|
+
const [x, y] = at(entry.dot);
|
|
1425
|
+
for (const [color, radius, blur] of [[LASER_GLOW, 8, 20], [LASER_CORE, 4, 0]]) {
|
|
1426
|
+
ctx.fillStyle = color;
|
|
1427
|
+
ctx.shadowBlur = blur;
|
|
1428
|
+
ctx.beginPath();
|
|
1429
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
1430
|
+
ctx.fill();
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
ctx.shadowBlur = 0;
|
|
1435
|
+
if (running) this._laserRaf = requestAnimationFrame(this._laserFrame);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Strokes being drawn in another window: `liveStroke(key, stroke)` shows
|
|
1440
|
+
* or updates one ({ slideId, tool, color, size, points }), only while
|
|
1441
|
+
* this window shows that slide; `endLiveStroke(key, { fade })` removes it,
|
|
1442
|
+
* fading, or once the saved layer draws it. A laser's points go to its
|
|
1443
|
+
* trail instead.
|
|
1444
|
+
*/
|
|
1445
|
+
liveStroke(key, { slideId, tool = 'pen', color = '#e11d48', size = 6, points }) {
|
|
1446
|
+
if (!this._inkLive || !points?.length) return;
|
|
1447
|
+
this._remoteInk ??= new Map();
|
|
1448
|
+
if (tool === 'laser') {
|
|
1449
|
+
const seen = this._remoteInk.get(key) ?? 0;
|
|
1450
|
+
if (points.length > seen) this.laserTrail(key, slideId, points.slice(seen));
|
|
1451
|
+
this._remoteInk.set(key, points.length);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
let path = this._remoteInk.get(key);
|
|
1455
|
+
if (slideId && slideId !== this._inkSlideId()) { path?.remove?.(); this._remoteInk.delete(key); return; }
|
|
1456
|
+
if (!path) {
|
|
1457
|
+
path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1458
|
+
this._styleLive(path, { tool, color, size });
|
|
1459
|
+
this._appendLive(path);
|
|
1460
|
+
this._remoteInk.set(key, path);
|
|
1461
|
+
}
|
|
1462
|
+
this._drawLive(path, points, { tool, size });
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
endLiveStroke(key, { fade = false } = {}) {
|
|
1466
|
+
const path = this._remoteInk?.get(key);
|
|
1467
|
+
if (path == null) return;
|
|
1468
|
+
this._remoteInk.delete(key);
|
|
1469
|
+
if (typeof path === 'number') { this.laserDot(key, null, null); return; }
|
|
1470
|
+
if (fade) { requestAnimationFrame(() => path.classList.add('fading')); setTimeout(() => path.remove(), 3200); }
|
|
1471
|
+
else this._removeLive(path);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** True while the deck is laid out for print or PDF. */
|
|
1475
|
+
get printing() { return !!this._printing; }
|
|
1476
|
+
/** Enter or leave print mode: reveal every step and tell slide code to show its final state. */
|
|
1477
|
+
set printing(on) {
|
|
1478
|
+
on = !!on;
|
|
1479
|
+
if (on === this.printing) return;
|
|
1480
|
+
this._printing = on;
|
|
1481
|
+
this.toggleAttribute('data-deck-static', on);
|
|
1482
|
+
this._slides.forEach((_, i) => this._applySteps(i));
|
|
1483
|
+
this.dispatchEvent(new CustomEvent('printchange', {
|
|
1484
|
+
detail: { printing: on },
|
|
1485
|
+
bubbles: true,
|
|
1486
|
+
composed: true,
|
|
1487
|
+
}));
|
|
1488
|
+
}
|
|
1489
|
+
/** Programmatically navigate to a specific slide (no step checks). */
|
|
1490
|
+
goTo(i) { this._go(i, 'api'); }
|
|
1491
|
+
/** Advance: reveals next step if the current slide has unrevealed steps, else goes to next slide. */
|
|
1492
|
+
next(reason = 'api') {
|
|
1493
|
+
const steps = this._getSteps(this._slides[this._index]);
|
|
1494
|
+
const curr = this._stepMap.get(this._index) ?? -1;
|
|
1495
|
+
if (steps.length > 0 && curr < steps.length - 1) {
|
|
1496
|
+
this._stepMap.set(this._index, curr + 1);
|
|
1497
|
+
this._applySteps(this._index);
|
|
1498
|
+
this._broadcastState(reason);
|
|
1499
|
+
this._flashOverlay();
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
this._go(this._index + 1, reason);
|
|
1503
|
+
}
|
|
1504
|
+
/** Go back: hides last revealed step if any, else goes to previous slide. */
|
|
1505
|
+
prev(reason = 'api') {
|
|
1506
|
+
const steps = this._getSteps(this._slides[this._index]);
|
|
1507
|
+
const curr = this._stepMap.get(this._index) ?? -1;
|
|
1508
|
+
if (steps.length > 0 && curr >= 0) {
|
|
1509
|
+
this._stepMap.set(this._index, curr - 1);
|
|
1510
|
+
this._applySteps(this._index);
|
|
1511
|
+
this._broadcastState(reason);
|
|
1512
|
+
this._flashOverlay();
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
this._go(this._index - 1, reason);
|
|
1516
|
+
}
|
|
1517
|
+
/** Reset to first slide and clear all step state. */
|
|
1518
|
+
reset() {
|
|
1519
|
+
this._stepMap.clear();
|
|
1520
|
+
// Moving to the first slide reports the new position itself; report it
|
|
1521
|
+
// once more only when the deck was already there, for the cleared steps.
|
|
1522
|
+
// Two reports for one reset arrived in other windows one after the
|
|
1523
|
+
// other, and the late one could undo a change made in between.
|
|
1524
|
+
if (this._index !== 0) { this._go(0, 'reset'); return; }
|
|
1525
|
+
this._applySteps(0);
|
|
1526
|
+
this._broadcastState('reset');
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (!customElements.get('deck-stage')) {
|
|
1531
|
+
customElements.define('deck-stage', DeckStage);
|
|
1532
|
+
}
|
|
1533
|
+
})();
|