@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.
@@ -0,0 +1,327 @@
1
+ import type { InjectionKey, PropType, VNode } from 'vue'
2
+ import { defineComponent, cloneVNode, h, inject, provide, ref, useId } from 'vue'
3
+ import { DialogClose, DialogContent, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot, TabsContent, TabsList, TabsRoot, TabsTrigger, ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
4
+ import type { ToolbarPlace } from '../types.ts'
5
+ import { ICONS } from '@lincyaw/design'
6
+ import type { IconName } from '@lincyaw/design'
7
+
8
+ const PLACE: InjectionKey<ToolbarPlace> = Symbol('presmium.toolbarPlace')
9
+ const OVERLAY_Z: InjectionKey<string> = Symbol('presmium.overlayZ')
10
+
11
+ function overlayStyle(kind: 'popover' | 'dialog') {
12
+ const parent = inject(OVERLAY_Z, undefined)
13
+ const zIndex = parent ? `calc(${parent} + 1)` : `var(--pm-z-${kind})`
14
+ provide(OVERLAY_Z, zIndex)
15
+ return { zIndex }
16
+ }
17
+
18
+
19
+ /** @experimental */
20
+ export function useToolbarPlace(): ToolbarPlace {
21
+ return inject(PLACE, 'bar')
22
+ }
23
+
24
+ /** @experimental */
25
+ export function provideToolbarPlace(place: ToolbarPlace): void {
26
+ provide(PLACE, place)
27
+ }
28
+
29
+ export const PmIcon = defineComponent({
30
+ name: 'PmIcon',
31
+ props: {
32
+ name: { type: String as PropType<IconName>, required: true },
33
+ size: { type: [Number, String], default: 20 },
34
+ strokeWidth: { type: Number, default: 1.7 },
35
+ },
36
+ setup(props) {
37
+ return () => h('svg', {
38
+ 'class': 'pm-ui-icon',
39
+ 'width': props.size,
40
+ 'height': props.size,
41
+ 'viewBox': '0 0 24 24',
42
+ 'fill': 'none',
43
+ 'stroke': 'currentColor',
44
+ 'stroke-width': props.strokeWidth,
45
+ 'stroke-linecap': 'round',
46
+ 'stroke-linejoin': 'round',
47
+ 'aria-hidden': 'true',
48
+ 'focusable': 'false',
49
+ }, (ICONS[props.name] ?? []).map(([tag, attrs]) => h(tag, attrs)))
50
+ },
51
+ })
52
+
53
+ export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
54
+
55
+ export const PmButton = defineComponent({
56
+ name: 'PmButton',
57
+ props: {
58
+ variant: { type: String as PropType<ButtonVariant>, default: 'default' },
59
+ small: Boolean,
60
+ icon: String as PropType<IconName>,
61
+ type: { type: String as PropType<'button' | 'submit' | 'reset'>, default: 'button' },
62
+ },
63
+ setup(props, { slots }) {
64
+ return () => h('button', {
65
+ type: props.type,
66
+ class: ['pm-ui-button', props.variant !== 'default' && `pm-ui-button--${props.variant}`, props.small && 'pm-ui-button--small'],
67
+ }, [props.icon ? h(PmIcon, { name: props.icon, size: props.small ? 14 : 16, strokeWidth: 2 }) : null, slots.default?.()])
68
+ },
69
+ })
70
+
71
+ export const PmIconButton = defineComponent({
72
+ name: 'PmIconButton',
73
+ props: {
74
+ icon: { type: String as PropType<IconName>, required: true },
75
+ label: { type: String, required: true },
76
+ small: Boolean,
77
+ },
78
+ setup(props) {
79
+ return () => h('button', {
80
+ 'type': 'button',
81
+ 'class': ['pm-ui-icon-button', props.small && 'pm-ui-icon-button--small'],
82
+ 'aria-label': props.label,
83
+ 'title': props.label,
84
+ }, [h(PmIcon, { name: props.icon, size: props.small ? 16 : 20 })])
85
+ },
86
+ })
87
+
88
+ export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
89
+
90
+ export const PmBadge = defineComponent({
91
+ name: 'PmBadge',
92
+ props: {
93
+ tone: { type: String as PropType<Tone>, default: 'neutral' },
94
+ pill: Boolean,
95
+ },
96
+ setup(props, { slots }) {
97
+ return () => h('span', { class: ['pm-ui-badge', props.tone !== 'neutral' && `pm-ui-badge--${props.tone}`, props.pill && 'pm-ui-badge--pill'] }, slots.default?.())
98
+ },
99
+ })
100
+
101
+ export const PmCard = defineComponent({
102
+ name: 'PmCard',
103
+ props: {
104
+ variant: { type: String as PropType<'default' | 'interactive' | 'dashed'>, default: 'default' },
105
+ tag: { type: String, default: 'div' },
106
+ },
107
+ setup(props, { slots }) {
108
+ return () => h(props.tag, { class: ['pm-ui-card', props.variant !== 'default' && `pm-ui-card--${props.variant}`] }, slots.default?.())
109
+ },
110
+ })
111
+
112
+ export const PmField = defineComponent({
113
+ name: 'PmField',
114
+ props: {
115
+ label: { type: String, required: true },
116
+ inline: Boolean,
117
+ },
118
+ setup(props, { slots }) {
119
+ return () => h('label', { class: ['pm-ui-field', props.inline && 'pm-ui-field--inline'] }, props.inline
120
+ ? [slots.default?.(), h('span', { class: 'pm-ui-field__label' }, props.label)]
121
+ : [h('span', { class: 'pm-ui-field__label' }, props.label), slots.default?.()])
122
+ },
123
+ })
124
+
125
+ export interface Choice {
126
+ value: string
127
+ label: string
128
+ }
129
+
130
+ export const PmSegmented = defineComponent({
131
+ name: 'PmSegmented',
132
+ props: {
133
+ modelValue: { type: String, required: true },
134
+ options: { type: Array as PropType<Choice[]>, required: true },
135
+ label: { type: String, required: true },
136
+ },
137
+ emits: ['update:modelValue'],
138
+ setup(props, { emit }) {
139
+ return () => h(ToggleGroupRoot, {
140
+ 'type': 'single', 'class': 'pm-ui-segmented', 'aria-label': props.label, 'modelValue': props.modelValue,
141
+ 'onUpdate:modelValue': value => { if (typeof value === 'string' && value) emit('update:modelValue', value) },
142
+ }, () => props.options.map(option => h(ToggleGroupItem, {
143
+ key: option.value, value: option.value, class: 'pm-ui-segmented__option',
144
+ }, () => option.label)))
145
+ },
146
+ })
147
+
148
+ export const PmTabs = defineComponent({
149
+ name: 'PmTabs',
150
+ props: {
151
+ modelValue: { type: String, required: true },
152
+ tabs: { type: Array as PropType<Choice[]>, required: true },
153
+ label: { type: String, required: true },
154
+ },
155
+ emits: ['update:modelValue'],
156
+ setup(props, { emit, slots }) {
157
+ return () => h(TabsRoot, {
158
+ 'class': 'pm-ui-tabset', 'modelValue': props.modelValue,
159
+ 'onUpdate:modelValue': value => emit('update:modelValue', String(value)),
160
+ }, () => [
161
+ h(TabsList, { class: 'pm-ui-tabs', 'aria-label': props.label }, () => props.tabs.map(tab =>
162
+ h(TabsTrigger, { key: tab.value, value: tab.value, class: 'pm-ui-tab' }, () => tab.label))),
163
+ slots.default ? h(TabsContent, { key: props.modelValue, value: props.modelValue, class: 'pm-ui-tabpanel', tabindex: 0 }, () => slots.default?.({ value: props.modelValue })) : null,
164
+ ])
165
+ },
166
+ })
167
+
168
+ export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start'
169
+
170
+ function placement(value: PopoverPlacement) {
171
+ return { side: value.startsWith('above') ? 'top' as const : 'bottom' as const, align: value.endsWith('start') ? 'start' as const : 'end' as const, sideOffset: 6, collisionPadding: 16 }
172
+ }
173
+
174
+ export const PmPopover = defineComponent({
175
+ name: 'PmPopover',
176
+ props: {
177
+ open: Boolean,
178
+ label: { type: String, required: true },
179
+ placement: { type: String as PropType<PopoverPlacement>, default: 'below-end' },
180
+ name: String,
181
+ },
182
+ emits: ['update:open'],
183
+ setup(props, { emit, slots }) {
184
+ const style = overlayStyle('popover')
185
+ const anchor = ref<HTMLElement>()
186
+ const id = useId()
187
+ let outside = false
188
+ return () => h(PopoverRoot, { 'open': props.open, 'onUpdate:open': value => emit('update:open', value) }, () => [
189
+ h(PopoverAnchor, { asChild: true }, () => h('span', { ref: anchor, class: ['pm-ui-anchor', props.name] }, slots.trigger?.().map(node => cloneVNode(node, {
190
+ 'aria-haspopup': 'dialog', 'aria-expanded': props.open, 'aria-controls': props.open ? id : undefined,
191
+ })))),
192
+ h(PopoverPortal, {}, () => h(PopoverContent, {
193
+ ...placement(props.placement), 'id': id, style,
194
+ onOpenAutoFocus: () => { outside = false },
195
+ onInteractOutside: () => { outside = true },
196
+ onCloseAutoFocus: (event: Event) => { event.preventDefault(); if (!outside) anchor.value?.querySelector<HTMLElement>('button, [href]')?.focus() },
197
+ 'class': ['pm-ui-popover', 'pm-ui-floating', 'pm-ui', props.name && `${props.name}__panel`], 'aria-label': props.label,
198
+ }, slots.default)),
199
+ ])
200
+ },
201
+ })
202
+
203
+ const dialogProps = {
204
+ open: Boolean,
205
+ label: { type: String, required: true },
206
+ closeLabel: { type: String, required: true },
207
+ name: String,
208
+ } as const
209
+
210
+ function dialogComponent(drawer: boolean) {
211
+ return defineComponent({
212
+ name: drawer ? 'PmDrawer' : 'PmDialog',
213
+ props: dialogProps,
214
+ emits: ['update:open'],
215
+ setup(props, { emit, slots }) {
216
+ const style = overlayStyle(drawer ? 'popover' : 'dialog')
217
+ const kind = drawer ? 'pm-ui-drawer' : 'pm-ui-dialog'
218
+ let opener: HTMLElement | undefined
219
+ return () => h(DialogRoot, { 'open': props.open, 'onUpdate:open': value => emit('update:open', value) }, () => [
220
+ slots.trigger ? h(DialogTrigger, { asChild: true }, slots.trigger) : null,
221
+ h(DialogPortal, {}, () => h(DialogContent, {
222
+ 'class': [kind, 'pm-ui', props.name && `${props.name}__panel`], 'style': style, 'aria-describedby': undefined,
223
+ onOpenAutoFocus: () => { opener = document.activeElement instanceof HTMLElement ? document.activeElement : undefined },
224
+ onCloseAutoFocus: (event: Event) => {
225
+ if (slots.trigger || !opener?.isConnected) return
226
+ event.preventDefault()
227
+ opener.focus()
228
+ },
229
+ }, () => [
230
+ h('div', { class: `${kind}__header` }, [
231
+ h(DialogTitle, { as: 'h2', class: `${kind}__title` }, () => props.label),
232
+ slots.actions?.(),
233
+ h(DialogClose, { asChild: true }, () => h(PmIconButton, { icon: 'close', label: props.closeLabel, small: true })),
234
+ ]),
235
+ h('div', { class: `${kind}__body` }, slots.default?.()),
236
+ ])),
237
+ ])
238
+ },
239
+ })
240
+ }
241
+
242
+ export const PmDrawer = dialogComponent(true)
243
+ export const PmDialog = dialogComponent(false)
244
+
245
+ export interface MenuItem {
246
+ id: string
247
+ label: string
248
+ icon?: IconName
249
+ danger?: boolean
250
+ disabled?: boolean
251
+ }
252
+
253
+ export const PmMenu = defineComponent({
254
+ name: 'PmMenu',
255
+ props: {
256
+ label: { type: String, required: true },
257
+ items: { type: Array as PropType<MenuItem[]>, required: true },
258
+ icon: { type: String as PropType<IconName>, default: 'more' },
259
+ placement: { type: String as PropType<PopoverPlacement>, default: 'below-end' },
260
+ },
261
+ emits: ['select'],
262
+ setup(props, { emit }) {
263
+ const style = overlayStyle('popover')
264
+ return () => h(DropdownMenuRoot, {}, () => [
265
+ h(DropdownMenuTrigger, { asChild: true }, () => h(PmIconButton, { icon: props.icon, label: props.label, small: true })),
266
+ h(DropdownMenuPortal, {}, () => h(DropdownMenuContent, { ...placement(props.placement), style, class: ['pm-ui-menu', 'pm-ui-floating', 'pm-ui'], 'aria-label': props.label }, () => props.items.map(item =>
267
+ h(DropdownMenuItem, {
268
+ key: item.id, disabled: item.disabled, textValue: item.label,
269
+ class: ['pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'],
270
+ onSelect: () => emit('select', item.id),
271
+ }, () => [item.icon ? h(PmIcon, { name: item.icon, size: 16 }) : null, item.label])))),
272
+ ])
273
+ },
274
+ })
275
+
276
+ export const PmEmpty = defineComponent({
277
+ name: 'PmEmpty',
278
+ props: {
279
+ title: String,
280
+ icon: String as PropType<IconName>,
281
+ },
282
+ setup(props, { slots }) {
283
+ return () => h('div', { class: 'pm-ui-empty' }, [
284
+ props.icon ? h(PmIcon, { name: props.icon, size: 28 }) : null,
285
+ props.title ? h('p', { class: 'pm-ui-empty__title' }, props.title) : null,
286
+ slots.default ? h('p', { style: { margin: 0 } }, slots.default()) : null,
287
+ slots.action?.(),
288
+ ])
289
+ },
290
+ })
291
+
292
+ export const PmNotice = defineComponent({
293
+ name: 'PmNotice',
294
+ props: {
295
+ tone: { type: String as PropType<'neutral' | 'accent' | 'ok' | 'warn' | 'danger'>, default: 'neutral' },
296
+ },
297
+ setup(props, { slots }) {
298
+ return (): VNode => h('p', { class: ['pm-ui-notice', props.tone !== 'neutral' && `pm-ui-notice--${props.tone}`], role: props.tone === 'danger' ? 'alert' : 'status' }, slots.default?.())
299
+ },
300
+ })
301
+
302
+ export const PmToolbarEntry = defineComponent({
303
+ name: 'PmToolbarEntry',
304
+ props: {
305
+ name: { type: String, required: true },
306
+ label: { type: String, required: true },
307
+ closeLabel: { type: String, required: true },
308
+ icon: String as PropType<IconName>,
309
+ open: Boolean,
310
+ },
311
+ emits: ['update:open'],
312
+ setup(props, { emit, slots }) {
313
+ const place = useToolbarPlace()
314
+ const update = (open: boolean) => emit('update:open', open)
315
+ const toggleAttrs = () => ({
316
+ 'class': `${props.name}__toggle`,
317
+ 'aria-expanded': props.open ? 'true' : 'false',
318
+ 'onClick': () => update(!props.open),
319
+ })
320
+ return () => {
321
+ return h(PmPopover, { 'open': props.open, 'label': props.label, 'name': props.name, 'placement': place === 'status' ? 'above-start' : 'below-end', 'onUpdate:open': update }, {
322
+ trigger: () => h(PmButton, { variant: 'ghost', small: true, icon: props.icon, ...toggleAttrs() }, () => props.label),
323
+ default: () => slots.default?.(),
324
+ })
325
+ }
326
+ },
327
+ })
@@ -0,0 +1 @@
1
+ @import '@lincyaw/design/design.css';
@@ -0,0 +1,32 @@
1
+ import type { Ref } from 'vue'
2
+ import { getCurrentInstance, inject, readonly, shallowRef } from 'vue'
3
+ import { createGlobalState, useMutationObserver } from '@vueuse/core'
4
+ import { HOST, installed } from '../key.ts'
5
+
6
+ export interface Theme {
7
+ readonly dark: Readonly<Ref<boolean>>
8
+ setDark(dark: boolean): void
9
+ toggle(): void
10
+ }
11
+
12
+ const observeDarkClass = createGlobalState(() => {
13
+ const root = () => typeof document === 'undefined' ? undefined : document.documentElement
14
+ const dark = shallowRef(root()?.classList.contains('dark') ?? false)
15
+ useMutationObserver(root, () => dark.value = root()?.classList.contains('dark') ?? false, { attributes: true, attributeFilter: ['class'] })
16
+ return dark
17
+ })
18
+
19
+ export function useTheme(): Theme {
20
+ const host = getCurrentInstance() ? inject(HOST, undefined) : installed.adapter
21
+ const own = getCurrentInstance() ? host?.useDarkMode?.() : undefined
22
+ const dark = own ?? observeDarkClass()
23
+ const setDark = (value: boolean) => {
24
+ if (own) own.value = value
25
+ else if (typeof document !== 'undefined') document.documentElement.classList.toggle('dark', value)
26
+ }
27
+ return {
28
+ dark: readonly(dark),
29
+ setDark,
30
+ toggle: () => setDark(!dark.value),
31
+ }
32
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ interface ImportMetaEnv {
2
+ BASE_URL: string
3
+ }
4
+
5
+ interface ImportMeta {
6
+ readonly env: ImportMetaEnv
7
+ }
package/src/host.ts ADDED
@@ -0,0 +1,82 @@
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)
15
+ }
16
+
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)')
20
+ return host
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)
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ import './design/design.css'
2
+
3
+ export { blockAt, blockElement, blockKey, codeLines, lineNumbers, linesText, positionSlide, splitBlockKey } from './blocks.ts'
4
+ export type { BlockPoint } from './blocks.ts'
5
+ export { componentErrors, PmBoundary } from './boundary.ts'
6
+ export type { ComponentError } from './boundary.ts'
7
+ export {
8
+ PmBadge,
9
+ PmButton,
10
+ PmCard,
11
+ PmDialog,
12
+ PmDrawer,
13
+ PmEmpty,
14
+ PmField,
15
+ PmIcon,
16
+ PmIconButton,
17
+ PmMenu,
18
+ PmNotice,
19
+ PmPopover,
20
+ PmSegmented,
21
+ PmTabs,
22
+ PmToolbarEntry,
23
+ provideToolbarPlace,
24
+ useToolbarPlace,
25
+ } from './design/components.ts'
26
+ export type { ButtonVariant, Choice, MenuItem, PopoverPlacement, Tone } from './design/components.ts'
27
+ export { iconMarkup, ICONS } from '@lincyaw/design'
28
+ export type { IconName } from '@lincyaw/design'
29
+ export { useTheme } from './design/theme.ts'
30
+ export type { Theme } from './design/theme.ts'
31
+ export {
32
+ installHost,
33
+ useCursor,
34
+ useHost,
35
+ usePrefersReducedMotion,
36
+ usePresentationMode,
37
+ useSectionConfig,
38
+ useService,
39
+ useSharedState,
40
+ useStartReport,
41
+ useStaticRender,
42
+ useStepState,
43
+ useViewRole,
44
+ } from './host.ts'
45
+ 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'
48
+ export { currentLocale, useLocale, useMessages } from './locale.ts'
49
+ export type { Catalog } from './locale.ts'
50
+ 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'
63
+ export type { WindowChannel } from './windows.ts'
64
+ export type {
65
+ HostAdapter,
66
+ Navigation,
67
+ NavigationPosition,
68
+ Operations,
69
+ ReplayHandler,
70
+ ServerClient,
71
+ SharedStateScope,
72
+ StatusProvider,
73
+ StatusRegistry,
74
+ SyncPublish,
75
+ SyncTransport,
76
+ SyncTransports,
77
+ Toolbar,
78
+ ToolbarItem,
79
+ ToolbarPlace,
80
+ ToolbarSlot,
81
+ ViewRole,
82
+ } from './types.ts'
83
+ export { localeOf } from '@lincyaw/kernel/contracts'
84
+ export type { Locale } from '@lincyaw/kernel/contracts'
package/src/kernel.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { Kernel, PRESENTER_PANELS } from '@lincyaw/kernel'
2
+ import type { EnvironmentSpec, Logger, PluginDefinition, PluginReport, PresentationMode, ServiceToken, StartReport } from '@lincyaw/kernel'
3
+ import { shallowRef } from 'vue'
4
+ import { PmApprovals } from './approvals.ts'
5
+ import { currentLocale } from './locale.ts'
6
+ import { HOST_MESSAGES } from './messages.ts'
7
+ import { createOperations, createStatusRegistry } from './operations.ts'
8
+ import { createPresenterPanels, createToolbar } from './panels.ts'
9
+ import { connectControl, provideServer } from './server.ts'
10
+ import { NAVIGATION, OPERATIONS, STATUS, SYNC_TRANSPORTS, TOOLBAR } from './types.ts'
11
+ import type { Navigation, ServerClient, SyncTransports } from './types.ts'
12
+
13
+ const registered: PluginDefinition[] = []
14
+ const running = shallowRef<Kernel>()
15
+ let starting: Promise<StartReport> | undefined
16
+ let disconnect: (() => void) | undefined
17
+ const failures = shallowRef<PluginReport[]>([])
18
+
19
+ export function registerPresmiumPlugin(plugin: PluginDefinition): void {
20
+ const { id } = plugin.manifest
21
+ if (starting) console.error(`[presmium] ${id} was registered after the kernel started; ignored`)
22
+ else if (registered.some(p => p.manifest.id === id)) console.error(`[presmium] plugin ${id} is already registered; ignored`)
23
+ else registered.push(plugin)
24
+ }
25
+
26
+ function isPluginDefinition(value: unknown): value is PluginDefinition {
27
+ const plugin = value as Partial<PluginDefinition> | null
28
+ return typeof plugin === 'object' && plugin !== null && typeof plugin.setup === 'function' && typeof plugin.manifest?.id === 'string'
29
+ }
30
+
31
+ export function pluginsOf(module: unknown, config: Record<string, unknown> = {}): PluginDefinition[] {
32
+ const exports = (module ?? {}) as Record<string, unknown>
33
+ const declared = typeof exports.presmiumPlugins === 'function' ? [(exports.presmiumPlugins as (config: Record<string, unknown>) => unknown)(config)] : []
34
+ const values = [...Object.values(exports), ...declared].flatMap(v => (Array.isArray(v) ? v : [v]))
35
+ return [...new Set(values.filter(isPluginDefinition))]
36
+ }
37
+
38
+ export interface StartOptions {
39
+ mode: PresentationMode
40
+ config?: Record<string, unknown>
41
+ beforeStart?(kernel: Kernel): void
42
+ log?: Logger
43
+ environments?: Record<string, EnvironmentSpec>
44
+ }
45
+
46
+ export function startPresmium(options: StartOptions): Promise<StartReport> {
47
+ starting ??= (async () => {
48
+ const kernel = new Kernel({ mode: options.mode, config: options.config, log: options.log, environments: options.environments })
49
+ options.beforeStart?.(kernel)
50
+ for (const plugin of registered) kernel.register(plugin)
51
+ const report = await kernel.start()
52
+ running.value = kernel
53
+ return report
54
+ })()
55
+ return starting
56
+ }
57
+
58
+ export async function stopPresmium(): Promise<void> {
59
+ const current = starting
60
+ if (!current) return
61
+ starting = undefined
62
+ disconnect?.()
63
+ disconnect = undefined
64
+ await current.catch(() => undefined)
65
+ const kernel = running.value
66
+ running.value = undefined
67
+ await kernel?.dispose()
68
+ }
69
+
70
+ export function stopPresmiumOnPageHide(start: () => void): void {
71
+ window.addEventListener('pagehide', () => void stopPresmium())
72
+ window.addEventListener('pageshow', (event) => {
73
+ if (event.persisted) start()
74
+ })
75
+ }
76
+
77
+ export function presmiumService<T>(token: ServiceToken<T>): T | undefined {
78
+ return running.value?.getService(token)
79
+ }
80
+
81
+ export function reportPluginFailure(id: string, reason: string): void {
82
+ failures.value = [...failures.value.filter(report => report.id !== id), { id, status: 'failed', reason }]
83
+ }
84
+
85
+ export function presmiumReport(): StartReport | undefined {
86
+ const report = running.value?.report()
87
+ return report && failures.value.length ? { ...report, plugins: [...report.plugins, ...failures.value] } : report
88
+ }
89
+
90
+ export interface HostStart {
91
+ mode: PresentationMode
92
+ config: Record<string, unknown>
93
+ navigation: Navigation
94
+ transports: SyncTransports
95
+ environments?: Record<string, EnvironmentSpec>
96
+ server?: ServerClient
97
+ }
98
+
99
+ export function startHost(options: HostStart): Promise<StartReport> {
100
+ if (options.server) provideServer(options.server)
101
+ const start = () => startPresmium({
102
+ mode: options.mode,
103
+ config: options.config,
104
+ environments: options.environments,
105
+ beforeStart(kernel) {
106
+ const toolbar = createToolbar()
107
+ const status = createStatusRegistry()
108
+ kernel.provide(PRESENTER_PANELS, createPresenterPanels())
109
+ kernel.provide(SYNC_TRANSPORTS, options.transports)
110
+ kernel.provide(NAVIGATION, options.navigation)
111
+ kernel.provide(TOOLBAR, toolbar)
112
+ kernel.provide(OPERATIONS, createOperations())
113
+ kernel.provide(STATUS, status)
114
+ disconnect?.()
115
+ disconnect = undefined
116
+ const { server } = options
117
+ if (!server?.available) return
118
+ toolbar.register({
119
+ id: 'presmium.approvals',
120
+ get title() {
121
+ return HOST_MESSAGES[currentLocale()].approvalsTitle
122
+ },
123
+ component: PmApprovals,
124
+ order: 1000,
125
+ })
126
+ disconnect = connectControl(server, { navigation: options.navigation, status, service: token => kernel.getService(token) })
127
+ },
128
+ })
129
+ stopPresmiumOnPageHide(() => void start())
130
+ return start()
131
+ }
package/src/key.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import type { HostAdapter } from './types.ts'
3
+
4
+ export const HOST: InjectionKey<HostAdapter> = Symbol('presmium.host')
5
+
6
+ export const installed: { adapter?: HostAdapter } = {}