@lincyaw/workbench 0.0.0-stage → 0.2.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 +66 -2
- package/package.json +68 -3
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +631 -0
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +36 -0
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +781 -0
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +462 -0
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +1025 -0
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import type { IconName } from '@lincyaw/design'
|
|
2
|
+
import { mountMascot } from '@lincyaw/design/mascot'
|
|
3
|
+
import type { MascotAction } from '@lincyaw/design/mascot'
|
|
4
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
5
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
6
|
+
import * as Popover from '@radix-ui/react-popover'
|
|
7
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
8
|
+
import * as Toolbar from '@radix-ui/react-toolbar'
|
|
9
|
+
import * as RadioGroup from '@radix-ui/react-radio-group'
|
|
10
|
+
import { forwardRef, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
11
|
+
import type { ComponentProps, CSSProperties, InputHTMLAttributes, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
|
12
|
+
import { Group as ResizableGroup, Panel as ResizablePanel, Separator as ResizableSeparator } from 'react-resizable-panels'
|
|
13
|
+
import { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
|
|
14
|
+
import { useMascotEnabled } from './mascot-preference.ts'
|
|
15
|
+
import type { ControlDensity } from './base-controls.tsx'
|
|
16
|
+
export { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
|
|
17
|
+
export type { ButtonVariant, ControlDensity } from './base-controls.tsx'
|
|
18
|
+
export { PmSortableList } from './sortable-list.tsx'
|
|
19
|
+
export type { SortableListItem, SortableListLabels, SortableListProps } from './sortable-list.tsx'
|
|
20
|
+
|
|
21
|
+
const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
|
|
22
|
+
|
|
23
|
+
/** The dependency owns pointer capture, iframe shielding, constraints and keyboard resizing. */
|
|
24
|
+
export function PmSplitGroup({ className, orientation = 'horizontal', ...props }: ComponentProps<typeof ResizableGroup>) {
|
|
25
|
+
return <ResizableGroup {...props} orientation={orientation} data-pm-orientation={orientation} className={cls('pm-ui-split-group', className)} />
|
|
26
|
+
}
|
|
27
|
+
export function PmSplitPanel({ className, hidden, background, scroll, style, ...props }: ComponentProps<typeof ResizablePanel> & { background?: boolean, scroll?: boolean }) {
|
|
28
|
+
// Panel assigns className/style to its content and HTML attributes to its
|
|
29
|
+
// sizing element. Mark that outer element explicitly for hiding and retention.
|
|
30
|
+
return <ResizablePanel {...props} hidden={hidden} data-pm-split-panel="" data-pm-background={background || undefined} aria-hidden={background || props['aria-hidden']} {...background ? { inert: '' } : {}}
|
|
31
|
+
style={{ overflow: scroll ? 'auto' : 'hidden', ...style }} className={cls('pm-ui-split-panel', className)} />
|
|
32
|
+
}
|
|
33
|
+
export function PmSplitHandle({ label, className, ...props }: ComponentProps<typeof ResizableSeparator> & { label: string }) {
|
|
34
|
+
return <ResizableSeparator {...props} aria-label={label} className={cls('pm-ui-split-handle', className)} />
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shared chrome for docked editors; content and actions remain owned by the caller. */
|
|
38
|
+
export function PmPanelHeader({ title, meta, leading, actions }: { title: string, meta?: ReactNode, leading?: ReactNode, actions?: ReactNode }) {
|
|
39
|
+
return <header className="pm-ui-panel-header">
|
|
40
|
+
{leading}<h2 className="pm-ui-panel-header__title">{title}</h2>
|
|
41
|
+
{meta && <span className="pm-ui-panel-header__meta">{meta}</span>}
|
|
42
|
+
{actions && <div className="pm-ui-panel-header__actions">{actions}</div>}
|
|
43
|
+
</header>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One tab stop, with arrow/Home/End navigation supplied by Radix. */
|
|
47
|
+
export function PmToolbar({ label, children, className, scroll }: { label: string, children: ReactNode, className?: string, scroll?: boolean }) {
|
|
48
|
+
return <Toolbar.Root className={cls(scroll && 'pm-ui-toolbar-scroll', className)} aria-label={label} loop>{children}</Toolbar.Root>
|
|
49
|
+
}
|
|
50
|
+
export const PmToolbarButton = Toolbar.Button
|
|
51
|
+
|
|
52
|
+
export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
|
|
53
|
+
export function PmTabs({ value, tabs, label, iconOnly, density = 'compact', variant = 'default', idPrefix, onValueChange, children }: { value: string, tabs: Choice[], label: string, iconOnly?: boolean, density?: ControlDensity, variant?: 'default' | 'dock', idPrefix?: string, onValueChange(value: string): void, children?: ReactNode }) {
|
|
54
|
+
return <Tabs.Root value={value} onValueChange={onValueChange} activationMode="manual" className="pm-ui-tabs-root" data-density={density} data-variant={variant}>
|
|
55
|
+
<Tabs.List className="pm-ui-tabs" aria-label={label}>{tabs.map(tab => {
|
|
56
|
+
const trigger = <Tabs.Trigger className="pm-ui-tab" value={tab.value} id={idPrefix ? `${idPrefix}-${tab.value}-tab` : undefined} aria-controls={idPrefix ? `${idPrefix}-${tab.value}-panel` : undefined} disabled={tab.disabled} aria-label={tab.label} data-icon-only={iconOnly || undefined}>{tab.icon && <PmIcon name={tab.icon} />}{!iconOnly && tab.label}</Tabs.Trigger>
|
|
57
|
+
return iconOnly ? <PmTooltip key={tab.value} label={tab.label}>{trigger}</PmTooltip> : <span key={tab.value} className="pm-ui-tab-slot">{trigger}</span>
|
|
58
|
+
})}</Tabs.List>{children}
|
|
59
|
+
</Tabs.Root>
|
|
60
|
+
}
|
|
61
|
+
export function PmTabPanel({ value, keepMounted, children }: { value: string, keepMounted?: boolean, children: ReactNode }) {
|
|
62
|
+
return <Tabs.Content value={value} forceMount={keepMounted || undefined} data-keep-mounted={keepMounted || undefined} className="pm-ui-tab-panel">{children}</Tabs.Content>
|
|
63
|
+
}
|
|
64
|
+
export function PmSegmented({ label, value, options, density = 'compact', disabled, onChange }: { label: string, value: string, options: Choice[], density?: ControlDensity, disabled?: boolean, onChange(value: string): void }) {
|
|
65
|
+
return <RadioGroup.Root className="pm-ui-segmented" aria-label={label} value={value} onValueChange={onChange} orientation="horizontal" data-density={density} disabled={disabled}>
|
|
66
|
+
{options.map(option => <RadioGroup.Item key={option.value} className="pm-ui-segmented__option" value={option.value} disabled={option.disabled}>{option.icon && <PmIcon name={option.icon} />}{option.label}</RadioGroup.Item>)}
|
|
67
|
+
</RadioGroup.Root>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SearchListItem {
|
|
71
|
+
value: string
|
|
72
|
+
label: string
|
|
73
|
+
/** Non-interactive supporting text; actions belong outside the choice. */
|
|
74
|
+
description?: ReactNode
|
|
75
|
+
keywords?: readonly string[]
|
|
76
|
+
icon?: IconName
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
}
|
|
79
|
+
export interface SearchListProps {
|
|
80
|
+
label: string
|
|
81
|
+
searchLabel: string
|
|
82
|
+
searchPlaceholder?: string
|
|
83
|
+
items: readonly SearchListItem[]
|
|
84
|
+
value?: string
|
|
85
|
+
onValueChange(value: string): void
|
|
86
|
+
/** Explicit activation is separate from moving a candidate with arrow keys. */
|
|
87
|
+
onActivate?(value: string): void
|
|
88
|
+
query?: string
|
|
89
|
+
onQueryChange?(query: string): void
|
|
90
|
+
resultsLabel(count: number): string
|
|
91
|
+
emptyLabel: string
|
|
92
|
+
loadingLabel: string
|
|
93
|
+
loading?: boolean
|
|
94
|
+
error?: ReactNode
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
density?: ControlDensity
|
|
97
|
+
autoFocus?: boolean
|
|
98
|
+
'aria-describedby'?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Search and a single choice compose with Dialog; Radix owns radio navigation and selection. */
|
|
102
|
+
export function PmSearchList({ label, searchLabel, searchPlaceholder, items, value, onValueChange, onActivate, query, onQueryChange, resultsLabel, emptyLabel, loadingLabel, loading, error, disabled, density = 'compact', autoFocus, 'aria-describedby': describedBy }: SearchListProps) {
|
|
103
|
+
const id = useId()
|
|
104
|
+
const [localQuery, setLocalQuery] = useState('')
|
|
105
|
+
const list = useRef<HTMLDivElement>(null)
|
|
106
|
+
const navigating = useRef(false)
|
|
107
|
+
const navigationRelease = useRef<ReturnType<typeof setTimeout>>()
|
|
108
|
+
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
|
|
109
|
+
const clearNavigation = () => { if (navigationRelease.current) clearTimeout(navigationRelease.current); navigationRelease.current = undefined; navigating.current = false }
|
|
110
|
+
useEffect(() => () => { if (navigationRelease.current) clearTimeout(navigationRelease.current) }, [])
|
|
111
|
+
const search = query ?? localQuery
|
|
112
|
+
const matches = useMemo(() => {
|
|
113
|
+
const terms = search.normalize('NFKC').toLowerCase().trim().split(/\s+/).filter(Boolean)
|
|
114
|
+
return items.filter(item => {
|
|
115
|
+
const text = [item.label, ...item.keywords ?? []].join(' ').normalize('NFKC').toLowerCase()
|
|
116
|
+
return terms.every(term => text.includes(term))
|
|
117
|
+
})
|
|
118
|
+
}, [items, search])
|
|
119
|
+
const blocked = Boolean(disabled || loading || error)
|
|
120
|
+
const errorId = error ? `${id}-error` : undefined
|
|
121
|
+
return <div className="pm-ui-search-list" data-density={density} onKeyDownCapture={event => {
|
|
122
|
+
if (navigationKeys.includes(event.key)) { clearNavigation(); navigating.current = true }
|
|
123
|
+
else if (event.key === 'Enter' || event.key === ' ') clearNavigation()
|
|
124
|
+
}} onKeyUpCapture={event => {
|
|
125
|
+
// Radix can defer arrow-key focus and its corresponding synthetic click until the next task.
|
|
126
|
+
if (navigationKeys.includes(event.key)) {
|
|
127
|
+
if (navigationRelease.current) clearTimeout(navigationRelease.current)
|
|
128
|
+
navigationRelease.current = setTimeout(() => { navigating.current = false; navigationRelease.current = undefined }, 0)
|
|
129
|
+
}
|
|
130
|
+
}} onPointerDownCapture={clearNavigation} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) clearNavigation() }}>
|
|
131
|
+
<PmInput label={searchLabel} value={search} placeholder={searchPlaceholder} prefixIcon="search" density={density} disabled={disabled} autoFocus={autoFocus} aria-controls={`${id}-results`} onChange={event => {
|
|
132
|
+
if (query === undefined) setLocalQuery(event.target.value)
|
|
133
|
+
onQueryChange?.(event.target.value)
|
|
134
|
+
}} onKeyDown={event => {
|
|
135
|
+
if (event.key !== 'ArrowDown' || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
136
|
+
const selected = list.current?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]:not(:disabled)')
|
|
137
|
+
const first = selected ?? list.current?.querySelector<HTMLElement>('[role="radio"]:not(:disabled)')
|
|
138
|
+
if (first) { event.preventDefault(); first.focus() }
|
|
139
|
+
}} />
|
|
140
|
+
<div className="pm-ui-search-list__heading"><span id={`${id}-label`}>{label}</span><span className="pm-ui-search-list__count" role="status" aria-live="polite" aria-atomic="true">{loading && <span className="pm-ui-loading" aria-hidden="true"><PmIcon name="loading" /></span>}{loading ? loadingLabel : resultsLabel(matches.length)}</span></div>
|
|
141
|
+
{error && <p id={errorId} className="pm-ui-field__error" role="alert">{error}</p>}
|
|
142
|
+
<RadioGroup.Root ref={list} id={`${id}-results`} className="pm-ui-search-list__results" aria-labelledby={`${id}-label`} aria-describedby={[describedBy, errorId].filter(Boolean).join(' ') || undefined} aria-invalid={error ? true : undefined} aria-busy={loading || undefined} value={value ?? ''} disabled={blocked} orientation="vertical" loop onValueChange={next => { if (!blocked) onValueChange(next) }}>
|
|
143
|
+
{matches.map((item, index) => <RadioGroup.Item key={item.value} value={item.value} disabled={item.disabled} className="pm-ui-search-list__option" aria-labelledby={`${id}-option-${index}`} aria-describedby={item.description ? `${id}-description-${index}` : undefined} onClick={() => { if (!blocked && !item.disabled && !navigating.current) onActivate?.(item.value) }} onKeyDown={event => { if (event.key === 'Enter' && onActivate && !blocked && !item.disabled) { event.preventDefault(); onActivate(item.value) } }}>
|
|
144
|
+
{item.icon && <PmIcon name={item.icon} />}
|
|
145
|
+
<span className="pm-ui-search-list__text"><span id={`${id}-option-${index}`} className="pm-ui-search-list__label" title={item.label}>{item.label}</span>{item.description && <span id={`${id}-description-${index}`} className="pm-ui-search-list__description">{item.description}</span>}</span>
|
|
146
|
+
<span className="pm-ui-search-list__check" aria-hidden="true">{item.value === value && <PmIcon name="check" />}</span>
|
|
147
|
+
</RadioGroup.Item>)}
|
|
148
|
+
{!matches.length && !loading && !error && <p className="pm-ui-search-list__empty">{emptyLabel}</p>}
|
|
149
|
+
</RadioGroup.Root>
|
|
150
|
+
</div>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
|
|
154
|
+
|
|
155
|
+
export function PmBadge({ tone = 'neutral', pill, children }: { tone?: Tone, pill?: boolean, children?: ReactNode }) {
|
|
156
|
+
return <span className={cls('pm-ui-badge', tone !== 'neutral' && `pm-ui-badge--${tone}`, pill && 'pm-ui-badge--pill')}>{children}</span>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function PmCard({ variant = 'default', className, children, ...rest }: { variant?: 'default' | 'interactive' | 'dashed', className?: string, children?: ReactNode, [attribute: `data-${string}`]: string | undefined }) {
|
|
160
|
+
return <div className={cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className)} {...rest}>{children}</div>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function PmField({ label, labelHidden, inline, group, hint, error, htmlFor, hintId, errorId, density, children }: { label: string, labelHidden?: boolean, inline?: boolean, group?: boolean, hint?: ReactNode, error?: ReactNode, htmlFor?: string, hintId?: string, errorId?: string, density?: ControlDensity, children?: ReactNode }) {
|
|
164
|
+
const feedback = <>{hint && <span id={hintId} className="pm-ui-field__hint">{hint}</span>}{error && <span id={errorId} className="pm-ui-field__error" role="alert">{error}</span>}</>
|
|
165
|
+
if (group) return <div className="pm-ui-field" data-density={density} role="group" aria-label={label}><span className="pm-ui-field__label">{label}</span>{children}{feedback}</div>
|
|
166
|
+
return (
|
|
167
|
+
<label className={cls('pm-ui-field', inline && 'pm-ui-field--inline')} data-density={density} htmlFor={htmlFor}>
|
|
168
|
+
{inline ? <>{children}<span id={htmlFor ? `${htmlFor}-label` : undefined} className="pm-ui-field__label">{label}</span></> : <><span id={htmlFor ? `${htmlFor}-label` : undefined} className={labelHidden ? 'pm-ui-sr-only' : 'pm-ui-field__label'}>{label}</span>{children}</>}
|
|
169
|
+
{feedback}
|
|
170
|
+
</label>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type FieldProps = { label: string, hint?: ReactNode, error?: ReactNode, density?: ControlDensity }
|
|
175
|
+
function useField({ id, hint, error, 'aria-describedby': describedBy }: { id?: string, hint?: ReactNode, error?: ReactNode, 'aria-describedby'?: string }) {
|
|
176
|
+
const generated = useId()
|
|
177
|
+
const controlId = id ?? generated
|
|
178
|
+
return { id: controlId, hintId: hint ? `${controlId}-hint` : undefined, errorId: error ? `${controlId}-error` : undefined,
|
|
179
|
+
describedBy: [describedBy, hint && `${controlId}-hint`, error && `${controlId}-error`].filter(Boolean).join(' ') || undefined }
|
|
180
|
+
}
|
|
181
|
+
export const PmInput = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & FieldProps & { labelHidden?: boolean, prefixIcon?: IconName, suffix?: ReactNode, width?: 'number' | 'time' }>(function PmInput({ label, labelHidden, hint, error, density = 'compact', prefixIcon, suffix, width, className, ...rest }, ref) {
|
|
182
|
+
const field = useField({ ...rest, hint, error })
|
|
183
|
+
const input = <input {...rest} ref={ref} id={field.id} data-density={density} data-width={width} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-input', className)} />
|
|
184
|
+
return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
185
|
+
{prefixIcon || suffix ? <span className="pm-ui-control" data-width={width} data-density={density}>{prefixIcon && <span className="pm-ui-control__prefix"><PmIcon name={prefixIcon} /></span>}{input}{suffix && <span className="pm-ui-control__suffix">{suffix}</span>}</span> : input}
|
|
186
|
+
</PmField>
|
|
187
|
+
})
|
|
188
|
+
export const PmTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & FieldProps & { autoGrow?: boolean }>(function PmTextarea({ label, hint, error, density = 'compact', autoGrow, className, ...rest }, ref) {
|
|
189
|
+
const field = useField({ ...rest, hint, error })
|
|
190
|
+
const node = useRef<HTMLTextAreaElement | null>(null)
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!autoGrow || !node.current) return
|
|
193
|
+
node.current.style.height = 'auto'
|
|
194
|
+
node.current.style.height = `${node.current.scrollHeight}px`
|
|
195
|
+
}, [autoGrow, rest.value])
|
|
196
|
+
return <PmField label={label} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
197
|
+
<textarea {...rest} ref={element => { node.current = element; if (typeof ref === 'function') ref(element); else if (ref) ref.current = element }} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-textarea', className)} />
|
|
198
|
+
</PmField>
|
|
199
|
+
})
|
|
200
|
+
export const PmSelect = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { labelHidden?: boolean }>(function PmSelect({ label, labelHidden, hint, error, density = 'compact', className, children, ...rest }, ref) {
|
|
201
|
+
const field = useField({ ...rest, hint, error })
|
|
202
|
+
return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
203
|
+
<select {...rest} ref={ref} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-select', className)}>{children}</select>
|
|
204
|
+
</PmField>
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
export interface ChoiceSelectItem extends Choice { description?: ReactNode }
|
|
208
|
+
export interface ChoiceSelectProps {
|
|
209
|
+
label: string
|
|
210
|
+
value: string
|
|
211
|
+
choices: readonly ChoiceSelectItem[]
|
|
212
|
+
onValueChange(value: string): void
|
|
213
|
+
placeholder?: string
|
|
214
|
+
hint?: ReactNode
|
|
215
|
+
error?: ReactNode
|
|
216
|
+
disabled?: boolean
|
|
217
|
+
required?: boolean
|
|
218
|
+
requiredMessage?: string
|
|
219
|
+
name?: string
|
|
220
|
+
id?: string
|
|
221
|
+
density?: ControlDensity
|
|
222
|
+
'aria-describedby'?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A consistent choice menu; Radix owns typeahead, navigation, dismissal and focus return. */
|
|
226
|
+
export function PmChoiceSelect({ label, value, choices, onValueChange, placeholder, hint, error, disabled, required, requiredMessage, name, id, density = 'regular', 'aria-describedby': describedBy }: ChoiceSelectProps) {
|
|
227
|
+
const [invalid, setInvalid] = useState(false)
|
|
228
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
229
|
+
const feedback = error ?? (invalid ? requiredMessage : undefined)
|
|
230
|
+
const field = useField({ id, hint, error: feedback, 'aria-describedby': describedBy })
|
|
231
|
+
const selected = choices.find(choice => choice.value === value)
|
|
232
|
+
useEffect(() => { setInvalid(false) }, [value])
|
|
233
|
+
const change = (next: string) => { setInvalid(false); onValueChange(next) }
|
|
234
|
+
return <PmField label={required ? `${label} *` : label} htmlFor={field.id} hint={hint} error={feedback} hintId={field.hintId} errorId={field.errorId} density={density}>
|
|
235
|
+
<DropdownMenu.Root>
|
|
236
|
+
<DropdownMenu.Trigger asChild><button ref={trigger} id={field.id} type="button" className="pm-ui-choice-select__trigger" data-density={density} data-placeholder={!selected || undefined} disabled={disabled} aria-labelledby={`${field.id}-label ${field.id}-value`} aria-describedby={field.describedBy} aria-invalid={Boolean(error || invalid) || undefined}>
|
|
237
|
+
{selected?.icon && <PmIcon name={selected.icon} />}<span id={`${field.id}-value`} className="pm-ui-choice-select__value">{selected?.label ?? placeholder ?? label}</span><PmIcon name="chevron-down" />
|
|
238
|
+
</button></DropdownMenu.Trigger>
|
|
239
|
+
<DropdownMenu.Portal><DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui pm-ui-choice-select__menu" data-density={density} aria-label={label} side="bottom" align="start" sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
240
|
+
<DropdownMenu.RadioGroup value={value} onValueChange={change}>
|
|
241
|
+
{choices.filter(choice => choice.value !== '').map(choice => <DropdownMenu.RadioItem key={choice.value} value={choice.value} textValue={choice.label} disabled={choice.disabled} className="pm-ui-menu__item pm-ui-choice-select__option">
|
|
242
|
+
{choice.icon && <PmIcon name={choice.icon} />}<span className="pm-ui-choice-select__text"><span>{choice.label}</span>{choice.description && <span className="pm-ui-choice-select__description">{choice.description}</span>}</span><span className="pm-ui-choice-select__check"><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator></span>
|
|
243
|
+
</DropdownMenu.RadioItem>)}
|
|
244
|
+
</DropdownMenu.RadioGroup>
|
|
245
|
+
</DropdownMenu.Content></DropdownMenu.Portal>
|
|
246
|
+
</DropdownMenu.Root>
|
|
247
|
+
{/* The hidden native proxy participates only in HTML form submission and required validation. */}
|
|
248
|
+
{(name || required) && <select className="pm-ui-choice-select__native" aria-hidden="true" tabIndex={-1} name={name} value={selected?.value ?? ''} required={required} disabled={disabled} onChange={event => change(event.target.value)} onInvalid={event => { event.preventDefault(); setInvalid(true); trigger.current?.focus() }}><option value="" />{choices.filter(choice => choice.value !== '').map(choice => <option key={choice.value} value={choice.value} disabled={choice.disabled}>{choice.label}</option>)}</select>}
|
|
249
|
+
</PmField>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface WorkspaceSwitcherProps {
|
|
253
|
+
label: string
|
|
254
|
+
value?: string
|
|
255
|
+
items: readonly SearchListItem[]
|
|
256
|
+
onValueChange(value: string): void
|
|
257
|
+
placeholder: string
|
|
258
|
+
searchLabel: string
|
|
259
|
+
searchPlaceholder?: string
|
|
260
|
+
emptyLabel: string
|
|
261
|
+
resultsLabel(count: number): string
|
|
262
|
+
loadingLabel: string
|
|
263
|
+
loading?: boolean
|
|
264
|
+
disabled?: boolean
|
|
265
|
+
createLabel: string
|
|
266
|
+
onCreate(): void
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Search candidate choices without navigating; activate by click, Space or Enter to switch. */
|
|
270
|
+
export function PmWorkspaceSwitcher({ label, value, items, onValueChange, placeholder, searchLabel, searchPlaceholder, emptyLabel, resultsLabel, loadingLabel, loading, disabled, createLabel, onCreate }: WorkspaceSwitcherProps) {
|
|
271
|
+
const [open, setOpen] = useState(false)
|
|
272
|
+
const [candidate, setCandidate] = useState(value)
|
|
273
|
+
const panel = useRef<HTMLDivElement>(null)
|
|
274
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
275
|
+
const creating = useRef(false)
|
|
276
|
+
const selected = items.find(item => item.value === value)
|
|
277
|
+
return <Popover.Root open={open} onOpenChange={next => { setOpen(next); if (next) { setCandidate(value); creating.current = false } }}>
|
|
278
|
+
<Popover.Trigger asChild><button ref={trigger} type="button" className="pm-ui-workspace-switcher" disabled={disabled || loading} aria-label={`${label}: ${selected?.label ?? placeholder}`} aria-busy={loading || undefined}>
|
|
279
|
+
<span className="pm-ui-workspace-switcher__mark" aria-hidden="true">{loading ? <span className="pm-ui-loading"><PmIcon name="loading" /></span> : selected ? Array.from(selected.label)[0] : <PmIcon name="folder" />}</span>
|
|
280
|
+
<span className="pm-ui-workspace-switcher__text"><span className="pm-ui-workspace-switcher__name">{selected?.label ?? placeholder}</span>{selected?.description && <span className="pm-ui-workspace-switcher__description">{selected.description}</span>}</span><PmIcon name="chevron-down" />
|
|
281
|
+
</button></Popover.Trigger>
|
|
282
|
+
<Popover.Portal><Popover.Content ref={panel} className="pm-ui-popover pm-ui-floating pm-ui pm-ui-workspace-switcher__panel" side="bottom" align="start" sideOffset={6} collisionPadding={16} aria-label={label} onOpenAutoFocus={event => { event.preventDefault(); panel.current?.querySelector('input')?.focus() }} onCloseAutoFocus={event => { if (creating.current) event.preventDefault() }} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
283
|
+
<PmSearchList label={label} searchLabel={searchLabel} searchPlaceholder={searchPlaceholder} items={items} value={candidate} onValueChange={setCandidate} onActivate={next => { setOpen(false); onValueChange(next) }} resultsLabel={resultsLabel} emptyLabel={emptyLabel} loadingLabel={loadingLabel} loading={loading} autoFocus />
|
|
284
|
+
<div className="pm-ui-workspace-switcher__footer"><PmButton variant="ghost" icon="plus" onClick={() => { creating.current = true; setOpen(false); trigger.current?.focus(); onCreate() }}>{createLabel}</PmButton></div>
|
|
285
|
+
</Popover.Content></Popover.Portal>
|
|
286
|
+
</Popover.Root>
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start'
|
|
290
|
+
const placementOf = (placement: PopoverPlacement) => ({ side: placement.startsWith('above') ? 'top' as const : 'bottom' as const, align: placement.endsWith('start') ? 'start' as const : 'end' as const })
|
|
291
|
+
|
|
292
|
+
export function PmDialog({ open, label, closeLabel, name, actions, description, footer, busy, onClose, onOpenAutoFocus, onCloseAutoFocus, children }: { open: boolean, label: string, closeLabel: string, name?: string, actions?: ReactNode, description?: string, footer?: ReactNode, busy?: boolean, onClose(): void, onOpenAutoFocus?(event: Event): void, onCloseAutoFocus?(event: Event): void, children?: ReactNode }) {
|
|
293
|
+
const id = useId()
|
|
294
|
+
// A descendant's React autoFocus runs before Radix's open-focus event.
|
|
295
|
+
const trigger = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (open) return
|
|
298
|
+
const remember = (event: FocusEvent) => {
|
|
299
|
+
const element = event.target
|
|
300
|
+
if (element instanceof HTMLElement && element !== document.body && element.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) trigger.current = element
|
|
301
|
+
}
|
|
302
|
+
document.addEventListener('focusin', remember, true)
|
|
303
|
+
return () => document.removeEventListener('focusin', remember, true)
|
|
304
|
+
}, [id, open])
|
|
305
|
+
return (
|
|
306
|
+
<Dialog.Root open={open} modal onOpenChange={next => { if (!next && !busy) onClose() }}>
|
|
307
|
+
<Dialog.Portal>
|
|
308
|
+
<Dialog.Overlay className="pm-ui-dialog-overlay" />
|
|
309
|
+
<Dialog.Content className={cls('pm-ui-dialog', 'pm-ui', name && `${name}__panel`)} data-pm-dialog={id} data-density="regular" aria-modal="true" aria-busy={busy || undefined} {...description ? {} : { 'aria-describedby': undefined }} onInteractOutside={event => event.preventDefault()} onEscapeKeyDown={event => { event.stopPropagation(); if (busy) event.preventDefault() }} onOpenAutoFocus={event => {
|
|
310
|
+
onOpenAutoFocus?.(event)
|
|
311
|
+
if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
|
|
312
|
+
const panel = event.target
|
|
313
|
+
if (panel.contains(document.activeElement)) { event.preventDefault(); return }
|
|
314
|
+
const target = [...panel.querySelectorAll<HTMLElement>('.pm-ui-dialog__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')
|
|
315
|
+
if (target) { event.preventDefault(); target.focus() }
|
|
316
|
+
}} onCloseAutoFocus={event => {
|
|
317
|
+
onCloseAutoFocus?.(event)
|
|
318
|
+
if (event.defaultPrevented) return
|
|
319
|
+
event.preventDefault()
|
|
320
|
+
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')
|
|
321
|
+
const target = available(trigger.current) ? trigger.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
|
|
322
|
+
target?.focus()
|
|
323
|
+
}}>
|
|
324
|
+
<div className="pm-ui-dialog__header">
|
|
325
|
+
<Dialog.Title className="pm-ui-dialog__title">{label}</Dialog.Title>
|
|
326
|
+
{actions}
|
|
327
|
+
<Dialog.Close asChild><PmIconButton icon="close" label={closeLabel} small disabled={busy} /></Dialog.Close>
|
|
328
|
+
</div>
|
|
329
|
+
{description && <Dialog.Description className="pm-ui-dialog__description">{description}</Dialog.Description>}
|
|
330
|
+
<div className="pm-ui-dialog__body">{children}</div>
|
|
331
|
+
{footer && <div className="pm-ui-dialog__footer">{footer}</div>}
|
|
332
|
+
</Dialog.Content>
|
|
333
|
+
</Dialog.Portal>
|
|
334
|
+
</Dialog.Root>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A non-modal floating panel on its trigger, with the shared `pm-ui-popover` surface (host's PmPopover for React pages). */
|
|
339
|
+
export function PmPopover({ open, label, name, placement = 'below-end', trigger, onOpenChange, children }: { open: boolean, label: string, name?: string, placement?: PopoverPlacement, trigger: ReactNode, onOpenChange(open: boolean): void, children?: ReactNode }) {
|
|
340
|
+
return (
|
|
341
|
+
<Popover.Root open={open} onOpenChange={onOpenChange}>
|
|
342
|
+
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
|
|
343
|
+
<Popover.Portal>
|
|
344
|
+
<Popover.Content className={cls('pm-ui-popover', 'pm-ui-floating', 'pm-ui', name)} aria-label={label} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
345
|
+
{children}
|
|
346
|
+
</Popover.Content>
|
|
347
|
+
</Popover.Portal>
|
|
348
|
+
</Popover.Root>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface MenuItem {
|
|
353
|
+
id: string
|
|
354
|
+
label: string
|
|
355
|
+
icon?: IconName
|
|
356
|
+
danger?: boolean
|
|
357
|
+
disabled?: boolean
|
|
358
|
+
shortcut?: string
|
|
359
|
+
checked?: boolean
|
|
360
|
+
separator?: boolean
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `toolbarItem`: the trigger joins the surrounding `PmToolbar`'s arrow-key navigation, e.g. as its overflow ("More") menu. */
|
|
364
|
+
export function PmMenu({ label, description, items, icon = 'more', placement = 'below-end', triggerRef, toolbarItem, onSelect }: { label: string, description?: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, triggerRef?: Ref<HTMLButtonElement>, toolbarItem?: boolean, onSelect(id: string): void }) {
|
|
365
|
+
const descriptionId = useId()
|
|
366
|
+
const trigger = <PmIconButton ref={triggerRef} icon={icon} label={label} small />
|
|
367
|
+
return (
|
|
368
|
+
<DropdownMenu.Root>
|
|
369
|
+
<DropdownMenu.Trigger asChild>{toolbarItem ? <Toolbar.Button asChild>{trigger}</Toolbar.Button> : trigger}</DropdownMenu.Trigger>
|
|
370
|
+
<DropdownMenu.Portal>
|
|
371
|
+
<DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui" data-density="compact" data-description={description ? '' : undefined} aria-label={label} aria-describedby={description ? descriptionId : undefined} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
|
|
372
|
+
{description && <p className="pm-ui-menu__description" id={descriptionId}>{description}</p>}
|
|
373
|
+
{items.map(item => (
|
|
374
|
+
<DropdownMenu.Group key={item.id}>{item.separator && <DropdownMenu.Separator className="pm-ui-menu__separator" />}{item.checked === undefined ? <DropdownMenu.Item textValue={item.label} className={cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger')} disabled={item.disabled} onSelect={() => onSelect(item.id)}>
|
|
375
|
+
{item.icon && <PmIcon name={item.icon} />}{item.label}{item.shortcut && <kbd className="pm-ui-shortcut">{item.shortcut}</kbd>}
|
|
376
|
+
</DropdownMenu.Item> : <DropdownMenu.CheckboxItem className="pm-ui-menu__item" checked={item.checked} disabled={item.disabled} textValue={item.label} onSelect={() => onSelect(item.id)}><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator>{item.label}</DropdownMenu.CheckboxItem>}</DropdownMenu.Group>
|
|
377
|
+
))}
|
|
378
|
+
</DropdownMenu.Content>
|
|
379
|
+
</DropdownMenu.Portal>
|
|
380
|
+
</DropdownMenu.Root>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function PmEmpty({ title, icon, action, children }: { title?: string, icon?: IconName, action?: ReactNode, children?: ReactNode }) {
|
|
385
|
+
return (
|
|
386
|
+
<div className="pm-ui-empty">
|
|
387
|
+
{icon && <PmIcon name={icon} size={28} />}
|
|
388
|
+
{title && <p className="pm-ui-empty__title">{title}</p>}
|
|
389
|
+
{children && <p className="pm-ui-empty__body">{children}</p>}
|
|
390
|
+
{action}
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Loading in the shape of the content: `document` is a page of headings and paragraphs, `list` a column of rows and
|
|
396
|
+
* `preview` a slide-shaped area. The label is the status read to assistive technology. */
|
|
397
|
+
export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
|
|
398
|
+
const bar = (kind?: 'heading' | 'row' | 'media', key?: number) => <span key={key} className="pm-ui-skeleton__bar" data-kind={kind} />
|
|
399
|
+
const paragraph = (lines: number, key: number) => <span key={key} className="pm-ui-skeleton__group">{Array.from({ length: lines }, (_, index) => bar(undefined, index))}</span>
|
|
400
|
+
return (
|
|
401
|
+
<div className="pm-ui-skeleton" data-shape={shape} role="status" aria-busy="true">
|
|
402
|
+
<span className="pm-ui-sr-only">{label}</span>
|
|
403
|
+
<span className="pm-ui-skeleton" aria-hidden="true">
|
|
404
|
+
{shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
|
|
405
|
+
: shape === 'preview' ? <>{bar('media')}{paragraph(2, 0)}</>
|
|
406
|
+
: <>{bar('heading')}{paragraph(4, 0)}{paragraph(3, 1)}{bar('heading')}{paragraph(4, 2)}</>}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function PmNotice({ tone = 'neutral', children }: { tone?: 'neutral' | 'accent' | 'ok' | 'warn' | 'danger', children?: ReactNode }) {
|
|
413
|
+
return <div className={cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`)} role={tone === 'danger' ? 'alert' : 'status'}>{children}</div>
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The Presmium kitten acting out what the assistant or a long task is doing. It is decoration: the text beside it
|
|
417
|
+
* says the same thing for everyone, so it is hidden from assistive technology. Nothing shows when the author turned
|
|
418
|
+
* the kitten off in Settings. */
|
|
419
|
+
export function PmMascot(props: { action: MascotAction, size?: 'small' | 'regular' }) {
|
|
420
|
+
return useMascotEnabled() ? <MascotPlayer {...props} /> : null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function MascotPlayer({ action, size = 'regular' }: { action: MascotAction, size?: 'small' | 'regular' }) {
|
|
424
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
425
|
+
const player = useRef<ReturnType<typeof mountMascot>>(undefined)
|
|
426
|
+
const first = useRef(action)
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
const mounted = mountMascot(ref.current!, first.current)
|
|
429
|
+
player.current = mounted
|
|
430
|
+
return () => mounted.stop()
|
|
431
|
+
}, [])
|
|
432
|
+
useEffect(() => { player.current?.set(action) }, [action])
|
|
433
|
+
return <span ref={ref} className={cls('pm-ui-mascot', size === 'small' && 'pm-ui-mascot--sm')} data-pm-mascot={action} aria-hidden="true" />
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface RangeProps {
|
|
437
|
+
label: string
|
|
438
|
+
value: number
|
|
439
|
+
min?: number
|
|
440
|
+
max: number
|
|
441
|
+
step?: number
|
|
442
|
+
disabled?: boolean
|
|
443
|
+
density?: ControlDensity
|
|
444
|
+
valueText?(value: number): string
|
|
445
|
+
onValueCommit(value: number): void
|
|
446
|
+
onPreviewChange?(value: number | undefined): void
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Native range owns pointer/keyboard mechanics; only a committed gesture requests adoption. */
|
|
450
|
+
export function PmRange({ label, value, min = 0, max, step = 1, disabled, density = 'compact', valueText, onValueCommit, onPreviewChange }: RangeProps) {
|
|
451
|
+
const [draft, setDraft] = useState<number>()
|
|
452
|
+
const pointer = useRef<'active' | 'cancelled'>(), latest = useRef<number>(), preview = useRef(onPreviewChange)
|
|
453
|
+
preview.current = onPreviewChange
|
|
454
|
+
const blocked = Boolean(disabled || !Number.isFinite(min) || !Number.isFinite(max) || max <= min)
|
|
455
|
+
const minimum = Number.isFinite(min) ? min : 0, maximum = Number.isFinite(max) ? Math.max(minimum, max) : minimum
|
|
456
|
+
const bounded = (next: number) => Math.max(minimum, Math.min(maximum, Number.isFinite(next) ? next : minimum))
|
|
457
|
+
const current = bounded(draft ?? value), progress = max > min ? (current - min) / (max - min) * 100 : 0
|
|
458
|
+
const cancel = () => { if (pointer.current) pointer.current = 'cancelled'; latest.current = undefined; setDraft(undefined); preview.current?.(undefined) }
|
|
459
|
+
useEffect(() => { cancel() }, [blocked, min, max])
|
|
460
|
+
useEffect(() => () => { pointer.current = undefined; latest.current = undefined; preview.current?.(undefined) }, [])
|
|
461
|
+
return <input type="range" className="pm-ui-range" data-density={density} aria-label={label} aria-valuetext={valueText?.(current)} min={minimum} max={maximum} step={step} value={current} disabled={blocked}
|
|
462
|
+
style={{ '--pm-range-progress': `${progress}%` } as CSSProperties}
|
|
463
|
+
onPointerDown={() => { if (!blocked) pointer.current = 'active' }}
|
|
464
|
+
onPointerCancel={() => { cancel(); pointer.current = undefined }} onBlur={cancel}
|
|
465
|
+
onPointerUp={() => { const active = pointer.current === 'active', next = latest.current; cancel(); pointer.current = undefined; if (active && !blocked && next !== undefined) onValueCommit(next) }}
|
|
466
|
+
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }}
|
|
467
|
+
onChange={event => {
|
|
468
|
+
if (blocked || pointer.current === 'cancelled') return
|
|
469
|
+
const next = bounded(event.currentTarget.valueAsNumber)
|
|
470
|
+
if (pointer.current === 'active') { latest.current = next; setDraft(next); preview.current?.(next) }
|
|
471
|
+
else onValueCommit(next)
|
|
472
|
+
}} />
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export type TimelineTiming = 'confirmed' | 'estimated' | 'unknown'
|
|
476
|
+
export interface TimelineSegment {
|
|
477
|
+
id: string
|
|
478
|
+
stepId?: string
|
|
479
|
+
label: string
|
|
480
|
+
startMs?: number
|
|
481
|
+
endMs?: number
|
|
482
|
+
timing: TimelineTiming
|
|
483
|
+
statusLabel?: string
|
|
484
|
+
disabled?: boolean
|
|
485
|
+
/** Real normalized peak amplitudes; absence never produces a decorative waveform. */
|
|
486
|
+
waveform?: readonly number[]
|
|
487
|
+
}
|
|
488
|
+
export interface TimelineTrack { id: 'visual' | 'voice' | 'caption', label: string, segments: readonly TimelineSegment[] }
|
|
489
|
+
export interface TimelineLabels {
|
|
490
|
+
position: string
|
|
491
|
+
seconds: string
|
|
492
|
+
seek: string
|
|
493
|
+
zoomIn: string
|
|
494
|
+
zoomOut: string
|
|
495
|
+
fit: string
|
|
496
|
+
estimated: string
|
|
497
|
+
unknown: string
|
|
498
|
+
loading: string
|
|
499
|
+
empty: string
|
|
500
|
+
seekHint: string
|
|
501
|
+
invalidPosition: string
|
|
502
|
+
}
|
|
503
|
+
export interface TimelineProps {
|
|
504
|
+
label: string
|
|
505
|
+
tracks: readonly TimelineTrack[]
|
|
506
|
+
labels: TimelineLabels
|
|
507
|
+
durationMs?: number
|
|
508
|
+
durationStatus?: TimelineTiming
|
|
509
|
+
currentTimeMs?: number
|
|
510
|
+
selectedStepId?: string
|
|
511
|
+
zoom?: number
|
|
512
|
+
onZoomChange?(zoom: number): void
|
|
513
|
+
onSelectStep(stepId: string): void
|
|
514
|
+
/** Optional segment activation; the caller owns whether its confirmed time may be sought. */
|
|
515
|
+
onSelectSegment?(track: TimelineTrack['id'], segment: TimelineSegment): void
|
|
516
|
+
/** Full-size narrow/coarse alternative to short proportional segments; no timing is inferred here. */
|
|
517
|
+
segmentPicker?: { track: TimelineTrack['id'], ids: readonly string[], label: string, placeholder: string }
|
|
518
|
+
/** Caller pauses and confirms its source/clock before updating controlled currentTimeMs. */
|
|
519
|
+
onSeek(timeMs: number): void
|
|
520
|
+
seekDisabled?: boolean
|
|
521
|
+
disabled?: boolean
|
|
522
|
+
loading?: boolean
|
|
523
|
+
seeking?: boolean
|
|
524
|
+
density?: ControlDensity
|
|
525
|
+
/** Omit the fallback step strip when the surrounding editor already provides step navigation. */
|
|
526
|
+
showSteps?: boolean
|
|
527
|
+
/** A flush docked editor, with status and zoom in the same tool row. */
|
|
528
|
+
variant?: 'default' | 'editor'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const timelineTime = (ms: number) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${(ms % 60000 / 1000).toFixed(3).padStart(6, '0')}`
|
|
532
|
+
function waveformPath(samples: readonly number[] | undefined): string | undefined {
|
|
533
|
+
if (!samples?.length || !samples.every(value => Number.isFinite(value) && value >= 0 && value <= 1)) return undefined
|
|
534
|
+
const length = Math.min(160, samples.length)
|
|
535
|
+
return Array.from({ length }, (_, index) => {
|
|
536
|
+
let peak = 0
|
|
537
|
+
for (let at = Math.floor(index * samples.length / length); at < Math.floor((index + 1) * samples.length / length); at++) peak = Math.max(peak, samples[at]!)
|
|
538
|
+
return `M${(index + 0.5) / length * 160},${10 - peak * 10}V${10 + peak * 10}`
|
|
539
|
+
}).join(' ')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Data-only timeline; source, playback, seeking authority and all labels remain with its caller. */
|
|
543
|
+
export function PmTimeline({ label, tracks, labels, durationMs, durationStatus, currentTimeMs, selectedStepId, zoom, onZoomChange, onSelectStep, onSelectSegment, segmentPicker, onSeek, seekDisabled, disabled, loading, seeking, density = 'compact', showSteps = true, variant = 'default' }: TimelineProps) {
|
|
544
|
+
const hintId = useId()
|
|
545
|
+
const viewport = useRef<HTMLDivElement>(null), tick = useRef<HTMLSpanElement>(null), composing = useRef(false)
|
|
546
|
+
const [width, setWidth] = useState(0), [tickWidth, setTickWidth] = useState(1), [localZoom, setLocalZoom] = useState(1), [raw, setRaw] = useState<string>(), [previewTime, setPreviewTime] = useState<number>()
|
|
547
|
+
const span = Number.isFinite(durationMs) && durationMs! >= 0 ? durationMs : undefined
|
|
548
|
+
const timing = durationStatus ?? (span === undefined ? 'unknown' : 'confirmed')
|
|
549
|
+
const timed = span !== undefined && span > 0 && timing !== 'unknown'
|
|
550
|
+
const blocked = Boolean(disabled || loading), cannotSeek = Boolean(blocked || seeking || seekDisabled || !timed || timing !== 'confirmed')
|
|
551
|
+
const scale = Math.max(1, Math.min(16, Number.isFinite(zoom ?? localZoom) ? zoom ?? localZoom : 1))
|
|
552
|
+
const position = Number.isFinite(currentTimeMs) && currentTimeMs! >= 0 ? currentTimeMs : undefined
|
|
553
|
+
const shownPosition = previewTime ?? position
|
|
554
|
+
const zoomTo = (value: number) => { const next = Math.max(1, Math.min(16, value)); if (zoom === undefined) setLocalZoom(next); onZoomChange?.(next) }
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
const element = viewport.current
|
|
557
|
+
if (!element) return
|
|
558
|
+
const update = () => {
|
|
559
|
+
const style = getComputedStyle(element)
|
|
560
|
+
setWidth(Math.max(0, element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)))
|
|
561
|
+
if (tick.current) setTickWidth(tick.current.getBoundingClientRect().width)
|
|
562
|
+
}
|
|
563
|
+
const observer = new ResizeObserver(update); observer.observe(element); update()
|
|
564
|
+
return () => observer.disconnect()
|
|
565
|
+
}, [timed])
|
|
566
|
+
useEffect(() => { if (cannotSeek) { setPreviewTime(undefined); setRaw(undefined) } }, [cannotSeek, span])
|
|
567
|
+
const requestSeek = (next: number) => { if (!cannotSeek && span !== undefined && Number.isFinite(next)) onSeek(Math.max(0, Math.min(span, Math.round(next)))) }
|
|
568
|
+
const number = raw?.normalize('NFKC').trim(), milliseconds = number !== undefined && /^\d+(?:\.\d+)?$/.test(number) ? Number(number) * 1000 : NaN
|
|
569
|
+
const invalid = raw !== undefined && (!Number.isFinite(milliseconds) || milliseconds < 0 || span === undefined || milliseconds > span)
|
|
570
|
+
const commitPosition = () => { if (composing.current || raw === undefined || invalid || cannotSeek) return; setRaw(undefined); requestSeek(milliseconds) }
|
|
571
|
+
const rows = useMemo(() => tracks.map(track => ({ ...track, segments: track.segments.map(segment => ({ ...segment, wave: waveformPath(segment.waveform), positioned: timed && segment.timing !== 'unknown' && Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs) && segment.startMs! >= 0 && segment.endMs! >= segment.startMs! && segment.endMs! <= span! })) })), [tracks, timed, span])
|
|
572
|
+
const pickerSegments = useMemo(() => {
|
|
573
|
+
const ids = new Set(segmentPicker?.ids)
|
|
574
|
+
return rows.find(track => track.id === segmentPicker?.track)?.segments.filter(segment => ids.has(segment.id) && segment.positioned && segment.timing === 'confirmed') ?? []
|
|
575
|
+
}, [rows, segmentPicker])
|
|
576
|
+
const steps = useMemo(() => {
|
|
577
|
+
const choices = new Map<string, TimelineSegment>()
|
|
578
|
+
for (const track of tracks) for (const segment of track.segments) if (segment.stepId && !choices.has(segment.stepId)) choices.set(segment.stepId, segment)
|
|
579
|
+
return [...choices.values()]
|
|
580
|
+
}, [tracks])
|
|
581
|
+
const ticks = useMemo(() => {
|
|
582
|
+
if (!timed || span === undefined) return []
|
|
583
|
+
const interval = span / Math.max(1, width * scale / tickWidth), power = 10 ** Math.floor(Math.log10(Math.max(1, interval)))
|
|
584
|
+
const unit = [1, 2, 5, 10].find(value => value * power >= interval)! * power
|
|
585
|
+
const marks = Array.from({ length: Math.min(200, Math.floor(span / unit) + 1) }, (_, index) => index * unit)
|
|
586
|
+
return [0, ...marks.filter(time => time > 0 && time < span && Math.min(time, span - time) / span * width * scale >= tickWidth), span]
|
|
587
|
+
}, [span, timed, width, scale, tickWidth])
|
|
588
|
+
const status = <div className="pm-ui-timeline__status" role="status">{loading ? labels.loading : timing === 'unknown' || span === undefined ? labels.unknown : `${timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(span)}`}</div>
|
|
589
|
+
return <section className="pm-ui-timeline" data-variant={variant} data-density={density} data-disabled={blocked || undefined} data-segment-picker={segmentPicker?.track} aria-label={label} aria-busy={loading || seeking || undefined}>
|
|
590
|
+
<div className="pm-ui-timeline__tools">
|
|
591
|
+
<PmInput label={`${labels.position} (${labels.seconds})`} labelHidden={variant === 'editor'} width="time" density={density} inputMode="decimal" maxLength={32} value={raw ?? (position === undefined ? '' : (position / 1000).toFixed(3))} placeholder={labels.unknown} error={invalid ? labels.invalidPosition : undefined} disabled={cannotSeek}
|
|
592
|
+
onChange={event => setRaw(event.target.value)} onBlur={commitPosition}
|
|
593
|
+
onCompositionStart={() => { composing.current = true }} onCompositionEnd={() => { composing.current = false }}
|
|
594
|
+
onKeyDown={event => { if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Enter') { event.preventDefault(); commitPosition() } else if (event.key === 'Escape') setRaw(undefined) }} />
|
|
595
|
+
{variant === 'editor' && status}
|
|
596
|
+
<div className="pm-ui-timeline__zoom">
|
|
597
|
+
<PmIconButton icon="minus" label={labels.zoomOut} density={density} disabled={blocked || scale <= 1} onClick={() => zoomTo(scale / 2)} />
|
|
598
|
+
<output className="pm-ui-timeline__scale">{Math.round(scale * 100)}%</output>
|
|
599
|
+
<PmIconButton icon="plus" label={labels.zoomIn} density={density} disabled={blocked || scale >= 16} onClick={() => zoomTo(scale * 2)} />
|
|
600
|
+
<PmIconButton icon="fit" label={labels.fit} density={density} disabled={blocked} onClick={() => { zoomTo(1); viewport.current?.scrollTo({ left: 0 }) }} />
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
{variant !== 'editor' && status}
|
|
604
|
+
<div className="pm-ui-timeline__tracks">
|
|
605
|
+
<div className="pm-ui-timeline__labels" aria-hidden="true">{timed && <><span /><span /></>}{tracks.map(track => <span key={track.id} data-track-label="">{track.label}</span>)}</div>
|
|
606
|
+
<div ref={viewport} className="pm-ui-timeline__viewport" tabIndex={0} aria-label={label} aria-describedby={hintId}>
|
|
607
|
+
<div className="pm-ui-timeline__canvas" style={{ width: timed ? `${width * scale}px` : '100%' }}>
|
|
608
|
+
{timed && <><div className="pm-ui-timeline__ruler" aria-hidden="true" onClick={event => { const rect = event.currentTarget.getBoundingClientRect(); requestSeek((event.clientX - rect.left) / rect.width * span!) }}>
|
|
609
|
+
{ticks.map((time, index) => <span key={time} ref={index === 0 ? tick : undefined} data-end={time === span || undefined} style={{ left: `${time / span! * 100}%` }}>{timelineTime(time).replace(/\.000$/, '')}</span>)}
|
|
610
|
+
</div><PmRange label={labels.seek} value={position ?? 0} max={span!} disabled={cannotSeek} density={density} valueText={timelineTime} onPreviewChange={setPreviewTime} onValueCommit={requestSeek} /></>}
|
|
611
|
+
{rows.map(track => <div key={track.id} className="pm-ui-timeline__track" data-track={track.id} role="group" aria-label={track.label}>
|
|
612
|
+
{track.segments.map(segment => {
|
|
613
|
+
const description = `${segment.label}${segment.statusLabel ? ` · ${segment.statusLabel}` : ''} · ${segment.positioned ? `${segment.timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(segment.startMs!)}–${timelineTime(segment.endMs!)}` : labels.unknown}`
|
|
614
|
+
const content = <>{segment.wave && segment.positioned && <svg className="pm-ui-timeline__wave" viewBox="0 0 160 20" preserveAspectRatio="none" aria-hidden="true"><path d={segment.wave} /></svg>}<span>{segment.label}</span></>
|
|
615
|
+
return segment.positioned ? <button key={segment.id} type="button" className="pm-ui-timeline__segment" data-timing={segment.timing} aria-label={description} title={description} aria-pressed={segment.stepId ? segment.stepId === selectedStepId : undefined} disabled={blocked || segment.disabled || !segment.stepId} onClick={() => { if (onSelectSegment) onSelectSegment(track.id, segment); else if (segment.stepId) onSelectStep(segment.stepId) }} style={{ left: `${segment.startMs! / span! * 100}%`, width: `${(segment.endMs! - segment.startMs!) / span! * 100}%` }}>{content}</button>
|
|
616
|
+
: <span className="pm-ui-timeline__unknown" key={segment.id} title={description}>{segment.label} · {labels.unknown}</span>
|
|
617
|
+
})}
|
|
618
|
+
</div>)}
|
|
619
|
+
{timed && shownPosition !== undefined && <span className="pm-ui-timeline__cursor" data-preview={previewTime !== undefined || undefined} style={{ left: `${Math.max(0, Math.min(1, shownPosition / span!)) * 100}%` }} aria-hidden="true" />}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
{segmentPicker && pickerSegments.length > 0 && <div className="pm-ui-timeline__segment-picker"><PmSelect density="touch" label={segmentPicker.label} value="" disabled={cannotSeek || !onSelectSegment}
|
|
624
|
+
onChange={event => { const segment = pickerSegments.find(segment => segment.id === event.target.value); if (segment && !cannotSeek) onSelectSegment?.(segmentPicker.track, segment) }}>
|
|
625
|
+
<option value="">{segmentPicker.placeholder}</option>{pickerSegments.map(segment => <option key={segment.id} value={segment.id} disabled={segment.disabled}>{timelineTime(segment.startMs!)} · {segment.label}</option>)}
|
|
626
|
+
</PmSelect></div>}
|
|
627
|
+
{showSteps && <div className="pm-ui-timeline__steps">{steps.map(segment => <PmButton key={segment.stepId} density={density} disabled={blocked || segment.disabled} aria-pressed={segment.stepId === selectedStepId} variant={segment.stepId === selectedStepId ? 'accent' : 'default'} onClick={() => onSelectStep(segment.stepId!)}>{segment.label}</PmButton>)}</div>}
|
|
628
|
+
{!tracks.some(track => track.segments.length) && <PmNotice>{labels.empty}</PmNotice>}
|
|
629
|
+
<p id={hintId} className="pm-ui-sr-only">{labels.seekHint}</p>
|
|
630
|
+
</section>
|
|
631
|
+
}
|