@lincyaw/workbench 0.1.0 → 0.2.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.
Files changed (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,179 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { SpeechEngineInfo, VoiceCatalogAnswer, VoicePreviewQuote } from '@lincyaw/kernel'
3
+ import { PmButton, PmDialog, PmField, PmInput, PmNotice, PmSearchList, PmSegmented, PmSelect } from '../components.tsx'
4
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { documentVoiceStepIds, previewDocumentVoiceChange, resolveDocumentStepVoice } from '../document-voices.ts'
6
+ import type { DocumentVoiceImpact } from '../document-voices.ts'
7
+ import { clearVoiceDraft, keepVoiceDraft, readVoiceDraft, voiceDraftStorageStatus } from '../voice-drafts.ts'
8
+ import type { VoiceDraft } from '../voice-drafts.ts'
9
+ import { PRODUCTION_MESSAGES } from '../production-messages.ts'
10
+ import { VOICE_CLONE_MESSAGES } from '../voice-clone-messages.ts'
11
+ import { VOICE_MESSAGES, voiceFailure } from '../voice-messages.ts'
12
+ import { prepareVoiceSnapshot, voiceSnapshotCurrent } from '../voice-snapshot.ts'
13
+ import type { VoiceSnapshot } from '../voice-snapshot.ts'
14
+ import { isAgentChanging } from '../workbench.ts'
15
+ import { VoiceCloneDialog } from './VoiceCloneDialog.tsx'
16
+ import { VoicePreviewDialog } from './VoicePreviewDialog.tsx'
17
+ import { VoiceScopeField } from './VoiceScopeField.tsx'
18
+
19
+ interface Review { draft: VoiceDraft, impact: DocumentVoiceImpact, scopeLabel: string, targets: string[], expected: { identity: string, version: string, fingerprint: string } }
20
+ const supportsLanguage = (languages: string[], lang: string) => !languages.length || languages.some(value => value.toLowerCase().split('-')[0] === lang.toLowerCase().split('-')[0])
21
+
22
+ /** Catalogue choices are drafts; one explicit, reviewed action changes the canonical source actor. */
23
+ export function DocumentVoiceDialog({ stepId, onClose }: { stepId: string, onClose(): void }) {
24
+ const workbench = useWorkbench(), actor = useWorkbenchState(state => state.authoring), locale = useWorkbenchState(state => state.locale)
25
+ const blocked = useWorkbenchState(state => Boolean(state.authoring?.loading || state.authoring?.composing) || isAgentChanging(state))
26
+ const t = VOICE_MESSAGES[locale], p = PRODUCTION_MESSAGES[locale], c = VOICE_CLONE_MESSAGES[locale]
27
+ const serviceVersion = useWorkbenchState(state => state.voiceServiceVersion)
28
+ const identity = useRef(actor!.identity!).current, directory = useRef(actor!.directory).current
29
+ const [restored] = useState(() => readVoiceDraft(workbench.client, identity, stepId))
30
+ const [draft, setDraft] = useState<VoiceDraft>(() => restored ?? { version: actor!.version!, scope: { kind: 'step', stepId }, source: resolveDocumentStepVoice(actor!.content!, stepId).value ?? null, replaceOverrides: false })
31
+ const [storageFailed, setStorageFailed] = useState(() => voiceDraftStorageStatus(workbench.client, identity, stepId) !== 'available')
32
+ const [engineFilter, setEngineFilter] = useState(draft.source?.type === 'tts' ? draft.source.voice.engine : undefined)
33
+ const [language, setLanguage] = useState(''), [query, setQuery] = useState(''), [catalog, setCatalog] = useState<VoiceCatalogAnswer>()
34
+ const [catalogBusy, setCatalogBusy] = useState(true), [catalogFailure, setCatalogFailure] = useState<string>(), [reload, setReload] = useState(0)
35
+ const [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>(), [review, setReview] = useState<Review>()
36
+ const [preview, setPreview] = useState<{ quote: VoicePreviewQuote, snapshot: VoiceSnapshot }>(), [cloning, setCloning] = useState(false)
37
+ const owner = useRef({ active: true, generation: 0 }), request = useRef<AbortController>()
38
+ useEffect(() => () => { owner.current.active = false; owner.current.generation++; request.current?.abort() }, [])
39
+ useEffect(() => {
40
+ const controller = new AbortController()
41
+ setCatalogBusy(true); setCatalogFailure(undefined)
42
+ void workbench.client.readVoiceCatalog(directory, engineFilter, undefined, controller.signal).then(value => {
43
+ if (!controller.signal.aborted) setCatalog(value)
44
+ }, error => { if (!controller.signal.aborted) setCatalogFailure(voiceFailure(error, locale)) }).finally(() => { if (!controller.signal.aborted) setCatalogBusy(false) })
45
+ return () => controller.abort()
46
+ }, [workbench, directory, engineFilter, reload, actor?.reset, locale, serviceVersion])
47
+ const document = actor?.content, validTarget = actor?.identity === identity && Boolean(document?.steps[stepId])
48
+ const engineId = engineFilter ?? catalog?.engine
49
+ const engines = catalog?.engines.filter(engine => engine.tts) ?? []
50
+ const engine: SpeechEngineInfo | undefined = engines.find(engine => engine.id === (draft.source?.type === 'tts' ? draft.source.voice.engine ?? engineId : engineId))
51
+ const voices = catalog?.catalog.status === 'available' ? catalog.catalog.voices : []
52
+ const lang = document?.metadata.lang ?? 'zh-CN'
53
+ const languages = [...new Set(voices.flatMap(voice => voice.lang))].sort()
54
+ const rateRange = engine?.parameters?.rate, styles = engine?.parameters?.styles
55
+ const normalizedRate = draft.rateInput?.normalize('NFKC').trim()
56
+ const rate = normalizedRate ? Number(normalizedRate) : undefined
57
+ const rateInvalid = draft.rateInput !== undefined && normalizedRate !== undefined && normalizedRate.length > 0 && (!rateRange || !/^\d+(?:\.\d+)?$/.test(normalizedRate) || !Number.isFinite(rate) || rate! < rateRange.min || rate! > rateRange.max)
58
+ const sourceType = draft.source === null ? 'inherit' : draft.source.type
59
+ const current = () => owner.current.active && workbench.getState().authoring?.identity === identity
60
+ const edit = (value: VoiceDraft) => {
61
+ request.current?.abort(); owner.current.generation++
62
+ setBusy(false); setFailure(undefined); setReview(undefined); setPreview(undefined); workbench.stopAuditions()
63
+ setDraft(value); setStorageFailed(!keepVoiceDraft(workbench.client, identity, stepId, value))
64
+ }
65
+ const checkedDraft = (value: VoiceDraft): VoiceDraft => ({ ...value, version: workbench.getState().authoring!.version! })
66
+ const checkScope = async (value = draft) => {
67
+ if (!current() || !validTarget || blocked || value.source?.type === 'tts' && (rateInvalid || !value.source.voice.voice.trim())) return
68
+ const generation = ++owner.current.generation, controller = new AbortController()
69
+ request.current?.abort(); request.current = controller; setBusy(true); setFailure(undefined)
70
+ try {
71
+ await workbench.flushPendingEdits(controller.signal)
72
+ if (!current() || generation !== owner.current.generation) return
73
+ const source = workbench.getState().authoring, latest = workbench.getDocument(identity)
74
+ if (!source?.version || !source.fingerprint || !latest || source.dirty || source.saving || source.error || source.restored || source.composing) throw new Error('voice-context-changed')
75
+ const proposal = checkedDraft(value), impact = previewDocumentVoiceChange(latest, proposal.scope, proposal.source, { replaceOverrides: proposal.replaceOverrides })
76
+ setDraft(proposal); setStorageFailed(!keepVoiceDraft(workbench.client, identity, stepId, proposal))
77
+ const targets = documentVoiceStepIds(latest, proposal.scope).map(id => { const step = latest.steps[id]!, page = latest.sections[step.sectionId]!; return `${page.title} · ${t.step(latest.sectionOrder.indexOf(page.id) + 1, page.stepOrder.indexOf(id) + 1)}` })
78
+ setReview({ draft: proposal, impact, scopeLabel: t.scopes[proposal.scope.kind], targets, expected: { identity, version: source.version, fingerprint: source.fingerprint } })
79
+ } catch (error) { if (current() && generation === owner.current.generation) setFailure(voiceFailure(error, locale)) }
80
+ finally { if (current() && generation === owner.current.generation) setBusy(false) }
81
+ }
82
+ const apply = async () => {
83
+ if (!review || !current()) return
84
+ const confirmation = review, generation = ++owner.current.generation
85
+ setBusy(true); setFailure(undefined)
86
+ try {
87
+ const saved = await workbench.applyDocumentVoice(confirmation.draft.scope, confirmation.draft.source, { replaceOverrides: confirmation.draft.replaceOverrides }, confirmation.expected)
88
+ if (!current() || generation !== owner.current.generation) return
89
+ if (!saved) throw new Error('voice-context-changed')
90
+ if (!clearVoiceDraft(workbench.client, identity, stepId, confirmation.draft)) { setStorageFailed(true); setReview(undefined); return }
91
+ onClose()
92
+ } catch (error) { if (current() && generation === owner.current.generation) { setReview(undefined); setFailure(voiceFailure(error, locale)) } }
93
+ finally { if (current() && generation === owner.current.generation) setBusy(false) }
94
+ }
95
+ const audition = async (mode: 'sample' | 'step') => {
96
+ if (draft.source?.type !== 'tts' || rateInvalid || !validTarget || blocked) return
97
+ const generation = ++owner.current.generation, controller = new AbortController(), voice = draft.source.voice
98
+ request.current?.abort(); request.current = controller; setBusy(true); setFailure(undefined)
99
+ try {
100
+ const snapshot = await prepareVoiceSnapshot(workbench, stepId, controller.signal)
101
+ const quote = await workbench.client.readVoicePreview(directory, { revision: snapshot.revision, voice, mode, ...mode === 'step' ? { target: snapshot.target } : {} }, controller.signal)
102
+ if (!current() || generation !== owner.current.generation) return
103
+ if (!voiceSnapshotCurrent(workbench, snapshot) || quote.source.version !== snapshot.source.version || quote.source.fingerprint !== snapshot.source.fingerprint || quote.source.documentId !== snapshot.source.documentId) throw new Error('voice-context-changed')
104
+ setPreview({ quote, snapshot })
105
+ } catch (error) { if (current() && generation === owner.current.generation) setFailure(voiceFailure(error, locale)) }
106
+ finally { if (current() && generation === owner.current.generation) setBusy(false) }
107
+ }
108
+ const reviewCurrent = Boolean(review && actor?.version === review.expected.version && actor.fingerprint === review.expected.fingerprint && !actor.dirty && !actor.saving && !actor.error && !blocked)
109
+ if (!document || !validTarget) return <PmDialog open label={t.settings} closeLabel={t.close} onClose={onClose}><PmNotice tone="warn">{p.draftChanged}</PmNotice></PmDialog>
110
+ return <>
111
+ <PmDialog open label={t.settings} closeLabel={t.close} onClose={onClose} footer={<><PmButton onClick={onClose}>{t.close}</PmButton><PmButton loading={busy} disabled={blocked || rateInvalid || draft.source?.type === 'tts' && !draft.source.voice.voice.trim() || draft.scope.kind === 'steps' && !draft.scope.stepIds.length} variant="primary" onClick={() => void checkScope()}>{t.check}</PmButton></>}>
112
+ <div className="pmw-voice__form">
113
+ {restored && <PmNotice tone="accent">{t.draftRecovered}</PmNotice>}
114
+ {storageFailed && <PmNotice tone="warn">{t.recoveryFailed}</PmNotice>}
115
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
116
+ <VoiceScopeField document={document} stepId={stepId} value={draft.scope} disabled={busy || blocked} onChange={scope => edit({ ...draft, scope, replaceOverrides: scope.kind === 'steps' && draft.source === null ? true : scope.kind === 'step' ? false : draft.replaceOverrides })} />
117
+ <PmField label={t.source} group><PmSegmented label={t.source} value={sourceType} disabled={busy || blocked} options={[
118
+ { value: 'tts', label: t.voice }, { value: 'none', label: p.none }, { value: 'inherit', label: t.inherit },
119
+ ...sourceType === 'recording' ? [{ value: 'recording', label: p.recording }] : [],
120
+ ]} onChange={value => {
121
+ if (value === 'none') edit({ ...draft, source: { type: 'none' }, rateInput: undefined })
122
+ else if (value === 'inherit') edit({ ...draft, source: null, rateInput: undefined, replaceOverrides: draft.scope.kind === 'steps' ? true : draft.replaceOverrides })
123
+ else if (value === 'tts') edit({ ...draft, source: { type: 'tts', voice: { ...engineId ? { engine: engineId } : {}, voice: '' } }, rateInput: undefined })
124
+ }} /></PmField>
125
+ {draft.source?.type === 'recording' && <span>{document.assets[draft.source.assetId]?.title || p.recording}</span>}
126
+ {draft.source?.type === 'tts' && <>
127
+ <PmSelect label={t.provider} value={engineId ?? ''} disabled={!engines.length || busy || blocked} onChange={event => { setEngineFilter(event.target.value); setLanguage(''); setQuery('') }}>
128
+ {!engines.length && <option value={engineId ?? ''}>{engineId ? `${engineId} · ${t.savedEngineUnavailable}` : t.noServiceOption}</option>}
129
+ {engines.map(engine => <option key={engine.id} value={engine.id}>{engine.id} · {engine.provider}</option>)}
130
+ {engineId && engines.length > 0 && !engines.some(engine => engine.id === engineId) && <option value={engineId}>{engineId} · {t.savedEngineUnavailable}</option>}
131
+ </PmSelect>
132
+ {catalogFailure && <PmButton small onClick={() => setReload(value => value + 1)}>{t.retry}</PmButton>}
133
+ {catalog?.catalog.status === 'unavailable' && <PmNotice tone="warn">{catalog.catalog.reason === 'not-configured' ? t.noService : t.catalogUnsupported}</PmNotice>}
134
+ {voices.length > 0 && <PmSelect label={t.language} value={language} disabled={busy || blocked} onChange={event => setLanguage(event.target.value)}><option value="">{t.allLanguages}</option>{languages.map(lang => <option key={lang} value={lang}>{lang}</option>)}</PmSelect>}
135
+ {(catalogBusy || catalogFailure || catalog?.catalog.status === 'available') && <PmSearchList label={t.available} searchLabel={t.search} searchPlaceholder={t.searchPlaceholder} emptyLabel={t.empty} loadingLabel={t.loadingVoices} loading={catalogBusy} error={catalogFailure} disabled={busy || blocked || catalog?.catalog.status !== 'available'} query={query} onQueryChange={setQuery}
136
+ value={draft.source.voice.engine === engineId || !draft.source.voice.engine ? draft.source.voice.voice : undefined}
137
+ items={voices.filter(voice => !language || voice.lang.includes(language)).map(voice => ({ value: voice.id, label: voice.label, description: [voice.lang.join(', '), voice.cloned ? c.cloned : '', supportsLanguage(voice.lang, lang) ? '' : t.languageMismatch].filter(Boolean).join(' · '), keywords: [voice.id, ...voice.lang, engineId ?? ''], disabled: !supportsLanguage(voice.lang, lang) }))}
138
+ resultsLabel={t.results} onValueChange={voice => edit({ ...draft, source: { type: 'tts', voice: { ...engineId ? { engine: engineId } : {}, voice } }, rateInput: undefined })} />}
139
+ {engine?.clone && <div className="pmw-voice__actions"><PmButton small disabled={busy || blocked} onClick={() => setCloning(true)}>{c.open}</PmButton></div>}
140
+ <PmField label={p.selectedVoice}><span>{draft.source.voice.voice || t.unset}</span></PmField>
141
+ {rateRange && <PmInput label={t.rate} width="number" inputMode="decimal" maxLength={32} value={draft.rateInput ?? (draft.source.voice.rate === undefined ? '' : String(draft.source.voice.rate))} error={rateInvalid ? t.rateInvalid(rateRange.min, rateRange.max) : undefined} disabled={busy || blocked} onChange={event => {
142
+ if (draft.source?.type !== 'tts') return
143
+ const raw = event.target.value, normalized = raw.normalize('NFKC').trim(), value = Number(normalized)
144
+ const valid = /^\d+(?:\.\d+)?$/.test(normalized) && Number.isFinite(value) && value >= rateRange.min && value <= rateRange.max
145
+ const voice = { ...draft.source.voice }
146
+ if (!normalized) delete voice.rate
147
+ else if (valid) voice.rate = value
148
+ edit({ ...draft, rateInput: raw, source: { type: 'tts', voice } })
149
+ }} />}
150
+ {styles?.length ? <PmSelect label={t.style} value={draft.source.voice.style ?? ''} disabled={busy || blocked} onChange={event => {
151
+ if (draft.source?.type !== 'tts') return
152
+ const voice = { ...draft.source.voice }
153
+ if (event.target.value) voice.style = event.target.value; else delete voice.style
154
+ edit({ ...draft, source: { type: 'tts', voice } })
155
+ }}><option value="">{t.styleDefault}</option>{styles.map(style => <option key={style} value={style}>{style}</option>)}</PmSelect> : null}
156
+ {draft.rateInput !== undefined && !rateRange && <PmNotice tone="warn">{p.draftChanged} <PmButton small onClick={() => edit({ ...draft, rateInput: undefined })}>{t.recheck}</PmButton></PmNotice>}
157
+ <div className="pmw-voice__actions"><PmButton small icon="audio" disabled={busy || blocked || !draft.source.voice.voice || rateInvalid} onClick={() => void audition('sample')}>{t.standard}</PmButton><PmButton small icon="audio" disabled={busy || blocked || !draft.source.voice.voice || rateInvalid} onClick={() => void audition('step')}>{p.currentScript}</PmButton></div>
158
+ <p className="pmw-pane__footer">{t.previewHint}</p>
159
+ </>}
160
+ {draft.scope.kind !== 'step' && !(draft.scope.kind === 'steps' && draft.source === null) && <PmField label={t.replace} group><PmSegmented label={t.replace} value={draft.replaceOverrides ? 'replace' : 'preserve'} disabled={busy || blocked} options={[{ value: 'preserve', label: t.preserve }, { value: 'replace', label: t.replaceAll }]} onChange={value => edit({ ...draft, replaceOverrides: value === 'replace' })} /></PmField>}
161
+ </div>
162
+ </PmDialog>
163
+ <PmDialog open={Boolean(review)} label={review?.draft.source === null ? p.restoreTitle : p.applyTitle} closeLabel={t.close} onClose={() => setReview(undefined)} busy={busy} description={review?.draft.source === null ? p.restoreHint : p.applyHint} footer={<><PmButton disabled={busy} onClick={() => setReview(undefined)}>{t.cancel}</PmButton><PmButton loading={busy} disabled={!reviewCurrent} variant={review?.draft.replaceOverrides && review.impact.overrides.pages + review.impact.overrides.steps > 0 ? 'danger' : 'primary'} onClick={() => void apply()}>{review?.draft.replaceOverrides && review.impact.overrides.pages + review.impact.overrides.steps > 0 ? p.replaceCount(review.impact.overrides.pages + review.impact.overrides.steps) : t.apply}</PmButton></>}>
164
+ {review && <div className="pmw-voice__form">
165
+ <PmField label={t.source}><span>{review.draft.source === null ? t.inherit : review.draft.source.type === 'none' ? p.none : review.draft.source.type === 'recording' ? document.assets[review.draft.source.assetId]?.title || p.recording : `${review.draft.source.voice.voice}${review.draft.source.voice.engine ? ` · ${review.draft.source.voice.engine}` : ''}`}</span></PmField>
166
+ {review.draft.source?.type === 'tts' && <>
167
+ {review.draft.source.voice.rate !== undefined && <PmField label={t.rate}><span>{review.draft.source.voice.rate}</span></PmField>}
168
+ {review.draft.source.voice.style && <PmField label={t.style}><span>{review.draft.source.voice.style}</span></PmField>}
169
+ </>}
170
+ <PmField label={t.scope}><span>{review.scopeLabel}</span></PmField><details><summary>{t.selected(review.targets.length)}</summary><ul>{review.targets.map(target => <li key={target}>{target}</li>)}</ul></details><p>{p.counts(review.impact.updated, review.impact.preserved, review.impact.unchanged)}</p><p>{p.overridden(review.impact.overrides.none, review.impact.overrides.recording)}</p>{review.draft.replaceOverrides && <PmNotice tone="warn">{t.replaceAll} {p.keepAssets}</PmNotice>}{!reviewCurrent && <PmNotice tone="warn">{p.draftChanged}</PmNotice>}
171
+ </div>}
172
+ </PmDialog>
173
+ {cloning && engine && <VoiceCloneDialog directory={directory} engine={engine} lang={lang} onClose={() => setCloning(false)} onUse={(voice) => {
174
+ setCloning(false); setReload(value => value + 1)
175
+ edit({ ...draft, source: { type: 'tts', voice: { engine: engine.id, voice } }, rateInput: undefined })
176
+ }} />}
177
+ {preview && <VoicePreviewDialog quote={preview.quote} snapshot={preview.snapshot} onClose={() => setPreview(undefined)} onRecheck={() => { const mode = preview.quote.mode; setPreview(undefined); void audition(mode) }} />}
178
+ </>
179
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { PmButton, PmField } from '../components.tsx'
3
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
4
+ import { resolveDocumentStepVoice } from '../document-voices.ts'
5
+ import { PRODUCTION_MESSAGES } from '../production-messages.ts'
6
+ import { isAgentChanging } from '../workbench.ts'
7
+ import { DocumentVoiceDialog } from './DocumentVoiceDialog.tsx'
8
+
9
+ /** One current voice and selector, shared by document captions and production. */
10
+ export function DocumentVoiceField({ stepId, disabled = false, onGenerate }: { stepId: string, disabled?: boolean, onGenerate?(): void }) {
11
+ const workbench = useWorkbench()
12
+ const source = useWorkbenchState(state => state.authoring)
13
+ const locale = useWorkbenchState(state => state.locale)
14
+ const blocked = useWorkbenchState(state => Boolean(state.authoring?.loading || state.authoring?.composing) || isAgentChanging(state))
15
+ const [open, setOpen] = useState(false)
16
+ useEffect(() => { setOpen(false) }, [source?.identity, stepId])
17
+ const document = source?.content, step = document?.steps[stepId], p = PRODUCTION_MESSAGES[locale]
18
+ if (!document || !step || !source?.identity) return null
19
+ const voice = resolveDocumentStepVoice(document, stepId)
20
+ const recording = voice.value?.type === 'recording' ? document.assets[voice.value.assetId] : undefined
21
+ const label = voice.value?.type === 'tts' ? voice.value.voice.voice
22
+ : voice.value?.type === 'none' ? p.none
23
+ : voice.value?.type === 'recording' ? recording?.title || p.recording : p.unset
24
+ return <>
25
+ <PmField group label={p.voice} hint={p.inherited[voice.inheritedFrom]}>
26
+ <span>{label}</span>
27
+ <div className="pmw-voice__actions">
28
+ <PmButton small icon="audio" disabled={disabled || blocked} onClick={() => { workbench.pauseDocumentStep(step.sectionId, stepId); workbench.stopAuditions(); setOpen(true) }}>{p.choose}</PmButton>
29
+ {onGenerate && <PmButton small icon="refresh" disabled={disabled || blocked} onClick={onGenerate}>{p.generate}</PmButton>}
30
+ {workbench.client.openSpeechSettings && <PmButton small variant="ghost" icon="sliders" onClick={() => { workbench.stopAuditions(); void workbench.pausePlayback(true).catch(() => {}); workbench.client.openSpeechSettings?.() }}>{p.serviceSettings}</PmButton>}
31
+ </div>
32
+ </PmField>
33
+ {open && <DocumentVoiceDialog key={`${source.identity}:${stepId}`} stepId={stepId} onClose={() => setOpen(false)} />}
34
+ </>
35
+ }
@@ -0,0 +1,133 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import type { CompileJob, OutputTarget } from '@lincyaw/kernel'
4
+ import type { IconName } from '@lincyaw/design'
5
+ import { PmBadge, PmButton, PmDialog, PmEmpty, PmIcon, PmIconButton, PmMascot, PmNotice } from '../components.tsx'
6
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
7
+ import { failureReason, timeAgo } from '../messages.ts'
8
+
9
+ // The media types a browser shows by itself; a file of another type is downloaded.
10
+ const SHOWN = new Set(['text/html', 'application/pdf'])
11
+ const isVideo = (type: string | undefined) => type?.startsWith('video/') === true
12
+
13
+ const TONES: Record<CompileJob['state'], 'neutral' | 'accent' | 'ok' | 'warn' | 'danger'> = { queued: 'neutral', running: 'accent', done: 'ok', failed: 'danger', cancelled: 'warn' }
14
+
15
+ function iconOf(target: string): IconName {
16
+ if (target === 'web') return 'website'
17
+ if (target === 'slides' || target === 'pdf' || target === 'pptx') return 'slides'
18
+ if (target === 'notebook') return 'notebook'
19
+ if (target === 'video' || target === 'lecture') return 'video'
20
+ return 'export'
21
+ }
22
+
23
+ /**
24
+ * Making the document into files, as Word's Export: what it can be made into, each a button, and what was made, by the
25
+ * author or the AI, newest first, with its progress, its log, and the file to open, play or download.
26
+ */
27
+ export function ExportPanel({ documentOnly = false, embedded = false, onClose }: { documentOnly?: boolean, embedded?: boolean, onClose(): void }) {
28
+ const t = useMessages()
29
+ const workbench = useWorkbench()
30
+ const document = useWorkbenchState(state => state.document)
31
+ useEffect(() => workbench.observeJobs(), [workbench, document])
32
+ const answer = useWorkbenchState(state => state.jobs)
33
+ const failure = useWorkbenchState(state => state.jobsFailure)
34
+ const source = useWorkbenchState(state => state.authoring)
35
+ const preparing = useWorkbenchState(state => state.jobPreparing)
36
+ const locale = useWorkbenchState(state => state.locale)
37
+ const [playing, setPlaying] = useState<CompileJob>()
38
+ const labelOf = (target: Pick<OutputTarget, 'id' | 'label'>) => (target.label[locale] !== target.id ? target.label[locale] : undefined) ?? t.outputLabels[target.id] ?? target.id
39
+ const included = (id: string) => !documentOnly || id !== 'video' && id !== 'lecture'
40
+ const targets = (answer?.targets ?? []).filter(target => included(target.id))
41
+ const jobs = (answer?.jobs ?? []).filter(job => included(job.target))
42
+ const label = (id: string) => labelOf(targets.find(target => target.id === id) ?? { id, label: {} })
43
+ const busy = (id: string) => jobs.some(job => job.target === id && (job.state === 'queued' || job.state === 'running'))
44
+ return (
45
+ <section className="pmw-pane" aria-labelledby={embedded ? undefined : 'pmw-export-title'} aria-label={embedded ? t.exportPane : undefined}>
46
+ {!embedded && <header className="pmw-pane__header">
47
+ <h2 id="pmw-export-title" className="pmw-pane__title">{documentOnly ? t.documentExport : t.exportPane}</h2>
48
+ <PmIconButton small icon="close" label={t.close} onClick={onClose} />
49
+ </header>}
50
+ {failure && <PmNotice tone="danger">{t.outputFailed(t.jobFailureReasons[failure] ?? failureReason(t, failure))}</PmNotice>}
51
+ {preparing && <PmNotice>{preparing.canCancel ? t.outputConfirmingSave : t.outputStarting} {preparing.canCancel && <PmButton small onClick={() => workbench.cancelJobPreparation()}>{t.cancelOutput}</PmButton>}</PmNotice>}
52
+ {!embedded && <h3 id="pmw-export-make" className="pmw-export__subtitle">{t.makeLabel}</h3>}
53
+ <ul className="pmw-export__targets" aria-labelledby={embedded ? undefined : 'pmw-export-make'} aria-label={embedded ? t.makeLabel : undefined}>
54
+ {targets.map(target => (
55
+ <li key={target.id}>
56
+ <PmButton small loading={preparing?.target === target.id} icon={iconOf(target.id)} aria-label={t.makeOutput(labelOf(target))} disabled={busy(target.id) || preparing !== undefined} onClick={() => void workbench.startJob(target.id)}>{labelOf(target)}</PmButton>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ <h3 className="pmw-export__subtitle">{t.outputsList}</h3>
61
+ {jobs.length === 0
62
+ ? <PmEmpty icon="export">{t.noOutputs}</PmEmpty>
63
+ : (
64
+ <ol className="pmw-jobs">
65
+ {jobs.map(job => <JobItem key={job.id} job={job} label={label(job.target)} current={Boolean(!source?.error && !source?.loading && !source?.dirty && !source?.saving && !source?.composing && !source?.restored && job.source && job.source.version === source?.version && job.source.fingerprint === source?.fingerprint)} onPlay={() => setPlaying(job)} />)}
66
+ </ol>
67
+ )}
68
+ <VideoDialog job={playing} label={playing ? label(playing.target) : ''} onClose={() => setPlaying(undefined)} />
69
+ </section>
70
+ )
71
+ }
72
+
73
+ /** A video a job made, played in a dialog with its captions (those in the workbench's language shown). */
74
+ export function VideoDialog({ job, label, onClose }: { job: CompileJob | undefined, label: string, onClose(): void }) {
75
+ const t = useMessages()
76
+ const locale = useWorkbenchState(state => state.locale)
77
+ return (
78
+ <PmDialog open={job !== undefined} label={label} closeLabel={t.close} onClose={onClose}>
79
+ {job?.main && (
80
+ <video className="pmw-export__video" controls autoPlay src={`${job.url}${job.main}`} crossOrigin="anonymous">
81
+ {Object.entries(job.captions ?? {}).map(([lang, path]) => <track key={lang} kind="subtitles" srcLang={lang} label={lang} src={`${job.url}${path}`} default={lang === locale} />)}
82
+ </video>
83
+ )}
84
+ </PmDialog>
85
+ )
86
+ }
87
+
88
+ function JobItem({ job, label, current, onPlay }: { job: CompileJob, label: string, current: boolean, onPlay(): void }) {
89
+ const t = useMessages()
90
+ const workbench = useWorkbench()
91
+ const locale = useWorkbenchState(state => state.locale)
92
+ const main = job.main && `${job.url}${job.main}`
93
+ const type = job.files.find(file => file.path === job.main)?.type
94
+ const progress = job.progress
95
+ return (
96
+ <li className="pmw-job" data-state={job.state}>
97
+ <span className="pmw-job__icon" aria-hidden="true"><PmIcon name={iconOf(job.target)} size={16} /></span>
98
+ <div className="pmw-job__body">
99
+ <p className="pmw-job__title">{label}</p>
100
+ <p className="pmw-job__meta">
101
+ <PmBadge tone={TONES[job.state]}>{t.jobStates[job.state]}</PmBadge>
102
+ <time dateTime={job.createdAt} title={new Date(job.createdAt).toLocaleString(locale)}>{timeAgo(job.createdAt, locale)}</time>
103
+ {job.state === 'done' && <span>· {!job.source ? t.outputVersionUnknown : current ? t.currentContent : t.olderContent}</span>}
104
+ {job.state === 'done' && job.files.length > 1 && <span>· {t.outputFiles(job.files.length)}</span>}
105
+ </p>
106
+ {job.state === 'running' && progress && (
107
+ <div className="pmw-job__progress">
108
+ {/* the kitten runs along the bar to how far the job has come; with no total yet it walks at the start */}
109
+ <span className="pmw-job__runner" style={{ '--pmw-job-done': progress.total ? Math.min(1, (progress.done ?? 0) / progress.total) : 0 } as CSSProperties}>
110
+ <PmMascot size="small" action={progress.total ? 'run' : 'walk'} />
111
+ </span>
112
+ <progress max={progress.total ?? 1} value={progress.total ? progress.done ?? 0 : undefined} aria-label={label} />
113
+ <span>{t.jobProgress(t.jobPhases[progress.phase] ?? t.outputLabels[progress.phase] ?? progress.phase, progress.done, progress.total)}</span>
114
+ </div>
115
+ )}
116
+ {job.error && <p className="pmw-job__error">{t.jobFailureReasons[job.error] ?? failureReason(t, job.error)}</p>}
117
+ {job.log.length > 0 && (
118
+ <details className="pmw-job__log">
119
+ <summary>{t.outputLog}</summary>
120
+ {/* Scrolled to its end, the latest lines in sight. */}
121
+ <div className="pmw-job__log-lines"><pre>{job.log.join('\n')}</pre></div>
122
+ </details>
123
+ )}
124
+ <div className="pmw-job__actions">
125
+ {job.state === 'done' && main && isVideo(type) && <PmButton small icon="play" onClick={onPlay}>{t.playOutput}</PmButton>}
126
+ {job.state === 'done' && main && type && SHOWN.has(type) && <a className="pm-ui-button pm-ui-button--small" href={main} target="_blank" rel="noopener"><PmIcon name="external" size={14} />{t.openOutput}</a>}
127
+ {job.state === 'done' && main && type !== 'text/html' && <a className="pm-ui-button pm-ui-button--small" href={`${main}?download`}><PmIcon name="download" size={14} />{t.downloadOutput}</a>}
128
+ {job.canCancel !== false && (job.state === 'queued' || job.state === 'running') && <PmButton small onClick={() => void workbench.cancelJob(job.id)}>{t.cancelOutput}</PmButton>}
129
+ </div>
130
+ </div>
131
+ </li>
132
+ )
133
+ }
@@ -0,0 +1,88 @@
1
+ import type { HistoryRevision } from '@lincyaw/kernel'
2
+ import { PmBadge, PmButton, PmEmpty, PmIcon, PmIconButton, PmNotice } from '../components.tsx'
3
+ import type { IconName } from '@lincyaw/design'
4
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { timeAgo } from '../messages.ts'
6
+ import { isAgentChanging } from '../workbench.ts'
7
+
8
+ /** A host-owned operation on a version, such as restoring an entire saved snapshot. This never replaces selective undo. */
9
+ export interface HistoryRevisionAction {
10
+ label: string
11
+ icon?: IconName
12
+ onSelect(revision: HistoryRevision): void
13
+ disabled?: boolean | ((revision: HistoryRevision) => boolean)
14
+ /** The revision whose operation the host is performing. Other history actions wait for it. */
15
+ pendingRevision?: string
16
+ }
17
+
18
+ export interface HistoryPanelProps {
19
+ onClose(): void
20
+ revisionAction?: HistoryRevisionAction
21
+ }
22
+
23
+ /** The document's versions, newest first: who changed what and when, with selective undo and optional host operations. */
24
+ export function HistoryPanel({ onClose, revisionAction }: HistoryPanelProps) {
25
+ const t = useMessages()
26
+ const workbench = useWorkbench()
27
+ const history = useWorkbenchState(state => state.history)
28
+ const outside = useWorkbenchState(state => state.outside)
29
+ const changing = useWorkbenchState(state => state.isHistoryBusy || state.isHistoryPreparing || isAgentChanging(state))
30
+ const busy = changing || revisionAction?.pendingRevision !== undefined
31
+ const readOnly = workbench.client.capabilities?.readOnly === true
32
+ const locale = useWorkbenchState(state => state.locale)
33
+ const memberId = useWorkbenchState(state => state.authoring?.member?.id)
34
+ const revisions = history?.revisions ?? []
35
+ // Undoing and redoing are not versions of their own: they mark the change they act on.
36
+ const changes = revisions.filter(revision => !revision.reverts)
37
+ const who = (revision: HistoryRevision) => (revision.origin.kind === 'agent' ? t.byAi : revision.origin.kind === 'document' ? t.byDocument : revision.origin.member?.id && revision.origin.member.id !== memberId ? revision.author : t.byYou)
38
+ const icon = (revision: HistoryRevision): IconName => (revision.origin.kind === 'agent' ? 'sparkle' : revision.origin.kind === 'document' ? 'document' : 'edit')
39
+ return (
40
+ <section className="pmw-pane" aria-labelledby="pmw-history-title">
41
+ <header className="pmw-pane__header">
42
+ <h2 id="pmw-history-title" className="pmw-pane__title">{t.versionHistory}</h2>
43
+ <PmIconButton small icon="close" label={t.close} onClick={onClose} />
44
+ </header>
45
+ {outside && outside.length > 0 && (
46
+ <PmNotice tone="warn">
47
+ <span>{t.outside(outside.length)}</span>
48
+ <PmButton small disabled={busy} onClick={() => void workbench.recordOutsideChanges()}>{t.recordOutside}</PmButton>
49
+ </PmNotice>
50
+ )}
51
+ {changes.length === 0
52
+ ? <PmEmpty icon="versions">{t.noHistory}</PmEmpty>
53
+ : (
54
+ <ol className="pmw-versions">
55
+ {changes.map((revision) => {
56
+ const undone = revision.undone === true
57
+ const [summary = ''] = revision.message.split('\n')
58
+ const hostDisabled = typeof revisionAction?.disabled === 'function' ? revisionAction.disabled(revision) : revisionAction?.disabled
59
+ return (
60
+ <li key={revision.id} className="pmw-version" data-origin={revision.origin.kind} data-undone={undone || undefined}>
61
+ <span className="pmw-version__icon" aria-hidden="true"><PmIcon name={icon(revision)} /></span>
62
+ <div className="pmw-version__body">
63
+ <p className="pmw-version__summary">{summary}</p>
64
+ <p className="pmw-version__meta">
65
+ <span>{who(revision)}</span>
66
+ <span>·</span>
67
+ <time dateTime={revision.date} title={new Date(revision.date).toLocaleString(locale)}>{timeAgo(revision.date, locale)}</time>
68
+ {undone && <PmBadge>{t.undone}</PmBadge>}
69
+ </p>
70
+ </div>
71
+ {!revision.operation && revision.revertible !== false && (
72
+ <PmButton small className="pmw-version__action" disabled={readOnly || busy || history?.loading} onClick={() => void workbench.revertRevision(revision.revertTarget ?? revision.id)}>
73
+ {undone ? t.redoChange : t.undoChange}
74
+ </PmButton>
75
+ )}
76
+ {revisionAction && <PmButton small className="pmw-version__action" icon={revisionAction.icon} disabled={readOnly || busy || history?.loading || hostDisabled} loading={revisionAction.pendingRevision === revision.id} onClick={() => revisionAction.onSelect(revision)}>{revisionAction.label}</PmButton>}
77
+ </li>
78
+ )
79
+ })}
80
+ </ol>
81
+ )}
82
+ {(history?.nextOffset !== undefined || history?.previousOffset !== undefined) && <div className="pmw-history-pages">
83
+ <PmButton small disabled={busy || history.previousOffset === undefined} loading={Boolean(history.loading)} onClick={() => { if (history.previousOffset !== undefined) void workbench.readHistoryPage(history.previousOffset) }}>{t.historyNewer}</PmButton>
84
+ <PmButton small disabled={busy || history.nextOffset === undefined} loading={Boolean(history.loading)} onClick={() => { if (history.nextOffset !== undefined) void workbench.readHistoryPage(history.nextOffset) }}>{t.historyOlder}</PmButton>
85
+ </div>}
86
+ </section>
87
+ )
88
+ }
@@ -0,0 +1,184 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { errorMessage } from '@lincyaw/kernel'
3
+ import type { DocumentSummary, ProjectPlan } from '@lincyaw/kernel'
4
+ import { PmButton, PmDialog, PmEmpty, PmInput, PmSelect, PmIcon, PmMenu, PmNotice } from '../components.tsx'
5
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { contentText } from '../messages.ts'
7
+
8
+ type Editing =
9
+ | { op: 'create' }
10
+ | { op: 'rename' | 'delete', doc: DocumentSummary }
11
+
12
+ /** The documents of the workspace: open one, make a new one, rename, copy or delete one. */
13
+ export function HomePanel() {
14
+ const t = useMessages()
15
+ const workbench = useWorkbench()
16
+ const answer = useWorkbenchState(state => state.documents)
17
+ const failure = useWorkbenchState(state => state.documentsFailure)
18
+ const bundleDisabled = useWorkbenchState(state => state.isBundleDisabled)
19
+ const [editing, setEditing] = useState<Editing>()
20
+ const [query, setQuery] = useState('')
21
+ const [done, setDone] = useState<string>()
22
+ const [error, setError] = useState<string>()
23
+ const [working, setWorking] = useState(false)
24
+ const handingOff = useRef(false)
25
+ useEffect(() => {
26
+ void workbench.loadDocuments()
27
+ }, [workbench])
28
+ const documents = answer?.documents ?? []
29
+ const operable = Boolean(answer?.git) && !answer?.single
30
+ const shown = documents.filter(doc => doc.title.toLowerCase().includes(query.trim().toLowerCase()))
31
+
32
+ const run = async (work: () => Promise<string>) => {
33
+ setError(undefined)
34
+ setWorking(true)
35
+ try {
36
+ setDone(await work())
37
+ setEditing(undefined)
38
+ } catch (failed) {
39
+ setError(errorMessage(failed))
40
+ } finally {
41
+ setWorking(false)
42
+ }
43
+ }
44
+
45
+ return (
46
+ <section className="pmw-home" aria-labelledby="pmw-home-title">
47
+ <header className="pmw-home__bar">
48
+ <h1 id="pmw-home-title" className="pmw-home__title">{answer?.title ?? t.documents}</h1>
49
+ {operable && <PmButton variant="primary" icon="plus" disabled={working} onClick={() => { handingOff.current = false; setError(undefined); setEditing({ op: 'create' }) }}>{t.newDocument}</PmButton>}
50
+ </header>
51
+ {failure && <PmNotice tone="danger">{bundleDisabled ? t.bundleDisabled : failure}</PmNotice>}
52
+ {done && <PmNotice tone="ok">{done}</PmNotice>}
53
+ {error && !editing && <PmNotice tone="danger">{error}</PmNotice>}
54
+ {answer && !answer.git && !answer.single && <PmNotice tone="warn">{t.notGit}</PmNotice>}
55
+ <div className="pmw-home__heading-row">
56
+ <h2 className="pmw-home__heading">{t.allDocuments}</h2>
57
+ {documents.length > 6 && (
58
+ <div className="pmw-home__search"><PmInput label={t.search} type="search" prefixIcon="search" value={query} placeholder={t.search} onChange={event => setQuery(event.target.value)} /></div>
59
+ )}
60
+ </div>
61
+ {answer && documents.length === 0
62
+ ? <PmEmpty icon="document" title={t.noDocuments} />
63
+ : shown.length === 0 && query
64
+ ? <p className="pmw-muted">{t.noMatch}</p>
65
+ : (
66
+ <ul className="pmw-home__cards" aria-label={t.allDocuments}>
67
+ {shown.map(doc => (
68
+ <li key={doc.dir} className="pmw-home__card" data-document={doc.dir}>
69
+ <button type="button" className="pmw-home__open" onClick={() => void workbench.openDocument(doc.dir)}>
70
+ <span className="pmw-home__doc-icon" aria-hidden="true"><PmIcon name="document" /></span>
71
+ <span className="pmw-home__doc-title">{doc.title}</span>
72
+ </button>
73
+ {operable && (
74
+ <PmMenu
75
+ label={t.more(doc.title)}
76
+ items={[{ id: 'rename', label: t.rename, icon: 'edit', disabled: working }, { id: 'copy', label: t.makeCopy, icon: 'copy', disabled: working || doc.dir === '' }, { id: 'delete', label: t.remove, icon: 'trash', danger: true, disabled: working || doc.dir === '' }]}
77
+ onSelect={(id) => {
78
+ if (id === 'copy') {
79
+ const title = contentText(doc.lang).copyTitle(doc.title)
80
+ void run(async () => {
81
+ await workbench.copyDocument(doc.dir, title)
82
+ return t.copied(title)
83
+ })
84
+ } else { setError(undefined); setEditing({ op: id as 'rename' | 'delete', doc }) }
85
+ }}
86
+ />
87
+ )}
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ )}
92
+ {editing && (
93
+ <DocumentDialog
94
+ editing={editing}
95
+ templates={answer?.templates ?? []}
96
+ error={error}
97
+ working={working}
98
+ onClose={() => {
99
+ setEditing(undefined)
100
+ setError(undefined)
101
+ }}
102
+ onCloseAutoFocus={event => {
103
+ if (!handingOff.current) return
104
+ handingOff.current = false
105
+ event.preventDefault()
106
+ }}
107
+ onCreate={(title, template) => void run(async () => {
108
+ const dir = await workbench.createDocument(title, template)
109
+ handingOff.current = true
110
+ await workbench.openDocument(dir, { focus: 'body' })
111
+ return t.created(title)
112
+ })}
113
+ onRename={(doc, title) => void run(async () => {
114
+ await workbench.retitleDocument(doc.dir, title)
115
+ return t.renamed(title)
116
+ })}
117
+ onDelete={(doc, plan) => void run(async () => {
118
+ await workbench.operateDocument({ op: 'delete', dir: doc.dir }, plan)
119
+ return t.removed(doc.title)
120
+ })}
121
+ />
122
+ )}
123
+ </section>
124
+ )
125
+ }
126
+
127
+ function DocumentDialog({ editing, templates, error, working, onClose, onCloseAutoFocus, onCreate, onRename, onDelete }: {
128
+ editing: Editing
129
+ templates: string[]
130
+ error?: string
131
+ working: boolean
132
+ onClose(): void
133
+ onCloseAutoFocus(event: Event): void
134
+ onCreate(title: string, template?: string): void
135
+ onRename(doc: DocumentSummary, title: string): void
136
+ onDelete(doc: DocumentSummary, plan: ProjectPlan): void
137
+ }) {
138
+ const t = useMessages()
139
+ const workbench = useWorkbench()
140
+ const [plan, setPlan] = useState<ProjectPlan>()
141
+ const [planFailure, setPlanFailure] = useState<string>()
142
+ useEffect(() => {
143
+ if (editing.op !== 'delete') return
144
+ let active = true
145
+ void workbench.planDocumentOperation({ op: 'delete', dir: editing.doc.dir }).then(value => { if (active) setPlan(value) }, error => { if (active) setPlanFailure(errorMessage(error)) })
146
+ return () => { active = false }
147
+ }, [workbench, editing])
148
+ const [title, setTitle] = useState(editing.op === 'rename' ? editing.doc.title : '')
149
+ const [template, setTemplate] = useState(templates.includes('default') ? 'default' : templates[0] ?? '')
150
+ const label = editing.op === 'create' ? t.newDocument : editing.op === 'rename' ? t.rename : t.remove
151
+ return (
152
+ <PmDialog open label={label} closeLabel={t.close} name="pmw-operation" busy={working} onClose={onClose} onCloseAutoFocus={onCloseAutoFocus}>
153
+ <form
154
+ className="pmw-operation"
155
+ onKeyDown={event => { if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)) event.preventDefault() }}
156
+ onSubmit={(event) => {
157
+ event.preventDefault()
158
+ if (working) return
159
+ if (editing.op === 'create') onCreate(title.trim(), template || undefined)
160
+ else if (editing.op === 'rename') onRename(editing.doc, title.trim())
161
+ else if (plan) onDelete(editing.doc, plan)
162
+ }}
163
+ >
164
+ {editing.op === 'delete'
165
+ ? <><p>{t.confirmRemove(editing.doc.title)}</p><p className="pmw-muted">{plan ? t.removeImpact(plan.impact.files) : planFailure ?? t.projectInspecting}</p></>
166
+ : (
167
+ <PmInput label={t.title} density="regular" value={title} required autoFocus readOnly={working} placeholder={t.titlePlaceholder} onChange={event => setTitle(event.target.value)} />
168
+ )}
169
+ {editing.op === 'create' && templates.length > 1 && (
170
+ <PmSelect label={t.template} density="regular" value={template} disabled={working} onChange={event => setTemplate(event.target.value)}>
171
+ {templates.map(name => <option key={name} value={name}>{name}</option>)}
172
+ </PmSelect>
173
+ )}
174
+ {error && <PmNotice tone="danger">{error}</PmNotice>}
175
+ <div className="pmw-operation__actions">
176
+ <PmButton disabled={working} onClick={onClose}>{t.cancel}</PmButton>
177
+ <PmButton type="submit" variant={editing.op === 'delete' ? 'danger' : 'primary'} loading={working} disabled={editing.op === 'delete' ? !plan : !title.trim()}>
178
+ {editing.op === 'create' ? t.create : editing.op === 'rename' ? t.save : t.remove}
179
+ </PmButton>
180
+ </div>
181
+ </form>
182
+ </PmDialog>
183
+ )
184
+ }