@lincyaw/host 0.0.0-stage → 1.0.0-rc.0
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/LICENSE +202 -0
- package/README.md +39 -2
- package/package.json +39 -3
- package/src/PresenterPanels.vue +91 -0
- package/src/StartReportPanel.vue +45 -0
- package/src/Toolbar.vue +49 -0
- package/src/approvals.ts +58 -0
- package/src/blocks.ts +82 -0
- package/src/boundary.ts +56 -0
- package/src/design/components.ts +327 -0
- package/src/design/design.css +1 -0
- package/src/design/theme.ts +32 -0
- package/src/env.d.ts +7 -0
- package/src/host.ts +82 -0
- package/src/index.ts +84 -0
- package/src/kernel.ts +131 -0
- package/src/key.ts +6 -0
- package/src/locale.ts +42 -0
- package/src/messages.ts +51 -0
- package/src/operations.ts +76 -0
- package/src/panels.ts +60 -0
- package/src/scope.ts +29 -0
- package/src/server.ts +173 -0
- package/src/session.ts +34 -0
- package/src/shared.ts +85 -0
- package/src/types.ts +107 -0
- package/src/vue-shim.d.ts +6 -0
- package/src/windows.ts +28 -0
package/src/locale.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { localeOf } from '@lincyaw/kernel/contracts'
|
|
2
|
+
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
|
+
|
|
7
|
+
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
|
+
}
|
|
22
|
+
}
|
|
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
|
+
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])
|
|
42
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PluginStatus } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import type { Catalog } from './locale.ts'
|
|
3
|
+
|
|
4
|
+
export interface HostMessages {
|
|
5
|
+
panels: string
|
|
6
|
+
startReport: string
|
|
7
|
+
starting: string
|
|
8
|
+
noPlugins: string
|
|
9
|
+
status: Record<PluginStatus, string>
|
|
10
|
+
because: string
|
|
11
|
+
componentError(component: string, message: string): string
|
|
12
|
+
problems(count: number): string
|
|
13
|
+
approvals(count: number): string
|
|
14
|
+
approvalsTitle: string
|
|
15
|
+
allow: string
|
|
16
|
+
deny: string
|
|
17
|
+
from(who: string): string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const HOST_MESSAGES: Catalog<HostMessages> = {
|
|
21
|
+
'zh-CN': {
|
|
22
|
+
panels: '课堂与工具',
|
|
23
|
+
startReport: '启动报告',
|
|
24
|
+
starting: '内核启动中……',
|
|
25
|
+
noPlugins: '没有注册插件。',
|
|
26
|
+
status: { started: '已启动', degraded: '已降级', disabled: '已禁用', failed: '启动失败' },
|
|
27
|
+
because: ':',
|
|
28
|
+
componentError: (component, message) => `${component} 出错:${message}`,
|
|
29
|
+
problems: count => `${count} 个问题`,
|
|
30
|
+
approvals: count => `待确认 ${count}`,
|
|
31
|
+
approvalsTitle: '待确认的操作',
|
|
32
|
+
allow: '允许',
|
|
33
|
+
deny: '拒绝',
|
|
34
|
+
from: who => `来自 ${who}`,
|
|
35
|
+
},
|
|
36
|
+
'en': {
|
|
37
|
+
panels: 'Panels',
|
|
38
|
+
startReport: 'Start report',
|
|
39
|
+
starting: 'Starting the kernel…',
|
|
40
|
+
noPlugins: 'No plugins registered.',
|
|
41
|
+
status: { started: 'started', degraded: 'degraded', disabled: 'disabled', failed: 'failed to start' },
|
|
42
|
+
because: ': ',
|
|
43
|
+
componentError: (component, message) => `${component} failed: ${message}`,
|
|
44
|
+
problems: count => `${count} ${count === 1 ? 'problem' : 'problems'}`,
|
|
45
|
+
approvals: count => `Approvals ${count}`,
|
|
46
|
+
approvalsTitle: 'Operations to approve',
|
|
47
|
+
allow: 'Allow',
|
|
48
|
+
deny: 'Deny',
|
|
49
|
+
from: who => `from ${who}`,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Operation, Unsubscribe } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { Operations, ReplayHandler, StatusProvider, StatusRegistry } from './types.ts'
|
|
4
|
+
|
|
5
|
+
export function createOperations(): Operations {
|
|
6
|
+
const listeners = new Set<(operation: Operation) => void>()
|
|
7
|
+
const handlers = new Map<string, ReplayHandler>()
|
|
8
|
+
const replaying = ref(false)
|
|
9
|
+
let unsettled = 0
|
|
10
|
+
return {
|
|
11
|
+
report(operation) {
|
|
12
|
+
if (replaying.value) return
|
|
13
|
+
for (const listener of [...listeners]) {
|
|
14
|
+
try {
|
|
15
|
+
listener(operation)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error('[presmium] an operations subscriber failed', error)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
subscribe(listener) {
|
|
22
|
+
listeners.add(listener)
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener)
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
onReplay(type, handler): Unsubscribe {
|
|
28
|
+
handlers.set(type, handler)
|
|
29
|
+
return () => {
|
|
30
|
+
if (handlers.get(type) === handler) handlers.delete(type)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
replay(operation, seeking = false) {
|
|
34
|
+
const handler = handlers.get(operation.type)
|
|
35
|
+
if (!handler) return false
|
|
36
|
+
unsettled++
|
|
37
|
+
replaying.value = true
|
|
38
|
+
try {
|
|
39
|
+
handler.apply(operation.data, seeking)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`[presmium] the replay of ${operation.type} failed`, error)
|
|
42
|
+
} finally {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (--unsettled === 0) replaying.value = false
|
|
45
|
+
}, 0)
|
|
46
|
+
}
|
|
47
|
+
return true
|
|
48
|
+
},
|
|
49
|
+
clear() {
|
|
50
|
+
for (const [type, handler] of handlers) {
|
|
51
|
+
try {
|
|
52
|
+
handler.clear?.()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`[presmium] clearing ${type} failed`, error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
replaying,
|
|
59
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PresenterPanel, PresenterPanels, Unsubscribe } from '@lincyaw/kernel'
|
|
2
|
+
import { currentLocale } from './locale.ts'
|
|
3
|
+
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
|
|
10
|
+
}
|
|
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')
|
|
60
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
|
|
2
|
+
import { computed, defineComponent, inject, provide, ref, toValue } from 'vue'
|
|
3
|
+
|
|
4
|
+
const STEP_SCOPE: InjectionKey<Readonly<Ref<number>>> = Symbol('presmium.stepScope')
|
|
5
|
+
|
|
6
|
+
const NO_SCOPE = ref(0)
|
|
7
|
+
|
|
8
|
+
export function provideStepScope(at: MaybeRefOrGetter<number>): void {
|
|
9
|
+
provide(STEP_SCOPE, computed(() => toValue(at)))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StepScope {
|
|
13
|
+
readonly at: Readonly<Ref<number>>
|
|
14
|
+
step(k: number): number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useStepScope(): StepScope {
|
|
18
|
+
const at = inject(STEP_SCOPE, NO_SCOPE)
|
|
19
|
+
return { at, step: k => at.value + k }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const PmStepScope = defineComponent({
|
|
23
|
+
name: 'PmStepScope',
|
|
24
|
+
props: { at: { type: Number, default: 0 } },
|
|
25
|
+
setup(props, { slots }) {
|
|
26
|
+
provideStepScope(() => props.at)
|
|
27
|
+
return () => slots.default?.()
|
|
28
|
+
},
|
|
29
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { SESSION } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import type { ApprovalRequest, Member, ServiceToken, Unsubscribe } from '@lincyaw/kernel/contracts'
|
|
3
|
+
import type { Ref } from 'vue'
|
|
4
|
+
import { computed, shallowRef, watch } from 'vue'
|
|
5
|
+
import { currentLocale } from './locale.ts'
|
|
6
|
+
import type { Navigation, NavigationPosition, ServerClient, StatusRegistry, ViewRole } from './types.ts'
|
|
7
|
+
|
|
8
|
+
export const SERVER_PATH = '/__presmium/presmium.server'
|
|
9
|
+
|
|
10
|
+
const UNAVAILABLE: ServerClient = {
|
|
11
|
+
available: false,
|
|
12
|
+
member: computed(() => undefined),
|
|
13
|
+
fetch: () => Promise.reject(new Error('no Presmium server serves this page')),
|
|
14
|
+
events() {
|
|
15
|
+
throw new Error('no Presmium server serves this page')
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const current = shallowRef<ServerClient>(UNAVAILABLE)
|
|
20
|
+
const documents = new WeakMap<ServerClient, string>()
|
|
21
|
+
|
|
22
|
+
const facade: ServerClient = {
|
|
23
|
+
get available() {
|
|
24
|
+
return current.value.available
|
|
25
|
+
},
|
|
26
|
+
get member() {
|
|
27
|
+
return current.value.member
|
|
28
|
+
},
|
|
29
|
+
fetch: (plugin, path, init) => current.value.fetch(plugin, path, init),
|
|
30
|
+
events: (plugin, path) => current.value.events(plugin, path),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useServer(): ServerClient {
|
|
34
|
+
return facade
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function provideServer(client: ServerClient | undefined): void {
|
|
38
|
+
current.value = client ?? UNAVAILABLE
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function documentHeader(document: string): string {
|
|
42
|
+
return encodeURIComponent(document)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createServerClient(options: { document?: string } = {}): ServerClient {
|
|
46
|
+
const document = options.document ?? ''
|
|
47
|
+
const member = shallowRef<Member>()
|
|
48
|
+
const url = (plugin: string, path: string) => `/__presmium/${plugin}/${path.replace(/^\//, '')}`
|
|
49
|
+
const client: ServerClient = {
|
|
50
|
+
available: true,
|
|
51
|
+
member: computed(() => member.value),
|
|
52
|
+
fetch(plugin, path, init = {}) {
|
|
53
|
+
const headers = new Headers(init.headers)
|
|
54
|
+
headers.set('Presmium-Document', documentHeader(document))
|
|
55
|
+
if (!headers.has('Accept-Language')) headers.set('Accept-Language', currentLocale())
|
|
56
|
+
return fetch(url(plugin, path), { ...init, headers })
|
|
57
|
+
},
|
|
58
|
+
events(plugin, path) {
|
|
59
|
+
const target = url(plugin, path)
|
|
60
|
+
return new EventSource(`${target}${target.includes('?') ? '&' : '?'}document=${documentHeader(document)}`)
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
void fetch(`${SERVER_PATH}/hello`, { headers: { 'Presmium-Document': documentHeader(document) } })
|
|
64
|
+
.then(response => (response.ok ? response.json() : response.text().then(() => undefined)))
|
|
65
|
+
.then((hello: { member?: Member } | undefined) => {
|
|
66
|
+
member.value = hello?.member
|
|
67
|
+
})
|
|
68
|
+
.catch(() => undefined)
|
|
69
|
+
documents.set(client, document)
|
|
70
|
+
return client
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PendingApproval {
|
|
74
|
+
id: string
|
|
75
|
+
request: ApprovalRequest
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ApprovalState {
|
|
79
|
+
pending: Ref<PendingApproval[]>
|
|
80
|
+
answer(id: string, allow: boolean): Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pending = shallowRef<PendingApproval[]>([])
|
|
84
|
+
let answering: (id: string, allow: boolean) => Promise<void> = async () => {}
|
|
85
|
+
|
|
86
|
+
export const approvals: ApprovalState = {
|
|
87
|
+
pending,
|
|
88
|
+
answer: (id, allow) => answering(id, allow),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function roleOf(position: NavigationPosition): ViewRole {
|
|
92
|
+
if (position.view === 'presenter') return 'presenter'
|
|
93
|
+
if (position.view === 'present') return 'audience'
|
|
94
|
+
if (position.view !== undefined) return 'reader'
|
|
95
|
+
return typeof location !== 'undefined' && /\/presenter(?:\/|$)/.test(location.pathname) ? 'presenter' : 'audience'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function connectControl(client: ServerClient, host: {
|
|
99
|
+
navigation: Navigation
|
|
100
|
+
status: StatusRegistry
|
|
101
|
+
service<T>(token: ServiceToken<T>): T | undefined
|
|
102
|
+
}): Unsubscribe {
|
|
103
|
+
const { navigation, status } = host
|
|
104
|
+
const document = documents.get(client) ?? ''
|
|
105
|
+
const id = Math.random().toString(36).slice(2, 12)
|
|
106
|
+
const post = (body: Record<string, unknown>) => fetch(`${SERVER_PATH}/control`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json', 'Presmium-Document': documentHeader(document) },
|
|
109
|
+
body: JSON.stringify({ window: id, ...body }),
|
|
110
|
+
}).then(async response => ({ ok: response.ok, status: response.status, text: await response.text() }))
|
|
111
|
+
answering = async (approval, allow) => {
|
|
112
|
+
const response = await post({ type: 'answer', id: approval, allow })
|
|
113
|
+
if (!response.ok) throw new Error(`the server did not take the answer (${response.status})`)
|
|
114
|
+
pending.value = pending.value.filter(p => p.id !== approval)
|
|
115
|
+
}
|
|
116
|
+
const source = new EventSource(`${SERVER_PATH}/control?window=${id}&document=${documentHeader(document)}`)
|
|
117
|
+
const on = <T>(event: string, handle: (data: T) => void) => source.addEventListener(event, (message) => {
|
|
118
|
+
try {
|
|
119
|
+
handle(JSON.parse((message as MessageEvent<string>).data) as T)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(`[presmium] control channel: bad ${event} message`, error)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
on<PendingApproval>('approval', (approval) => {
|
|
125
|
+
if (!pending.value.some(p => p.id === approval.id)) pending.value = [...pending.value, approval]
|
|
126
|
+
})
|
|
127
|
+
on<{ id: string }>('settled', (settled) => {
|
|
128
|
+
pending.value = pending.value.filter(p => p.id !== settled.id)
|
|
129
|
+
})
|
|
130
|
+
on<NavigationPosition>('go', position => navigation.go(position))
|
|
131
|
+
on<{ action: 'start' | 'end', name?: string }>('session', ({ action, name }) => {
|
|
132
|
+
const session = host.service(SESSION)
|
|
133
|
+
if (!session) return
|
|
134
|
+
void (action === 'start' ? session.create(name ?? '') : session.end()).catch((error: unknown) => {
|
|
135
|
+
console.error('[presmium] the classroom command failed', error)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
let reported = ''
|
|
140
|
+
const report = () => {
|
|
141
|
+
const position = navigation.position.value
|
|
142
|
+
const summaries = Object.fromEntries(status.list().map((provider) => {
|
|
143
|
+
try {
|
|
144
|
+
return [provider.id, provider.read()]
|
|
145
|
+
} catch {
|
|
146
|
+
return [provider.id, null]
|
|
147
|
+
}
|
|
148
|
+
}))
|
|
149
|
+
const body = JSON.stringify({ position, role: roleOf(position), status: summaries })
|
|
150
|
+
if (body === reported) return
|
|
151
|
+
reported = body
|
|
152
|
+
void post({ type: 'report', ...JSON.parse(body) as Record<string, unknown> }).catch(() => undefined)
|
|
153
|
+
}
|
|
154
|
+
source.addEventListener('open', () => {
|
|
155
|
+
reported = ''
|
|
156
|
+
report()
|
|
157
|
+
})
|
|
158
|
+
let soon: ReturnType<typeof setTimeout> | undefined
|
|
159
|
+
const stopWatch = watch(() => navigation.position.value, () => {
|
|
160
|
+
soon ??= setTimeout(() => {
|
|
161
|
+
soon = undefined
|
|
162
|
+
report()
|
|
163
|
+
}, 200)
|
|
164
|
+
}, { deep: true })
|
|
165
|
+
const timer = setInterval(report, 1000)
|
|
166
|
+
return () => {
|
|
167
|
+
stopWatch()
|
|
168
|
+
clearTimeout(soon)
|
|
169
|
+
clearInterval(timer)
|
|
170
|
+
source.close()
|
|
171
|
+
pending.value = []
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SESSION } from '@lincyaw/kernel'
|
|
2
|
+
import type { SessionService, SessionSnapshot } from '@lincyaw/kernel'
|
|
3
|
+
import type { ComputedRef, Ref } from 'vue'
|
|
4
|
+
import { computed, getCurrentScope, onScopeDispose, shallowRef, watchEffect } from 'vue'
|
|
5
|
+
import { useService } from './host.ts'
|
|
6
|
+
|
|
7
|
+
export function useSessionSnapshot(): { session: ComputedRef<SessionService | undefined>, snapshot: Readonly<Ref<SessionSnapshot | undefined>> } {
|
|
8
|
+
const session = useService(SESSION)
|
|
9
|
+
const snapshot = shallowRef<SessionSnapshot>()
|
|
10
|
+
watchEffect((onCleanup) => {
|
|
11
|
+
const current = session.value
|
|
12
|
+
snapshot.value = current?.snapshot()
|
|
13
|
+
if (current) onCleanup(current.subscribe(next => snapshot.value = next))
|
|
14
|
+
})
|
|
15
|
+
return { session, snapshot }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const groups = new Map<string, Ref<symbol[]>>()
|
|
19
|
+
|
|
20
|
+
export function claimFirst(group: string): { owns: Readonly<Ref<boolean>>, release(): void } {
|
|
21
|
+
let owners = groups.get(group)
|
|
22
|
+
if (!owners) {
|
|
23
|
+
owners = shallowRef<symbol[]>([])
|
|
24
|
+
groups.set(group, owners)
|
|
25
|
+
}
|
|
26
|
+
const list = owners
|
|
27
|
+
const token = Symbol(group)
|
|
28
|
+
list.value = [...list.value, token]
|
|
29
|
+
const release = () => {
|
|
30
|
+
list.value = list.value.filter(t => t !== token)
|
|
31
|
+
}
|
|
32
|
+
if (getCurrentScope()) onScopeDispose(release)
|
|
33
|
+
return { owns: computed(() => list.value[0] === token), release }
|
|
34
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import { effectScope, ref, toRaw, watch } from 'vue'
|
|
3
|
+
import type { SharedStateScope, SyncPublish, SyncTransport, SyncTransports } from './types.ts'
|
|
4
|
+
import { joinWindows } from './windows.ts'
|
|
5
|
+
|
|
6
|
+
interface Channel {
|
|
7
|
+
name: string
|
|
8
|
+
receive(patch: Record<string, unknown>): void
|
|
9
|
+
value(): unknown
|
|
10
|
+
publishers: Map<SyncTransport, SyncPublish>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SharedStates {
|
|
14
|
+
sharedState<T>(key: string, initial: T, scope: SharedStateScope): Ref<T>
|
|
15
|
+
transports: SyncTransports
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const plain = (value: unknown): unknown => value === undefined ? undefined : JSON.parse(JSON.stringify(toRaw(value)))
|
|
19
|
+
|
|
20
|
+
export function createSharedStates(deck: string): SharedStates {
|
|
21
|
+
const states = new Map<string, Ref<unknown>>()
|
|
22
|
+
const channels = new Map<string, Channel>()
|
|
23
|
+
const transports = new Set<SyncTransport>()
|
|
24
|
+
const detached = effectScope(true)
|
|
25
|
+
|
|
26
|
+
const connect = (transport: SyncTransport, channel: Channel) => {
|
|
27
|
+
const publish = transport.connect(channel.name, channel.receive, { value: channel.value() }, false)
|
|
28
|
+
if (publish) channel.publishers.set(transport, publish)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function create(id: string, initial: unknown, scope: SharedStateScope): Ref<unknown> {
|
|
32
|
+
const state = ref(plain(initial))
|
|
33
|
+
if (scope === 'view') return state
|
|
34
|
+
const name = `presmium.shared:${deck}:${id}`
|
|
35
|
+
let from: 'window' | 'remote' | undefined
|
|
36
|
+
let changed = false
|
|
37
|
+
const apply = (value: unknown, source: 'window' | 'remote') => {
|
|
38
|
+
from = source
|
|
39
|
+
state.value = value
|
|
40
|
+
from = undefined
|
|
41
|
+
}
|
|
42
|
+
const windows = joinWindows(name, value => apply(value, 'window'), () => changed ? plain(state.value) : undefined)
|
|
43
|
+
const channel: Channel = {
|
|
44
|
+
name,
|
|
45
|
+
receive(patch) {
|
|
46
|
+
if ('value' in patch) apply(patch.value, 'remote')
|
|
47
|
+
},
|
|
48
|
+
value: () => plain(state.value),
|
|
49
|
+
publishers: new Map(),
|
|
50
|
+
}
|
|
51
|
+
watch(state, (value) => {
|
|
52
|
+
changed = true
|
|
53
|
+
const data = plain(value)
|
|
54
|
+
if (from === undefined) windows?.send(data)
|
|
55
|
+
channel.publishers.forEach(publish => publish({ value: data }, from !== undefined))
|
|
56
|
+
}, { deep: true, flush: 'sync' })
|
|
57
|
+
if (scope === 'remote') {
|
|
58
|
+
channels.set(id, channel)
|
|
59
|
+
transports.forEach(transport => connect(transport, channel))
|
|
60
|
+
}
|
|
61
|
+
return state
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
sharedState<T>(key: string, initial: T, scope: SharedStateScope): Ref<T> {
|
|
66
|
+
const id = `${scope}:${key}`
|
|
67
|
+
let state = states.get(id)
|
|
68
|
+
if (!state) {
|
|
69
|
+
state = detached.run(() => create(id, initial, scope))!
|
|
70
|
+
states.set(id, state)
|
|
71
|
+
}
|
|
72
|
+
return state as Ref<T>
|
|
73
|
+
},
|
|
74
|
+
transports: {
|
|
75
|
+
add(transport) {
|
|
76
|
+
transports.add(transport)
|
|
77
|
+
channels.forEach(channel => connect(transport, channel))
|
|
78
|
+
return () => {
|
|
79
|
+
transports.delete(transport)
|
|
80
|
+
channels.forEach(channel => channel.publishers.delete(transport))
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import { defineService } from '@lincyaw/kernel/contracts'
|
|
3
|
+
import type { Cursor, Member, Operation, PresentationMode, ServiceToken, StartReport, Unsubscribe } from '@lincyaw/kernel/contracts'
|
|
4
|
+
import type { IconName } from '@lincyaw/design'
|
|
5
|
+
|
|
6
|
+
export type ViewRole = 'audience' | 'presenter' | 'reader'
|
|
7
|
+
|
|
8
|
+
export type SharedStateScope = 'view' | 'local' | 'remote'
|
|
9
|
+
|
|
10
|
+
export interface HostAdapter {
|
|
11
|
+
readonly mode: PresentationMode
|
|
12
|
+
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>
|
|
19
|
+
getService<T>(token: ServiceToken<T>): T | undefined
|
|
20
|
+
startReport(): StartReport | undefined
|
|
21
|
+
useDarkMode?(): Ref<boolean>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SyncPublish = (state: Readonly<Record<string, unknown>>, fromRemote: boolean) => void
|
|
25
|
+
|
|
26
|
+
export interface SyncTransport {
|
|
27
|
+
connect(
|
|
28
|
+
channel: string,
|
|
29
|
+
receive: (patch: Record<string, unknown>) => void,
|
|
30
|
+
state: Readonly<Record<string, unknown>>,
|
|
31
|
+
persist: boolean,
|
|
32
|
+
): SyncPublish | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SyncTransports {
|
|
36
|
+
add(transport: SyncTransport): Unsubscribe
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SYNC_TRANSPORTS = defineService<SyncTransports>('presmium.syncTransports')
|
|
40
|
+
|
|
41
|
+
export interface NavigationPosition extends Cursor {
|
|
42
|
+
view?: string
|
|
43
|
+
section?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Navigation {
|
|
47
|
+
readonly position: Readonly<Ref<NavigationPosition>>
|
|
48
|
+
go(position: NavigationPosition): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NAVIGATION = defineService<Navigation>('presmium.navigation')
|
|
52
|
+
|
|
53
|
+
export type ToolbarSlot = 'tools' | 'settings' | 'status'
|
|
54
|
+
|
|
55
|
+
export type ToolbarPlace = 'bar' | 'status'
|
|
56
|
+
|
|
57
|
+
export interface ToolbarItem {
|
|
58
|
+
id: string
|
|
59
|
+
title: string
|
|
60
|
+
component: unknown
|
|
61
|
+
order?: number
|
|
62
|
+
slot?: ToolbarSlot
|
|
63
|
+
icon?: IconName
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Toolbar {
|
|
67
|
+
register(item: ToolbarItem): Unsubscribe
|
|
68
|
+
list(): ToolbarItem[]
|
|
69
|
+
subscribe(listener: () => void): Unsubscribe
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const TOOLBAR = defineService<Toolbar>('presmium.toolbar')
|
|
73
|
+
|
|
74
|
+
export interface ServerClient {
|
|
75
|
+
readonly available: boolean
|
|
76
|
+
readonly member: Readonly<Ref<Member | undefined>>
|
|
77
|
+
fetch(plugin: string, path: string, init?: RequestInit): Promise<Response>
|
|
78
|
+
events(plugin: string, path: string): EventSource
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface StatusProvider {
|
|
82
|
+
id: string
|
|
83
|
+
read(): unknown
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StatusRegistry {
|
|
87
|
+
register(provider: StatusProvider): Unsubscribe
|
|
88
|
+
list(): StatusProvider[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const STATUS = defineService<StatusRegistry>('presmium.status')
|
|
92
|
+
|
|
93
|
+
export interface ReplayHandler {
|
|
94
|
+
apply(data: Record<string, unknown>, seeking: boolean): void
|
|
95
|
+
clear?(): void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Operations {
|
|
99
|
+
report(operation: Operation): void
|
|
100
|
+
subscribe(listener: (operation: Operation) => void): Unsubscribe
|
|
101
|
+
onReplay(type: string, handler: ReplayHandler): Unsubscribe
|
|
102
|
+
replay(operation: Operation, seeking?: boolean): boolean
|
|
103
|
+
clear(): void
|
|
104
|
+
readonly replaying: Ref<boolean>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const OPERATIONS = defineService<Operations>('presmium.operations')
|