@pathmx/player 0.5.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/gestures.ts ADDED
@@ -0,0 +1,143 @@
1
+ type PlayerGestureCommands = Readonly<{
2
+ enabled(): boolean
3
+ surface(): HTMLElement | undefined
4
+ moveBeat(offset: -1 | 1): void
5
+ moveBlock(offset: -1 | 1): void
6
+ }>
7
+
8
+ type PointerOrigin = Readonly<{
9
+ id: number
10
+ x: number
11
+ y: number
12
+ time: number
13
+ axis?: "horizontal" | "vertical"
14
+ }>
15
+
16
+ const TAP_SLOP = 12
17
+ const TAP_DURATION = 500
18
+ const DIRECTION_LOCK_DISTANCE = 10
19
+ const SWIPE_DISTANCE = 48
20
+ const SWIPE_DOMINANCE = 1.5
21
+ const PREVIOUS_TAP_ZONE = 1 / 3
22
+
23
+ function ownsGesture(surface: HTMLElement, target: EventTarget | null) {
24
+ if (!(target instanceof Element) || !surface.contains(target)) return false
25
+ return !target.closest(
26
+ [
27
+ "a",
28
+ "button",
29
+ "input",
30
+ "select",
31
+ "textarea",
32
+ "summary",
33
+ "video",
34
+ "audio",
35
+ "iframe",
36
+ "[contenteditable]:not([contenteditable='false'])",
37
+ "[role='button']",
38
+ "[role='link']",
39
+ "[data-pmx-player-control]",
40
+ ].join(", "),
41
+ )
42
+ }
43
+
44
+ /** Install Slides-only touch navigation without claiming interactive content. */
45
+ export function installPlayerGestures(commands: PlayerGestureCommands) {
46
+ let origin: PointerOrigin | undefined
47
+ let tapTimer: number | undefined
48
+
49
+ const reset = () => {
50
+ origin = undefined
51
+ }
52
+ const clearTap = () => {
53
+ if (tapTimer !== undefined) window.clearTimeout(tapTimer)
54
+ tapTimer = undefined
55
+ }
56
+ const pointerdown = (event: PointerEvent) => {
57
+ clearTap()
58
+ if (
59
+ !commands.enabled() ||
60
+ event.pointerType !== "touch" ||
61
+ !event.isPrimary ||
62
+ event.button !== 0
63
+ ) {
64
+ reset()
65
+ return
66
+ }
67
+ const surface = commands.surface()
68
+ if (!surface || !ownsGesture(surface, event.target)) {
69
+ reset()
70
+ return
71
+ }
72
+ origin = {
73
+ id: event.pointerId,
74
+ x: event.clientX,
75
+ y: event.clientY,
76
+ time: event.timeStamp,
77
+ }
78
+ }
79
+ const pointerup = (event: PointerEvent) => {
80
+ const start = origin
81
+ reset()
82
+ if (!start || start.id !== event.pointerId || !commands.enabled()) return
83
+ const surface = commands.surface()
84
+ if (!surface || !ownsGesture(surface, event.target)) return
85
+
86
+ const x = event.clientX - start.x
87
+ const y = event.clientY - start.y
88
+ const distance = Math.hypot(x, y)
89
+ if (
90
+ Math.abs(x) >= SWIPE_DISTANCE &&
91
+ (start.axis === "horizontal" ||
92
+ (!start.axis && Math.abs(x) > Math.abs(y) * SWIPE_DOMINANCE))
93
+ ) {
94
+ event.preventDefault()
95
+ commands.moveBlock(x < 0 ? 1 : -1)
96
+ return
97
+ }
98
+ if (distance > TAP_SLOP || event.timeStamp - start.time > TAP_DURATION)
99
+ return
100
+ event.preventDefault()
101
+ const rect = surface.getBoundingClientRect()
102
+ const position = (event.clientX - rect.left) / rect.width
103
+ tapTimer = window.setTimeout(() => {
104
+ tapTimer = undefined
105
+ if (!commands.enabled()) return
106
+ commands.moveBeat(position < PREVIOUS_TAP_ZONE ? -1 : 1)
107
+ })
108
+ }
109
+ const pointermove = (event: PointerEvent) => {
110
+ const start = origin
111
+ if (!start || start.id !== event.pointerId || !commands.enabled()) return
112
+ const x = event.clientX - start.x
113
+ const y = event.clientY - start.y
114
+ if (!start.axis && Math.hypot(x, y) >= DIRECTION_LOCK_DISTANCE) {
115
+ const axis =
116
+ Math.abs(x) > Math.abs(y) * SWIPE_DOMINANCE
117
+ ? "horizontal"
118
+ : Math.abs(y) > Math.abs(x) * SWIPE_DOMINANCE
119
+ ? "vertical"
120
+ : undefined
121
+ if (axis) origin = { ...start, axis }
122
+ }
123
+ if (origin?.axis === "horizontal") event.preventDefault()
124
+ }
125
+ const pointercancel = () => {
126
+ reset()
127
+ clearTap()
128
+ }
129
+
130
+ document.addEventListener("pointerdown", pointerdown, { capture: true })
131
+ document.addEventListener("pointermove", pointermove, { capture: true })
132
+ document.addEventListener("pointerup", pointerup, { capture: true })
133
+ document.addEventListener("pointercancel", pointercancel, { capture: true })
134
+ return () => {
135
+ clearTap()
136
+ document.removeEventListener("pointerdown", pointerdown, { capture: true })
137
+ document.removeEventListener("pointermove", pointermove, { capture: true })
138
+ document.removeEventListener("pointerup", pointerup, { capture: true })
139
+ document.removeEventListener("pointercancel", pointercancel, {
140
+ capture: true,
141
+ })
142
+ }
143
+ }
package/grid.ts ADDED
@@ -0,0 +1,199 @@
1
+ import type { PlayRoute } from "./route.ts"
2
+
3
+ export type PlayGridDirection = "up" | "down" | "left" | "right"
4
+
5
+ type GridTarget = {
6
+ block: HTMLElement
7
+ beatIndex: number
8
+ current: boolean
9
+ }
10
+
11
+ type PreviousAttributes = {
12
+ ariaCurrent: string | null
13
+ gridBlock: string | null
14
+ tabIndex: string | null
15
+ }
16
+
17
+ type PlayGridOptions = Readonly<{
18
+ route(): PlayRoute | undefined
19
+ activeIndex(): number
20
+ seenThrough(): number
21
+ select(beatIndex: number): void
22
+ }>
23
+
24
+ const ROOT_ATTRIBUTE = "data-pmx-play-grid"
25
+ const BLOCK_ATTRIBUTE = "data-pmx-play-grid-block"
26
+ const ROW_TOLERANCE = 8
27
+
28
+ function setOrRemove(element: HTMLElement, name: string, value: string | null) {
29
+ if (value === null) element.removeAttribute(name)
30
+ else element.setAttribute(name, value)
31
+ }
32
+
33
+ function gridTargets(
34
+ route: PlayRoute,
35
+ activeIndex: number,
36
+ seenThrough: number,
37
+ ) {
38
+ const ranges = new Map<HTMLElement, { first: number; last: number }>()
39
+ route.beats.forEach((beat, index) => {
40
+ const range = ranges.get(beat.block)
41
+ if (range) range.last = index
42
+ else ranges.set(beat.block, { first: index, last: index })
43
+ })
44
+ return [...ranges].map(([block, range]) => ({
45
+ block,
46
+ beatIndex: range.first,
47
+ current: range.first <= activeIndex && activeIndex <= range.last,
48
+ }))
49
+ }
50
+
51
+ function targetState(target: GridTarget, seenThrough: number) {
52
+ if (target.current) return "current"
53
+ return target.beatIndex <= seenThrough ? "seen" : "upcoming"
54
+ }
55
+
56
+ function positioned(targets: readonly GridTarget[]) {
57
+ return targets
58
+ .map((target, order) => {
59
+ const rect = target.block.getBoundingClientRect()
60
+ return {
61
+ centerX: rect.left + rect.width / 2,
62
+ order,
63
+ rect,
64
+ target,
65
+ }
66
+ })
67
+ .sort(
68
+ (left, right) =>
69
+ left.rect.top - right.rect.top ||
70
+ left.rect.left - right.rect.left ||
71
+ left.order - right.order,
72
+ )
73
+ }
74
+
75
+ function arrowTarget(
76
+ targets: readonly GridTarget[],
77
+ current: GridTarget | undefined,
78
+ direction: PlayGridDirection,
79
+ ) {
80
+ const items = positioned(targets)
81
+ if (!items.length) return
82
+ const active =
83
+ items.find((item) => item.target.block === current?.block) ?? items[0]!
84
+
85
+ if (direction === "left" || direction === "right") {
86
+ const index = items.indexOf(active)
87
+ const offset = direction === "left" ? -1 : 1
88
+ return items[Math.max(0, Math.min(items.length - 1, index + offset))]
89
+ ?.target
90
+ }
91
+
92
+ const rows: Array<{ top: number; items: typeof items }> = []
93
+ for (const item of items) {
94
+ const row = rows.find(
95
+ (candidate) => Math.abs(candidate.top - item.rect.top) <= ROW_TOLERANCE,
96
+ )
97
+ if (row) row.items.push(item)
98
+ else rows.push({ top: item.rect.top, items: [item] })
99
+ }
100
+ const rowIndex = rows.findIndex((row) => row.items.includes(active))
101
+ const row = rows[rowIndex + (direction === "up" ? -1 : 1)]
102
+ if (!row) return active.target
103
+ return row.items.reduce((nearest, candidate) =>
104
+ Math.abs(candidate.centerX - active.centerX) <
105
+ Math.abs(nearest.centerX - active.centerX)
106
+ ? candidate
107
+ : nearest,
108
+ ).target
109
+ }
110
+
111
+ /** Temporarily projects the rendered Blocks into a selectable overview. */
112
+ export function createPlayGrid(options: PlayGridOptions) {
113
+ let open = false
114
+ let targets: GridTarget[] = []
115
+ let focused: GridTarget | undefined
116
+ let disposeTargets = () => {}
117
+ let focusFrame = 0
118
+
119
+ function clearTargets() {
120
+ cancelAnimationFrame(focusFrame)
121
+ disposeTargets()
122
+ disposeTargets = () => {}
123
+ targets = []
124
+ focused = undefined
125
+ }
126
+
127
+ function applyTargets() {
128
+ clearTargets()
129
+ const route = options.route()
130
+ if (!open || !route) return
131
+ targets = gridTargets(route, options.activeIndex(), options.seenThrough())
132
+ const previous = new Map<HTMLElement, PreviousAttributes>()
133
+ const listeners = targets.map((target) => {
134
+ const { block } = target
135
+ previous.set(block, {
136
+ ariaCurrent: block.getAttribute("aria-current"),
137
+ gridBlock: block.getAttribute(BLOCK_ATTRIBUTE),
138
+ tabIndex: block.getAttribute("tabindex"),
139
+ })
140
+ block.setAttribute(
141
+ BLOCK_ATTRIBUTE,
142
+ targetState(target, options.seenThrough()),
143
+ )
144
+ block.setAttribute("tabindex", "0")
145
+ if (target.current) block.setAttribute("aria-current", "true")
146
+ else block.removeAttribute("aria-current")
147
+
148
+ const select = (event: MouseEvent) => {
149
+ event.preventDefault()
150
+ event.stopPropagation()
151
+ options.select(target.beatIndex)
152
+ }
153
+ const rememberFocus = () => {
154
+ focused = target
155
+ }
156
+ block.addEventListener("click", select, { capture: true })
157
+ block.addEventListener("focusin", rememberFocus)
158
+ return () => {
159
+ block.removeEventListener("click", select, { capture: true })
160
+ block.removeEventListener("focusin", rememberFocus)
161
+ }
162
+ })
163
+ focused = targets.find((target) => target.current) ?? targets[0]
164
+ focusFrame = requestAnimationFrame(() => {
165
+ focused?.block.focus({ preventScroll: true })
166
+ focused?.block.scrollIntoView({ block: "center", inline: "nearest" })
167
+ })
168
+ disposeTargets = () => {
169
+ for (const dispose of listeners) dispose()
170
+ for (const [block, attributes] of previous) {
171
+ setOrRemove(block, "aria-current", attributes.ariaCurrent)
172
+ setOrRemove(block, BLOCK_ATTRIBUTE, attributes.gridBlock)
173
+ setOrRemove(block, "tabindex", attributes.tabIndex)
174
+ }
175
+ }
176
+ }
177
+
178
+ function setOpen(next: boolean) {
179
+ if (next === open) return
180
+ open = next
181
+ document.documentElement.toggleAttribute(ROOT_ATTRIBUTE, open)
182
+ if (open) applyTargets()
183
+ else clearTargets()
184
+ }
185
+
186
+ return {
187
+ isOpen: () => open,
188
+ setOpen,
189
+ move(direction: PlayGridDirection) {
190
+ if (!open) return
191
+ focused = arrowTarget(targets, focused, direction)
192
+ focused?.block.focus({ preventScroll: true })
193
+ focused?.block.scrollIntoView({ block: "nearest", inline: "nearest" })
194
+ },
195
+ selectFocused() {
196
+ if (open && focused) options.select(focused.beatIndex)
197
+ },
198
+ }
199
+ }
package/guide.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Beat } from "./route.ts"
2
+
3
+ export type PlayGuideFrame = Readonly<{
4
+ guideLeft: number
5
+ top: number
6
+ height: number
7
+ targetLeft: number
8
+ targetWidth: number
9
+ highlight: "none" | "row" | "code"
10
+ }>
11
+
12
+ const GUIDE_GAP = 12
13
+ const GUIDE_WIDTH = 3
14
+
15
+ function pixelValue(value: string, fallback = 0) {
16
+ const parsed = Number.parseFloat(value)
17
+ return Number.isFinite(parsed) ? parsed : fallback
18
+ }
19
+
20
+ function contentLeft(target: HTMLElement, rect: DOMRect) {
21
+ if (!target.matches("li")) return rect.left
22
+ const style = getComputedStyle(target)
23
+ if (style.listStylePosition === "inside" || style.listStyleType === "none") {
24
+ return rect.left
25
+ }
26
+ const marker = getComputedStyle(target, "::marker")
27
+ const fontSize = pixelValue(marker.fontSize, pixelValue(style.fontSize))
28
+ const markerWidth = pixelValue(marker.width, fontSize * 0.4)
29
+ return rect.left - markerWidth - fontSize * 0.5
30
+ }
31
+
32
+ function guideLeft(target: HTMLElement, rect: DOMRect) {
33
+ return contentLeft(target, rect) - GUIDE_GAP - GUIDE_WIDTH
34
+ }
35
+
36
+ function normalFrame(
37
+ target: HTMLElement,
38
+ rect: DOMRect,
39
+ highlight: PlayGuideFrame["highlight"] = "none",
40
+ ): PlayGuideFrame {
41
+ return {
42
+ guideLeft: guideLeft(target, rect),
43
+ top: rect.top,
44
+ height: Math.max(3, rect.height),
45
+ targetLeft: rect.left,
46
+ targetWidth: rect.width,
47
+ highlight,
48
+ }
49
+ }
50
+
51
+ function codeFrame(beat: Beat, rect: DOMRect): PlayGuideFrame {
52
+ const code = beat.target.querySelector<HTMLElement>("pre code")
53
+ const lines = beat.member?.type === "code-lines" ? beat.member.lines : []
54
+ if (!code || !lines.length) return normalFrame(beat.target, rect)
55
+ const codeRect = code.getBoundingClientRect()
56
+ const style = getComputedStyle(code)
57
+ const lineHeight = Number.parseFloat(style.lineHeight)
58
+ const paddingTop = pixelValue(style.paddingTop)
59
+ const paddingLeft = pixelValue(style.paddingLeft)
60
+ const paddingRight = pixelValue(style.paddingRight)
61
+ const sourceLines = Math.max(1, code.textContent?.split("\n").length ?? 1)
62
+ const measuredLineHeight = Number.isFinite(lineHeight)
63
+ ? lineHeight
64
+ : codeRect.height / sourceLines
65
+ const first = Math.min(...lines)
66
+ const last = Math.max(...lines)
67
+ return {
68
+ guideLeft: guideLeft(beat.target, rect),
69
+ top: codeRect.top + paddingTop + (first - 1) * measuredLineHeight,
70
+ height: Math.max(3, (last - first + 1) * measuredLineHeight),
71
+ targetLeft: codeRect.left + paddingLeft,
72
+ targetWidth: Math.max(0, codeRect.width - paddingLeft - paddingRight),
73
+ highlight: "code",
74
+ }
75
+ }
76
+
77
+ /** Measure the Player-owned guide against the active rendered Beat member. */
78
+ export function measurePlayGuide(beat: Beat): PlayGuideFrame {
79
+ const rect = beat.target.getBoundingClientRect()
80
+ if (beat.member?.type === "code-lines") return codeFrame(beat, rect)
81
+ return normalFrame(
82
+ beat.target,
83
+ rect,
84
+ beat.member?.type === "table-row" ? "row" : "none",
85
+ )
86
+ }
package/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import {
2
+ defineAppComponent,
3
+ html,
4
+ type AppContext,
5
+ type Plugin,
6
+ } from "@pathmx/core"
7
+ import { explicitBeatFragment } from "./fragments.ts"
8
+ import { rewritePlayMedia } from "./media.ts"
9
+ import { rewritePlayNotes } from "./notes.ts"
10
+
11
+ function rewriteBeatFragments(html: string, app: AppContext) {
12
+ if (!html.includes("data-pmx-beat")) return html
13
+ return app.html.rewrite(html, [
14
+ [
15
+ "[data-pmx-beat]",
16
+ {
17
+ element(beat) {
18
+ if (beat.getAttribute("id")) return
19
+ const fragment = explicitBeatFragment(
20
+ beat.getAttribute("data-pmx-beat"),
21
+ )
22
+ if (fragment) beat.setAttribute("id", fragment)
23
+ },
24
+ },
25
+ ],
26
+ ])
27
+ }
28
+
29
+ const PlayerApp = defineAppComponent({
30
+ tag: "player:app",
31
+ client: new URL("./client.ts", import.meta.url),
32
+ async render(_use, _compiled, ctx) {
33
+ const description = await ctx.view.describeSource(ctx.source.id)
34
+ if (!description) return ""
35
+ return html`<div
36
+ data-component="player:app"
37
+ data-description="${JSON.stringify(description)}"
38
+ ></div>`
39
+ },
40
+ })
41
+
42
+ /** Authored React app for focused reading and slide presentation. */
43
+ export function PlayerPlugin(): Plugin {
44
+ return {
45
+ id: "player",
46
+ name: "PlayerPlugin",
47
+ requires: ["image", "block-layout"],
48
+ components: [PlayerApp],
49
+ postcompile(html, { block }, app) {
50
+ if (!block) return html
51
+ return rewriteBeatFragments(
52
+ rewritePlayMedia(rewritePlayNotes(html), app),
53
+ app,
54
+ )
55
+ },
56
+ }
57
+ }
58
+
59
+ export default PlayerPlugin
package/keyboard.ts ADDED
@@ -0,0 +1,172 @@
1
+ type PlayerKeyboardCommands = Readonly<{
2
+ active(): boolean
3
+ start(): void
4
+ controlsExpanded(): boolean
5
+ gridOpen(): boolean
6
+ actionCount(): number
7
+ activateAction(index: number): void
8
+ adjustIntensity(offset: -1 | 1): void
9
+ move(offset: -1 | 1): void
10
+ moveBlock(offset: -1 | 1): void
11
+ moveGrid(direction: "up" | "down" | "left" | "right"): void
12
+ selectGrid(): void
13
+ toggleGrid(): void
14
+ toggleMode(): void
15
+ exit(): void
16
+ }>
17
+
18
+ function isInteractive(target: EventTarget | null) {
19
+ return (
20
+ target instanceof Element &&
21
+ Boolean(
22
+ target.closest(
23
+ "a, button, input, select, textarea, summary, [contenteditable]:not([contenteditable='false'])",
24
+ ),
25
+ )
26
+ )
27
+ }
28
+
29
+ function isPlayerBeatButton(target: EventTarget | null) {
30
+ return (
31
+ target instanceof HTMLButtonElement && target.hasAttribute("data-pmx-beat")
32
+ )
33
+ }
34
+
35
+ function isPlayerControl(target: EventTarget | null) {
36
+ return (
37
+ target instanceof Element &&
38
+ Boolean(target.closest("[data-pmx-player-control]"))
39
+ )
40
+ }
41
+
42
+ function isEditable(target: EventTarget | null) {
43
+ return (
44
+ target instanceof Element &&
45
+ Boolean(
46
+ target.closest(
47
+ "input, select, textarea, [contenteditable]:not([contenteditable='false'])",
48
+ ),
49
+ )
50
+ )
51
+ }
52
+
53
+ function isBeatNavigationKey(event: KeyboardEvent) {
54
+ return (
55
+ event.key === "ArrowDown" ||
56
+ event.key === "ArrowUp" ||
57
+ event.key === "ArrowRight" ||
58
+ event.key === "ArrowLeft" ||
59
+ event.key === "PageDown" ||
60
+ event.key === "PageUp"
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Play entry is a mode change: a launch link or button must not keep keyboard
66
+ * ownership and turn the first arrow press into native page scrolling.
67
+ * Editable controls retain focus so programmatic activation cannot interrupt
68
+ * input unexpectedly.
69
+ */
70
+ export function returnKeyboardToDocument() {
71
+ const active = document.activeElement
72
+ if (!(active instanceof HTMLElement)) return
73
+ if (
74
+ active.matches(
75
+ "input, select, textarea, [contenteditable]:not([contenteditable='false'])",
76
+ )
77
+ ) {
78
+ return
79
+ }
80
+ active.blur()
81
+ }
82
+
83
+ /** Own Player's document-level keyboard mapping outside the session controller. */
84
+ export function installPlayerKeyboard(commands: PlayerKeyboardCommands) {
85
+ const keydown = (event: KeyboardEvent) => {
86
+ const activation =
87
+ event.key === "Enter" &&
88
+ (event.metaKey || event.ctrlKey) &&
89
+ !event.altKey &&
90
+ !event.shiftKey
91
+ if (activation) {
92
+ if (!isEditable(event.target)) {
93
+ event.preventDefault()
94
+ if (!commands.active()) commands.start()
95
+ }
96
+ return
97
+ }
98
+ if (!commands.active()) return
99
+ if (commands.controlsExpanded()) return
100
+ const interactive =
101
+ isInteractive(event.target) && !isPlayerControl(event.target)
102
+ if (
103
+ interactive &&
104
+ !isPlayerBeatButton(event.target) &&
105
+ (!isBeatNavigationKey(event) ||
106
+ isEditable(event.target) ||
107
+ isPlayerControl(event.target))
108
+ )
109
+ return
110
+ let run: (() => void) | undefined
111
+ const key = event.key.toLowerCase()
112
+ const unmodified = !event.altKey && !event.metaKey && !event.ctrlKey
113
+ if (commands.gridOpen()) {
114
+ if (event.key === "Escape" || (unmodified && key === "g")) {
115
+ run = commands.toggleGrid
116
+ } else if (event.key === "ArrowDown") {
117
+ run = () => commands.moveGrid("down")
118
+ } else if (event.key === "ArrowUp") {
119
+ run = () => commands.moveGrid("up")
120
+ } else if (event.key === "ArrowRight") {
121
+ run = () => commands.moveGrid("right")
122
+ } else if (event.key === "ArrowLeft") {
123
+ run = () => commands.moveGrid("left")
124
+ } else if (event.key === "Enter" || event.key === " ") {
125
+ run = commands.selectGrid
126
+ }
127
+ } else if (event.key === "Escape") run = commands.exit
128
+ else if (unmodified && key === "g") run = commands.toggleGrid
129
+ else if (unmodified && key === "m") run = commands.toggleMode
130
+ else if (unmodified && (event.key === "+" || event.key === "=")) {
131
+ run = () => commands.adjustIntensity(1)
132
+ } else if (unmodified && (event.key === "-" || event.key === "_")) {
133
+ run = () => commands.adjustIntensity(-1)
134
+ } else if (!interactive && /^[1-9]$/.test(event.key)) {
135
+ const index = Number(event.key) - 1
136
+ if (index < commands.actionCount()) {
137
+ run = () => commands.activateAction(index)
138
+ }
139
+ } else if (
140
+ !interactive &&
141
+ event.key === "Enter" &&
142
+ commands.actionCount()
143
+ ) {
144
+ run = () => commands.activateAction(0)
145
+ } else if (
146
+ event.key === "ArrowDown" ||
147
+ event.key === "PageDown" ||
148
+ (event.key === " " && !event.shiftKey)
149
+ ) {
150
+ run = () => commands.move(1)
151
+ } else if (
152
+ event.key === "ArrowUp" ||
153
+ event.key === "PageUp" ||
154
+ (event.key === " " && event.shiftKey)
155
+ ) {
156
+ run = () => commands.move(-1)
157
+ } else if (event.key === "ArrowRight") {
158
+ run = () => commands.moveBlock(1)
159
+ } else if (event.key === "ArrowLeft") {
160
+ run = () => commands.moveBlock(-1)
161
+ }
162
+ if (!run) return
163
+ event.preventDefault()
164
+ run()
165
+ if (!commands.gridOpen() && isBeatNavigationKey(event)) {
166
+ returnKeyboardToDocument()
167
+ }
168
+ }
169
+ document.addEventListener("keydown", keydown, { capture: true })
170
+ return () =>
171
+ document.removeEventListener("keydown", keydown, { capture: true })
172
+ }
package/media.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { AppContext } from "@pathmx/core"
2
+
3
+ /** Keep background images out of the presentation Beat route. */
4
+ export function rewritePlayMedia(html: string, app: AppContext) {
5
+ if (!html.includes('data-pmx-image="background"')) return html
6
+ return app.html.rewrite(html, [
7
+ [
8
+ 'img[data-pmx-image="background"]',
9
+ {
10
+ element(image) {
11
+ image.setAttribute("data-pmx-beats", "off")
12
+ },
13
+ },
14
+ ],
15
+ ])
16
+ }
17
+
18
+ function standaloneImageFrame(image: HTMLImageElement) {
19
+ const parent = image.parentElement
20
+ return parent?.matches("p") &&
21
+ parent.childElementCount === 1 &&
22
+ parent.firstElementChild === image
23
+ ? parent
24
+ : image
25
+ }
26
+
27
+ /** Mark the rendered container that participates in Player slide layout. */
28
+ export function applyPlayMediaFrames(root: ParentNode) {
29
+ for (const previous of root.querySelectorAll<HTMLElement>(
30
+ "[data-pmx-play-media-frame]",
31
+ )) {
32
+ previous.removeAttribute("data-pmx-play-media-frame")
33
+ previous.removeAttribute("data-pmx-play-media")
34
+ previous.removeAttribute("data-pmx-play-fit")
35
+ }
36
+
37
+ for (const image of root.querySelectorAll<HTMLImageElement>("img")) {
38
+ const frame = standaloneImageFrame(image)
39
+ frame.setAttribute("data-pmx-play-media-frame", "")
40
+ if (image.dataset.pmxImage) {
41
+ frame.dataset.pmxPlayMedia = image.dataset.pmxImage
42
+ }
43
+ if (image.dataset.pmxImageFit) {
44
+ frame.dataset.pmxPlayFit = image.dataset.pmxImageFit
45
+ }
46
+ }
47
+ }