@lincyaw/host 1.0.0-rc.0 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
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
|
|
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
|
|
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
|
-
```
|
|
14
|
-
<script setup lang="ts">
|
|
13
|
+
```tsx
|
|
15
14
|
import { useMessages, useStepState } from '@lincyaw/host'
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
`
|
|
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.
|
|
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.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
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
|
-
"
|
|
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
|
-
"@
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"@
|
|
40
|
-
"@
|
|
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.1",
|
|
45
|
+
"@lincyaw/kernel": "1.0.0-rc.1"
|
|
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
|
|
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 './
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
},
|
|
13
|
-
|
|
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
|
|
44
|
-
if (!element || !
|
|
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
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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?():
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
}
|