@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
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @lincyaw/host
2
2
 
3
- Presmium's host interface for plugin components: step state, services, shared state, interface text and the design system, the same in the web form (VitePress) and in the slides (Slidev).
3
+ Presmium's host interface for plugin components: step state, services, shared state, interface text, the site path and the design system, the same in the web form and in the slides (both are the viewer).
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,30 +10,29 @@ Plugins declare it as a peer dependency (`"@lincyaw/host": "^1.0.0"`); a documen
10
10
 
11
11
  ## Usage
12
12
 
13
- ```vue
14
- <script setup lang="ts">
13
+ ```tsx
15
14
  import { useMessages, useStepState } from '@lincyaw/host'
16
15
 
17
- const shown = useStepState({ from: 1 })
18
- const t = useMessages({ 'zh-CN': { done: '完成' }, 'en': { done: 'Done' } })
19
- </script>
20
-
21
- <template>
22
- <p v-if="shown.reached">{{ t.done }}</p>
23
- </template>
16
+ export default function Completion() {
17
+ const shown = useStepState({ from: 1 })
18
+ const t = useMessages({ 'zh-CN': { done: '完成' }, en: { done: 'Done' } })
19
+ return shown.reached ? <p>{t.done}</p> : null
20
+ }
24
21
  ```
25
22
 
26
- Components get everything through this package, never from `@slidev/*` or `vitepress`, so both forms render them. It also provides the design system: the `Pm*` components (`PmButton`, `PmDialog`, `PmIcon` and others) in Vue, over the `--pm-*` tokens, the classes and the fonts (IBM Plex Sans, IBM Plex Mono and Source Serif 4, SIL Open Font License 1.1) of `@lincyaw/design`, which it loads into every page.
23
+ Components get everything through this package, so both forms render them; browser code finds the site path with `siteBase()`, not `import.meta.env.BASE_URL`. It also provides the design system: the `Pm*` components (`PmButton`, `PmDialog`, `PmIcon` and others) in React, over the `--pm-*` tokens, the classes and the fonts (IBM Plex Sans, IBM Plex Mono and Source Serif 4, SIL Open Font License 1.1) of `@lincyaw/design`, which it loads into every page.
24
+
25
+ The interactive primitives wrap Radix Primitives; positioning, dismissal, keyboard navigation and focus management come from the library. Theme observation uses host React hooks, and shared icon names map to Lucide.
27
26
 
28
- The interactive primitives wrap Reka UI; positioning, dismissal, keyboard navigation and focus management come from the library. Theme observation uses VueUse, and the shared icon names map to Lucide.
27
+ Hooks return ordinary React values. `useSharedState` returns `[value, setValue]`; service observables use `Store<T>` and `useStore(store)`. Outside components, read services with `presmiumService` and server access with `serverClient()`, then release subscriptions in plugin disposal.
29
28
 
30
29
  ## Experimental parts
31
30
 
32
- `useToolbarPlace` and `provideToolbarPlace` are experimental and outside the 1.x semver promise; their declarations carry the TSDoc tag `@experimental`, and the interface list names them.
31
+ `useToolbarPlacement` and `provideToolbarPlacement` are experimental and outside the 1.x semver promise; their declarations carry the TSDoc tag `@experimental`, and the interface list names them.
33
32
 
34
33
  ## Documentation
35
34
 
36
- - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/lecture.en.md)
35
+ - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/locales/en/lecture.json)
37
36
 
38
37
  ## License
39
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/host",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "Presmium's host interface for plugin components in every form: step state, services, interface text, and the design system (tokens, components, fonts)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -12,7 +12,7 @@
12
12
  "bugs": "https://github.com/Lincyaw/presmium/issues",
13
13
  "keywords": [
14
14
  "presmium",
15
- "vue",
15
+ "react",
16
16
  "design-system"
17
17
  ],
18
18
  "type": "module",
@@ -33,10 +33,19 @@
33
33
  "@fontsource/ibm-plex-mono": "5.3.0",
34
34
  "@fontsource/ibm-plex-sans": "5.3.0",
35
35
  "@fontsource/source-serif-4": "5.3.1",
36
- "@vueuse/core": "15.0.0",
37
- "reka-ui": "2.11.0",
38
- "vue": "^3.5.42",
39
- "@lincyaw/design": "1.0.0-rc.0",
40
- "@lincyaw/kernel": "1.0.0-rc.0"
36
+ "@radix-ui/react-dialog": "^1.3.1",
37
+ "@radix-ui/react-dropdown-menu": "^2.2.1",
38
+ "@radix-ui/react-popover": "^1.1.15",
39
+ "@radix-ui/react-radio-group": "^1.5.1",
40
+ "@radix-ui/react-tabs": "^1.1.13",
41
+ "@radix-ui/react-tooltip": "^1.2.8",
42
+ "react": "18.3.1",
43
+ "react-dom": "18.3.1",
44
+ "@lincyaw/design": "1.0.0-rc.2",
45
+ "@lincyaw/kernel": "1.0.0-rc.2"
46
+ },
47
+ "devDependencies": {
48
+ "@types/react": "18.3.28",
49
+ "@types/react-dom": "18.3.7"
41
50
  }
42
51
  }
package/src/actions.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { RefObject } from 'react'
3
+ import { blockKeyOf } from './blocks.ts'
4
+ import type { Operations, ReplayHandler } from './contracts.ts'
5
+ export type ComponentAction = (seeking: boolean) => void
6
+ export interface ActionOptions { clear?(): void }
7
+ interface Registration { element(): Element | undefined, actions: Readonly<Record<string, ComponentAction>>, clear?(): void }
8
+ const registered = new Set<Registration>()
9
+ const done = new Map<string, string[]>()
10
+ let operations: Operations | undefined
11
+ const record = (block: string, action: string) => done.set(block, [...done.get(block) ?? [], action])
12
+ function keyOf(element: Element | null | undefined) { const block = element?.closest<HTMLElement>('.pm-block[data-block]'); return block ? blockKeyOf(block) : undefined }
13
+ export function useActions(element: RefObject<Element | null | undefined>, actions: Readonly<Record<string, ComponentAction>>, options: ActionOptions = {}): (name: string) => void {
14
+ const current = useRef({ actions, options }); current.current = { actions, options }
15
+ useEffect(() => { const registration: Registration = { element: () => element.current ?? undefined, get actions() { return current.current.actions }, clear: () => current.current.options.clear?.() }; registered.add(registration); const block = keyOf(element.current); for (const name of block ? done.get(block) ?? [] : []) current.current.actions[name]?.(true); return () => { registered.delete(registration) } }, [element])
16
+ return name => { const action = current.current.actions[name]; if (!action) return; action(false); const block = keyOf(element.current); if (!block) return; record(block, name); operations?.report({ type: 'click', data: { block, action: name } }) }
17
+ }
18
+ const clickReplay: ReplayHandler = {
19
+ apply(data, seeking) { if (typeof data.block !== 'string' || typeof data.action !== 'string') return; record(data.block, data.action); for (const registration of registered) { const action = registration.actions[data.action]; if (action && keyOf(registration.element()) === data.block) action(seeking) } },
20
+ clear() { done.clear(); for (const registration of registered) registration.clear?.() },
21
+ }
22
+ export function connectActions(to: Operations): void { operations = to; to.onReplay('click', clickReplay) }
package/src/approvals.ts CHANGED
@@ -1,58 +1,12 @@
1
- import type { PropType } from 'vue'
2
- import { defineComponent, h, ref, watch } from 'vue'
1
+ import { createElement as h, useState } from 'react'
3
2
  import { PmButton, PmNotice, PmPopover } from './design/components.ts'
4
3
  import { useMessages } from './locale.ts'
5
4
  import { HOST_MESSAGES } from './messages.ts'
6
- import { approvals } from './server.ts'
7
-
8
- export const PmApprovals = defineComponent({
9
- name: 'PmApprovals',
10
- props: {
11
- placement: { type: String as PropType<'below' | 'above'>, default: 'below' },
12
- },
13
- setup(props) {
14
- const t = useMessages(HOST_MESSAGES)
15
- const open = ref(false)
16
- const failed = ref<string>()
17
- watch(() => approvals.pending.value.length, (count) => {
18
- if (count === 0) open.value = false
19
- })
20
- const answer = (id: string, allow: boolean) => {
21
- failed.value = undefined
22
- approvals.answer(id, allow).catch((error: unknown) => {
23
- failed.value = error instanceof Error ? error.message : String(error)
24
- })
25
- }
26
- return () => {
27
- const pending = approvals.pending.value
28
- if (pending.length === 0) return null
29
- return h(PmPopover, {
30
- 'open': open.value,
31
- 'label': t.value.approvalsTitle,
32
- 'name': 'pm-approvals',
33
- 'placement': props.placement === 'above' ? 'above-end' : 'below-end',
34
- 'onUpdate:open': (next: boolean) => open.value = next,
35
- }, {
36
- trigger: () => h(PmButton, {
37
- 'variant': 'accent',
38
- 'small': true,
39
- 'class': 'pm-approvals__toggle',
40
- 'aria-expanded': open.value ? 'true' : 'false',
41
- 'onClick': () => open.value = !open.value,
42
- }, () => t.value.approvals(pending.length)),
43
- default: () => [
44
- ...pending.map(({ id, request }) => h('div', { 'key': id, 'class': 'pm-approvals__item', 'data-approval': id, 'style': { display: 'grid', gap: 'var(--pm-space-2)' } }, [
45
- h('p', { class: 'pm-approvals__title', style: { margin: 0, fontWeight: 600 } }, request.title),
46
- h('p', { class: 'pm-approvals__from', style: { margin: 0, color: 'var(--pm-color-muted)' } }, t.value.from(request.from)),
47
- request.detail ? h('pre', { class: 'pm-approvals__detail pm-ui-code', style: { margin: 0, whiteSpace: 'pre-wrap' } }, request.detail) : null,
48
- h('div', { class: 'pm-approvals__actions', style: { display: 'flex', gap: 'var(--pm-space-2)' } }, [
49
- h(PmButton, { variant: 'primary', small: true, onClick: () => answer(id, true) }, () => t.value.allow),
50
- h(PmButton, { small: true, onClick: () => answer(id, false) }, () => t.value.deny),
51
- ]),
52
- ])),
53
- failed.value ? h(PmNotice, { class: 'pm-approvals__error', tone: 'danger' }, () => failed.value) : null,
54
- ],
55
- })
56
- }
57
- },
58
- })
5
+ import { approvals } from './control.ts'
6
+ import { useStore } from './store.ts'
7
+ export function PmApprovals({ placement = 'below' }: { placement?: 'below' | 'above' }) {
8
+ const t = useMessages(HOST_MESSAGES); const [open, setOpen] = useState(false); const [failed, setFailed] = useState<string>(); const pending = useStore(approvals.pending)
9
+ if (!pending.length) return null
10
+ const answer = (id: string, allow: boolean) => { setFailed(undefined); void approvals.answer(id, allow).catch(() => setFailed(t.answerFailed)) }
11
+ return h(PmPopover, { open, label: t.approvalsTitle, name: 'pm-approvals', placement: placement === 'above' ? 'above-end' : 'below-end', onOpenChange: setOpen, trigger: h(PmButton, { variant: 'accent', small: true }, t.approvals(pending.length)) }, pending.map(({ id, request }) => h('div', { key: id, 'data-approval': id }, h('p', {}, request.title), h('p', {}, t.from(t.origins[request.from] ?? t.otherOrigin)), ...(request.detail ?? '').split('\n').filter(Boolean).map((line, key) => h('p', { key }, line)), h(PmButton, { small: true, variant: 'primary', onClick: () => answer(id, true) }, t.allow), h(PmButton, { small: true, onClick: () => answer(id, false) }, t.deny))), failed ? h(PmNotice, { tone: 'danger' }, failed) : null)
12
+ }
package/src/blocks.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { NavigationPosition } from './types.ts'
2
-
3
1
  const UNSEEN = '[inert], .pm-hidden'
4
2
 
5
3
  export function blockKey(section: string, block: string): string {
@@ -35,13 +33,19 @@ export function codeLines(block: Element): HTMLElement[] {
35
33
  return [...block.querySelectorAll<HTMLElement>('.pm-code .line')]
36
34
  }
37
35
 
36
+ /** The `<section>/<block>` key of a block's element (`.pm-block[data-block]`). */
37
+ export function blockKeyOf(block: HTMLElement): string | undefined {
38
+ const section = block.closest<HTMLElement>('[data-section]')?.dataset.section
39
+ return section ? blockKey(section, block.dataset.block!) : undefined
40
+ }
41
+
38
42
  export function blockAt(x: number, y: number, options: { skip?: Element, within?: string } = {}): BlockPoint | undefined {
39
43
  if (typeof document === 'undefined') return undefined
40
44
  const { skip, within } = options
41
45
  const hit = document.elementsFromPoint(x, y).find(el => el !== skip && !skip?.contains(el))
42
46
  const element = hit?.closest<HTMLElement>(`${within ? `${within} ` : ''}.pm-block[data-block]`)
43
- const section = element?.closest<HTMLElement>('[data-section]')?.dataset.section
44
- if (!element || !section || !seen(element)) return undefined
47
+ const key = element && blockKeyOf(element)
48
+ if (!element || !key || !seen(element)) return undefined
45
49
  const box = element.getBoundingClientRect()
46
50
  if (!box.width || !box.height) return undefined
47
51
  const index = codeLines(element).findIndex((line) => {
@@ -49,7 +53,7 @@ export function blockAt(x: number, y: number, options: { skip?: Element, within?
49
53
  return y >= r.top && y < r.bottom
50
54
  })
51
55
  return {
52
- key: blockKey(section, element.dataset.block!),
56
+ key,
53
57
  element,
54
58
  x: (x - box.left) / box.width,
55
59
  y: (y - box.top) / box.height,
@@ -75,8 +79,3 @@ export function linesText(numbers: number[]): string {
75
79
  }
76
80
  return runs.join(',')
77
81
  }
78
-
79
- export function positionSlide(position: NavigationPosition, sections: readonly { id: string, index: number }[]): number {
80
- const named = position.section === undefined ? undefined : sections.find(s => s.id === position.section)
81
- return named?.index ?? position.slide
82
- }
@@ -0,0 +1,13 @@
1
+ import { createStore } from './store.ts'
2
+ import type { Store } from './store.ts'
3
+ const groups = new Map<string, Store<symbol[]>>()
4
+ export function claimFirst(group: string): { owns: Store<boolean>, release(): void } {
5
+ let owners = groups.get(group)
6
+ if (!owners) { owners = createStore<symbol[]>([]); groups.set(group, owners) }
7
+ const list = owners
8
+ const claim = Symbol(group)
9
+ list.set([...list.getSnapshot(), claim])
10
+ const owns = createStore(list.getSnapshot()[0] === claim)
11
+ const stop = list.subscribe(() => owns.set(list.getSnapshot()[0] === claim))
12
+ return { owns, release() { stop(); list.set(list.getSnapshot().filter(other => other !== claim)) } }
13
+ }
@@ -0,0 +1,11 @@
1
+ import { codeLines, lineNumbers } from './blocks.ts'
2
+
3
+ export function codeLinesBox(block: HTMLElement, lines: string): DOMRect | undefined {
4
+ const rows = codeLines(block)
5
+ const named = lineNumbers(lines).map(n => rows[n - 1]).filter((row): row is HTMLElement => Boolean(row))
6
+ if (named.length === 0) return undefined
7
+ const code = block.querySelector('.pm-code')?.getBoundingClientRect() ?? block.getBoundingClientRect()
8
+ const top = Math.min(...named.map(row => row.getBoundingClientRect().top))
9
+ const bottom = Math.max(...named.map(row => row.getBoundingClientRect().bottom))
10
+ return new DOMRect(code.left, top, code.width, bottom - top)
11
+ }
@@ -1,6 +1,8 @@
1
- import type { Ref } from 'vue'
1
+ import type { Dispatch, SetStateAction } from 'react'
2
+ import type { Store } from './store.ts'
2
3
  import { defineService } from '@lincyaw/kernel/contracts'
3
4
  import type { Cursor, Member, Operation, PresentationMode, ServiceToken, StartReport, Unsubscribe } from '@lincyaw/kernel/contracts'
5
+ import type { IRDocument, ProductionPlaybackCommand, ProductionPlaybackState } from '@lincyaw/kernel'
4
6
  import type { IconName } from '@lincyaw/design'
5
7
 
6
8
  export type ViewRole = 'audience' | 'presenter' | 'reader'
@@ -10,15 +12,17 @@ export type SharedStateScope = 'view' | 'local' | 'remote'
10
12
  export interface HostAdapter {
11
13
  readonly mode: PresentationMode
12
14
  readonly lang?: string
13
- useCursor(): Readonly<Ref<Cursor>>
14
- useStep(): Readonly<Ref<number>>
15
- useViewRole(): Readonly<Ref<ViewRole>>
16
- useStaticRender(): Readonly<Ref<boolean>>
17
- useSectionConfig(pluginId: string): Readonly<Ref<Record<string, unknown>>>
18
- useSharedState<T>(key: string, initial: T, scope: SharedStateScope): Ref<T>
15
+ useDoc?(): IRDocument
16
+ useCursor(): Cursor
17
+ useStep(): number
18
+ useViewRole(): ViewRole
19
+ useStaticRender(): boolean
20
+ useSectionConfig(pluginId: string): Record<string, unknown>
21
+ useSharedState<T>(key: string, initial: T, scope: SharedStateScope): [T, Dispatch<SetStateAction<T>>]
22
+ sharedStateStore?<T>(key: string, initial: T, scope: SharedStateScope): Store<T>
19
23
  getService<T>(token: ServiceToken<T>): T | undefined
20
24
  startReport(): StartReport | undefined
21
- useDarkMode?(): Ref<boolean>
25
+ useDarkMode?(): [boolean, Dispatch<SetStateAction<boolean>>]
22
26
  }
23
27
 
24
28
  export type SyncPublish = (state: Readonly<Record<string, unknown>>, fromRemote: boolean) => void
@@ -39,12 +43,14 @@ export interface SyncTransports {
39
43
  export const SYNC_TRANSPORTS = defineService<SyncTransports>('presmium.syncTransports')
40
44
 
41
45
  export interface NavigationPosition extends Cursor {
46
+ /** Transient seek intent; authoring steps and Production retain no duplicate timing. */
47
+ boundary?: 'start' | 'end'
42
48
  view?: string
43
49
  section?: string
44
50
  }
45
51
 
46
52
  export interface Navigation {
47
- readonly position: Readonly<Ref<NavigationPosition>>
53
+ readonly position: Store<NavigationPosition>
48
54
  go(position: NavigationPosition): void
49
55
  }
50
56
 
@@ -52,7 +58,7 @@ export const NAVIGATION = defineService<Navigation>('presmium.navigation')
52
58
 
53
59
  export type ToolbarSlot = 'tools' | 'settings' | 'status'
54
60
 
55
- export type ToolbarPlace = 'bar' | 'status'
61
+ export type ToolbarPlacement = 'bar' | 'status'
56
62
 
57
63
  export interface ToolbarItem {
58
64
  id: string
@@ -73,7 +79,7 @@ export const TOOLBAR = defineService<Toolbar>('presmium.toolbar')
73
79
 
74
80
  export interface ServerClient {
75
81
  readonly available: boolean
76
- readonly member: Readonly<Ref<Member | undefined>>
82
+ readonly member: Store<Member | undefined>
77
83
  fetch(plugin: string, path: string, init?: RequestInit): Promise<Response>
78
84
  events(plugin: string, path: string): EventSource
79
85
  }
@@ -101,7 +107,51 @@ export interface Operations {
101
107
  onReplay(type: string, handler: ReplayHandler): Unsubscribe
102
108
  replay(operation: Operation, seeking?: boolean): boolean
103
109
  clear(): void
104
- readonly replaying: Ref<boolean>
110
+ readonly replaying: Store<boolean>
105
111
  }
106
112
 
107
113
  export const OPERATIONS = defineService<Operations>('presmium.operations')
114
+
115
+ /** A registered player keeps its existing clock; this lease only coordinates whether it may start. */
116
+ export interface PlaybackLease {
117
+ requestStart(): Promise<boolean>
118
+ cancel(): void
119
+ dispose(): void
120
+ }
121
+
122
+ export interface PlaybackControl {
123
+ register(pause: () => void): PlaybackLease
124
+ pause(): void
125
+ /** Embedded previews await their author's audio focus before starting. Ordinary pages need no gate. */
126
+ setGate(authorize: (signal: AbortSignal) => Promise<void>): Unsubscribe
127
+ }
128
+
129
+ export const PLAYBACK_CONTROL = defineService<PlaybackControl>('presmium.playbackControl')
130
+
131
+ /** Observes the loaded player's clock and selects stable step boundaries; author interfaces never synthesize time. */
132
+ export interface ProductionPlayback {
133
+ readonly version: 1
134
+ readonly state: Pick<Store<ProductionPlaybackState | undefined>, 'getSnapshot' | 'subscribe'>
135
+ control(command: ProductionPlaybackCommand, signal?: AbortSignal): Promise<ProductionPlaybackState>
136
+ }
137
+ export const PRODUCTION_PLAYBACK = defineService<ProductionPlayback>('presmium.productionPlayback')
138
+
139
+ /** A loaded timed-caption player owns presentation captions until its lease is released. */
140
+ export interface TimedCaptions {
141
+ readonly active: Store<boolean>
142
+ claim(): Unsubscribe
143
+ }
144
+ export const TIMED_CAPTIONS = defineService<TimedCaptions>('presmium.timedCaptions')
145
+
146
+ export interface StepClockDriver {
147
+ readonly elapsed: Store<number | undefined>
148
+ readonly recording?: boolean
149
+ }
150
+
151
+ export interface StepClock {
152
+ drive(driver: StepClockDriver): Unsubscribe
153
+ wait(work: Promise<unknown>): void
154
+ settled(): Promise<void>
155
+ }
156
+
157
+ export const STEP_CLOCK = defineService<StepClock>('presmium.stepClock')
package/src/control.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { SESSION } from '@lincyaw/kernel/contracts'
2
+ import type { ApprovalRequest, ServiceToken, Unsubscribe } from '@lincyaw/kernel/contracts'
3
+ import { createStore } from './store.ts'
4
+ import type { Store } from './store.ts'
5
+ import type { Navigation, NavigationPosition, ServerClient, StatusRegistry, ViewRole } from './contracts.ts'
6
+ import { clientDocument, clientEventSubscription, documentHeader, pluginRoutePath } from './server.ts'
7
+
8
+ export interface PendingApproval {
9
+ id: string
10
+ request: ApprovalRequest
11
+ }
12
+
13
+ export interface ApprovalState {
14
+ pending: Store<PendingApproval[]>
15
+ answer(id: string, allow: boolean): Promise<void>
16
+ }
17
+
18
+ const pending = createStore<PendingApproval[]>([])
19
+ let answering: (id: string, allow: boolean) => Promise<void> = async () => {}
20
+
21
+ export const approvals: ApprovalState = {
22
+ pending,
23
+ answer: (id, allow) => answering(id, allow),
24
+ }
25
+
26
+ function roleOf(position: NavigationPosition): ViewRole {
27
+ if (position.view === 'presenter') return 'presenter'
28
+ if (position.view === 'present') return 'audience'
29
+ if (position.view !== undefined) return 'reader'
30
+ return typeof location !== 'undefined' && /\/presenter(?:\/|$)/.test(location.pathname) ? 'presenter' : 'audience'
31
+ }
32
+
33
+ export function connectControl(client: ServerClient, host: {
34
+ navigation: Navigation
35
+ status: StatusRegistry
36
+ service<T>(token: ServiceToken<T>): T | undefined
37
+ }): Unsubscribe {
38
+ const { navigation, status } = host
39
+ const document = clientDocument(client)
40
+ const id = Math.random().toString(36).slice(2, 12)
41
+ const control = `${pluginRoutePath(client, 'presmium.server')}control`
42
+ const post = (body: Record<string, unknown>) => fetch(control, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'Presmium-Document': documentHeader(document) },
45
+ body: JSON.stringify({ window: id, ...body }),
46
+ }).then(async response => ({ ok: response.ok, status: response.status, text: await response.text() }))
47
+ answering = async (approval, allow) => {
48
+ const response = await post({ type: 'answer', id: approval, allow })
49
+ if (!response.ok) throw new Error(`the server did not take the answer (${response.status})`)
50
+ pending.set(pending.getSnapshot().filter(p => p.id !== approval))
51
+ }
52
+ const subscribe = clientEventSubscription(client)
53
+ const source = subscribe ? undefined : new EventSource(`${control}?window=${id}&document=${documentHeader(document)}`)
54
+ const handlers = new Map<string, (data: string) => void>()
55
+ const on = <T>(event: string, handle: (data: T) => void) => {
56
+ const receive = (data: string) => {
57
+ try {
58
+ handle(JSON.parse(data) as T)
59
+ } catch (error) {
60
+ console.error(`[presmium] control channel: bad ${event} message`, error)
61
+ }
62
+ }
63
+ handlers.set(event, receive)
64
+ source?.addEventListener(event, message => receive((message as MessageEvent<string>).data))
65
+ }
66
+ on<PendingApproval>('approval', (approval) => {
67
+ if (!pending.getSnapshot().some(p => p.id === approval.id)) pending.set([...pending.getSnapshot(), approval])
68
+ })
69
+ on<{ id: string }>('settled', (settled) => {
70
+ pending.set(pending.getSnapshot().filter(p => p.id !== settled.id))
71
+ })
72
+ on<NavigationPosition>('go', position => navigation.go(position))
73
+ on<{ action: 'start' | 'end', name?: string }>('session', ({ action, name }) => {
74
+ const session = host.service(SESSION)
75
+ if (!session) return
76
+ void (action === 'start' ? session.create(name ?? '') : session.end()).catch((error: unknown) => {
77
+ console.error('[presmium] the classroom command failed', error)
78
+ })
79
+ })
80
+
81
+ let reported = ''
82
+ const report = () => {
83
+ const position = navigation.position.getSnapshot()
84
+ const summaries = Object.fromEntries(status.list().map((provider) => {
85
+ try {
86
+ return [provider.id, provider.read()]
87
+ } catch {
88
+ return [provider.id, null]
89
+ }
90
+ }))
91
+ const body = JSON.stringify({ position, role: roleOf(position), status: summaries })
92
+ if (body === reported) return
93
+ reported = body
94
+ void post({ type: 'report', ...JSON.parse(body) as Record<string, unknown> }).catch(() => undefined)
95
+ }
96
+ const opened = () => { reported = ''; report() }
97
+ source?.addEventListener('open', opened)
98
+ const stopEvents = subscribe?.({ topic: 'viewer-control', document, window: id }, message => {
99
+ if (message.type === 'open') opened()
100
+ else if (message.type === 'event') handlers.get(message.event)?.(message.data)
101
+ })
102
+ let soon: ReturnType<typeof setTimeout> | undefined
103
+ const stopWatch = navigation.position.subscribe(() => {
104
+ soon ??= setTimeout(() => {
105
+ soon = undefined
106
+ report()
107
+ }, 200)
108
+ })
109
+ const timer = setInterval(report, 1000)
110
+ return () => {
111
+ stopWatch()
112
+ clearTimeout(soon)
113
+ clearInterval(timer)
114
+ stopEvents?.(); source?.close()
115
+ pending.set([])
116
+ }
117
+ }
@@ -0,0 +1,16 @@
1
+ import { useEffect } from 'react'
2
+ import { createStore, useStore } from '../store.ts'
3
+ import { useHost } from '../host.ts'
4
+ export interface Appearance { readonly dark: boolean, setDark(dark: boolean): void, toggle(): void }
5
+ const darkClass = createStore(false)
6
+ let observer: MutationObserver | undefined
7
+ function observe() { if (typeof document === 'undefined' || observer) return; const root = document.documentElement; const read = () => darkClass.set(root.classList.contains('dark')); read(); observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ['class'] }) }
8
+ export function useAppearance(): Appearance {
9
+ const host = useHost()
10
+ const observed = useStore(darkClass)
11
+ const own = host.useDarkMode?.()
12
+ useEffect(observe, [])
13
+ const dark = own?.[0] ?? observed
14
+ const setDark = (on: boolean) => { if (own) own[1](on); else document.documentElement.classList.toggle('dark', on) }
15
+ return { dark, setDark, toggle: () => setDark(!dark) }
16
+ }
@@ -0,0 +1,31 @@
1
+ import { createContext, createElement as h, useContext } from 'react'
2
+ import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+
5
+ const CaptionContainer = createContext<HTMLElement | null>(null)
6
+
7
+ /** The viewer supplies a stable caption surface on its scaled slide canvas. */
8
+ export function PmCaptionProvider({ container, children }: { container: HTMLElement | null, children?: ReactNode }) {
9
+ return h(CaptionContainer.Provider, { value: container }, children)
10
+ }
11
+
12
+ export interface CaptionProps extends HTMLAttributes<HTMLElement> {
13
+ label: string
14
+ lines: readonly string[]
15
+ hint?: string
16
+ overlay?: boolean
17
+ }
18
+
19
+ /** Read-only caption text; its caller owns the source and playback position. */
20
+ export function PmCaption({ label, lines, hint, overlay = false, className, onKeyDown, ...props }: CaptionProps) {
21
+ const container = useContext(CaptionContainer)
22
+ const keyDown = (event: KeyboardEvent<HTMLElement>) => {
23
+ onKeyDown?.(event)
24
+ if (overlay && !event.defaultPrevented && !event.nativeEvent.isComposing && !event.altKey && !event.ctrlKey && !event.metaKey
25
+ && ['Home', 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', ' '].includes(event.key)) event.stopPropagation()
26
+ }
27
+ const caption = h('section', { ...props, onKeyDown: keyDown, className: ['pm-ui-caption', className].filter(Boolean).join(' '), 'data-overlay': overlay || undefined, 'aria-label': label, 'aria-live': 'polite', 'aria-atomic': true, tabIndex: overlay ? 0 : props.tabIndex },
28
+ hint ? h('p', { className: 'pm-ui-caption__hint' }, hint) : null,
29
+ lines.map((text, index) => h('p', { key: index, className: 'pm-ui-caption__line' }, text)))
30
+ return overlay ? container && createPortal(caption, container) : caption
31
+ }