@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/preferences.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PlayIntensity, PlayMode, PlayThemeChoice } from "./controls.tsx"
|
|
2
|
+
import { isPlayTheme } from "./themes.ts"
|
|
3
|
+
|
|
4
|
+
export type PlayPreferences = Readonly<{
|
|
5
|
+
mode: PlayMode
|
|
6
|
+
intensity: PlayIntensity
|
|
7
|
+
theme: PlayThemeChoice
|
|
8
|
+
guide: boolean
|
|
9
|
+
notes: boolean
|
|
10
|
+
autoHide: boolean
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
const PREFERENCES_KEY = "pathmx:play-preferences"
|
|
14
|
+
const LEGACY_CONTROLS_KEY = "pathmx:play-controls"
|
|
15
|
+
const DEFAULTS: PlayPreferences = {
|
|
16
|
+
mode: "focus",
|
|
17
|
+
intensity: "medium",
|
|
18
|
+
theme: "source",
|
|
19
|
+
guide: true,
|
|
20
|
+
notes: true,
|
|
21
|
+
autoHide: true,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readPreferences(): PlayPreferences {
|
|
25
|
+
try {
|
|
26
|
+
const value = localStorage.getItem(PREFERENCES_KEY)
|
|
27
|
+
const stored = value ? (JSON.parse(value) as Record<string, unknown>) : {}
|
|
28
|
+
const legacyAutoHide =
|
|
29
|
+
localStorage.getItem(LEGACY_CONTROLS_KEY) !== "always"
|
|
30
|
+
return {
|
|
31
|
+
mode:
|
|
32
|
+
stored.mode === "focus" || stored.mode === "presentation"
|
|
33
|
+
? stored.mode
|
|
34
|
+
: DEFAULTS.mode,
|
|
35
|
+
intensity:
|
|
36
|
+
stored.intensity === "low" ||
|
|
37
|
+
stored.intensity === "medium" ||
|
|
38
|
+
stored.intensity === "high"
|
|
39
|
+
? stored.intensity
|
|
40
|
+
: DEFAULTS.intensity,
|
|
41
|
+
theme:
|
|
42
|
+
stored.theme === "source" ||
|
|
43
|
+
(typeof stored.theme === "string" && isPlayTheme(stored.theme))
|
|
44
|
+
? stored.theme
|
|
45
|
+
: DEFAULTS.theme,
|
|
46
|
+
guide: typeof stored.guide === "boolean" ? stored.guide : DEFAULTS.guide,
|
|
47
|
+
notes: typeof stored.notes === "boolean" ? stored.notes : DEFAULTS.notes,
|
|
48
|
+
autoHide:
|
|
49
|
+
typeof stored.autoHide === "boolean" ? stored.autoHide : legacyAutoHide,
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
return DEFAULTS
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Own the browser-local Player settings boundary and legacy migration. */
|
|
57
|
+
export function createPlayPreferenceStore() {
|
|
58
|
+
let preferences = readPreferences()
|
|
59
|
+
return {
|
|
60
|
+
snapshot: () => preferences,
|
|
61
|
+
update(next: Partial<PlayPreferences>) {
|
|
62
|
+
preferences = { ...preferences, ...next }
|
|
63
|
+
try {
|
|
64
|
+
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences))
|
|
65
|
+
} catch {
|
|
66
|
+
// Local preference storage is optional.
|
|
67
|
+
}
|
|
68
|
+
return preferences
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
package/progress.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CSSProperties } from "react"
|
|
2
|
+
|
|
3
|
+
export type PlayProgressBlock = Readonly<{
|
|
4
|
+
start: number
|
|
5
|
+
total: number
|
|
6
|
+
}>
|
|
7
|
+
|
|
8
|
+
type PlayProgressProps = Readonly<{
|
|
9
|
+
playing: boolean
|
|
10
|
+
mode: "focus" | "presentation"
|
|
11
|
+
index: number
|
|
12
|
+
total: number
|
|
13
|
+
blocks: readonly PlayProgressBlock[]
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
/** Render continuous Focus progress or Beat-filled Block Slides progress. */
|
|
17
|
+
export function PlayProgress(props: PlayProgressProps) {
|
|
18
|
+
const compact = props.blocks.length > 18
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className="pmx-play-progressbar"
|
|
22
|
+
data-pmx-play-progressbar=""
|
|
23
|
+
data-mode={props.mode}
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
hidden={!props.playing}
|
|
26
|
+
style={
|
|
27
|
+
{
|
|
28
|
+
"--pmx-play-progress": props.total
|
|
29
|
+
? String((props.index + 1) / props.total)
|
|
30
|
+
: "0",
|
|
31
|
+
"--pmx-play-block-gap": compact ? "3px" : "5px",
|
|
32
|
+
} as CSSProperties
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
{props.mode === "presentation" ? (
|
|
36
|
+
<span className="pmx-play-progress-blocks">
|
|
37
|
+
{props.blocks.map((block) => (
|
|
38
|
+
<span
|
|
39
|
+
className="pmx-play-progress-block"
|
|
40
|
+
data-pmx-play-progress-block=""
|
|
41
|
+
data-beats={block.total}
|
|
42
|
+
data-state={
|
|
43
|
+
props.index >= block.start + block.total
|
|
44
|
+
? "complete"
|
|
45
|
+
: props.index >= block.start
|
|
46
|
+
? "current"
|
|
47
|
+
: "upcoming"
|
|
48
|
+
}
|
|
49
|
+
key={block.start}
|
|
50
|
+
style={
|
|
51
|
+
{
|
|
52
|
+
"--pmx-play-block-progress":
|
|
53
|
+
props.index < block.start
|
|
54
|
+
? "0"
|
|
55
|
+
: props.index >= block.start + block.total
|
|
56
|
+
? "1"
|
|
57
|
+
: String((props.index - block.start + 1) / block.total),
|
|
58
|
+
} as CSSProperties
|
|
59
|
+
}
|
|
60
|
+
>
|
|
61
|
+
<span data-pmx-play-progress-block-fill="" />
|
|
62
|
+
</span>
|
|
63
|
+
))}
|
|
64
|
+
</span>
|
|
65
|
+
) : (
|
|
66
|
+
<span data-pmx-play-progress-fill="" />
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":0,"identifier":"letters from sweden","customizations":{}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"identifier":"merriweather","type":0,"customizations":{}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"identifier":"next","type":0,"customizations":{}}
|
package/route.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { explicitBeatFragment } from "./fragments.ts"
|
|
2
|
+
|
|
3
|
+
export type Beat = {
|
|
4
|
+
type: "beat"
|
|
5
|
+
id: string
|
|
6
|
+
fragment?: string
|
|
7
|
+
block: HTMLElement
|
|
8
|
+
element: HTMLElement
|
|
9
|
+
target: HTMLElement
|
|
10
|
+
label: string
|
|
11
|
+
member?:
|
|
12
|
+
| Readonly<{ type: "table-row" }>
|
|
13
|
+
| Readonly<{ type: "code-lines"; lines: readonly number[] }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PlayRoute = {
|
|
17
|
+
document: HTMLElement
|
|
18
|
+
beats: Beat[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BLOCK_SELECTOR = ":scope > section[data-pmx-block]"
|
|
22
|
+
const BEAT_SELECTOR = [
|
|
23
|
+
"[data-pmx-beat]",
|
|
24
|
+
"h1",
|
|
25
|
+
"h2",
|
|
26
|
+
"h3",
|
|
27
|
+
"h4",
|
|
28
|
+
"h5",
|
|
29
|
+
"h6",
|
|
30
|
+
"p",
|
|
31
|
+
"li",
|
|
32
|
+
"blockquote",
|
|
33
|
+
"dl",
|
|
34
|
+
"figure",
|
|
35
|
+
"picture",
|
|
36
|
+
"img",
|
|
37
|
+
"svg",
|
|
38
|
+
"canvas",
|
|
39
|
+
"iframe",
|
|
40
|
+
"video",
|
|
41
|
+
"audio",
|
|
42
|
+
"pre",
|
|
43
|
+
".pmx-code-block",
|
|
44
|
+
"table",
|
|
45
|
+
"details",
|
|
46
|
+
"form",
|
|
47
|
+
"fieldset",
|
|
48
|
+
'[data-pathmx-math="display"]',
|
|
49
|
+
'[role="img"]',
|
|
50
|
+
"[data-component]",
|
|
51
|
+
].join(",")
|
|
52
|
+
|
|
53
|
+
function elementMode(element: Element) {
|
|
54
|
+
return element.closest<HTMLElement>("[data-pmx-beats]")?.dataset.pmxBeats
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isUnavailable(element: HTMLElement) {
|
|
58
|
+
return Boolean(
|
|
59
|
+
element.closest(
|
|
60
|
+
'[hidden], template, script, style, [aria-hidden="true"], [data-pmx-app], [data-pmx-beats="off"]',
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isMeaningful(element: HTMLElement) {
|
|
66
|
+
if (element.matches("p") && !element.textContent?.trim()) return false
|
|
67
|
+
if (element.matches("img")) return Boolean(element.getAttribute("alt"))
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function playTarget(element: HTMLElement) {
|
|
72
|
+
const selector = element.dataset.pmxPlayTarget
|
|
73
|
+
if (!selector) return element
|
|
74
|
+
try {
|
|
75
|
+
const target = document.querySelector(selector)
|
|
76
|
+
return target instanceof HTMLElement ? target : element
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn(`[pathmx-play] invalid target selector: ${selector}`)
|
|
79
|
+
return element
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function labelFor(element: HTMLElement, fallback: string) {
|
|
84
|
+
const label =
|
|
85
|
+
element.dataset.pmxBeatLabel ??
|
|
86
|
+
element.getAttribute("aria-label") ??
|
|
87
|
+
element.getAttribute("alt") ??
|
|
88
|
+
element.textContent
|
|
89
|
+
const normalized = label?.replace(/\s+/g, " ").trim()
|
|
90
|
+
return normalized ? normalized.slice(0, 120) : fallback
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function codeLineGroups(element: HTMLElement) {
|
|
94
|
+
const authored = element.dataset.pmxCodeSteps
|
|
95
|
+
if (!authored) return []
|
|
96
|
+
return authored.split("|").flatMap((part) => {
|
|
97
|
+
const [startText, endText = startText] = part.split("-")
|
|
98
|
+
const start = Number(startText)
|
|
99
|
+
const end = Number(endText)
|
|
100
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1) {
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
const last = Math.min(Math.max(start, end), start + 200)
|
|
104
|
+
return [
|
|
105
|
+
Array.from({ length: last - start + 1 }, (_, index) => start + index),
|
|
106
|
+
]
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function authoredBlockFragment(block: HTMLElement) {
|
|
111
|
+
return /^block-\d+$/u.test(block.id) ? undefined : block.id || undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Return the compiled presenter notes owned by one rendered Block. */
|
|
115
|
+
export function playNoteForBlock(block: HTMLElement) {
|
|
116
|
+
const notes = block.querySelectorAll<HTMLElement>("[data-pmx-play-note]")
|
|
117
|
+
if (!notes.length) return
|
|
118
|
+
return [...notes].map((note) => `<p>${note.innerHTML.trim()}</p>`).join("")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Derive one flat, ordered Play route from the rendered document. */
|
|
122
|
+
export function extractPlayRoute(): PlayRoute | undefined {
|
|
123
|
+
const playDocument = document.querySelector(".pmx-document")
|
|
124
|
+
if (!(playDocument instanceof HTMLElement)) return
|
|
125
|
+
|
|
126
|
+
const beats: Beat[] = []
|
|
127
|
+
const ids = new Set<string>()
|
|
128
|
+
const blocks = playDocument.querySelectorAll<HTMLElement>(BLOCK_SELECTOR)
|
|
129
|
+
|
|
130
|
+
blocks.forEach((block, blockIndex) => {
|
|
131
|
+
const blockId = block.id || `block-${blockIndex}`
|
|
132
|
+
const accepted: HTMLElement[] = []
|
|
133
|
+
|
|
134
|
+
for (const element of block.querySelectorAll<HTMLElement>(BEAT_SELECTOR)) {
|
|
135
|
+
if (isUnavailable(element)) continue
|
|
136
|
+
const explicit = element.hasAttribute("data-pmx-beat")
|
|
137
|
+
if (!explicit && elementMode(element) === "explicit") continue
|
|
138
|
+
if (!explicit && !isMeaningful(element)) continue
|
|
139
|
+
|
|
140
|
+
const component = element.closest<HTMLElement>("[data-component]")
|
|
141
|
+
if (!explicit && component && component !== element) continue
|
|
142
|
+
if (!explicit && accepted.some((parent) => parent.contains(element))) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const authoredValue = element.dataset.pmxBeat?.trim()
|
|
147
|
+
const authoredId = explicitBeatFragment(authoredValue)
|
|
148
|
+
if (authoredValue && !authoredId) {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[pathmx-play] invalid Beat fragment ignored: ${authoredValue}`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const id = authoredId || `${blockId}:beat-${beats.length}`
|
|
154
|
+
if (ids.has(id)) {
|
|
155
|
+
console.warn(`[pathmx-play] duplicate beat id ignored: ${id}`)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
ids.add(id)
|
|
159
|
+
accepted.push(element)
|
|
160
|
+
const target = playTarget(element)
|
|
161
|
+
if (authoredId && !element.id) {
|
|
162
|
+
const owner = document.getElementById(authoredId)
|
|
163
|
+
if (!owner || owner === element) element.id = authoredId
|
|
164
|
+
else {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[pathmx-play] Beat fragment collides with an existing id: ${authoredId}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
beats.push({
|
|
171
|
+
type: "beat",
|
|
172
|
+
id,
|
|
173
|
+
fragment: element.id || target.id || authoredId,
|
|
174
|
+
block,
|
|
175
|
+
element,
|
|
176
|
+
target,
|
|
177
|
+
label: labelFor(element, id),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if (element.matches("table")) {
|
|
181
|
+
for (const [rowIndex, row] of element
|
|
182
|
+
.querySelectorAll<HTMLElement>("tbody > tr")
|
|
183
|
+
.entries()) {
|
|
184
|
+
const rowId = `${id}:row-${rowIndex + 1}`
|
|
185
|
+
ids.add(rowId)
|
|
186
|
+
beats.push({
|
|
187
|
+
type: "beat",
|
|
188
|
+
id: rowId,
|
|
189
|
+
block,
|
|
190
|
+
element: row,
|
|
191
|
+
target: row,
|
|
192
|
+
label: labelFor(row, `Table row ${rowIndex + 1}`),
|
|
193
|
+
member: { type: "table-row" },
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (element.matches(".pmx-code-block")) {
|
|
199
|
+
for (const [groupIndex, lines] of codeLineGroups(element).entries()) {
|
|
200
|
+
const groupId = `${id}:lines-${groupIndex + 1}`
|
|
201
|
+
ids.add(groupId)
|
|
202
|
+
beats.push({
|
|
203
|
+
type: "beat",
|
|
204
|
+
id: groupId,
|
|
205
|
+
block,
|
|
206
|
+
element,
|
|
207
|
+
target: element,
|
|
208
|
+
label: `Code lines ${lines.join(", ")}`,
|
|
209
|
+
member: { type: "code-lines", lines },
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (accepted.length === 0) {
|
|
216
|
+
const id = `${blockId}:beat`
|
|
217
|
+
ids.add(id)
|
|
218
|
+
beats.push({
|
|
219
|
+
type: "beat",
|
|
220
|
+
id,
|
|
221
|
+
fragment: authoredBlockFragment(block),
|
|
222
|
+
block,
|
|
223
|
+
element: block,
|
|
224
|
+
target: block,
|
|
225
|
+
label: labelFor(block, blockId),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return beats.length ? { document: playDocument, beats } : undefined
|
|
231
|
+
}
|
package/scroll.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { PlayMode } from "./controls.ts"
|
|
2
|
+
|
|
3
|
+
export type PlayScrollBehavior = "smooth" | "instant"
|
|
4
|
+
|
|
5
|
+
export type PlayScrollSync = {
|
|
6
|
+
navigate(
|
|
7
|
+
target: HTMLElement,
|
|
8
|
+
block: HTMLElement,
|
|
9
|
+
mode: PlayMode,
|
|
10
|
+
behavior?: PlayScrollBehavior,
|
|
11
|
+
): void
|
|
12
|
+
preserve(target: HTMLElement, viewportTop: number): void
|
|
13
|
+
setEnabled(enabled: boolean): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SNAP_END_SELECTOR = ":scope > [data-pmx-play-snap-end]"
|
|
17
|
+
|
|
18
|
+
/** Keep bottom-edge snap targets limited to document-scrolling tall Blocks. */
|
|
19
|
+
export function syncPresentationSnapTargets(
|
|
20
|
+
playDocument: HTMLElement,
|
|
21
|
+
enabled: boolean,
|
|
22
|
+
) {
|
|
23
|
+
const blocks = playDocument.querySelectorAll<HTMLElement>(
|
|
24
|
+
":scope > section[data-pmx-block]",
|
|
25
|
+
)
|
|
26
|
+
for (const block of blocks) {
|
|
27
|
+
const existing = block.querySelector<HTMLElement>(SNAP_END_SELECTOR)
|
|
28
|
+
const needsEndTarget =
|
|
29
|
+
enabled && block.getBoundingClientRect().height > innerHeight + 1
|
|
30
|
+
if (!needsEndTarget) {
|
|
31
|
+
existing?.remove()
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (existing) continue
|
|
35
|
+
const marker = document.createElement("span")
|
|
36
|
+
marker.dataset.pmxPlaySnapEnd = ""
|
|
37
|
+
marker.setAttribute("aria-hidden", "true")
|
|
38
|
+
block.append(marker)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keep native scrolling authoritative. User scrolling suspends the spotlight;
|
|
44
|
+
* once it settles, the controller adopts the visible Beat without scrolling
|
|
45
|
+
* back. Programmatic Play navigation never enters that adoption path.
|
|
46
|
+
*/
|
|
47
|
+
export function createPlayScrollSync(callbacks: {
|
|
48
|
+
start(): void
|
|
49
|
+
settle(): void
|
|
50
|
+
track(tracking: boolean): void
|
|
51
|
+
}): PlayScrollSync {
|
|
52
|
+
let enabled = false
|
|
53
|
+
let programmatic = false
|
|
54
|
+
let scrolling = false
|
|
55
|
+
let tracking = false
|
|
56
|
+
let settleTimer: number | undefined
|
|
57
|
+
let programTimer: number | undefined
|
|
58
|
+
let snapReleaseFrame: number | undefined
|
|
59
|
+
let userIntentUntil = 0
|
|
60
|
+
|
|
61
|
+
const markUserIntent = () => {
|
|
62
|
+
userIntentUntil = performance.now() + 700
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const clearTimer = (timer: number | undefined) => {
|
|
66
|
+
if (timer !== undefined) window.clearTimeout(timer)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const releasePresentationSnap = () => {
|
|
70
|
+
if (snapReleaseFrame !== undefined) {
|
|
71
|
+
cancelAnimationFrame(snapReleaseFrame)
|
|
72
|
+
snapReleaseFrame = undefined
|
|
73
|
+
}
|
|
74
|
+
document.documentElement.removeAttribute(
|
|
75
|
+
"data-pmx-play-programmatic-scroll",
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const suspendPresentationSnap = () => {
|
|
80
|
+
releasePresentationSnap()
|
|
81
|
+
document.documentElement.setAttribute(
|
|
82
|
+
"data-pmx-play-programmatic-scroll",
|
|
83
|
+
"",
|
|
84
|
+
)
|
|
85
|
+
snapReleaseFrame = requestAnimationFrame(() => {
|
|
86
|
+
snapReleaseFrame = requestAnimationFrame(releasePresentationSnap)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const setTracking = (value: boolean) => {
|
|
91
|
+
if (tracking === value) return
|
|
92
|
+
tracking = value
|
|
93
|
+
callbacks.track(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const settle = () => {
|
|
97
|
+
clearTimer(settleTimer)
|
|
98
|
+
settleTimer = undefined
|
|
99
|
+
if (programmatic) return
|
|
100
|
+
if (!scrolling) return
|
|
101
|
+
scrolling = false
|
|
102
|
+
userIntentUntil = 0
|
|
103
|
+
callbacks.settle()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const onScroll = (event: Event) => {
|
|
107
|
+
if (event.target !== document) return
|
|
108
|
+
if (!enabled) return
|
|
109
|
+
if (programmatic) {
|
|
110
|
+
clearTimer(programTimer)
|
|
111
|
+
programTimer = window.setTimeout(() => {
|
|
112
|
+
programmatic = false
|
|
113
|
+
setTracking(false)
|
|
114
|
+
}, 700)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (!scrolling) {
|
|
118
|
+
if (performance.now() > userIntentUntil) return
|
|
119
|
+
scrolling = true
|
|
120
|
+
callbacks.start()
|
|
121
|
+
}
|
|
122
|
+
clearTimer(settleTimer)
|
|
123
|
+
settleTimer = window.setTimeout(settle, 160)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onUserIntent = () => {
|
|
127
|
+
if (!enabled) return
|
|
128
|
+
releasePresentationSnap()
|
|
129
|
+
markUserIntent()
|
|
130
|
+
programmatic = false
|
|
131
|
+
setTracking(false)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const onKeyboardIntent = (event: KeyboardEvent) => {
|
|
135
|
+
if (
|
|
136
|
+
event.altKey ||
|
|
137
|
+
event.metaKey ||
|
|
138
|
+
event.ctrlKey ||
|
|
139
|
+
![
|
|
140
|
+
"ArrowDown",
|
|
141
|
+
"ArrowUp",
|
|
142
|
+
"PageDown",
|
|
143
|
+
"PageUp",
|
|
144
|
+
"Home",
|
|
145
|
+
"End",
|
|
146
|
+
" ",
|
|
147
|
+
].includes(event.key)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
queueMicrotask(() => {
|
|
151
|
+
if (!event.defaultPrevented) onUserIntent()
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const onScrollEnd = (event: Event) => {
|
|
156
|
+
if (event.target !== document) return
|
|
157
|
+
if (programmatic) {
|
|
158
|
+
clearTimer(programTimer)
|
|
159
|
+
programmatic = false
|
|
160
|
+
setTracking(false)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
settle()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const focusOffset = (target: HTMLElement) => {
|
|
167
|
+
const rect = target.getBoundingClientRect()
|
|
168
|
+
const safeTop = innerHeight * 0.16
|
|
169
|
+
const safeBottom = innerHeight * 0.84
|
|
170
|
+
const fullyComfortable = rect.top >= safeTop && rect.bottom <= safeBottom
|
|
171
|
+
if (fullyComfortable) return 0
|
|
172
|
+
const playhead =
|
|
173
|
+
innerHeight * (rect.height > innerHeight * 0.65 ? 0.1 : 0.42)
|
|
174
|
+
return rect.top - playhead
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const presentationScrollTarget = (
|
|
178
|
+
target: HTMLElement,
|
|
179
|
+
block: HTMLElement,
|
|
180
|
+
) => {
|
|
181
|
+
const targetRect = target.getBoundingClientRect()
|
|
182
|
+
const blockRect = block.getBoundingClientRect()
|
|
183
|
+
const inset = Math.min(32, innerHeight * 0.08)
|
|
184
|
+
const visibleTop = Math.max(0, blockRect.top) + inset
|
|
185
|
+
const visibleBottom = Math.min(innerHeight, blockRect.bottom) - inset
|
|
186
|
+
if (targetRect.top >= visibleTop && targetRect.bottom <= visibleBottom) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const localTop = targetRect.top - blockRect.top + block.scrollTop
|
|
191
|
+
const blockStartViewport = Math.min(block.clientHeight, innerHeight)
|
|
192
|
+
const fitsAtBlockStart =
|
|
193
|
+
localTop >= inset &&
|
|
194
|
+
localTop + targetRect.height <= blockStartViewport - inset
|
|
195
|
+
return fitsAtBlockStart ? block : target
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
document.addEventListener("scroll", onScroll, {
|
|
199
|
+
capture: true,
|
|
200
|
+
passive: true,
|
|
201
|
+
})
|
|
202
|
+
document.addEventListener("scrollend", onScrollEnd, { capture: true })
|
|
203
|
+
window.addEventListener("wheel", onUserIntent, { passive: true })
|
|
204
|
+
window.addEventListener("touchstart", onUserIntent, { passive: true })
|
|
205
|
+
window.addEventListener("pointerdown", onUserIntent, { passive: true })
|
|
206
|
+
document.addEventListener("keydown", onKeyboardIntent, { capture: true })
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
navigate(target, block, mode, requestedBehavior = "smooth") {
|
|
210
|
+
programmatic = true
|
|
211
|
+
setTracking(true)
|
|
212
|
+
scrolling = false
|
|
213
|
+
userIntentUntil = 0
|
|
214
|
+
clearTimer(settleTimer)
|
|
215
|
+
clearTimer(programTimer)
|
|
216
|
+
const behavior =
|
|
217
|
+
requestedBehavior === "instant" ||
|
|
218
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
219
|
+
? "instant"
|
|
220
|
+
: "smooth"
|
|
221
|
+
if (mode === "presentation") {
|
|
222
|
+
const destination = presentationScrollTarget(target, block)
|
|
223
|
+
if (!destination) {
|
|
224
|
+
programmatic = false
|
|
225
|
+
setTracking(false)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (behavior === "instant") suspendPresentationSnap()
|
|
229
|
+
destination.scrollIntoView({
|
|
230
|
+
behavior,
|
|
231
|
+
block: destination === block ? "start" : "nearest",
|
|
232
|
+
inline: "nearest",
|
|
233
|
+
})
|
|
234
|
+
} else {
|
|
235
|
+
const top = focusOffset(target)
|
|
236
|
+
if (Math.abs(top) < 0.5) {
|
|
237
|
+
programmatic = false
|
|
238
|
+
setTracking(false)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
window.scrollBy({ top, behavior })
|
|
242
|
+
}
|
|
243
|
+
programTimer = window.setTimeout(() => {
|
|
244
|
+
programmatic = false
|
|
245
|
+
setTracking(false)
|
|
246
|
+
}, 700)
|
|
247
|
+
},
|
|
248
|
+
preserve(target, viewportTop) {
|
|
249
|
+
programmatic = true
|
|
250
|
+
setTracking(true)
|
|
251
|
+
scrolling = false
|
|
252
|
+
userIntentUntil = 0
|
|
253
|
+
clearTimer(settleTimer)
|
|
254
|
+
clearTimer(programTimer)
|
|
255
|
+
const shift = target.getBoundingClientRect().top - viewportTop
|
|
256
|
+
if (Math.abs(shift) > 0.5) {
|
|
257
|
+
window.scrollBy({ top: shift, behavior: "instant" })
|
|
258
|
+
}
|
|
259
|
+
programTimer = window.setTimeout(() => {
|
|
260
|
+
programmatic = false
|
|
261
|
+
setTracking(false)
|
|
262
|
+
}, 100)
|
|
263
|
+
},
|
|
264
|
+
setEnabled(value) {
|
|
265
|
+
enabled = value
|
|
266
|
+
if (value) return
|
|
267
|
+
programmatic = false
|
|
268
|
+
setTracking(false)
|
|
269
|
+
scrolling = false
|
|
270
|
+
userIntentUntil = 0
|
|
271
|
+
clearTimer(settleTimer)
|
|
272
|
+
clearTimer(programTimer)
|
|
273
|
+
releasePresentationSnap()
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
package/themes.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SourceDescription } from "@pathmx/core"
|
|
2
|
+
|
|
3
|
+
export type PlayThemeId = "native" | "editorial" | "signal" | "code"
|
|
4
|
+
|
|
5
|
+
export type PlayTheme = Readonly<{
|
|
6
|
+
id: PlayThemeId
|
|
7
|
+
label: string
|
|
8
|
+
description: string
|
|
9
|
+
}>
|
|
10
|
+
|
|
11
|
+
export const PLAY_THEMES: readonly PlayTheme[] = [
|
|
12
|
+
{ id: "native", label: "Native", description: "Neutral PathMX baseline" },
|
|
13
|
+
{
|
|
14
|
+
id: "editorial",
|
|
15
|
+
label: "Editorial",
|
|
16
|
+
description: "Warm serif-led reading",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "signal",
|
|
20
|
+
label: "Signal",
|
|
21
|
+
description: "Bold, high-contrast talks",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "code",
|
|
25
|
+
label: "Code",
|
|
26
|
+
description: "Dark technical teaching",
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function isPlayTheme(value: unknown): value is PlayThemeId {
|
|
31
|
+
return PLAY_THEMES.some((theme) => theme.id === value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sourcePlayTheme(description: SourceDescription): PlayThemeId {
|
|
35
|
+
const play = description.source.data.play
|
|
36
|
+
if (!play || typeof play !== "object" || Array.isArray(play)) return "native"
|
|
37
|
+
const theme = (play as Readonly<Record<string, unknown>>).theme
|
|
38
|
+
return isPlayTheme(theme) ? theme : "native"
|
|
39
|
+
}
|