@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/src/host.ts CHANGED
@@ -1,82 +1,47 @@
1
- import { stepStateAt } from '@lincyaw/kernel/contracts'
2
- import type { PresentationMode, ServiceToken, StartReport, StepRange, StepState } from '@lincyaw/kernel/contracts'
3
- import type { App, ComputedRef, Ref } from 'vue'
4
- import { computed, getCurrentInstance, getCurrentScope, inject, onScopeDispose, readonly, ref } from 'vue'
5
- import { PmBoundary } from './boundary.ts'
6
- import { HOST, installed } from './key.ts'
7
- import { PmStepScope, useStepScope } from './scope.ts'
8
- import type { HostAdapter, SharedStateScope } from './types.ts'
9
-
10
- export function installHost(app: App, adapter: HostAdapter): void {
11
- installed.adapter = adapter
12
- app.provide(HOST, adapter)
13
- app.component('PmBoundary', PmBoundary)
14
- app.component('PmStepScope', PmStepScope)
1
+ import { createElement, useContext, useEffect, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { stepStateAt } from '@lincyaw/kernel'
4
+ import type { ServiceToken, StepRange, StepState } from '@lincyaw/kernel/contracts'
5
+ import { HOST, installed } from './host-key.ts'
6
+ import type { HostAdapter, SharedStateScope } from './contracts.ts'
7
+ import { useStepScope } from './step-scope.ts'
8
+ import { runtimeRevision } from './runtime.ts'
9
+ import { createStore, useStore } from './store.ts'
10
+ import type { Store } from './store.ts'
11
+ import { PmBoundary } from './error-boundary.ts'
12
+
13
+ export function installHost(adapter: HostAdapter): void { installed.adapter = adapter }
14
+ export function HostProvider({ adapter, children }: { adapter: HostAdapter, children?: ReactNode }) {
15
+ return createElement(HOST.Provider, { value: adapter }, children)
15
16
  }
16
-
17
17
  export function useHost(): HostAdapter {
18
- const host = inject(HOST, undefined)
19
- if (!host) throw new Error('[presmium] no host installed: the host adapter must call installHost(app, adapter)')
18
+ const host = useContext(HOST) ?? installed.adapter
19
+ if (!host) throw new Error('[presmium] no host installed')
20
20
  return host
21
21
  }
22
-
23
- export function useStepState(range: StepRange): Readonly<Ref<StepState>> {
24
- const step = useHost().useStep()
25
- const { at } = useStepScope()
26
- return computed(() => stepStateAt(range, step.value - at.value))
27
- }
28
-
29
- export function useCursor() {
30
- return useHost().useCursor()
31
- }
32
-
33
- export function useViewRole() {
34
- return useHost().useViewRole()
35
- }
36
-
37
- export function usePresentationMode(): PresentationMode {
38
- return useHost().mode
39
- }
40
-
41
- export function useService<T>(token: ServiceToken<T>): ComputedRef<T | undefined> {
42
- const host = useHost()
43
- return computed(() => host.getService(token))
44
- }
45
-
46
- export function useStartReport(): ComputedRef<StartReport | undefined> {
47
- const host = useHost()
48
- return computed(() => host.startReport())
49
- }
50
-
51
- export function useStaticRender(): Readonly<Ref<boolean>> {
52
- return useHost().useStaticRender()
53
- }
54
-
55
- export function useSectionConfig(pluginId: string): Readonly<Ref<Record<string, unknown>>> {
56
- return useHost().useSectionConfig(pluginId)
57
- }
58
-
59
- export interface SharedStateOptions {
60
- scope?: SharedStateScope
61
- }
62
-
63
- export function useSharedState<T>(key: string, initial: T, options: SharedStateOptions = {}): Ref<T> {
64
- const host = (getCurrentInstance() ? inject(HOST, undefined) : undefined) ?? installed.adapter
65
- if (!host) throw new Error('[presmium] no host installed: the host adapter must call installHost(app, adapter)')
66
- return host.useSharedState(key, initial, options.scope ?? 'local')
67
- }
68
-
69
- const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'
70
-
71
- export function usePrefersReducedMotion(): Readonly<Ref<boolean>> {
72
- const query = typeof window === 'undefined' ? undefined : window.matchMedia?.(REDUCED_MOTION)
73
- const reduced = ref(query?.matches ?? false)
74
- if (query) {
75
- const update = (event: MediaQueryListEvent) => {
76
- reduced.value = event.matches
77
- }
78
- query.addEventListener('change', update)
79
- if (getCurrentScope()) onScopeDispose(() => query.removeEventListener('change', update))
80
- }
81
- return readonly(reduced)
22
+ export function useDoc() { const host = useHost(); if (!host.useDoc) throw new Error("[presmium] this host has no document"); return host.useDoc() }
23
+ export function useStep(): number { return useHost().useStep() }
24
+ export function useStepState(range: StepRange): StepState { const step = useStep(); const scope = useStepScope(); return stepStateAt(range, scope.localStepAt(step)) }
25
+ export function useCursor() { return useHost().useCursor() }
26
+ export function useViewRole() { return useHost().useViewRole() }
27
+ export function usePresentationMode() { const host = useHost(); host.useViewRole(); return host.mode }
28
+ export function useService<T>(token: ServiceToken<T>): T | undefined { useStore(runtimeRevision); return useHost().getService(token) }
29
+ export function useStartReport() { useStore(runtimeRevision); return useHost().startReport() }
30
+ export function useStaticRender() { return useHost().useStaticRender() }
31
+ export function useSectionConfig(pluginId: string) { return useHost().useSectionConfig(pluginId) }
32
+ export interface SharedStateOptions { scope?: SharedStateScope }
33
+ export function useSharedState<T>(key: string, initial: T, options: SharedStateOptions = {}) { return useHost().useSharedState(key, initial, options.scope ?? 'local') }
34
+ export function usePrefersReducedMotion(): boolean {
35
+ const [reduced, setReduced] = useState(() => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
36
+ useEffect(() => { const query = matchMedia('(prefers-reduced-motion: reduce)'); const update = () => setReduced(query.matches); update(); query.addEventListener('change', update); return () => query.removeEventListener('change', update) }, [])
37
+ return reduced
38
+ }
39
+
40
+ const detachedShared = new Map<string, Store<unknown>>()
41
+ export function getSharedStateStore<T>(key: string, initial: T, options: SharedStateOptions = {}): Store<T> {
42
+ const scope = options.scope ?? 'local'
43
+ if (installed.adapter?.sharedStateStore) return installed.adapter.sharedStateStore(key, initial, scope)
44
+ const id = `${scope}:${key}`
45
+ let store = detachedShared.get(id); if (!store) { store = createStore<unknown>(initial); detachedShared.set(id, store) }
46
+ return store as Store<T>
82
47
  }
package/src/index.ts CHANGED
@@ -1,9 +1,12 @@
1
+ /// <reference path="./styles.d.ts" />
1
2
  import './design/design.css'
2
3
 
3
- export { blockAt, blockElement, blockKey, codeLines, lineNumbers, linesText, positionSlide, splitBlockKey } from './blocks.ts'
4
+ export { useActions } from './actions.ts'
5
+ export type { ActionOptions, ComponentAction } from './actions.ts'
6
+ export { blockAt, blockElement, blockKey, codeLines, lineNumbers, linesText, splitBlockKey } from './blocks.ts'
4
7
  export type { BlockPoint } from './blocks.ts'
5
- export { componentErrors, PmBoundary } from './boundary.ts'
6
- export type { ComponentError } from './boundary.ts'
8
+ export { componentErrors, componentErrorRevision, PmBoundary } from './error-boundary.ts'
9
+ export type { ComponentError } from './error-boundary.ts'
7
10
  export {
8
11
  PmBadge,
9
12
  PmButton,
@@ -11,6 +14,7 @@ export {
11
14
  PmDialog,
12
15
  PmDrawer,
13
16
  PmEmpty,
17
+ PmSkeleton,
14
18
  PmField,
15
19
  PmIcon,
16
20
  PmIconButton,
@@ -19,17 +23,26 @@ export {
19
23
  PmPopover,
20
24
  PmSegmented,
21
25
  PmTabs,
26
+ PmTabPanel,
27
+ PmTooltip,
22
28
  PmToolbarEntry,
23
- provideToolbarPlace,
24
- useToolbarPlace,
29
+ provideToolbarPlacement,
30
+ useToolbarPlacement,
25
31
  } from './design/components.ts'
26
- export type { ButtonVariant, Choice, MenuItem, PopoverPlacement, Tone } from './design/components.ts'
32
+ export type { ButtonVariant, Choice, ControlDensity, MenuItem, PopoverPlacement, Tone } from './design/components.ts'
33
+ export { PmColumns, PmStack, PmTabGroup } from './design/layout.ts'
34
+ export { PmCaption, PmCaptionProvider } from './design/caption.ts'
35
+ export type { CaptionProps } from './design/caption.ts'
36
+ export type { ContentLayoutProps, ColumnsLayoutProps, TabLayoutEntry } from './design/layout.ts'
27
37
  export { iconMarkup, ICONS } from '@lincyaw/design'
28
38
  export type { IconName } from '@lincyaw/design'
29
- export { useTheme } from './design/theme.ts'
30
- export type { Theme } from './design/theme.ts'
39
+ export { useAppearance } from './design/appearance.ts'
40
+ export type { Appearance } from './design/appearance.ts'
31
41
  export {
32
42
  installHost,
43
+ HostProvider,
44
+ useStep,
45
+ useDoc,
33
46
  useCursor,
34
47
  useHost,
35
48
  usePrefersReducedMotion,
@@ -37,48 +50,65 @@ export {
37
50
  useSectionConfig,
38
51
  useService,
39
52
  useSharedState,
53
+ getSharedStateStore,
40
54
  useStartReport,
41
55
  useStaticRender,
42
56
  useStepState,
43
57
  useViewRole,
44
58
  } from './host.ts'
45
59
  export type { SharedStateOptions } from './host.ts'
46
- export { pluginsOf, presmiumReport, presmiumService, registerPresmiumPlugin, reportPluginFailure, startHost, startPresmium, stopPresmium, stopPresmiumOnPageHide } from './kernel.ts'
47
- export type { HostStart, StartOptions } from './kernel.ts'
60
+ export { pluginsOf, presmiumReport, presmiumService, registerPresmiumPlugin, reportPluginFailure, setHostActive, startHost, stopHost } from './runtime.ts'
61
+ export type { HostStart } from './runtime.ts'
48
62
  export { currentLocale, useLocale, useMessages } from './locale.ts'
49
63
  export type { Catalog } from './locale.ts'
50
64
  export { PmApprovals } from './approvals.ts'
51
- export { createOperations, createStatusRegistry } from './operations.ts'
52
- export { createPresenterPanels, createToolbar } from './panels.ts'
53
- export { createServerClient, provideServer, useServer } from './server.ts'
54
- export { default as PmPresenterPanels } from './PresenterPanels.vue'
55
- export { PmStepScope, provideStepScope, useStepScope } from './scope.ts'
56
- export { claimFirst, useSessionSnapshot } from './session.ts'
57
- export type { StepScope } from './scope.ts'
58
- export { createSharedStates } from './shared.ts'
59
- export type { SharedStates } from './shared.ts'
60
- export { default as PmToolbar } from './Toolbar.vue'
61
- export { NAVIGATION, OPERATIONS, STATUS, SYNC_TRANSPORTS, TOOLBAR } from './types.ts'
62
- export { deckChannel, joinWindows } from './windows.ts'
65
+ export { claimFirst } from './claim-first.ts'
66
+ export { createServerClient, pluginRoutePath, provideServer, useServer, serverClient } from './server.ts'
67
+ export { PmPresenterPanels } from './panels.ts'
68
+ export { useStepTime } from './step-clock.ts'
69
+ export type { StepTimeOptions } from './step-clock.ts'
70
+ export { StepScope, useBlockStepRange, useStepScope } from './step-scope.ts'
71
+ export { useSessionSnapshot } from './session.ts'
72
+ export type { StepScope as StepScopeValue } from './step-scope.ts'
73
+ export { createSharedStates } from './shared-state.ts'
74
+ export type { SharedStates } from './shared-state.ts'
75
+ export { PmToolbar } from './panels.ts'
76
+ export { NAVIGATION, OPERATIONS, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, STATUS, STEP_CLOCK, SYNC_TRANSPORTS, TIMED_CAPTIONS, TOOLBAR } from './contracts.ts'
77
+ export type { TimedCaptions } from './contracts.ts'
78
+ export { joinWindows } from './windows.ts'
79
+ export { setSiteBase, siteBase } from './site.ts'
63
80
  export type { WindowChannel } from './windows.ts'
64
81
  export type {
65
82
  HostAdapter,
66
83
  Navigation,
67
84
  NavigationPosition,
68
85
  Operations,
86
+ PlaybackControl,
87
+ PlaybackLease,
88
+ ProductionPlayback,
69
89
  ReplayHandler,
70
90
  ServerClient,
71
91
  SharedStateScope,
72
92
  StatusProvider,
73
93
  StatusRegistry,
94
+ StepClock,
95
+ StepClockDriver,
74
96
  SyncPublish,
75
97
  SyncTransport,
76
98
  SyncTransports,
77
99
  Toolbar,
78
100
  ToolbarItem,
79
- ToolbarPlace,
101
+ ToolbarPlacement,
80
102
  ToolbarSlot,
81
103
  ViewRole,
82
- } from './types.ts'
83
- export { localeOf } from '@lincyaw/kernel/contracts'
104
+ } from './contracts.ts'
105
+ export { localeOf } from '@lincyaw/kernel'
84
106
  export type { Locale } from '@lincyaw/kernel/contracts'
107
+ export { codeLinesBox } from './code-lines-box.ts'
108
+ export { createResidentWorker } from './resident-worker.ts'
109
+ export type { ResidentWorker, ResidentWorkerFailure, ResidentWorkerOptions, ResidentWorkerOutcome } from './resident-worker.ts'
110
+
111
+ export { createStore, useStore, observe } from './store.ts'
112
+ export type { Store } from './store.ts'
113
+
114
+ export { runtimeRevision } from "./runtime.ts"
package/src/locale.ts CHANGED
@@ -1,42 +1,20 @@
1
- import { localeOf } from '@lincyaw/kernel/contracts'
1
+ import { useEffect } from 'react'
2
+ import { localeOf } from '@lincyaw/kernel'
2
3
  import type { Locale } from '@lincyaw/kernel/contracts'
3
- import type { ComputedRef } from 'vue'
4
- import { computed, getCurrentInstance, inject, shallowRef } from 'vue'
5
- import { HOST, installed } from './key.ts'
6
-
4
+ import { installed } from './host-key.ts'
5
+ import { createStore, useStore } from './store.ts'
7
6
  export type Catalog<T> = Record<Locale, T>
8
-
9
- const htmlLang = shallowRef<string>()
10
- let observed = false
11
-
12
- function observeHtmlLang(): void {
13
- if (observed || typeof document === 'undefined') return
14
- observed = true
15
- const root = document.documentElement
16
- htmlLang.value = root.lang
17
- if (typeof MutationObserver !== 'undefined') {
18
- new MutationObserver(() => {
19
- htmlLang.value = root.lang
20
- }).observe(root, { attributes: true, attributeFilter: ['lang'] })
21
- }
7
+ const language = createStore<string | undefined>(undefined)
8
+ let observer: MutationObserver | undefined
9
+ function observeLanguage() {
10
+ if (typeof document === 'undefined' || observer) return
11
+ language.set(document.documentElement.lang)
12
+ observer = new MutationObserver(() => language.set(document.documentElement.lang))
13
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
22
14
  }
23
-
24
- function resolveLocale(fallbackLang?: string): Locale {
25
- observeHtmlLang()
26
- const browser = typeof navigator === 'undefined' ? undefined : navigator.language
27
- return localeOf(htmlLang.value || fallbackLang) ?? localeOf(browser) ?? 'en'
28
- }
29
-
30
15
  export function currentLocale(): Locale {
31
- return resolveLocale(installed.adapter?.lang)
32
- }
33
-
34
- export function useLocale(): ComputedRef<Locale> {
35
- const host = getCurrentInstance() ? inject(HOST, undefined) : undefined
36
- return computed(() => resolveLocale(host?.lang))
37
- }
38
-
39
- export function useMessages<T>(catalog: Catalog<T>): ComputedRef<T> {
40
- const locale = useLocale()
41
- return computed(() => catalog[locale.value])
16
+ const lang = typeof document === 'undefined' ? undefined : document.documentElement.lang
17
+ return localeOf(lang || installed.adapter?.lang) ?? localeOf(typeof navigator === 'undefined' ? undefined : navigator.language) ?? 'en'
42
18
  }
19
+ export function useLocale(): Locale { useStore(language); useEffect(observeLanguage, []); return currentLocale() }
20
+ export function useMessages<T>(catalog: Catalog<T>): T { return catalog[useLocale()] }
package/src/messages.ts CHANGED
@@ -8,44 +8,59 @@ export interface HostMessages {
8
8
  noPlugins: string
9
9
  status: Record<PluginStatus, string>
10
10
  because: string
11
+ unnamedPlugin: string
11
12
  componentError(component: string, message: string): string
12
13
  problems(count: number): string
13
14
  approvals(count: number): string
14
15
  approvalsTitle: string
15
16
  allow: string
16
17
  deny: string
18
+ answerFailed: string
19
+ close: string
17
20
  from(who: string): string
21
+ origins: Record<string, string>
22
+ otherOrigin: string
18
23
  }
19
24
 
20
25
  export const HOST_MESSAGES: Catalog<HostMessages> = {
21
26
  'zh-CN': {
22
27
  panels: '课堂与工具',
23
28
  startReport: '启动报告',
24
- starting: '内核启动中……',
29
+ starting: '正在启动…',
25
30
  noPlugins: '没有注册插件。',
26
- status: { started: '已启动', degraded: '已降级', disabled: '已禁用', failed: '启动失败' },
31
+ status: { started: '已启动', degraded: '部分可用', disabled: '已禁用', failed: '启动失败' },
27
32
  because: ':',
33
+ unnamedPlugin: '其他插件',
28
34
  componentError: (component, message) => `${component} 出错:${message}`,
29
35
  problems: count => `${count} 个问题`,
30
36
  approvals: count => `待确认 ${count}`,
31
37
  approvalsTitle: '待确认的操作',
32
38
  allow: '允许',
33
39
  deny: '拒绝',
34
- from: who => `来自 ${who}`,
40
+ answerFailed: '无法发送答复。请重试。',
41
+ close: '关闭',
42
+ from: who => `来自${/^[A-Za-z0-9]/.test(who) ? ' ' : ''}${who}`,
43
+ origins: { 'presmium.mcp': '外部 AI 工具' },
44
+ otherOrigin: '其他程序',
35
45
  },
36
46
  'en': {
37
- panels: 'Panels',
38
- startReport: 'Start report',
39
- starting: 'Starting the kernel…',
47
+ panels: 'Classroom and Tools',
48
+ startReport: 'Startup Report',
49
+ starting: 'Starting…',
40
50
  noPlugins: 'No plugins registered.',
41
- status: { started: 'started', degraded: 'degraded', disabled: 'disabled', failed: 'failed to start' },
51
+ status: { started: 'Started', degraded: 'Partly available', disabled: 'Disabled', failed: 'Failed to start' },
42
52
  because: ': ',
53
+ unnamedPlugin: 'Other plugin',
43
54
  componentError: (component, message) => `${component} failed: ${message}`,
44
55
  problems: count => `${count} ${count === 1 ? 'problem' : 'problems'}`,
45
- approvals: count => `Approvals ${count}`,
46
- approvalsTitle: 'Operations to approve',
56
+ approvals: count => `Pending Approvals (${count})`,
57
+ approvalsTitle: 'Pending Approvals',
47
58
  allow: 'Allow',
48
59
  deny: 'Deny',
49
- from: who => `from ${who}`,
60
+ answerFailed: 'Couldn’t send the answer. Please try again.',
61
+ close: 'Close',
62
+ from: who => `From ${who}`,
63
+ origins: { 'presmium.mcp': 'an external AI tool' },
64
+ otherOrigin: 'another program',
50
65
  },
51
66
  }
package/src/operations.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import type { Operation, Unsubscribe } from '@lincyaw/kernel/contracts'
2
- import { ref } from 'vue'
3
- import type { Operations, ReplayHandler, StatusProvider, StatusRegistry } from './types.ts'
2
+ import { createStore } from './store.ts'
3
+ import type { Operations, ReplayHandler } from './contracts.ts'
4
4
 
5
5
  export function createOperations(): Operations {
6
6
  const listeners = new Set<(operation: Operation) => void>()
7
7
  const handlers = new Map<string, ReplayHandler>()
8
- const replaying = ref(false)
8
+ const replaying = createStore(false)
9
9
  let unsettled = 0
10
10
  return {
11
11
  report(operation) {
12
- if (replaying.value) return
12
+ if (replaying.getSnapshot()) return
13
13
  for (const listener of [...listeners]) {
14
14
  try {
15
15
  listener(operation)
@@ -34,14 +34,14 @@ export function createOperations(): Operations {
34
34
  const handler = handlers.get(operation.type)
35
35
  if (!handler) return false
36
36
  unsettled++
37
- replaying.value = true
37
+ replaying.set(true)
38
38
  try {
39
39
  handler.apply(operation.data, seeking)
40
40
  } catch (error) {
41
41
  console.error(`[presmium] the replay of ${operation.type} failed`, error)
42
42
  } finally {
43
43
  setTimeout(() => {
44
- if (--unsettled === 0) replaying.value = false
44
+ if (--unsettled === 0) replaying.set(false)
45
45
  }, 0)
46
46
  }
47
47
  return true
@@ -58,19 +58,3 @@ export function createOperations(): Operations {
58
58
  replaying,
59
59
  }
60
60
  }
61
-
62
- export function createStatusRegistry(onChange?: () => void): StatusRegistry {
63
- const providers = new Map<string, StatusProvider>()
64
- return {
65
- register(provider) {
66
- providers.set(provider.id, provider)
67
- onChange?.()
68
- return () => {
69
- if (providers.get(provider.id) !== provider) return
70
- providers.delete(provider.id)
71
- onChange?.()
72
- }
73
- },
74
- list: () => [...providers.values()],
75
- }
76
- }
package/src/panels.ts CHANGED
@@ -1,60 +1,28 @@
1
- import type { PresenterPanel, PresenterPanels, Unsubscribe } from '@lincyaw/kernel'
2
- import { currentLocale } from './locale.ts'
1
+ import { createElement as h, useEffect, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { ComponentType } from 'react'
4
+ import { PRESENTER_PANELS } from '@lincyaw/kernel'
5
+ import type { PresenterPanel } from '@lincyaw/kernel'
6
+ import { TOOLBAR } from './contracts.ts'
7
+ import type { ToolbarItem, ToolbarPlacement } from './contracts.ts'
8
+ import { useService, useStartReport } from './host.ts'
9
+ import { useMessages } from './locale.ts'
10
+ import { componentErrors, componentErrorRevision } from './error-boundary.ts'
11
+ import { useStore } from './store.ts'
3
12
  import { HOST_MESSAGES } from './messages.ts'
4
- import StartReportPanel from './StartReportPanel.vue'
5
- import type { Toolbar, ToolbarItem } from './types.ts'
6
-
7
- interface Item {
8
- id: string
9
- order?: number
13
+ import { ToolbarPlacementContext } from './design/components.ts'
14
+ export function PmToolbar({ filter = (item: ToolbarItem) => !item.slot || item.slot === 'tools' || item.slot === 'settings', placement = 'bar', className }: { className?: string, filter?: (item: ToolbarItem) => boolean, placement?: ToolbarPlacement }) {
15
+ const service = useService(TOOLBAR); const [items, setItems] = useState<ToolbarItem[]>([])
16
+ useEffect(() => { const update = () => setItems(service?.list() ?? []); update(); return service?.subscribe(update) }, [service])
17
+ return h(ToolbarPlacementContext.Provider, { value: placement }, h('span', { className: `pm-toolbar${className ? ` ${className}` : ''}`, 'data-placement': placement }, items.filter(filter).map(item => h(item.component as ComponentType, { key: item.id, 'data-toolbar-item': item.id } as any))))
10
18
  }
11
-
12
- interface Registry<T extends Item> {
13
- register(item: T): Unsubscribe
14
- list(): T[]
15
- subscribe(listener: () => void): Unsubscribe
16
- }
17
-
18
- function createRegistry<T extends Item>(kind: string): Registry<T> {
19
- const items = new Map<string, T>()
20
- const listeners = new Set<() => void>()
21
- const notify = () => listeners.forEach(listener => listener())
22
- return {
23
- register(item) {
24
- if (items.has(item.id)) throw new Error(`${kind} ${item.id} is already registered`)
25
- items.set(item.id, item)
26
- notify()
27
- return () => {
28
- if (items.get(item.id) !== item) return
29
- items.delete(item.id)
30
- notify()
31
- }
32
- },
33
- list() {
34
- return [...items.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
35
- },
36
- subscribe(listener) {
37
- listeners.add(listener)
38
- return () => {
39
- listeners.delete(listener)
40
- }
41
- },
42
- }
43
- }
44
-
45
- export function createPresenterPanels(): PresenterPanels {
46
- const service = createRegistry<PresenterPanel>('presenter panel')
47
- service.register({
48
- id: 'presmium.core.start-report',
49
- get title() {
50
- return HOST_MESSAGES[currentLocale()].startReport
51
- },
52
- component: StartReportPanel,
53
- order: -1,
54
- })
55
- return service
56
- }
57
-
58
- export function createToolbar(): Toolbar {
59
- return createRegistry<ToolbarItem>('toolbar item')
19
+ export function PmPresenterPanels({ to = 'body', docked = false }: { to?: string, docked?: boolean }) {
20
+ const t = useMessages(HOST_MESSAGES); const service = useService(PRESENTER_PANELS); const [panels, setPanels] = useState<PresenterPanel[]>([])
21
+ useEffect(() => { const update = () => setPanels(service?.list() ?? []); update(); return service?.subscribe(update) }, [service])
22
+ const [target, setTarget] = useState<Element | null>(null)
23
+ useEffect(() => { setTarget(document.querySelector(to)) }, [to])
24
+ const report = useStartReport(); useStore(componentErrorRevision)
25
+ const problems = (report?.plugins.filter(plugin => plugin.status === 'failed' || plugin.status === 'disabled').length ?? 0) + componentErrors().length
26
+ const content = service ? h('details', { className: `pm-presenter-panels${docked ? ' pm-presenter-panels--docked' : ''}` }, h('summary', {}, t.panels, problems ? h('span', { className: 'pm-presenter-panels__badge', title: t.problems(problems) }, problems) : null), panels.map(panel => h('section', { key: panel.id, 'data-panel': panel.id }, h('h3', {}, panel.title), h(panel.component as ComponentType)))) : null
27
+ return target ? createPortal(content, target) : null
60
28
  }
@@ -0,0 +1,67 @@
1
+ import type { PlaybackControl, PlaybackLease } from './contracts.ts'
2
+
3
+ interface Participant {
4
+ pause(): void
5
+ cancel(): void
6
+ }
7
+
8
+ /** Coordinates starts and cancellation without owning time, media or a second player. */
9
+ export function createPlaybackControl(): PlaybackControl {
10
+ const participants = new Set<Participant>()
11
+ let gate: { authorize(signal: AbortSignal): Promise<void> } | undefined
12
+ const pause = () => {
13
+ for (const participant of [...participants]) {
14
+ participant.cancel()
15
+ participant.pause()
16
+ }
17
+ }
18
+ return {
19
+ pause,
20
+ setGate(authorize) {
21
+ pause()
22
+ const installed = { authorize }
23
+ gate = installed
24
+ return () => {
25
+ if (gate !== installed) return
26
+ gate = undefined
27
+ pause()
28
+ }
29
+ },
30
+ register(stop) {
31
+ let active: AbortController | undefined
32
+ let disposed = false
33
+ const cancel = () => { active?.abort(); active = undefined }
34
+ const participant = { pause: stop, cancel }
35
+ participants.add(participant)
36
+ const lease: PlaybackLease = {
37
+ async requestStart() {
38
+ cancel()
39
+ if (disposed) return false
40
+ for (const other of [...participants]) if (other !== participant) {
41
+ other.cancel()
42
+ other.pause()
43
+ }
44
+ const request = new AbortController()
45
+ active = request
46
+ const requestedGate = gate
47
+ try {
48
+ await requestedGate?.authorize(request.signal)
49
+ return !disposed && active === request && !request.signal.aborted && gate === requestedGate
50
+ } catch (error) {
51
+ if (request.signal.aborted || error instanceof Error && error.name === 'AbortError') return false
52
+ throw error
53
+ }
54
+ },
55
+ cancel,
56
+ dispose() {
57
+ if (disposed) return
58
+ disposed = true
59
+ cancel()
60
+ participants.delete(participant)
61
+ stop()
62
+ },
63
+ }
64
+ return lease
65
+ },
66
+ }
67
+ }