@lincyaw/host 0.0.0-stage → 1.0.0-rc.1

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/src/site.ts ADDED
@@ -0,0 +1,14 @@
1
+ // The document's site path: where its published files and its plugins' run-time files are. The prebuilt viewer that
2
+ // shows every document is built at no site path of its own, so the page's shell tells it; the slides form's build is
3
+ // at the document's path, which Vite gives.
4
+ let base: string | undefined
5
+
6
+ /** Sets the document's site path (ending with `/`), as the page's shell knows it. */
7
+ export function setSiteBase(value: string): void {
8
+ base = value.endsWith('/') ? value : `${value}/`
9
+ }
10
+
11
+ /** The document's site path, ending with `/`: below it are `media/` and the plugins' run-time files (`pyodide/`). */
12
+ export function siteBase(): string {
13
+ return base ?? import.meta.env?.BASE_URL ?? '/'
14
+ }
@@ -0,0 +1,8 @@
1
+ import { createElement as h } from 'react'
2
+ import { useStartReport } from './host.ts'
3
+ import { useLocale, useMessages } from './locale.ts'
4
+ import { HOST_MESSAGES } from './messages.ts'
5
+ import { useStore } from './store.ts'
6
+ import { componentErrors, componentErrorRevision } from './error-boundary.ts'
7
+ // Plugins by the names they declare; a reason the kernel gives (service ids, error text) stays in the console, where it logs it.
8
+ export default function StartReportPanel() { const t = useMessages(HOST_MESSAGES); const locale = useLocale(); const report = useStartReport(); useStore(componentErrorRevision); return h('div', { className: 'pm-start-report' }, report === undefined ? h('p', {}, t.starting) : report.plugins.length ? h('ul', {}, report.plugins.map(plugin => h('li', { key: plugin.id, 'data-plugin': plugin.id, 'data-status': plugin.status }, `${plugin.name?.[locale] ?? t.unnamedPlugin}${t.because}${t.status[plugin.status]}`))) : h('p', {}, t.noPlugins), componentErrors().map((error, key) => h('p', { key, role: 'alert' }, t.componentError(error.component, error.message)))) }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { Unsubscribe } from '@lincyaw/kernel/contracts'
3
+ import type { Navigation, NavigationPosition, StepClock, StepClockDriver } from './contracts.ts'
4
+ import { useHost, usePrefersReducedMotion } from './host.ts'
5
+ import { createStore, observe, useStore } from './store.ts'
6
+ interface StepChange { slide: number, step: number, at: number, forward: boolean }
7
+ const driver = createStore<StepClockDriver | undefined>(undefined)
8
+ const change = createStore<StepChange | undefined>(undefined)
9
+ const now = createStore(0)
10
+ const live = new Set<() => boolean>()
11
+ let frame = 0
12
+ const clockNow = () => typeof performance === 'undefined' ? 0 : performance.now()
13
+ function tick() { frame = 0; now.set(clockNow()); if ([...live].some(running => running())) frame = requestAnimationFrame(tick) }
14
+ function ensureTicking() { if (frame || typeof requestAnimationFrame === 'undefined') return; now.set(clockNow()); frame = requestAnimationFrame(tick) }
15
+ function isForward(to: NavigationPosition, from: StepChange | undefined) { return !from || to.slide > from.slide || (to.slide === from.slide && to.step > from.step) }
16
+ const nextFrame = () => new Promise<void>(done => typeof requestAnimationFrame === 'undefined' ? setTimeout(done, 0) : requestAnimationFrame(() => done()))
17
+ function handOff(elapsed: number) { const at = change.getSnapshot(); if (at) change.set({ ...at, at: clockNow() - elapsed, forward: true }); ensureTicking() }
18
+ export function createStepClock(navigation: Navigation): { clock: StepClock, stop: Unsubscribe } {
19
+ const pending = new Set<Promise<unknown>>()
20
+ let failure: { error: unknown } | undefined
21
+ let stopped = false
22
+ const rethrow = () => { const failed = failure; failure = undefined; if (failed) throw failed.error }
23
+ const stop = observe(navigation.position, position => { const before = change.getSnapshot(); if (before && before.slide === position.slide && before.step === position.step) return; change.set({ slide: position.slide, step: position.step, at: clockNow(), forward: isForward(position, before) }); ensureTicking() }, true)
24
+ let unfollow: Unsubscribe | undefined
25
+ const clock: StepClock = {
26
+ drive(next) {
27
+ unfollow?.(); driver.set(next)
28
+ unfollow = observe(next.elapsed, (now, before) => { if (now === undefined && before !== undefined) handOff(before) })
29
+ return () => { if (driver.getSnapshot() !== next) return; const last = next.elapsed.getSnapshot(); unfollow?.(); unfollow = undefined; driver.set(undefined); if (last !== undefined) handOff(last) }
30
+ },
31
+ wait(work) { if (!stopped) pending.add(work); void work.catch(error => { if (!stopped) failure ??= { error } }).finally(() => pending.delete(work)) },
32
+ async settled() { let waited = false; for (let round = 0; round < 100; round++) { if (stopped) return; if (!pending.size) { if (waited) { waited = false; await nextFrame() } if (!pending.size) return rethrow() } await Promise.allSettled([...pending]); waited = true } throw new Error('the page kept preparing its picture: settled() stopped waiting after 100 rounds') },
33
+ }
34
+ return { clock, stop() { if (stopped) return; stopped = true; stop(); unfollow?.(); driver.set(undefined); change.set(undefined); pending.clear(); failure = undefined } }
35
+ }
36
+ export interface StepTimeOptions { until?: number }
37
+ const settle = (time: number, until: number | undefined) => until !== undefined && time >= until ? Infinity : Math.max(0, time)
38
+ export function useStepTime(options: StepTimeOptions = {}): number {
39
+ const host = useHost(); const step = host.useStep(); const cursor = host.useCursor(); const reduced = usePrefersReducedMotion()
40
+ const at = useStore(change); const player = useStore(driver); const elapsed = useStore(player?.elapsed); const currentTime = useStore(now)
41
+ let time = Infinity
42
+ if (Number.isFinite(step) && at && at.step === step && at.slide === cursor.slide) {
43
+ if (player && elapsed !== undefined) time = reduced && !player.recording ? Infinity : settle(elapsed, options.until)
44
+ else if (!reduced && at.forward) time = settle(currentTime - at.at, options.until)
45
+ }
46
+ const renderedTime = useRef(time); renderedTime.current = time
47
+ useEffect(() => { const running = () => Number.isFinite(renderedTime.current) && driver.getSnapshot()?.elapsed.getSnapshot() === undefined; live.add(running); ensureTicking(); return () => { live.delete(running) } }, [step, cursor.slide, player, options.until, reduced])
48
+ return time
49
+ }
@@ -0,0 +1,38 @@
1
+ import { createContext, createElement, useContext, useMemo } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import type { StepRange } from '@lincyaw/kernel/contracts'
4
+ import type { IRBlock } from '@lincyaw/kernel'
5
+ type States = NonNullable<IRBlock['stepStates']>
6
+ interface Scope extends StepRange { states?: States, current?: number, localPreview?: boolean }
7
+ export const STEP_SCOPE = createContext<Scope>({ from: 0 })
8
+ const stateAt = (states: States, step: number) => states[Math.max(0, Math.min(states.length - 1, Number.isFinite(step) ? Math.floor(step) : states.length - 1))]
9
+ export function StepScope({ at, until, states, step, localPreview, children }: { at: number, until?: number, states?: States, step?: number, localPreview?: boolean, children?: ReactNode }) {
10
+ const parent = useContext(STEP_SCOPE)
11
+ const end = until === undefined ? parent.to : parent.to === undefined ? until : Math.min(until, parent.to)
12
+ const value = useMemo(() => ({ from: states ? at : Math.max(at, parent.from), to: end, states, current: step, localPreview: localPreview ?? parent.localPreview }), [at, states, step, localPreview, parent.localPreview, parent.from, end])
13
+ return createElement(STEP_SCOPE.Provider, { value }, children)
14
+ }
15
+ export interface StepScope {
16
+ readonly at: number
17
+ readonly state?: string
18
+ readonly parameters?: Record<string, unknown>
19
+ /** A standalone reading widget keeps its local controls while selecting finite teaching steps. */
20
+ readonly localPreview?: boolean
21
+ localStepAt(globalStep: number): number
22
+ /** First authored visible occurrence; -1 when this local state is not in the section. */
23
+ step(k: number): number
24
+ /** First authored visible snapshot of an implementation's local step. */
25
+ stateAt(k: number): Pick<NonNullable<IRBlock['stepStates']>[number], 'state' | 'parameters'> | undefined
26
+ }
27
+ export function useStepScope(): StepScope {
28
+ const { from, states, current, localPreview } = useContext(STEP_SCOPE)
29
+ const selected = states && stateAt(states, current ?? Infinity)
30
+ return { at: from, state: selected?.state, parameters: selected?.parameters, localPreview,
31
+ localStepAt: k => !Number.isFinite(k) ? k : states ? stateAt(states, k)?.localStep ?? 0 : k - from,
32
+ step: k => states ? states.findIndex(value => value.visible && value.localStep === k) : from + k,
33
+ stateAt: k => states?.find(value => value.visible && value.localStep === k) }
34
+ }
35
+ export function useBlockStepRange(): StepRange {
36
+ const { from, to, states } = useContext(STEP_SCOPE)
37
+ return { from: 0, to: states ? Math.max(0, ...states.map(state => state.localStep)) : to === undefined ? undefined : to - from }
38
+ }
package/src/store.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import type { Dispatch, SetStateAction } from 'react'
3
+ import type { Unsubscribe } from '@lincyaw/kernel/contracts'
4
+
5
+ /** Framework-independent, synchronous state shared by playback, the viewer and React bindings. */
6
+ export interface Store<T> {
7
+ getSnapshot(): T
8
+ subscribe(listener: () => void): Unsubscribe
9
+ set: Dispatch<SetStateAction<T>>
10
+ }
11
+ export function createStore<T>(initial: T): Store<T> {
12
+ let value = initial
13
+ const listeners = new Set<() => void>()
14
+ return {
15
+ getSnapshot: () => value,
16
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener) } },
17
+ set(next) {
18
+ const changed = typeof next === 'function' ? (next as (before: T) => T)(value) : next
19
+ if (Object.is(changed, value)) return
20
+ value = changed
21
+ for (const listener of [...listeners]) listener()
22
+ },
23
+ }
24
+ }
25
+ const idleSubscribe = () => () => {}
26
+ export function useStore<T>(store: Store<T> | undefined, fallback?: T): T {
27
+ return useSyncExternalStore(store?.subscribe ?? idleSubscribe, store?.getSnapshot ?? (() => fallback as T), store?.getSnapshot ?? (() => fallback as T))
28
+ }
29
+ export function observe<T>(store: Store<T>, listener: (value: T, before: T) => void, immediate = false): Unsubscribe {
30
+ let before = store.getSnapshot()
31
+ if (immediate) listener(before, before)
32
+ return store.subscribe(() => { const value = store.getSnapshot(); const previous = before; before = value; listener(value, previous) })
33
+ }
@@ -0,0 +1 @@
1
+ declare module '*.css' {}
package/src/windows.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { siteBase } from './site.ts'
2
+
3
+ type Message = { join: true } | { value: unknown }
4
+
5
+ export interface WindowChannel {
6
+ send(value: unknown): void
7
+ close(): void
8
+ }
9
+
10
+ function deckChannel(name: string): string {
11
+ return `${name} @ ${siteBase()}`
12
+ }
13
+
14
+ export function joinWindows(name: string, receive: (value: unknown) => void, known: () => unknown): WindowChannel | undefined {
15
+ if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return undefined
16
+ const channel = new BroadcastChannel(deckChannel(name))
17
+ channel.onmessage = ({ data }: MessageEvent<Message>) => {
18
+ if ('value' in data) {
19
+ receive(data.value)
20
+ return
21
+ }
22
+ const value = known()
23
+ if (value !== undefined) channel.postMessage({ value } satisfies Message)
24
+ }
25
+ channel.postMessage({ join: true } satisfies Message)
26
+ return {
27
+ send: value => channel.postMessage({ value } satisfies Message),
28
+ close: () => channel.close(),
29
+ }
30
+ }