@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,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
|
+
}
|