@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/LICENSE +43 -0
- package/LICENSE.md +172 -0
- package/README.md +161 -0
- package/actions.ts +69 -0
- package/client.ts +1047 -0
- package/controls.tsx +716 -0
- package/fragments.ts +6 -0
- package/gestures.ts +143 -0
- package/grid.ts +199 -0
- package/guide.ts +86 -0
- package/index.ts +59 -0
- package/keyboard.ts +172 -0
- package/media.ts +47 -0
- package/navigation.ts +97 -0
- package/notes.css +65 -0
- package/notes.ts +10 -0
- package/package.json +32 -0
- package/play.css +1052 -0
- package/preferences.ts +71 -0
- package/progress.tsx +70 -0
- package/ref/deckset-exports/Letters from Sweden.dstheme +1 -0
- package/ref/deckset-exports/Merriweather.dstheme +1 -0
- package/ref/deckset-exports/Next.dstheme +1 -0
- package/route.ts +231 -0
- package/scroll.ts +276 -0
- package/themes.ts +39 -0
- package/variants.ts +36 -0
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
|
+
}
|