@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.
- package/README.md +14 -15
- package/package.json +16 -7
- package/src/actions.ts +22 -0
- package/src/approvals.ts +9 -55
- package/src/blocks.ts +9 -10
- package/src/claim-first.ts +13 -0
- package/src/code-lines-box.ts +11 -0
- package/src/{types.ts → contracts.ts} +62 -12
- 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 +132 -316
- package/src/design/design.css +8 -0
- package/src/design/layout.ts +28 -0
- package/src/error-boundary.ts +18 -0
- package/src/host-key.ts +4 -0
- package/src/host.ts +42 -77
- package/src/index.ts +55 -25
- package/src/locale.ts +15 -37
- package/src/messages.ts +25 -10
- package/src/operations.ts +6 -22
- package/src/panels.ts +25 -57
- 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 +27 -164
- package/src/session.ts +5 -29
- 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 +4 -2
- package/src/PresenterPanels.vue +0 -91
- package/src/StartReportPanel.vue +0 -45
- package/src/Toolbar.vue +0 -49
- package/src/boundary.ts +0 -56
- package/src/design/theme.ts +0 -32
- package/src/kernel.ts +0 -131
- package/src/key.ts +0 -6
- package/src/scope.ts +0 -29
- package/src/shared.ts +0 -85
- 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
|
+
}
|
package/src/styles.d.ts
ADDED
|
@@ -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
|
-
|
|
9
|
-
return `${name} @ ${
|
|
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 {
|
package/src/PresenterPanels.vue
DELETED
|
@@ -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>
|
package/src/StartReportPanel.vue
DELETED
|
@@ -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
|
-
})
|
package/src/design/theme.ts
DELETED
|
@@ -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
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
|
-
}
|