@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.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,462 @@
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
+ import type { ReactNode, RefObject } from 'react'
3
+ import { PmButton, PmIconButton, PmMascot, PmMenu, PmNotice, PmSkeleton, PmSortableList, PmToolbar, PmToolbarButton } from '../components.tsx'
4
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { failureReason, timeAgo } from '../messages.ts'
6
+ import { SHORTCUTS } from '../shortcuts.ts'
7
+ import { DocumentOutline, DocumentWordCounter } from '../outline.ts'
8
+ import { isAgentChanging } from '../workbench.ts'
9
+ import { ExportPanel } from './ExportPanel.tsx'
10
+ import { HistoryPanel } from './HistoryPanel.tsx'
11
+ import type { HistoryRevisionAction } from './HistoryPanel.tsx'
12
+ import { PreviewPanel } from './PreviewPanel.tsx'
13
+ import { ProductionPanel } from './ProductionPanel.tsx'
14
+ import { PRODUCTION_MESSAGES } from '../production-messages.ts'
15
+ import { PageRenameDialog } from './PageRenameDialog.tsx'
16
+ import { PageDeleteDialog } from './PageDeleteDialog.tsx'
17
+ import { SourceDocumentPanel } from './SourceDocumentPanel.tsx'
18
+ import { DocumentNarrationPanel } from './DocumentNarrationPanel.tsx'
19
+
20
+ export type DocumentAside = 'comments' | 'history' | 'export' | 'narration'
21
+
22
+ const isTyping = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || target.closest('input, textarea, select, [contenteditable]') !== null)
23
+
24
+ // The navigation pane starts open where there is room for it beside the page.
25
+ const ROOMY = '(min-width: 1280px)'
26
+ // Below this width the command bar keeps one row: its less frequent commands move into its "More" menu.
27
+ const NARROW = '(max-width: 767px)'
28
+
29
+ function useMediaQuery(query: string): boolean {
30
+ return useSyncExternalStore(changed => {
31
+ if (typeof matchMedia !== 'function') return () => {}
32
+ const list = matchMedia(query)
33
+ list.addEventListener('change', changed)
34
+ return () => list.removeEventListener('change', changed)
35
+ }, () => typeof matchMedia === 'function' && matchMedia(query).matches, () => false)
36
+ }
37
+
38
+ // Esc belongs to the topmost dialog or menu first; only then does it leave the presentation.
39
+ const hasOpenLayer = () => document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]') !== null
40
+
41
+ /**
42
+ * The open document as an editor shows it: the command bar, what needs the author's attention, the navigation pane,
43
+ * the page (or the slide) on its canvas, the comments or the history, and the status bar. `framed`: the layout around
44
+ * it shows the document's title and the way back to the documents, so the command bar does not.
45
+ */
46
+ export function DocumentView({ active = true, framed = false, onBack, actions, historyRevisionAction }: { active?: boolean, framed?: boolean, onBack?(): void, actions?: ReactNode, historyRevisionAction?: HistoryRevisionAction }) {
47
+ const workbench = useWorkbench()
48
+ const [aside, setAside] = useState<DocumentAside | null>(null)
49
+ const [workspace, setWorkspace] = useState<'document' | 'reading' | 'presentation' | 'production'>('document')
50
+ const presenting = workspace === 'presentation', producing = workspace === 'production', reading = workspace === 'reading'
51
+ const [changingView, setChangingView] = useState(false), [viewFailure, setViewFailure] = useState<string>()
52
+ const transition = useRef(0), exiting = useRef<Promise<void>>()
53
+ const preparing = useRef(false)
54
+ const presentButton = useRef<HTMLButtonElement>(null)
55
+ const [commentsPortal, setCommentsPortal] = useState<HTMLDivElement | null>(null)
56
+ const t = useMessages()
57
+ const document = useWorkbenchState(state => state.document)
58
+ const view = useWorkbenchState(state => state.webView)
59
+ const previousView = useRef({ document, workspace, view, active })
60
+ const currentScope = useRef({ document, active })
61
+ currentScope.current = { document, active }
62
+ const [navigation, setNavigation] = useState(() => typeof matchMedia === 'function' && matchMedia(ROOMY).matches)
63
+ const roomy = useMediaQuery(ROOMY)
64
+ const previousRoomy = useRef(roomy)
65
+ useEffect(() => {
66
+ if (previousRoomy.current && !roomy) setNavigation(false)
67
+ previousRoomy.current = roomy
68
+ }, [roomy])
69
+ // Where the pane covers the page, another document opens with it closed, so its body is not hidden.
70
+ const [navigationDocument, setNavigationDocument] = useState(document)
71
+ if (navigationDocument !== document) {
72
+ setNavigationDocument(document)
73
+ if (navigation && typeof matchMedia === 'function' && !matchMedia(ROOMY).matches) setNavigation(false)
74
+ }
75
+ const compilePresentation = async (ready?: () => void, forReading = false): Promise<void> => {
76
+ if (preparing.current || workbench.getState().isPreparingPresentation) return
77
+ preparing.current = true
78
+ const generation = ++transition.current, identity = workbench.getState().authoring?.identity
79
+ setViewFailure(undefined)
80
+ setChangingView(true)
81
+ const current = () => generation === transition.current && currentScope.current.active && currentScope.current.document === document && workbench.getState().authoring?.identity === identity
82
+ try {
83
+ const prepared = await workbench.preparePresentation()
84
+ if (!current()) return
85
+ if (prepared) ready?.()
86
+ else setViewFailure(forReading ? t.readingFailed : workbench.getState().presentationFailure)
87
+ } catch { if (current()) setViewFailure(forReading ? t.readingFailed : t.presentationCompileFailed) }
88
+ finally { preparing.current = false; if (current()) setChangingView(false) }
89
+ }
90
+ const openProduction = async () => {
91
+ if (changingView || workbench.client.capabilities?.production === false) return
92
+ const generation = ++transition.current, identity = workbench.getState().authoring?.identity
93
+ const current = () => generation === transition.current && currentScope.current.active && currentScope.current.document === document && workbench.getState().authoring?.identity === identity
94
+ setChangingView(true); setViewFailure(undefined)
95
+ try {
96
+ await workbench.pausePlayback()
97
+ await workbench.flushPendingEdits()
98
+ if (current()) { workbench.setMode('editing'); setWorkspace('production') }
99
+ } catch { if (current()) setViewFailure(t.presentationPending) }
100
+ finally { if (current()) setChangingView(false) }
101
+ }
102
+ const exitPresent = (): Promise<void> => {
103
+ if (exiting.current) return exiting.current
104
+ const generation = ++transition.current, identity = workbench.getState().authoring?.identity
105
+ setChangingView(true); setViewFailure(undefined)
106
+ const current = () => generation === transition.current && currentScope.current.active && currentScope.current.document === document && workbench.getState().authoring?.identity === identity
107
+ const work = Promise.resolve().then(async () => {
108
+ try {
109
+ // The child flushes its latest position before acknowledging this pause.
110
+ await workbench.pausePlayback()
111
+ if (!current()) return
112
+ setWorkspace('document')
113
+ workbench.setMode('editing')
114
+ workbench.setWebView('document')
115
+ } catch {
116
+ if (current()) setViewFailure(t.presentationReturnFailed)
117
+ if (exiting.current === work) exiting.current = undefined
118
+ } finally { if (current()) setChangingView(false) }
119
+ })
120
+ exiting.current = work
121
+ return work
122
+ }
123
+ useEffect(() => {
124
+ transition.current++; exiting.current = undefined
125
+ setWorkspace('document')
126
+ setChangingView(false); setViewFailure(undefined)
127
+ setAside(null)
128
+ }, [document])
129
+ useEffect(() => { if (!active) { transition.current++; exiting.current = undefined; setChangingView(false) } }, [active])
130
+ useEffect(() => () => { transition.current++ }, [])
131
+ useEffect(() => {
132
+ const previous = previousView.current
133
+ previousView.current = { document, workspace, view, active }
134
+ // Keep a successful exit owned until React has committed the closed view.
135
+ if (workspace === 'document') {
136
+ exiting.current = undefined
137
+ // Leaving the presentation hands the keyboard back to the command that opened it.
138
+ if (previous.workspace === 'presentation' && previous.document === document) {
139
+ const focused = globalThis.document.activeElement
140
+ if (!focused || focused === globalThis.document.body) requestAnimationFrame(() => presentButton.current?.focus())
141
+ }
142
+ } else if (view === 'document' && (presenting || producing && active && previous.active
143
+ && previous.document === document && previous.workspace === workspace && previous.view === 'present')) void exitPresent()
144
+ }, [document, view, workspace, active])
145
+ useEffect(() => {
146
+ if (!active) return
147
+ // Saving now works everywhere, typing included, so the browser never offers to save the page itself. It listens in the
148
+ // capture phase: the narration fields keep their key presses inside themselves.
149
+ const save = (event: KeyboardEvent) => {
150
+ if (event.isComposing || !(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 's') return
151
+ event.preventDefault()
152
+ if (!event.repeat) void workbench.flushPendingEdits().catch(() => {})
153
+ }
154
+ const keys = (event: KeyboardEvent) => {
155
+ if (event.defaultPrevented || isTyping(event.target) || !(event.ctrlKey || event.metaKey) || event.altKey) return
156
+ const key = event.key.toLowerCase()
157
+ if (key === 'z' && !event.shiftKey) {
158
+ event.preventDefault()
159
+ void workbench.undo()
160
+ } else if ((key === 'z' && event.shiftKey) || key === 'y') {
161
+ event.preventDefault()
162
+ void workbench.redo()
163
+ }
164
+ }
165
+ window.addEventListener('keydown', save, true)
166
+ window.addEventListener('keydown', keys)
167
+ return () => { window.removeEventListener('keydown', save, true); window.removeEventListener('keydown', keys) }
168
+ }, [active, workbench])
169
+ useEffect(() => {
170
+ if (!active || !presenting) return
171
+ // The frame has the keyboard while presenting; when focus is back in the workbench, Esc still leaves.
172
+ const escape = (event: KeyboardEvent) => {
173
+ if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing || isTyping(event.target) || hasOpenLayer()) return
174
+ event.preventDefault()
175
+ void exitPresent()
176
+ }
177
+ window.addEventListener('keydown', escape, true)
178
+ return () => window.removeEventListener('keydown', escape, true)
179
+ }, [active, presenting])
180
+ return (
181
+ <div className="pmw-document" data-workspace={producing ? 'production' : presenting ? 'presentation' : reading ? 'reading' : 'content'} data-presenting={presenting || undefined} data-aside={aside ?? undefined} data-navigation={navigation || undefined}>
182
+ {reading && <PmToolbar className="pmw-toolbar" label={t.commands}>
183
+ <span className="pmw-toolbar__title">{t.readDocument}</span>
184
+ <PmToolbarButton asChild><PmIconButton small icon="edit" label={t.editDocument} disabled={changingView} onClick={() => void exitPresent()} /></PmToolbarButton>
185
+ <PmToolbarButton asChild><PmIconButton small icon="refresh" label={t.reloadPreview} loading={changingView} onClick={() => void compilePresentation(() => workbench.reloadPreview(), true)} /></PmToolbarButton>
186
+ <PmToolbarButton asChild><PmIconButton small icon="slides" label={t.present} disabled={changingView} onClick={() => void compilePresentation(() => { workbench.setWebView('present'); setWorkspace('presentation') })} /></PmToolbarButton>
187
+ </PmToolbar>}
188
+ {!producing && !presenting && !reading && <DocumentToolbar presentButton={presentButton} framed={framed} onBack={onBack} actions={actions} aside={aside} navigation={navigation} changingView={changingView} onProduction={() => void openProduction()} onRead={() => void compilePresentation(() => {
189
+ workbench.setMode('viewing')
190
+ workbench.setWebView('document')
191
+ setWorkspace('reading')
192
+ }, true)} onPresent={() => void compilePresentation(() => {
193
+ workbench.setMode('viewing')
194
+ workbench.setWebView('present')
195
+ setWorkspace('presentation')
196
+ })} onNavigation={() => { setNavigation(shown => !shown); if (!navigation && matchMedia('(max-width: 1279px)').matches) setAside(null) }} onAside={next => { setAside(current => (current === next ? null : next)); if (matchMedia('(max-width: 1279px)').matches) setNavigation(false) }} />}
197
+ {viewFailure && <PmNotice tone="danger">{viewFailure}</PmNotice>}
198
+ <DocumentNotices />
199
+ <div className="pmw-document__body" role="region" aria-label={producing ? PRODUCTION_MESSAGES[workbench.getState().locale].title : presenting ? t.presentationPreview : reading ? t.readDocument : t.documentEditor}>
200
+ {!presenting && !producing && !reading && navigation && <NavigationPane onClose={() => setNavigation(false)} onSelect={() => { if (matchMedia('(max-width: 1279px)').matches) setNavigation(false) }} />}
201
+ <div className="pmw-document__main"><div className="pmw-document__source" hidden={presenting || producing || reading}><SourceDocumentPanel commentsPortal={commentsPortal} onCommentDraftRestore={() => setAside(current => current === 'comments' ? null : current)} /></div>{producing && <ProductionPanel active={active} closing={changingView} onClose={() => void exitPresent()} />}{presenting && <PreviewPanel presentation active={active} onPresentationAction={action => { if (action === 'refresh') void compilePresentation(); else void exitPresent() }} />}{reading && <PreviewPanel active={active} />}</div>
202
+ {!presenting && !producing && !reading && aside && (
203
+ <aside className="pmw-document__aside pm-ui-block-editor" aria-label={{ narration: t.documentNarration, comments: t.comments, export: t.exportPane, history: t.versionHistory }[aside]}>
204
+ {aside === 'comments' ? <section className="pmw-pane"><header className="pmw-pane__header"><h2 className="pmw-pane__title">{t.comments}</h2><PmIconButton small icon="close" label={t.close} onClick={() => setAside(null)} /></header><div ref={setCommentsPortal} /></section> : aside === 'narration' ? <DocumentNarrationPanel onClose={() => setAside(null)} /> : aside === 'export' ? <ExportPanel documentOnly onClose={() => setAside(null)} /> : <HistoryPanel onClose={() => setAside(null)} revisionAction={historyRevisionAction} />}
205
+ </aside>
206
+ )}
207
+ </div>
208
+ {!presenting && !producing && !reading && <StatusBar />}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ /** The document's sections, or its slides: where the preview is, and a click to go to another. */
214
+ function NavigationPane({ onClose, onSelect }: { onClose(): void, onSelect(): void }) {
215
+ const t = useMessages()
216
+ const workbench = useWorkbench()
217
+ const outline = useMemo(() => new DocumentOutline(), [])
218
+ const items = useWorkbenchState(state => outline.read(state.authoring?.content, state.authoring?.identity))
219
+ const identity = useWorkbenchState(state => state.authoring?.identity)
220
+ const documentId = useWorkbenchState(state => state.authoring?.content?.id)
221
+ const reset = useWorkbenchState(state => state.authoring?.reset)
222
+ const locale = useWorkbenchState(state => state.locale)
223
+ const slides = useWorkbenchState(state => state.previewRenderer === 'slides' || state.webView !== 'document')
224
+ const current = useWorkbenchState(state => state.position?.section)
225
+ const editing = useWorkbenchState(state => state.mode === 'editing' && workbench.client.capabilities?.readOnly !== true)
226
+ const creating = useWorkbenchState(state => Boolean(state.isCreatingPage))
227
+ const pageBusy = useWorkbenchState(state => Boolean(state.isEditingPage || state.isHistoryPreparing || state.isHistoryBusy))
228
+ const failure = useWorkbenchState(state => state.pageEditFailure)
229
+ const loading = useWorkbenchState(state => Boolean(state.authoring?.loading && !state.authoring.content))
230
+ const document = useWorkbenchState(state => state.document)
231
+ const [renaming, setRenaming] = useState<{ id: string, title: string, trigger?: HTMLButtonElement }>()
232
+ const [deleting, setDeleting] = useState<{ id: string, title: string, trigger?: HTMLButtonElement }>()
233
+ const canDelete = useWorkbenchState(state => Boolean(state.authoring?.content))
234
+ const menuTriggers = useRef(new Map<string, HTMLButtonElement>())
235
+ useEffect(() => { setRenaming(undefined); setDeleting(undefined) }, [document])
236
+ const canCreate = useWorkbenchState(state => Boolean(state.authoring?.content) && !state.authoring?.loading && !state.authoring?.composing && !state.authoring?.restored && !state.authoring?.error && !isAgentChanging(state))
237
+ const reorderDisabled = useWorkbenchState(state => !state.authoring?.content || Boolean(state.authoring.loading || state.authoring.composing || state.authoring.restored || state.authoring.error || state.isCreatingPage || state.isEditingPage || state.isHistoryPreparing || state.isHistoryBusy) || isAgentChanging(state))
238
+ const pages = editing || slides
239
+ const top = Math.min(...items.map(item => Math.max(1, item.depth)))
240
+ const pageActions = (item: typeof items[number], index: number) => {
241
+ const capability = workbench.pageEditCapability(item.id), previousPeer = index > 0, nextPeer = index < items.length - 1
242
+ return <PmMenu label={t.pageMore(item.title)} description={capability.reason} triggerRef={node => { if (node) menuTriggers.current.set(item.id, node); else menuTriggers.current.delete(item.id) }} items={[
243
+ { id: 'rename', label: t.rename, icon: 'edit', disabled: !capability.rename || !canCreate || pageBusy || creating },
244
+ { id: 'copy', label: t.pageCopy, icon: 'copy', disabled: !canCreate || pageBusy || creating },
245
+ { id: 'up', label: t.movePageUp, disabled: !capability.move || !canCreate || pageBusy || creating || !previousPeer },
246
+ { id: 'down', label: t.movePageDown, disabled: !capability.move || !canCreate || pageBusy || creating || !nextPeer },
247
+ ...(canDelete ? [{ id: 'delete', label: t.pageDelete, icon: 'trash' as const, danger: true, separator: true, disabled: !canCreate || pageBusy || creating }] : []),
248
+ ]} onSelect={id => {
249
+ workbench.clearPageEditFailure()
250
+ if (id === 'rename') setRenaming({ id: item.id, title: item.title, trigger: menuTriggers.current.get(item.id) })
251
+ else if (id === 'copy') void workbench.duplicatePage(item.id)
252
+ else if (id === 'delete') setDeleting({ id: item.id, title: item.title, trigger: menuTriggers.current.get(item.id) })
253
+ else if (id === 'up' && index > 0) void workbench.movePage(item.id, items[index - 1]!.id)
254
+ else if (id === 'down') void workbench.movePage(item.id, items[index + 2]?.id ?? null)
255
+ }} />
256
+ }
257
+ return (
258
+ <nav className="pmw-navigation" aria-label={t.navigation}>
259
+ <header className="pmw-pane__header">
260
+ <h2 className="pmw-pane__title">{editing ? t.pages : slides ? t.renderers.slides : t.navigation}</h2>
261
+ {editing && <PmIconButton icon="plus" label={t.newPage} small loading={creating} disabled={!canCreate || pageBusy || !items.length} onClick={() => { if (workbench.createPage()) onSelect() }} />}
262
+ <PmIconButton icon="close" label={t.closeNavigation} small onClick={onClose} />
263
+ </header>
264
+ {failure && !renaming && !deleting && <PmNotice tone="danger">{failure}</PmNotice>}
265
+ {items.length === 0 && loading ? <div className="pmw-navigation__empty"><PmSkeleton shape="list" label={t.loadingPages} /></div>
266
+ : items.length === 0
267
+ ? <p className="pmw-muted pmw-navigation__empty">{t.noSections}</p>
268
+ : editing ? <PmSortableList label={t.pages} scopeKey={JSON.stringify([identity, documentId, document])} resetKey={reset} className="pmw-navigation__list" locale={locale}
269
+ items={items.map((item, index) => ({ id: item.id, label: item.title, prefix: item.number, reorderDisabled: !workbench.pageEditCapability(item.id).move, actions: pageActions(item, index) }))}
270
+ value={current} reorderDisabled={reorderDisabled} loading={pageBusy || creating}
271
+ onSelect={id => { workbench.goToSection(id); onSelect() }}
272
+ onMove={async (id, before) => {
273
+ const state = workbench.getState()
274
+ if (state.document !== document || state.authoring?.identity !== identity || state.authoring?.content?.id !== documentId) return
275
+ if (!await workbench.movePage(id, before)) throw new Error('page-reorder-failed')
276
+ }} /> : (
277
+ <ol className="pmw-navigation__list" data-kind={pages ? 'slides' : 'headings'}>
278
+ {items.map(item => (
279
+ <li key={item.id} className="pmw-navigation__row">
280
+ <button
281
+ type="button"
282
+ className="pmw-navigation__item"
283
+ data-depth={slides ? undefined : Math.min(Math.max(1, item.depth) - top, 3)}
284
+ aria-current={item.id === current ? 'location' : undefined}
285
+ onClick={() => { workbench.goToSection(item.id); onSelect() }}
286
+ >
287
+ {pages && <span className="pmw-navigation__number">{item.number}</span>}
288
+ <span className="pmw-navigation__title">{item.title}</span>
289
+ </button>
290
+ </li>
291
+ ))}
292
+ </ol>
293
+ )}
294
+ {renaming && <PageRenameDialog section={renaming.id} title={renaming.title} trigger={renaming.trigger} onClose={() => { setRenaming(undefined); workbench.clearPageEditFailure() }} />}
295
+ {deleting && <PageDeleteDialog section={deleting.id} title={deleting.title} trigger={deleting.trigger} onClose={() => { setDeleting(undefined); workbench.clearPageEditFailure() }} />}
296
+ </nav>
297
+ )
298
+ }
299
+
300
+ /** Where the author is, how long the document is, and what the mode lets them do. */
301
+ function StatusBar() {
302
+ const t = useMessages()
303
+ const doc = useWorkbenchState(state => state.authoring?.content)
304
+ const identity = useWorkbenchState(state => state.authoring?.identity)
305
+ const slides = useWorkbenchState(state => state.previewRenderer === 'slides' || state.webView !== 'document')
306
+ const section = useWorkbenchState(state => state.position?.section)
307
+ const counter = useMemo(() => new DocumentWordCounter(), [])
308
+ const words = useMemo(() => counter.count(doc, identity), [counter, doc, identity])
309
+ const total = doc?.sectionOrder.length ?? 0
310
+ const at = section === undefined ? -1 : doc?.sectionOrder.indexOf(section) ?? -1
311
+ return (
312
+ <footer className="pmw-statusbar" aria-label={t.statusBar}>
313
+ {at >= 0 && <span className="pmw-statusbar__item">{slides ? t.slideOf(at + 1, total) : t.pageOf(at + 1, total)}</span>}
314
+ {doc && <span className="pmw-statusbar__item">{t.words(words)}</span>}
315
+ </footer>
316
+ )
317
+ }
318
+
319
+ function DocumentToolbar({ presentButton, framed, onBack, actions, aside, navigation, changingView, onProduction, onRead, onPresent, onNavigation, onAside }: { presentButton: RefObject<HTMLButtonElement>, framed: boolean, onBack?(): void, actions?: ReactNode, aside: DocumentAside | null, navigation: boolean, changingView: boolean, onProduction(): void, onRead(): void, onPresent(): void, onNavigation(): void, onAside(aside: DocumentAside): void }) {
320
+ const t = useMessages()
321
+ const workbench = useWorkbench()
322
+ const p = PRODUCTION_MESSAGES[useWorkbenchState(state => state.locale)]
323
+ const title = useWorkbenchState(state => state.authoring?.content?.metadata.title ?? state.documents?.documents.find(doc => doc.dir === state.document)?.title ?? '')
324
+ const history = useWorkbenchState(state => state.history)
325
+ const editorHistory = useWorkbenchState(state => state.editorHistory)
326
+ const dirty = useWorkbenchState(state => Boolean(state.authoring?.dirty))
327
+ const busy = useWorkbenchState(state => state.isHistoryBusy || state.isHistoryPreparing || Boolean(state.isCreatingPage) || Boolean(state.isEditingPage || state.authoring?.composing) || isAgentChanging(state))
328
+ const timingDrafts = useWorkbenchState(state => state.hasDocumentTimingDrafts)
329
+ const preparing = useWorkbenchState(state => Boolean(state.isPreparingPresentation))
330
+ const blocked = useWorkbenchState(state => Boolean(!state.authoring?.content || state.authoring.loading || state.authoring.dirty || state.authoring.saving || state.authoring.restored || state.authoring.error || state.authoring.composing || state.hasDocumentTimingDrafts || state.isEditingPage || state.isCreatingPage || isAgentChanging(state)))
331
+ const openComments = useWorkbenchState(state => Object.values(state.authoring?.content?.comments ?? {}).filter(comment => !comment.resolved && !comment.deletedAt).length)
332
+ const hasStep = useWorkbenchState(state => !state.authoring?.loading && Boolean(state.authoring?.content?.sectionOrder.length))
333
+ const narrow = useMediaQuery(NARROW)
334
+ const capabilities = workbench.client.capabilities
335
+ const compactCommands = narrow && !framed
336
+ const undoDisabled = Boolean(capabilities?.readOnly || !editorHistory?.undo && (editorHistory?.field || !history?.undo && !dirty) || busy)
337
+ const redoDisabled = Boolean(capabilities?.readOnly || !editorHistory?.redo && (editorHistory?.field || dirty || !history?.redo) || busy)
338
+ const commentsLabel = openComments ? `${t.comments} (${openComments})` : t.comments
339
+ return (
340
+ <PmToolbar className={framed ? 'pmw-toolbar' : 'pmw-toolbar pmw-toolbar--document'} label={t.commands}>
341
+ {!framed && <div className="pmw-toolbar__document">
342
+ <PmToolbarButton asChild><PmIconButton icon="arrow-left" label={t.back} small onClick={() => onBack ? onBack() : workbench.closeDocument()} /></PmToolbarButton>
343
+ <h1 className="pmw-toolbar__title" title={title}>{title}</h1>
344
+ </div>}
345
+ <PmToolbarButton asChild><PmIconButton small icon="navigation" label={t.navigationPane} aria-pressed={navigation} onClick={onNavigation} /></PmToolbarButton>
346
+ {!compactCommands && <>
347
+ <PmToolbarButton asChild><PmIconButton small icon="undo" label={t.undo} shortcut={SHORTCUTS.undo} disabled={undoDisabled} onClick={() => void workbench.undo()} /></PmToolbarButton>
348
+ <PmToolbarButton asChild><PmIconButton small icon="redo" label={t.redo} shortcut={SHORTCUTS.redo} disabled={redoDisabled} onClick={() => void workbench.redo()} /></PmToolbarButton>
349
+ </>}
350
+ <SaveStatus />
351
+ <span className="pmw-spacer" />
352
+ {!narrow && <>
353
+ {capabilities?.production !== false && <PmToolbarButton asChild><PmButton small icon="arrange" loading={changingView} disabled={busy || !hasStep} onClick={onProduction}>{p.title}</PmButton></PmToolbarButton>}
354
+ {capabilities?.narration !== false && <PmToolbarButton asChild><PmIconButton small icon="captions" label={t.documentNarration} hint={t.documentNarrationHint} aria-pressed={aside === 'narration'} disabled={!hasStep} onClick={() => onAside('narration')} /></PmToolbarButton>}
355
+ </>}
356
+ {!compactCommands && <PmToolbarButton asChild><PmIconButton small icon="comment" label={commentsLabel} aria-pressed={aside === 'comments'} onClick={() => onAside('comments')} /></PmToolbarButton>}
357
+ {!narrow && <PmToolbarButton asChild><PmIconButton small icon="versions" label={t.versionHistory} aria-pressed={aside === 'history'} onClick={() => onAside('history')} /></PmToolbarButton>}
358
+ <span className="pmw-toolbar__divider" aria-hidden="true" />
359
+ <PmToolbarButton asChild><PmIconButton small icon="eye" label={t.readDocument} disabled={blocked || changingView} onClick={onRead} /></PmToolbarButton>
360
+ <PmToolbarButton asChild><PmIconButton ref={presentButton} small icon="slides" label={t.present} hint={preparing || changingView ? t.presentationPreparing : timingDrafts ? t.timingDraftPending : blocked ? t.presentationPending : t.presentHint} loading={preparing || changingView} disabled={blocked || changingView} onClick={onPresent} /></PmToolbarButton>
361
+ {capabilities?.export !== false && <PmToolbarButton asChild><PmIconButton small icon="export" label={t.exportPane} aria-pressed={aside === 'export'} onClick={() => onAside('export')} /></PmToolbarButton>}
362
+ {actions}
363
+ {narrow && <PmMenu toolbarItem label={t.moreCommands} items={[
364
+ ...compactCommands ? [
365
+ { id: 'undo', label: t.undo, icon: 'undo' as const, shortcut: SHORTCUTS.undo, disabled: undoDisabled },
366
+ { id: 'redo', label: t.redo, icon: 'redo' as const, shortcut: SHORTCUTS.redo, disabled: redoDisabled },
367
+ { id: 'comments', label: commentsLabel, icon: 'comment' as const, checked: aside === 'comments', separator: true },
368
+ ] : [],
369
+ ...capabilities?.production !== false ? [{ id: 'production', label: p.title, icon: 'arrange' as const, disabled: busy || !hasStep || changingView }] : [],
370
+ ...capabilities?.narration !== false ? [{ id: 'narration', label: t.documentNarration, icon: 'captions' as const, disabled: !hasStep }] : [],
371
+ { id: 'history', label: t.versionHistory, icon: 'versions' },
372
+ ]} onSelect={id => { if (id === 'undo') void workbench.undo(); else if (id === 'redo') void workbench.redo(); else if (id === 'production') onProduction(); else onAside(id as DocumentAside) }} />}
373
+ </PmToolbar>
374
+ )
375
+ }
376
+
377
+ function SaveStatus() {
378
+ const t = useMessages()
379
+ const save = useWorkbenchState(state => state.save)
380
+ const dirty = useWorkbenchState(state => Boolean(state.authoring?.dirty))
381
+ const backupPending = useWorkbenchState(state => Boolean(state.authoring?.backupPending))
382
+ const locale = useWorkbenchState(state => state.locale)
383
+ const [, tick] = useState(0)
384
+ useEffect(() => {
385
+ if (save.kind !== 'saved' || dirty) return
386
+ const timer = setInterval(() => tick(n => n + 1), 30_000)
387
+ return () => clearInterval(timer)
388
+ }, [save, dirty])
389
+ const text = dirty ? backupPending ? t.documentDraftBackingUp : t.unsaved : save.kind === 'saving'
390
+ ? t.saving
391
+ : save.kind === 'saved' ? t.savedAt(timeAgo(save.at, locale)) : save.kind === 'idle' ? t.saved : ''
392
+ // A narrow toolbar keeps the short state ("Saved") whole rather than truncating the line with its time.
393
+ const short = !dirty && save.kind === 'saved' ? t.saved : undefined
394
+ return <span className="pmw-toolbar__status" role="status" aria-live="polite" title={text} data-save={dirty ? 'dirty' : save.kind} data-backup={dirty && backupPending ? 'pending' : undefined}>
395
+ {short ? <><span className="pmw-toolbar__status-full">{text}</span><span className="pmw-toolbar__status-short">{short}</span></> : text}
396
+ </span>
397
+ }
398
+
399
+ /** What needs the author's attention: the AI's change, an edit to hand over, a failed save, content that did not display. */
400
+ function DocumentNotices() {
401
+ const t = useMessages()
402
+ const workbench = useWorkbench()
403
+ const review = useWorkbenchState(state => state.review)
404
+ const save = useWorkbenchState(state => state.save)
405
+ const sourceError = useWorkbenchState(state => state.authoring?.error)
406
+ const timingDrafts = useWorkbenchState(state => state.hasDocumentTimingDrafts)
407
+ const historyFailure = useWorkbenchState(state => state.historyFailure)
408
+ const focusFailure = useWorkbenchState(state => state.focusFailure)
409
+ const askFailure = useWorkbenchState(state => (state.pointer ? undefined : state.askFailure))
410
+ return (
411
+ <div className="pmw-notices">
412
+ {review?.running && <PmNotice tone="accent"><PmMascot size="small" action={review.changes.length > 0 ? 'write' : 'read'} />{t.agentWorking}</PmNotice>}
413
+ {review && !review.running && review.changes.length > 0 && (
414
+ <PmNotice tone="accent">
415
+ <PmMascot size="small" action="ask" />
416
+ <span>{t.agentWaiting(review.changes.length)}</span>
417
+ <span className="pmw-notice__actions">
418
+ <PmButton small onClick={() => void workbench.rejectReview()}>{t.reject}</PmButton>
419
+ <PmButton small variant="primary" onClick={() => void workbench.acceptReview()}>{t.accept}</PmButton>
420
+ </span>
421
+ </PmNotice>
422
+ )}
423
+ {save.kind === 'locked' && <PmNotice tone="warn">{t.saveLocked}</PmNotice>}
424
+ {timingDrafts && <PmNotice tone="warn">{t.timingDraftPending}</PmNotice>}
425
+ {save.kind === 'failed' && !sourceError && <PmNotice tone="danger">{t.saveFailed(failureReason(t, save.message))}</PmNotice>}
426
+ {historyFailure && <PmNotice tone="danger">{t.historyFailed(t.historyFailureReasons[historyFailure] ?? failureReason(t, historyFailure))}</PmNotice>}
427
+ {focusFailure && <PmNotice tone="warn">{t.focusFailed(failureReason(t, focusFailure))}</PmNotice>}
428
+ {askFailure && <PmNotice tone="danger">{t.askFailed(failureReason(t, askFailure))}</PmNotice>}
429
+ <ProblemsNotice />
430
+ </div>
431
+ )
432
+ }
433
+
434
+ /** Content that did not display, said plainly, with the AI to fix it and the details for whoever wants them. */
435
+ function ProblemsNotice() {
436
+ const t = useMessages()
437
+ const workbench = useWorkbench()
438
+ const snapshot = useWorkbenchState(state => state.diagnostics)
439
+ const unavailable = useWorkbenchState(state => state.isDiagnosticsUnavailable)
440
+ const canRepair = useWorkbenchState(state => state.canRepairDiagnostics)
441
+ const repairing = useWorkbenchState(state => state.isRepairingDiagnostics)
442
+ const repairFailure = useWorkbenchState(state => state.diagnosticsRepairFailure)
443
+ if (workbench.client.capabilities?.diagnostics === 'none') return null
444
+ const problems = (snapshot?.diagnostics ?? []).filter(diagnostic => diagnostic.severity === 'error' && !diagnostic.checkId.startsWith('presmium.workbench.'))
445
+ if (unavailable) return <PmNotice tone="warn"><span>{t.diagnosticsUnavailable}</span><PmButton small onClick={() => workbench.retryDiagnostics()}>{t.retry}</PmButton></PmNotice>
446
+ if (problems.length === 0) return repairFailure ? <PmNotice tone="danger">{t.fixFailed(failureReason(t, repairFailure))}</PmNotice> : null
447
+ return (
448
+ <PmNotice tone="warn">
449
+ <div className="pmw-problems">
450
+ <div className="pmw-problems__summary">
451
+ <span>{t.problems(problems.length)}</span>
452
+ {canRepair && <PmButton small variant="accent" icon="sparkle" disabled={repairing} onClick={() => void workbench.requestRepairDiagnostics()}>{repairing ? t.fixing : t.fixWithAi}</PmButton>}
453
+ </div>
454
+ {repairFailure && <p className="pmw-problems__failure">{t.fixFailed(failureReason(t, repairFailure))}</p>}
455
+ <details className="pmw-problems__details">
456
+ <summary>{t.details}</summary>
457
+ <ul>{problems.map(problem => <li key={problem.id}>{problem.message}</li>)}</ul>
458
+ </details>
459
+ </div>
460
+ </PmNotice>
461
+ )
462
+ }