@lincyaw/host 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +38 -2
- package/package.json +48 -3
- package/src/actions.ts +22 -0
- package/src/approvals.ts +12 -0
- package/src/blocks.ts +81 -0
- package/src/claim-first.ts +13 -0
- package/src/code-lines-box.ts +11 -0
- package/src/contracts.ts +157 -0
- 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 +143 -0
- package/src/design/design.css +9 -0
- package/src/design/layout.ts +28 -0
- package/src/env.d.ts +7 -0
- package/src/error-boundary.ts +18 -0
- package/src/host-key.ts +4 -0
- package/src/host.ts +47 -0
- package/src/index.ts +114 -0
- package/src/locale.ts +20 -0
- package/src/messages.ts +66 -0
- package/src/operations.ts +60 -0
- package/src/panels.ts +28 -0
- 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 +36 -0
- package/src/session.ts +10 -0
- 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 +30 -0
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
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createContext, createElement as h, forwardRef, useContext, useEffect, useId, useRef } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
|
+
import * as Popover from '@radix-ui/react-popover'
|
|
5
|
+
import * as Dropdown from '@radix-ui/react-dropdown-menu'
|
|
6
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
7
|
+
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
8
|
+
import * as RadioGroup from '@radix-ui/react-radio-group'
|
|
9
|
+
import { useLocale } from '../locale.ts'
|
|
10
|
+
import { HOST_MESSAGES } from '../messages.ts'
|
|
11
|
+
import { ICONS } from '@lincyaw/design'
|
|
12
|
+
import type { IconName } from '@lincyaw/design'
|
|
13
|
+
import type { ToolbarPlacement } from '../contracts.ts'
|
|
14
|
+
export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
|
|
15
|
+
export type ControlDensity = 'compact' | 'regular' | 'touch'
|
|
16
|
+
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
|
|
17
|
+
export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start' | 'above' | 'below'
|
|
18
|
+
export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
|
|
19
|
+
export interface MenuItem { id: string, label: string, icon?: IconName, danger?: boolean, disabled?: boolean, shortcut?: string, checked?: boolean, separator?: boolean }
|
|
20
|
+
const cls = (...values: (string | false | undefined)[]) => values.filter(Boolean).join(' ')
|
|
21
|
+
export function PmIcon({ name, size: _size, strokeWidth = 1.7, ...attrs }: { name: IconName, size?: number | string, strokeWidth?: number } & HTMLAttributes<SVGElement>) {
|
|
22
|
+
return h('svg', { ...attrs, className: cls('pm-ui-icon', attrs.className), viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false }, ICONS[name]?.map(([tag, attributes], key) => h(tag, { ...attributes, key })))
|
|
23
|
+
}
|
|
24
|
+
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant, small?: boolean, icon?: IconName, density?: ControlDensity, loading?: boolean }
|
|
25
|
+
export const PmButton = forwardRef<HTMLButtonElement, ButtonProps>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, className, children, disabled, ...rest }, ref) {
|
|
26
|
+
return h('button', { ...rest, ref, type: rest.type ?? 'button', 'data-density': density, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className) },
|
|
27
|
+
h('span', { className: cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text') },
|
|
28
|
+
icon || loading !== undefined ? h('span', { className: 'pm-ui-button__icon', 'aria-hidden': true }, loading ? h(PmIcon, { name: 'loading', className: 'pm-ui-loading' }) : icon ? h(PmIcon, { name: icon }) : null) : null,
|
|
29
|
+
children))
|
|
30
|
+
})
|
|
31
|
+
export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
|
|
32
|
+
return h(Tooltip.Provider, { delayDuration: 300, skipDelayDuration: 0, children:
|
|
33
|
+
h(Tooltip.Root, {}, h(Tooltip.Trigger, { asChild: true }, children), h(Tooltip.Portal, {},
|
|
34
|
+
h(Tooltip.Content, { className: 'pm-ui-tooltip pm-ui', side: 'top', sideOffset: 6, collisionPadding: 8 }, label, shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, shortcut) : null))) })
|
|
35
|
+
}
|
|
36
|
+
export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & { icon: IconName, label: string, hint?: string, shortcut?: string }>(function PmIconButton({ icon, label, hint, shortcut, small, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
|
|
37
|
+
const button = h('button', { ...rest, ref, type: 'button', 'data-density': density, 'aria-label': label, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-icon-button', className) }, h(PmIcon, { name: loading ? 'loading' : icon, className: loading ? 'pm-ui-loading' : undefined }))
|
|
38
|
+
return h(PmTooltip, { label: hint ?? label, shortcut, children: button })
|
|
39
|
+
})
|
|
40
|
+
export function PmBadge({ tone = 'neutral', pill, children, className, ...rest }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone, pill?: boolean }) { return h('span', { ...rest, className: cls('pm-ui-badge', tone !== 'neutral' && `pm-ui-badge--${tone}`, pill && 'pm-ui-badge--pill', className) }, children) }
|
|
41
|
+
export function PmCard({ variant = 'default', className, children, ...rest }: HTMLAttributes<HTMLDivElement> & { variant?: string }) { return h('div', { ...rest, className: cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className) }, children) }
|
|
42
|
+
export function PmField({ label, inline, group, children }: { label: string, inline?: boolean, group?: boolean, children?: ReactNode }) { return h(group ? 'div' : 'label', { className: cls('pm-ui-field', inline && 'pm-ui-field--inline'), ...(group ? { role: 'group', 'aria-label': label } : {}) }, h('span', { className: 'pm-ui-field__label' }, label), children) }
|
|
43
|
+
interface OverlayProps {
|
|
44
|
+
open: boolean
|
|
45
|
+
label: string
|
|
46
|
+
closeLabel?: string
|
|
47
|
+
name?: string
|
|
48
|
+
children?: ReactNode
|
|
49
|
+
trigger?: ReactNode
|
|
50
|
+
actions?: ReactNode
|
|
51
|
+
description?: string
|
|
52
|
+
footer?: ReactNode
|
|
53
|
+
busy?: boolean
|
|
54
|
+
onOpenChange?(open: boolean): void
|
|
55
|
+
onClose?(): void
|
|
56
|
+
onOpenAutoFocus?(event: Event): void
|
|
57
|
+
onCloseAutoFocus?(event: Event): void
|
|
58
|
+
}
|
|
59
|
+
function DialogSurface({ open, label, closeLabel, name, children, actions, trigger, description, footer, busy, onOpenChange, onClose, onOpenAutoFocus, onCloseAutoFocus, drawer = false }: OverlayProps & { drawer?: boolean }) {
|
|
60
|
+
const kind = drawer ? 'pm-ui-drawer' : 'pm-ui-dialog'
|
|
61
|
+
const locale = useLocale()
|
|
62
|
+
const id = useId()
|
|
63
|
+
const focusTarget = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (open) return
|
|
66
|
+
const remember = (event: FocusEvent) => {
|
|
67
|
+
if (event.target instanceof HTMLElement && event.target !== document.body && event.target.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) focusTarget.current = event.target
|
|
68
|
+
}
|
|
69
|
+
document.addEventListener('focusin', remember, true)
|
|
70
|
+
return () => document.removeEventListener('focusin', remember, true)
|
|
71
|
+
}, [id, open])
|
|
72
|
+
return h(Dialog.Root, { open, modal: true, onOpenChange: next => { if (!next && busy) return; onOpenChange?.(next); if (!next) onClose?.() } },
|
|
73
|
+
trigger ? h(Dialog.Trigger, { asChild: true }, trigger) : null,
|
|
74
|
+
h(Dialog.Portal, {}, h(Dialog.Overlay, { className: 'pm-ui-dialog-overlay' }),
|
|
75
|
+
h(Dialog.Content, {
|
|
76
|
+
className: cls(kind, 'pm-ui', name && `${name}__panel`), ...{ 'data-density': 'regular', 'data-pm-dialog': id }, 'aria-busy': busy || undefined,
|
|
77
|
+
...description ? {} : { 'aria-describedby': undefined },
|
|
78
|
+
onInteractOutside: event => event.preventDefault(),
|
|
79
|
+
onEscapeKeyDown: event => { event.stopPropagation(); if (busy) event.preventDefault() },
|
|
80
|
+
onOpenAutoFocus: event => {
|
|
81
|
+
onOpenAutoFocus?.(event)
|
|
82
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
|
|
83
|
+
const panel = event.target
|
|
84
|
+
if (panel.contains(document.activeElement)) { event.preventDefault(); return }
|
|
85
|
+
const target = [...panel.querySelectorAll<HTMLElement>(`.${kind}__body :is(button, input, textarea, select, a[href], [tabindex]), .pm-ui-dialog__footer :is(button, input, textarea, select, a[href], [tabindex])`)].find(element => element.tabIndex >= 0 && !element.closest('[inert], [hidden]') && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
|
|
86
|
+
if (target) { event.preventDefault(); target.focus() }
|
|
87
|
+
},
|
|
88
|
+
onCloseAutoFocus: event => {
|
|
89
|
+
onCloseAutoFocus?.(event)
|
|
90
|
+
if (event.defaultPrevented) return
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
const available = (element: HTMLElement | null): element is HTMLElement => Boolean(element?.isConnected && element !== document.body && !element.closest(`[data-pm-dialog="${id}"], [inert], [hidden]`) && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
|
|
93
|
+
const target = available(focusTarget.current) ? focusTarget.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
|
|
94
|
+
target?.focus()
|
|
95
|
+
},
|
|
96
|
+
}, h('div', { className: `${kind}__header` },
|
|
97
|
+
h(Dialog.Title, { className: `${kind}__title` }, label), actions,
|
|
98
|
+
h(Dialog.Close, { asChild: true }, h(PmIconButton, { icon: 'close', label: closeLabel ?? HOST_MESSAGES[locale].close, small: true, disabled: busy }))),
|
|
99
|
+
description ? h(Dialog.Description, { className: 'pm-ui-dialog__description' }, description) : null,
|
|
100
|
+
h('div', { className: `${kind}__body` }, children),
|
|
101
|
+
footer ? h('div', { className: 'pm-ui-dialog__footer' }, footer) : null)))
|
|
102
|
+
}
|
|
103
|
+
export function PmDialog(props: OverlayProps) { return h(DialogSurface, props) }
|
|
104
|
+
export function PmDrawer(props: OverlayProps) { return h(DialogSurface, { ...props, drawer: true }) }
|
|
105
|
+
const placement = (value: PopoverPlacement) => ({ side: value.startsWith('above') ? 'top' as const : 'bottom' as const, align: value.endsWith('start') ? 'start' as const : 'end' as const })
|
|
106
|
+
export function PmPopover({ open, label, name, children, trigger, placement: place = 'below-end', onOpenChange }: OverlayProps & { placement?: PopoverPlacement }) { return h(Popover.Root, { open, onOpenChange }, trigger ? h(Popover.Trigger, { asChild: true }, trigger) : null, h(Popover.Portal, {}, h(Popover.Content, { ...placement(place), className: cls('pm-ui-popover pm-ui-floating pm-ui', name), 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() }, children))) }
|
|
107
|
+
export function PmMenu({ label, items, icon = 'more', placement: place = 'below-end', onSelect }: { label: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, onSelect?(id: string): void }) {
|
|
108
|
+
return h(Dropdown.Root, {},
|
|
109
|
+
h(Dropdown.Trigger, { asChild: true }, h(PmIconButton, { icon, label, small: true })),
|
|
110
|
+
h(Dropdown.Portal, {}, h(Dropdown.Content, { ...placement(place), className: 'pm-ui-menu pm-ui-floating pm-ui', ...{ 'data-density': 'compact' }, 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() },
|
|
111
|
+
items.map(item => h(Dropdown.Group, { key: item.id }, item.separator ? h(Dropdown.Separator, { className: 'pm-ui-menu__separator' }) : null,
|
|
112
|
+
item.checked === undefined ? h(Dropdown.Item, { disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
|
|
113
|
+
item.icon ? h(PmIcon, { name: item.icon }) : null, item.label, item.shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, item.shortcut) : null)
|
|
114
|
+
: h(Dropdown.CheckboxItem, { checked: item.checked, disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
|
|
115
|
+
h(Dropdown.ItemIndicator, {}, h(PmIcon, { name: 'check' })), item.label))))))
|
|
116
|
+
}
|
|
117
|
+
export function PmTabs({ value, modelValue, tabs, label, children, onValueChange, iconOnly, density = 'compact' }: { value?: string, modelValue?: string, tabs: Choice[], label: string, children?: ReactNode, iconOnly?: boolean, density?: ControlDensity, onValueChange?(value: string): void }) {
|
|
118
|
+
return h(Tabs.Root, { value: value ?? modelValue, onValueChange, activationMode: 'manual', className: 'pm-ui-tabs-root', ...{ 'data-density': density } },
|
|
119
|
+
h(Tabs.List, { className: 'pm-ui-tabs', 'aria-label': label }, tabs.map(tab => {
|
|
120
|
+
const trigger = h(Tabs.Trigger, { key: tab.value, value: tab.value, className: 'pm-ui-tab', disabled: tab.disabled, 'aria-label': tab.label, ...{ 'data-icon-only': iconOnly || undefined } }, tab.icon ? h(PmIcon, { name: tab.icon }) : null, iconOnly ? null : tab.label)
|
|
121
|
+
return iconOnly ? h(PmTooltip, { key: tab.value, label: tab.label, children: trigger }) : trigger
|
|
122
|
+
})), children)
|
|
123
|
+
}
|
|
124
|
+
export function PmTabPanel({ value, children, forceMount, hidden }: { value: string, children?: ReactNode, forceMount?: true, hidden?: boolean }) { return h(Tabs.Content, { value, forceMount, hidden, className: 'pm-ui-tab-panel' }, children) }
|
|
125
|
+
export function PmSegmented({ value, modelValue, choices, items, label, onValueChange, density = 'compact', disabled }: { value?: string, modelValue?: string, choices?: Choice[], items?: Choice[], label: string, density?: ControlDensity, disabled?: boolean, onValueChange?(value: string): void }) {
|
|
126
|
+
return h(RadioGroup.Root, { className: 'pm-ui-segmented', 'aria-label': label, value: value ?? modelValue, onValueChange, orientation: 'horizontal', ...{ 'data-density': density }, disabled },
|
|
127
|
+
(choices ?? items ?? []).map(item => h(RadioGroup.Item, { key: item.value, value: item.value, className: 'pm-ui-segmented__option', disabled: item.disabled }, item.icon ? h(PmIcon, { name: item.icon }) : null, item.label)))
|
|
128
|
+
}
|
|
129
|
+
export function PmNotice({ tone = 'neutral', children, className, ...rest }: HTMLAttributes<HTMLDivElement> & { tone?: Tone }) { return h('div', { ...rest, className: cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`, className), role: tone === 'danger' ? 'alert' : 'status' }, children) }
|
|
130
|
+
export function PmEmpty({ title, icon, action, children }: { title?: string, icon?: IconName, action?: ReactNode, children?: ReactNode }) { return h('div', { className: 'pm-ui-empty' }, icon ? h(PmIcon, { name: icon }) : null, title ? h('p', { className: 'pm-ui-empty__title' }, title) : null, children, action) }
|
|
131
|
+
/** Loading in the shape of the content: `document` headings and paragraphs, `list` rows, `preview` a slide-shaped area. */
|
|
132
|
+
export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
|
|
133
|
+
const bar = (kind?: string, key?: number) => h('span', { key, className: 'pm-ui-skeleton__bar', 'data-kind': kind })
|
|
134
|
+
const paragraph = (lines: number, key: number) => h('span', { key, className: 'pm-ui-skeleton__group' }, Array.from({ length: lines }, (_, index) => bar(undefined, index)))
|
|
135
|
+
const bars = shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
|
|
136
|
+
: shape === 'preview' ? [bar('media', 0), paragraph(2, 1)]
|
|
137
|
+
: [bar('heading', 0), paragraph(4, 1), paragraph(3, 2), bar('heading', 3), paragraph(4, 4)]
|
|
138
|
+
return h('div', { className: 'pm-ui-skeleton', 'data-shape': shape, role: 'status', 'aria-busy': 'true' }, h('span', { className: 'pm-ui-sr-only' }, label), h('span', { className: 'pm-ui-skeleton', 'aria-hidden': 'true' }, bars))
|
|
139
|
+
}
|
|
140
|
+
export const ToolbarPlacementContext = createContext<ToolbarPlacement>('bar')
|
|
141
|
+
export const provideToolbarPlacement = ToolbarPlacementContext.Provider
|
|
142
|
+
export function useToolbarPlacement() { return useContext(ToolbarPlacementContext) }
|
|
143
|
+
export function PmToolbarEntry({ title, label = title ?? '', name, icon = 'more', open = false, onOpenChange, children }: { title?: string, name?: string, label?: string, closeLabel?: string, icon?: IconName, open?: boolean, onOpenChange?(open: boolean): void, children?: ReactNode }) { const where = useToolbarPlacement(); return h(PmPopover, { open, onOpenChange, name, label, placement: where === 'status' ? 'above-start' : 'below-end', trigger: h(PmIconButton, { icon, label, small: true, className: name && `${name}__toggle`, 'aria-expanded': open }) }, children) }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
@import '@lincyaw/design/design.css';
|
|
2
|
+
|
|
3
|
+
.pm-toolbar { display: inline-flex; align-items: center; gap: var(--pm-space-2); }
|
|
4
|
+
.pm-toolbar[data-placement='status'] { gap: var(--pm-space-4); }
|
|
5
|
+
.pm-presenter-panels { position: fixed; right: var(--pm-space-2); bottom: var(--pm-overlay-bar, var(--pm-space-12)); z-index: var(--pm-z-overlay-view); max-width: var(--pm-aside-width); max-height: 50vh; overflow: auto; padding: var(--pm-space-1) var(--pm-space-3); border: var(--pm-size-hairline) solid var(--pm-color-line); border-radius: var(--pm-radius-small); background: var(--pm-color-surface); color: var(--pm-color-ink); font-size: var(--pm-text-sm); box-shadow: var(--pm-shadow-float); }
|
|
6
|
+
.pm-presenter-panels--docked { position: static; max-width: none; max-height: none; margin-top: var(--pm-space-4); box-shadow: none; }
|
|
7
|
+
.pm-presenter-panels summary { cursor: pointer; font-weight: 600; }
|
|
8
|
+
.pm-presenter-panels__badge { display: inline-block; padding: 0 var(--pm-space-1); border-radius: var(--pm-radius-pill); background: var(--pm-color-danger); color: var(--pm-color-surface); text-align: center; }
|
|
9
|
+
.pm-presenter-panels h3 { margin: var(--pm-space-2) 0 var(--pm-space-1); font-size: var(--pm-text-md); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Children, createElement as h, useState } from 'react'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
+
import { PmTabs, PmTabPanel } from './components.ts'
|
|
4
|
+
|
|
5
|
+
export interface ContentLayoutProps { title?: string, children?: ReactNode }
|
|
6
|
+
export interface ColumnsLayoutProps extends ContentLayoutProps { columnWidths?: number[] }
|
|
7
|
+
const heading = (title?: string) => title ? h('h3', { className: 'pm-layout__title' }, title) : null
|
|
8
|
+
|
|
9
|
+
/** Authored column proportions are content geometry; narrow surfaces stack the same children. */
|
|
10
|
+
export function PmColumns({ title, columnWidths, children }: ColumnsLayoutProps) {
|
|
11
|
+
const items = Children.toArray(children)
|
|
12
|
+
const columns = items.map((_, index) => `minmax(0, ${Number.isFinite(columnWidths?.[index]) && columnWidths![index]! > 0 ? columnWidths![index] : 1}fr)`).join(' ')
|
|
13
|
+
const style: CSSProperties & { '--pm-layout-columns': string } = { '--pm-layout-columns': columns || 'minmax(0, 1fr)' }
|
|
14
|
+
return h('div', { className: 'pm-layout-columns', style }, heading(title), items.map((item, index) => h('div', { className: 'pm-layout__item', key: index }, item)))
|
|
15
|
+
}
|
|
16
|
+
export function PmStack({ title, children }: ContentLayoutProps) {
|
|
17
|
+
return h('div', { className: 'pm-layout-stack' }, heading(title), children)
|
|
18
|
+
}
|
|
19
|
+
export interface TabLayoutEntry { id: string, title: string, content: ReactNode, visible?: boolean }
|
|
20
|
+
export function PmTabGroup({ title, label, tabs, staticView = false }: { title?: string, label: string, tabs: TabLayoutEntry[], staticView?: boolean }) {
|
|
21
|
+
const [chosen, setChosen] = useState<string>()
|
|
22
|
+
if (staticView) return h('div', { className: 'pm-layout-stack', 'data-layout-static': 'tabs' }, heading(title), tabs.map(tab => h('section', { key: tab.id, className: 'pm-layout-stack' }, h('h4', { className: 'pm-layout__title' }, tab.title), tab.content)))
|
|
23
|
+
const visible = tabs.filter(tab => tab.visible !== false)
|
|
24
|
+
const active = visible.find(tab => tab.id === chosen)?.id ?? visible[0]?.id ?? ''
|
|
25
|
+
return h('div', { className: 'pm-layout-stack' }, heading(title), h(PmTabs, {
|
|
26
|
+
label: title || label, value: active, tabs: visible.map(tab => ({ value: tab.id, label: tab.title })), onValueChange: setChosen,
|
|
27
|
+
}, tabs.map(tab => h(PmTabPanel, { key: tab.id, value: tab.id, forceMount: true, hidden: tab.id !== active || tab.visible === false }, tab.content))))
|
|
28
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Component, createElement as h } from 'react'
|
|
2
|
+
import type { ReactNode, ErrorInfo } from 'react'
|
|
3
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
4
|
+
import { createStore } from './store.ts'
|
|
5
|
+
import { HOST_MESSAGES } from './messages.ts'
|
|
6
|
+
import { currentLocale } from './locale.ts'
|
|
7
|
+
export interface ComponentError { component: string, message: string }
|
|
8
|
+
export const componentErrorRevision = createStore(0)
|
|
9
|
+
const errors = new Map<object, ComponentError>()
|
|
10
|
+
export function componentErrors(): readonly ComponentError[] { return [...errors.values()] }
|
|
11
|
+
export class PmBoundary extends Component<{ name?: string, resetKey?: unknown, children?: ReactNode }, { error?: ComponentError }> {
|
|
12
|
+
state: { error?: ComponentError } = {}
|
|
13
|
+
static getDerivedStateFromError(error: unknown) { return { error: { component: 'component', message: errorMessage(error) } } }
|
|
14
|
+
componentDidCatch(error: unknown, _info: ErrorInfo) { const problem = { component: this.props.name ?? 'component', message: errorMessage(error) }; errors.set(this, problem); componentErrorRevision.set(n => n + 1); this.setState({ error: problem }); console.error(`[presmium] ${problem.component} failed`, error) }
|
|
15
|
+
componentDidUpdate(before: Readonly<{ name?: string, resetKey?: unknown }>) { if (before.resetKey !== this.props.resetKey && this.state.error) { errors.delete(this); componentErrorRevision.set(n => n + 1); this.setState({ error: undefined }) } }
|
|
16
|
+
componentWillUnmount() { if (errors.delete(this)) componentErrorRevision.set(n => n + 1) }
|
|
17
|
+
render() { const error = this.state.error; return error ? h('div', { className: 'pm-boundary-error', role: 'alert', 'data-pm-error': error.component }, HOST_MESSAGES[currentLocale()].componentError(error.component, error.message)) : this.props.children }
|
|
18
|
+
}
|
package/src/host-key.ts
ADDED
package/src/host.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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)
|
|
16
|
+
}
|
|
17
|
+
export function useHost(): HostAdapter {
|
|
18
|
+
const host = useContext(HOST) ?? installed.adapter
|
|
19
|
+
if (!host) throw new Error('[presmium] no host installed')
|
|
20
|
+
return host
|
|
21
|
+
}
|
|
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>
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/// <reference path="./styles.d.ts" />
|
|
2
|
+
import './design/design.css'
|
|
3
|
+
|
|
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'
|
|
7
|
+
export type { BlockPoint } from './blocks.ts'
|
|
8
|
+
export { componentErrors, componentErrorRevision, PmBoundary } from './error-boundary.ts'
|
|
9
|
+
export type { ComponentError } from './error-boundary.ts'
|
|
10
|
+
export {
|
|
11
|
+
PmBadge,
|
|
12
|
+
PmButton,
|
|
13
|
+
PmCard,
|
|
14
|
+
PmDialog,
|
|
15
|
+
PmDrawer,
|
|
16
|
+
PmEmpty,
|
|
17
|
+
PmSkeleton,
|
|
18
|
+
PmField,
|
|
19
|
+
PmIcon,
|
|
20
|
+
PmIconButton,
|
|
21
|
+
PmMenu,
|
|
22
|
+
PmNotice,
|
|
23
|
+
PmPopover,
|
|
24
|
+
PmSegmented,
|
|
25
|
+
PmTabs,
|
|
26
|
+
PmTabPanel,
|
|
27
|
+
PmTooltip,
|
|
28
|
+
PmToolbarEntry,
|
|
29
|
+
provideToolbarPlacement,
|
|
30
|
+
useToolbarPlacement,
|
|
31
|
+
} 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'
|
|
37
|
+
export { iconMarkup, ICONS } from '@lincyaw/design'
|
|
38
|
+
export type { IconName } from '@lincyaw/design'
|
|
39
|
+
export { useAppearance } from './design/appearance.ts'
|
|
40
|
+
export type { Appearance } from './design/appearance.ts'
|
|
41
|
+
export {
|
|
42
|
+
installHost,
|
|
43
|
+
HostProvider,
|
|
44
|
+
useStep,
|
|
45
|
+
useDoc,
|
|
46
|
+
useCursor,
|
|
47
|
+
useHost,
|
|
48
|
+
usePrefersReducedMotion,
|
|
49
|
+
usePresentationMode,
|
|
50
|
+
useSectionConfig,
|
|
51
|
+
useService,
|
|
52
|
+
useSharedState,
|
|
53
|
+
getSharedStateStore,
|
|
54
|
+
useStartReport,
|
|
55
|
+
useStaticRender,
|
|
56
|
+
useStepState,
|
|
57
|
+
useViewRole,
|
|
58
|
+
} from './host.ts'
|
|
59
|
+
export type { SharedStateOptions } from './host.ts'
|
|
60
|
+
export { pluginsOf, presmiumReport, presmiumService, registerPresmiumPlugin, reportPluginFailure, setHostActive, startHost, stopHost } from './runtime.ts'
|
|
61
|
+
export type { HostStart } from './runtime.ts'
|
|
62
|
+
export { currentLocale, useLocale, useMessages } from './locale.ts'
|
|
63
|
+
export type { Catalog } from './locale.ts'
|
|
64
|
+
export { PmApprovals } from './approvals.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'
|
|
80
|
+
export type { WindowChannel } from './windows.ts'
|
|
81
|
+
export type {
|
|
82
|
+
HostAdapter,
|
|
83
|
+
Navigation,
|
|
84
|
+
NavigationPosition,
|
|
85
|
+
Operations,
|
|
86
|
+
PlaybackControl,
|
|
87
|
+
PlaybackLease,
|
|
88
|
+
ProductionPlayback,
|
|
89
|
+
ReplayHandler,
|
|
90
|
+
ServerClient,
|
|
91
|
+
SharedStateScope,
|
|
92
|
+
StatusProvider,
|
|
93
|
+
StatusRegistry,
|
|
94
|
+
StepClock,
|
|
95
|
+
StepClockDriver,
|
|
96
|
+
SyncPublish,
|
|
97
|
+
SyncTransport,
|
|
98
|
+
SyncTransports,
|
|
99
|
+
Toolbar,
|
|
100
|
+
ToolbarItem,
|
|
101
|
+
ToolbarPlacement,
|
|
102
|
+
ToolbarSlot,
|
|
103
|
+
ViewRole,
|
|
104
|
+
} from './contracts.ts'
|
|
105
|
+
export { localeOf } from '@lincyaw/kernel'
|
|
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
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { localeOf } from '@lincyaw/kernel'
|
|
3
|
+
import type { Locale } from '@lincyaw/kernel/contracts'
|
|
4
|
+
import { installed } from './host-key.ts'
|
|
5
|
+
import { createStore, useStore } from './store.ts'
|
|
6
|
+
export type Catalog<T> = Record<Locale, T>
|
|
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'] })
|
|
14
|
+
}
|
|
15
|
+
export function currentLocale(): Locale {
|
|
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'
|
|
18
|
+
}
|
|
19
|
+
export function useLocale(): Locale { useStore(language); useEffect(observeLanguage, []); return currentLocale() }
|
|
20
|
+
export function useMessages<T>(catalog: Catalog<T>): T { return catalog[useLocale()] }
|