@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/LICENSE +202 -0
- package/README.md +38 -2
- package/package.json +48 -3
- package/src/actions.ts +22 -0
- package/src/approvals.ts +12 -0
- package/src/blocks.ts +81 -0
- package/src/claim-first.ts +13 -0
- package/src/code-lines-box.ts +11 -0
- package/src/contracts.ts +157 -0
- package/src/control.ts +117 -0
- package/src/design/appearance.ts +16 -0
- package/src/design/caption.ts +31 -0
- package/src/design/components.ts +143 -0
- package/src/design/design.css +9 -0
- package/src/design/layout.ts +28 -0
- package/src/env.d.ts +7 -0
- package/src/error-boundary.ts +18 -0
- package/src/host-key.ts +4 -0
- package/src/host.ts +47 -0
- package/src/index.ts +114 -0
- package/src/locale.ts +20 -0
- package/src/messages.ts +66 -0
- package/src/operations.ts +60 -0
- package/src/panels.ts +28 -0
- package/src/playback-control.ts +67 -0
- package/src/registries.ts +86 -0
- package/src/resident-worker.ts +84 -0
- package/src/runtime.ts +187 -0
- package/src/server.ts +36 -0
- package/src/session.ts +10 -0
- package/src/shared-state.ts +30 -0
- package/src/site.ts +14 -0
- package/src/start-report-panel.ts +8 -0
- package/src/step-clock.ts +49 -0
- package/src/step-scope.ts +38 -0
- package/src/store.ts +33 -0
- package/src/styles.d.ts +1 -0
- package/src/windows.ts +30 -0
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
|
+
}
|
package/src/styles.d.ts
ADDED
|
@@ -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
|
+
}
|