@lincyaw/host 1.0.0-rc.0 → 1.0.0-rc.2

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.
Files changed (46) hide show
  1. package/README.md +14 -15
  2. package/package.json +16 -7
  3. package/src/actions.ts +22 -0
  4. package/src/approvals.ts +9 -55
  5. package/src/blocks.ts +9 -10
  6. package/src/claim-first.ts +13 -0
  7. package/src/code-lines-box.ts +11 -0
  8. package/src/{types.ts → contracts.ts} +62 -12
  9. package/src/control.ts +117 -0
  10. package/src/design/appearance.ts +16 -0
  11. package/src/design/caption.ts +31 -0
  12. package/src/design/components.ts +132 -316
  13. package/src/design/design.css +8 -0
  14. package/src/design/layout.ts +28 -0
  15. package/src/error-boundary.ts +18 -0
  16. package/src/host-key.ts +4 -0
  17. package/src/host.ts +42 -77
  18. package/src/index.ts +55 -25
  19. package/src/locale.ts +15 -37
  20. package/src/messages.ts +25 -10
  21. package/src/operations.ts +6 -22
  22. package/src/panels.ts +25 -57
  23. package/src/playback-control.ts +67 -0
  24. package/src/registries.ts +86 -0
  25. package/src/resident-worker.ts +84 -0
  26. package/src/runtime.ts +187 -0
  27. package/src/server.ts +27 -164
  28. package/src/session.ts +5 -29
  29. package/src/shared-state.ts +30 -0
  30. package/src/site.ts +14 -0
  31. package/src/start-report-panel.ts +8 -0
  32. package/src/step-clock.ts +49 -0
  33. package/src/step-scope.ts +38 -0
  34. package/src/store.ts +33 -0
  35. package/src/styles.d.ts +1 -0
  36. package/src/windows.ts +4 -2
  37. package/src/PresenterPanels.vue +0 -91
  38. package/src/StartReportPanel.vue +0 -45
  39. package/src/Toolbar.vue +0 -49
  40. package/src/boundary.ts +0 -56
  41. package/src/design/theme.ts +0 -32
  42. package/src/kernel.ts +0 -131
  43. package/src/key.ts +0 -6
  44. package/src/scope.ts +0 -29
  45. package/src/shared.ts +0 -85
  46. package/src/vue-shim.d.ts +0 -6
@@ -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 CHANGED
@@ -1,3 +1,5 @@
1
+ import { siteBase } from './site.ts'
2
+
1
3
  type Message = { join: true } | { value: unknown }
2
4
 
3
5
  export interface WindowChannel {
@@ -5,8 +7,8 @@ export interface WindowChannel {
5
7
  close(): void
6
8
  }
7
9
 
8
- export function deckChannel(name: string): string {
9
- return `${name} @ ${import.meta.env?.BASE_URL ?? '/'}`
10
+ function deckChannel(name: string): string {
11
+ return `${name} @ ${siteBase()}`
10
12
  }
11
13
 
12
14
  export function joinWindows(name: string, receive: (value: unknown) => void, known: () => unknown): WindowChannel | undefined {
@@ -1,91 +0,0 @@
1
- <script setup lang="ts">
2
- import { PRESENTER_PANELS } from '@lincyaw/kernel'
3
- import type { PresenterPanel } from '@lincyaw/kernel'
4
- import { computed, shallowRef, watchEffect } from 'vue'
5
- import { componentErrors } from './boundary.ts'
6
- import { useService, useStartReport } from './host.ts'
7
- import { useMessages } from './locale.ts'
8
- import { HOST_MESSAGES } from './messages.ts'
9
-
10
- const props = withDefaults(defineProps<{
11
- to?: string
12
- docked?: boolean
13
- }>(), { to: 'body', docked: false })
14
-
15
- const t = useMessages(HOST_MESSAGES)
16
- const service = useService(PRESENTER_PANELS)
17
- const report = useStartReport()
18
- const panels = shallowRef<PresenterPanel[]>([])
19
-
20
- watchEffect((onCleanup) => {
21
- const registry = service.value
22
- if (!registry) return
23
- const update = () => {
24
- panels.value = registry.list()
25
- }
26
- update()
27
- onCleanup(registry.subscribe(update))
28
- })
29
-
30
- const problems = computed(() => {
31
- const plugins = report.value?.plugins.filter(p => p.status === 'disabled' || p.status === 'failed').length ?? 0
32
- return plugins + componentErrors().length
33
- })
34
- </script>
35
-
36
- <template>
37
- <Teleport :to="props.to" defer>
38
- <details v-if="service" class="pm-presenter-panels" :class="{ 'pm-presenter-panels--docked': props.docked }">
39
- <summary>
40
- {{ t.panels }} <span v-if="problems > 0" class="pm-presenter-panels__badge" :title="t.problems(problems)">{{ problems }}</span>
41
- </summary>
42
- <section v-for="panel in panels" :key="panel.id" :data-panel="panel.id">
43
- <h3>{{ panel.title }}</h3>
44
- <component :is="panel.component" />
45
- </section>
46
- </details>
47
- </Teleport>
48
- </template>
49
-
50
- <style scoped>
51
- .pm-presenter-panels {
52
- position: fixed;
53
- right: 0.5rem;
54
- bottom: var(--pm-presenter-panels-bottom, 4.25rem);
55
- z-index: var(--pm-presenter-panels-z, 100);
56
- max-width: min(24rem, calc(25vw - 1rem));
57
- max-height: 50vh;
58
- overflow: auto;
59
- padding: 0.25rem 0.75rem;
60
- border: 1px solid var(--pm-color-line);
61
- border-radius: var(--pm-radius-small);
62
- background: var(--pm-color-surface);
63
- color: var(--pm-color-ink);
64
- font-size: 0.8rem;
65
- box-shadow: var(--pm-shadow-float);
66
- }
67
- .pm-presenter-panels--docked {
68
- position: static;
69
- max-width: none;
70
- max-height: none;
71
- margin-top: 1rem;
72
- box-shadow: none;
73
- }
74
- summary {
75
- cursor: pointer;
76
- font-weight: 600;
77
- }
78
- .pm-presenter-panels__badge {
79
- display: inline-block;
80
- min-width: 1.4em;
81
- padding: 0 0.3em;
82
- border-radius: var(--pm-radius-pill);
83
- background: var(--pm-color-danger);
84
- color: var(--pm-color-surface);
85
- text-align: center;
86
- }
87
- h3 {
88
- margin: 0.5rem 0 0.25rem;
89
- font-size: 0.85rem;
90
- }
91
- </style>
@@ -1,45 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import { componentErrors } from './boundary.ts'
4
- import { useStartReport } from './host.ts'
5
- import { useMessages } from './locale.ts'
6
- import { HOST_MESSAGES } from './messages.ts'
7
-
8
- const t = useMessages(HOST_MESSAGES)
9
- const report = useStartReport()
10
- const errors = computed(componentErrors)
11
- </script>
12
-
13
- <template>
14
- <p v-if="!report">
15
- {{ t.starting }}
16
- </p>
17
- <p v-else-if="report.plugins.length === 0">
18
- {{ t.noPlugins }}
19
- </p>
20
- <ul v-else class="pm-start-report">
21
- <li v-for="plugin in report.plugins" :key="plugin.id" :data-plugin="plugin.id" :data-status="plugin.status">
22
- <code>{{ plugin.id }}</code> {{ t.status[plugin.status] }}<span v-if="plugin.reason">{{ t.because }}{{ plugin.reason }}</span>
23
- </li>
24
- </ul>
25
- <ul v-if="errors.length > 0" class="pm-component-errors">
26
- <li v-for="error in errors" :key="`${error.component}:${error.message}`" :data-component="error.component">
27
- {{ t.componentError(error.component, error.message) }}
28
- </li>
29
- </ul>
30
- </template>
31
-
32
- <style scoped>
33
- ul {
34
- margin: 0;
35
- padding-left: 1.2em;
36
- }
37
- li[data-status='disabled'],
38
- li[data-status='failed'],
39
- .pm-component-errors li {
40
- color: #dc2626;
41
- }
42
- li[data-status='degraded'] {
43
- color: #d97706;
44
- }
45
- </style>
package/src/Toolbar.vue DELETED
@@ -1,49 +0,0 @@
1
- <script setup lang="ts">
2
- import type { PropType } from 'vue'
3
- import { onMounted, ref, shallowRef, watchEffect } from 'vue'
4
- import { provideToolbarPlace } from './design/components.ts'
5
- import { useService } from './host.ts'
6
- import { TOOLBAR } from './types.ts'
7
- import type { ToolbarItem, ToolbarPlace } from './types.ts'
8
-
9
- const props = defineProps({
10
- filter: { type: Function as PropType<(item: ToolbarItem) => boolean>, default: (item: ToolbarItem) => !item.slot || item.slot === 'tools' || item.slot === 'settings' },
11
- place: { type: String as PropType<ToolbarPlace>, default: 'bar' },
12
- })
13
-
14
- provideToolbarPlace(props.place)
15
- const service = useService(TOOLBAR)
16
- const items = shallowRef<ToolbarItem[]>([])
17
- const mounted = ref(false)
18
- onMounted(() => {
19
- mounted.value = true
20
- })
21
-
22
- watchEffect((onCleanup) => {
23
- const registry = service.value
24
- if (!registry) return
25
- const update = () => {
26
- items.value = registry.list().filter(props.filter)
27
- }
28
- update()
29
- onCleanup(registry.subscribe(update))
30
- })
31
- </script>
32
-
33
- <template>
34
- <span v-if="mounted && items.length" class="pm-toolbar" :data-place="place">
35
- <component :is="item.component" v-for="item in items" :key="item.id" :data-toolbar-item="item.id" />
36
- </span>
37
- </template>
38
-
39
- <style scoped>
40
- .pm-toolbar {
41
- display: inline-flex;
42
- align-items: center;
43
- gap: 0.5rem;
44
- }
45
-
46
- .pm-toolbar[data-place='status'] {
47
- gap: 1.125rem;
48
- }
49
- </style>
package/src/boundary.ts DELETED
@@ -1,56 +0,0 @@
1
- import { errorMessage } from '@lincyaw/kernel'
2
- import { defineComponent, h, onBeforeUnmount, onErrorCaptured, shallowRef, watch } from 'vue'
3
- import { useMessages } from './locale.ts'
4
- import { HOST_MESSAGES } from './messages.ts'
5
-
6
- export interface ComponentError {
7
- component: string
8
- message: string
9
- }
10
-
11
- const caughtErrors = shallowRef<ReadonlyMap<object, ComponentError>>(new Map())
12
-
13
- export function componentErrors(): readonly ComponentError[] {
14
- const errors: ComponentError[] = []
15
- for (const error of caughtErrors.value.values()) {
16
- if (!errors.some(e => e.component === error.component && e.message === error.message)) errors.push(error)
17
- }
18
- return errors
19
- }
20
-
21
- function hold(owner: object, error: ComponentError | undefined): void {
22
- if (!error && !caughtErrors.value.has(owner)) return
23
- const next = new Map(caughtErrors.value)
24
- if (error) next.set(owner, error)
25
- else next.delete(owner)
26
- caughtErrors.value = next
27
- }
28
-
29
- export const PmBoundary = defineComponent({
30
- name: 'PmBoundary',
31
- props: {
32
- name: { type: String, default: 'component' },
33
- resetKey: { type: [String, Number], default: undefined },
34
- },
35
- setup(props, { slots }) {
36
- const t = useMessages(HOST_MESSAGES)
37
- const owner = {}
38
- const failed = shallowRef<ComponentError>()
39
- onErrorCaptured((error) => {
40
- failed.value = { component: props.name, message: errorMessage(error) }
41
- hold(owner, failed.value)
42
- console.error(`[presmium] ${props.name} failed`, error)
43
- return false
44
- })
45
- watch(() => props.resetKey, () => {
46
- failed.value = undefined
47
- hold(owner, undefined)
48
- })
49
- onBeforeUnmount(() => hold(owner, undefined))
50
- return () => failed.value
51
- ? h('div', { 'class': 'pm-boundary-error', 'role': 'alert', 'data-pm-error': failed.value.component }, [
52
- t.value.componentError(failed.value.component, failed.value.message),
53
- ])
54
- : slots.default?.()
55
- },
56
- })
@@ -1,32 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import { getCurrentInstance, inject, readonly, shallowRef } from 'vue'
3
- import { createGlobalState, useMutationObserver } from '@vueuse/core'
4
- import { HOST, installed } from '../key.ts'
5
-
6
- export interface Theme {
7
- readonly dark: Readonly<Ref<boolean>>
8
- setDark(dark: boolean): void
9
- toggle(): void
10
- }
11
-
12
- const observeDarkClass = createGlobalState(() => {
13
- const root = () => typeof document === 'undefined' ? undefined : document.documentElement
14
- const dark = shallowRef(root()?.classList.contains('dark') ?? false)
15
- useMutationObserver(root, () => dark.value = root()?.classList.contains('dark') ?? false, { attributes: true, attributeFilter: ['class'] })
16
- return dark
17
- })
18
-
19
- export function useTheme(): Theme {
20
- const host = getCurrentInstance() ? inject(HOST, undefined) : installed.adapter
21
- const own = getCurrentInstance() ? host?.useDarkMode?.() : undefined
22
- const dark = own ?? observeDarkClass()
23
- const setDark = (value: boolean) => {
24
- if (own) own.value = value
25
- else if (typeof document !== 'undefined') document.documentElement.classList.toggle('dark', value)
26
- }
27
- return {
28
- dark: readonly(dark),
29
- setDark,
30
- toggle: () => setDark(!dark.value),
31
- }
32
- }
package/src/kernel.ts DELETED
@@ -1,131 +0,0 @@
1
- import { Kernel, PRESENTER_PANELS } from '@lincyaw/kernel'
2
- import type { EnvironmentSpec, Logger, PluginDefinition, PluginReport, PresentationMode, ServiceToken, StartReport } from '@lincyaw/kernel'
3
- import { shallowRef } from 'vue'
4
- import { PmApprovals } from './approvals.ts'
5
- import { currentLocale } from './locale.ts'
6
- import { HOST_MESSAGES } from './messages.ts'
7
- import { createOperations, createStatusRegistry } from './operations.ts'
8
- import { createPresenterPanels, createToolbar } from './panels.ts'
9
- import { connectControl, provideServer } from './server.ts'
10
- import { NAVIGATION, OPERATIONS, STATUS, SYNC_TRANSPORTS, TOOLBAR } from './types.ts'
11
- import type { Navigation, ServerClient, SyncTransports } from './types.ts'
12
-
13
- const registered: PluginDefinition[] = []
14
- const running = shallowRef<Kernel>()
15
- let starting: Promise<StartReport> | undefined
16
- let disconnect: (() => void) | undefined
17
- const failures = shallowRef<PluginReport[]>([])
18
-
19
- export function registerPresmiumPlugin(plugin: PluginDefinition): void {
20
- const { id } = plugin.manifest
21
- if (starting) console.error(`[presmium] ${id} was registered after the kernel started; ignored`)
22
- else if (registered.some(p => p.manifest.id === id)) console.error(`[presmium] plugin ${id} is already registered; ignored`)
23
- else registered.push(plugin)
24
- }
25
-
26
- function isPluginDefinition(value: unknown): value is PluginDefinition {
27
- const plugin = value as Partial<PluginDefinition> | null
28
- return typeof plugin === 'object' && plugin !== null && typeof plugin.setup === 'function' && typeof plugin.manifest?.id === 'string'
29
- }
30
-
31
- export function pluginsOf(module: unknown, config: Record<string, unknown> = {}): PluginDefinition[] {
32
- const exports = (module ?? {}) as Record<string, unknown>
33
- const declared = typeof exports.presmiumPlugins === 'function' ? [(exports.presmiumPlugins as (config: Record<string, unknown>) => unknown)(config)] : []
34
- const values = [...Object.values(exports), ...declared].flatMap(v => (Array.isArray(v) ? v : [v]))
35
- return [...new Set(values.filter(isPluginDefinition))]
36
- }
37
-
38
- export interface StartOptions {
39
- mode: PresentationMode
40
- config?: Record<string, unknown>
41
- beforeStart?(kernel: Kernel): void
42
- log?: Logger
43
- environments?: Record<string, EnvironmentSpec>
44
- }
45
-
46
- export function startPresmium(options: StartOptions): Promise<StartReport> {
47
- starting ??= (async () => {
48
- const kernel = new Kernel({ mode: options.mode, config: options.config, log: options.log, environments: options.environments })
49
- options.beforeStart?.(kernel)
50
- for (const plugin of registered) kernel.register(plugin)
51
- const report = await kernel.start()
52
- running.value = kernel
53
- return report
54
- })()
55
- return starting
56
- }
57
-
58
- export async function stopPresmium(): Promise<void> {
59
- const current = starting
60
- if (!current) return
61
- starting = undefined
62
- disconnect?.()
63
- disconnect = undefined
64
- await current.catch(() => undefined)
65
- const kernel = running.value
66
- running.value = undefined
67
- await kernel?.dispose()
68
- }
69
-
70
- export function stopPresmiumOnPageHide(start: () => void): void {
71
- window.addEventListener('pagehide', () => void stopPresmium())
72
- window.addEventListener('pageshow', (event) => {
73
- if (event.persisted) start()
74
- })
75
- }
76
-
77
- export function presmiumService<T>(token: ServiceToken<T>): T | undefined {
78
- return running.value?.getService(token)
79
- }
80
-
81
- export function reportPluginFailure(id: string, reason: string): void {
82
- failures.value = [...failures.value.filter(report => report.id !== id), { id, status: 'failed', reason }]
83
- }
84
-
85
- export function presmiumReport(): StartReport | undefined {
86
- const report = running.value?.report()
87
- return report && failures.value.length ? { ...report, plugins: [...report.plugins, ...failures.value] } : report
88
- }
89
-
90
- export interface HostStart {
91
- mode: PresentationMode
92
- config: Record<string, unknown>
93
- navigation: Navigation
94
- transports: SyncTransports
95
- environments?: Record<string, EnvironmentSpec>
96
- server?: ServerClient
97
- }
98
-
99
- export function startHost(options: HostStart): Promise<StartReport> {
100
- if (options.server) provideServer(options.server)
101
- const start = () => startPresmium({
102
- mode: options.mode,
103
- config: options.config,
104
- environments: options.environments,
105
- beforeStart(kernel) {
106
- const toolbar = createToolbar()
107
- const status = createStatusRegistry()
108
- kernel.provide(PRESENTER_PANELS, createPresenterPanels())
109
- kernel.provide(SYNC_TRANSPORTS, options.transports)
110
- kernel.provide(NAVIGATION, options.navigation)
111
- kernel.provide(TOOLBAR, toolbar)
112
- kernel.provide(OPERATIONS, createOperations())
113
- kernel.provide(STATUS, status)
114
- disconnect?.()
115
- disconnect = undefined
116
- const { server } = options
117
- if (!server?.available) return
118
- toolbar.register({
119
- id: 'presmium.approvals',
120
- get title() {
121
- return HOST_MESSAGES[currentLocale()].approvalsTitle
122
- },
123
- component: PmApprovals,
124
- order: 1000,
125
- })
126
- disconnect = connectControl(server, { navigation: options.navigation, status, service: token => kernel.getService(token) })
127
- },
128
- })
129
- stopPresmiumOnPageHide(() => void start())
130
- return start()
131
- }
package/src/key.ts DELETED
@@ -1,6 +0,0 @@
1
- import type { InjectionKey } from 'vue'
2
- import type { HostAdapter } from './types.ts'
3
-
4
- export const HOST: InjectionKey<HostAdapter> = Symbol('presmium.host')
5
-
6
- export const installed: { adapter?: HostAdapter } = {}
package/src/scope.ts DELETED
@@ -1,29 +0,0 @@
1
- import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
2
- import { computed, defineComponent, inject, provide, ref, toValue } from 'vue'
3
-
4
- const STEP_SCOPE: InjectionKey<Readonly<Ref<number>>> = Symbol('presmium.stepScope')
5
-
6
- const NO_SCOPE = ref(0)
7
-
8
- export function provideStepScope(at: MaybeRefOrGetter<number>): void {
9
- provide(STEP_SCOPE, computed(() => toValue(at)))
10
- }
11
-
12
- export interface StepScope {
13
- readonly at: Readonly<Ref<number>>
14
- step(k: number): number
15
- }
16
-
17
- export function useStepScope(): StepScope {
18
- const at = inject(STEP_SCOPE, NO_SCOPE)
19
- return { at, step: k => at.value + k }
20
- }
21
-
22
- export const PmStepScope = defineComponent({
23
- name: 'PmStepScope',
24
- props: { at: { type: Number, default: 0 } },
25
- setup(props, { slots }) {
26
- provideStepScope(() => props.at)
27
- return () => slots.default?.()
28
- },
29
- })
package/src/shared.ts DELETED
@@ -1,85 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import { effectScope, ref, toRaw, watch } from 'vue'
3
- import type { SharedStateScope, SyncPublish, SyncTransport, SyncTransports } from './types.ts'
4
- import { joinWindows } from './windows.ts'
5
-
6
- interface Channel {
7
- name: string
8
- receive(patch: Record<string, unknown>): void
9
- value(): unknown
10
- publishers: Map<SyncTransport, SyncPublish>
11
- }
12
-
13
- export interface SharedStates {
14
- sharedState<T>(key: string, initial: T, scope: SharedStateScope): Ref<T>
15
- transports: SyncTransports
16
- }
17
-
18
- const plain = (value: unknown): unknown => value === undefined ? undefined : JSON.parse(JSON.stringify(toRaw(value)))
19
-
20
- export function createSharedStates(deck: string): SharedStates {
21
- const states = new Map<string, Ref<unknown>>()
22
- const channels = new Map<string, Channel>()
23
- const transports = new Set<SyncTransport>()
24
- const detached = effectScope(true)
25
-
26
- const connect = (transport: SyncTransport, channel: Channel) => {
27
- const publish = transport.connect(channel.name, channel.receive, { value: channel.value() }, false)
28
- if (publish) channel.publishers.set(transport, publish)
29
- }
30
-
31
- function create(id: string, initial: unknown, scope: SharedStateScope): Ref<unknown> {
32
- const state = ref(plain(initial))
33
- if (scope === 'view') return state
34
- const name = `presmium.shared:${deck}:${id}`
35
- let from: 'window' | 'remote' | undefined
36
- let changed = false
37
- const apply = (value: unknown, source: 'window' | 'remote') => {
38
- from = source
39
- state.value = value
40
- from = undefined
41
- }
42
- const windows = joinWindows(name, value => apply(value, 'window'), () => changed ? plain(state.value) : undefined)
43
- const channel: Channel = {
44
- name,
45
- receive(patch) {
46
- if ('value' in patch) apply(patch.value, 'remote')
47
- },
48
- value: () => plain(state.value),
49
- publishers: new Map(),
50
- }
51
- watch(state, (value) => {
52
- changed = true
53
- const data = plain(value)
54
- if (from === undefined) windows?.send(data)
55
- channel.publishers.forEach(publish => publish({ value: data }, from !== undefined))
56
- }, { deep: true, flush: 'sync' })
57
- if (scope === 'remote') {
58
- channels.set(id, channel)
59
- transports.forEach(transport => connect(transport, channel))
60
- }
61
- return state
62
- }
63
-
64
- return {
65
- sharedState<T>(key: string, initial: T, scope: SharedStateScope): Ref<T> {
66
- const id = `${scope}:${key}`
67
- let state = states.get(id)
68
- if (!state) {
69
- state = detached.run(() => create(id, initial, scope))!
70
- states.set(id, state)
71
- }
72
- return state as Ref<T>
73
- },
74
- transports: {
75
- add(transport) {
76
- transports.add(transport)
77
- channels.forEach(channel => connect(transport, channel))
78
- return () => {
79
- transports.delete(transport)
80
- channels.forEach(channel => channel.publishers.delete(transport))
81
- }
82
- },
83
- },
84
- }
85
- }