@lincyaw/workbench 0.1.0 → 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 (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,239 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
2
+ import type { CSSProperties, ReactNode } from 'react'
3
+ import type { BlockPreviewCommand } from '@lincyaw/kernel'
4
+ import { toBlockPreviewPlaybackCancelled, toBlockPreviewPlaybackRequest, toBlockPreviewPosition } from '@lincyaw/kernel'
5
+ import { PmIconButton, PmIcon, PmSkeleton } from '../components.tsx'
6
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
7
+ import { prepareDocumentPreview } from '../document-preview.ts'
8
+ import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
9
+ import { BLOCK_PREVIEW_MESSAGES } from './block-preview-messages.ts'
10
+ import { documentBlockSection, DocumentStepNarration } from './DocumentStepNarration.tsx'
11
+ import { DocumentBlockPlayback } from './block-playback.ts'
12
+ import { ComponentPropertiesDialog } from './ComponentPropertiesDialog.tsx'
13
+ import { COMPONENT_PROPERTY_MESSAGES } from '../component-property-messages.ts'
14
+
15
+ interface Snapshot { address: string, revision: string }
16
+ // `preparing`: the server is still building what the preview needs; it is asked again until ready.
17
+ interface PreviewState { snapshot?: Snapshot, status: 'idle' | 'loading' | 'preparing' | 'ready' | 'failed', failure?: 'build' | 'display' }
18
+ /** A read-only runtime is a custom native block, independent from the document's editable JSON and selection. */
19
+ export function DocumentBlockPreview({ blockId, fallback }: { blockId: string, fallback?: ReactNode }) {
20
+ const workbench = useWorkbench()
21
+ const source = useWorkbenchState(state => state.authoring)
22
+ const directory = useWorkbenchState(state => state.document)
23
+ const documentView = useWorkbenchState(state => state.webView === 'document')
24
+ const dark = useWorkbenchState(state => state.isDark)
25
+ const locale = useWorkbenchState(state => state.locale), t = DOCUMENT_EDITOR_MESSAGES[locale], p = BLOCK_PREVIEW_MESSAGES[locale]
26
+ const position = useWorkbenchState(state => state.position)
27
+ const navigation = useWorkbenchState(state => state.documentPositionToken)
28
+ const navigationIntent = useWorkbenchState(state => state.documentPositionIntent)
29
+ const sectionId = source?.content && documentBlockSection(source.content, blockId)
30
+ const section = sectionId ? source?.content?.sections[sectionId] : undefined
31
+ const stepId = section?.stepOrder[position && position.section === sectionId ? position.step : 0] ?? section?.stepOrder[0]
32
+ const [narrationOpen, setNarrationOpen] = useState(() => Boolean(workbench.pendingDocumentField(blockId, source?.identity)
33
+ || source?.identity && stepId && (workbench.documentTimingDraft(stepId, 'minimumHoldMs', source.identity, source.reset) || workbench.documentTimingDraft(stepId, 'trailingPauseMs', source.identity, source.reset)))), narrationPanel = useId()
34
+ const root = useRef<HTMLElement>(null), viewport = useRef<HTMLDivElement>(null), frame = useRef<HTMLIFrameElement | null>(null)
35
+ const mountFrame = useCallback((element: HTMLIFrameElement | null) => {
36
+ frame.current = element
37
+ element?.toggleAttribute('inert', true)
38
+ }, [])
39
+ const [visible, setVisible] = useState(false), [attempt, setAttempt] = useState(0), [height, setHeight] = useState<number>()
40
+ const [exposed, setExposed] = useState(false)
41
+ const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden')
42
+ const [state, setState] = useState<PreviewState>({ status: 'idle' })
43
+ const [properties, setProperties] = useState<{ identity: string, stepId?: string }>()
44
+ const currentState = useRef(state)
45
+ currentState.current = state
46
+ const runtimeGeneration = useRef(0), pauseRuntime = useRef<() => void>(() => {})
47
+ const mismatches = useRef(0)
48
+ const frameReady = useRef(false), [readiness, setReadiness] = useState(0)
49
+ const reportedStep = useRef<string>()
50
+ const reportedNavigation = useRef<string>()
51
+ const editingStep = useRef<string>()
52
+ const playback = useRef<DocumentBlockPlayback>()
53
+ const fail = useCallback((failure: 'build' | 'display' = 'display') => {
54
+ // Retire this child before React removes it. A queued ready/position message
55
+ // cannot restore a failed runtime or adopt its position in the document.
56
+ pauseRuntime.current()
57
+ runtimeGeneration.current++
58
+ frameReady.current = false
59
+ playback.current?.dispose()
60
+ playback.current = undefined
61
+ setState({ status: 'failed', failure })
62
+ }, [])
63
+ useEffect(() => {
64
+ const changed = () => setPageVisible(document.visibilityState !== 'hidden')
65
+ document.addEventListener('visibilitychange', changed)
66
+ changed()
67
+ return () => document.removeEventListener('visibilitychange', changed)
68
+ }, [])
69
+ useEffect(() => {
70
+ const element = viewport.current
71
+ if (!element) return
72
+ if (typeof IntersectionObserver === 'undefined') { setExposed(true); return }
73
+ // Offscreen frames can fetch ahead, but browsers suspend their animation
74
+ // frames. Their paint/clock handshake must not time out before they are seen.
75
+ const observer = new IntersectionObserver(entries => setExposed(entries.some(entry => entry.isIntersecting)))
76
+ observer.observe(element)
77
+ return () => observer.disconnect()
78
+ }, [])
79
+ const [loadedVersion, setLoadedVersion] = useState<string>()
80
+ const [loadedProject, setLoadedProject] = useState(0), [loadedAttempt, setLoadedAttempt] = useState(0)
81
+ const enabled = visible && documentView
82
+ useEffect(() => {
83
+ const element = root.current
84
+ if (!element) return
85
+ if (typeof IntersectionObserver === 'undefined') { setVisible(true); return }
86
+ const observer = new IntersectionObserver(entries => setVisible(entries.some(entry => entry.isIntersecting)), { rootMargin: '200px' })
87
+ observer.observe(element)
88
+ return () => observer.disconnect()
89
+ }, [])
90
+ useEffect(() => {
91
+ if (!enabled || directory === undefined || !source?.version) return
92
+ if (!source.fingerprint) { fail('build'); return }
93
+ let current = true
94
+ const version = source.version
95
+ setState(before => ({ ...before, status: 'loading', failure: undefined }))
96
+ const preparing = () => { if (current) setState(before => (before.status === 'loading' ? { ...before, status: 'preparing' } : before)) }
97
+ void prepareDocumentPreview(workbench.client, { directory, version, fingerprint: source.fingerprint, project: source.projectRevision ?? 0 }, attempt > 0, preparing).then(snapshot => {
98
+ if (!current) return
99
+ setState({ snapshot, status: 'loading' }); setLoadedVersion(version); setLoadedProject(source.projectRevision ?? 0); setLoadedAttempt(attempt)
100
+ }, () => { if (current) fail('build') })
101
+ return () => { current = false }
102
+ }, [enabled, directory, source?.version, source?.fingerprint, source?.projectRevision, attempt, workbench, fail])
103
+ const address = state.snapshot ? new URL(state.snapshot.address) : undefined
104
+ address?.searchParams.set('presmium-block', blockId)
105
+ address?.searchParams.set('presmium-appearance', dark ? 'dark' : 'light')
106
+ // A saved version changes only the runtime frame; it never resets the native editor.
107
+ address?.searchParams.set('presmium-block-version', loadedVersion ?? '')
108
+ address?.searchParams.set('presmium-block-refresh', `${loadedProject}:${loadedAttempt}`)
109
+ const src = address?.href, origin = address?.origin
110
+ const command = useCallback((action: 'seek' | 'pause', at?: 'start' | 'end', token = navigation) => {
111
+ if (!origin || !state.snapshot || !sectionId || !stepId) return
112
+ const value: BlockPreviewCommand = { type: 'presmium:block-preview-command', action, document: directory ?? '', blockId, revision: state.snapshot.revision, sectionId, stepId, navigation: token, ...at ? { at } : {} }
113
+ frame.current?.contentWindow?.postMessage(value, origin)
114
+ }, [origin, state.snapshot, directory, blockId, sectionId, stepId, navigation])
115
+ pauseRuntime.current = () => command('pause')
116
+ const pause = useCallback(() => {
117
+ if (sectionId && stepId) {
118
+ editingStep.current = JSON.stringify([sectionId, stepId])
119
+ workbench.pauseDocumentStep(sectionId, stepId)
120
+ reportedStep.current = editingStep.current
121
+ reportedNavigation.current = workbench.getState().documentPositionToken
122
+ }
123
+ command('pause', undefined, reportedNavigation.current); workbench.stopAuditions()
124
+ }, [command, workbench, sectionId, stepId])
125
+ useEffect(() => {
126
+ if (!src || !origin || !enabled || !state.snapshot) return
127
+ const group = new DocumentBlockPlayback({
128
+ activate: stop => workbench.startDocumentPlayback(stop), failure: t.previewFailure,
129
+ current: request => {
130
+ const current = workbench.getState(), document = current.authoring?.content
131
+ return Boolean(current.document === directory && current.authoring?.version === loadedVersion && (current.authoring?.projectRevision ?? 0) === loadedProject && document
132
+ && request.navigation === current.documentPositionToken
133
+ && documentBlockSection(document, blockId) === request.sectionId && document.sections[request.sectionId]?.stepOrder.includes(request.stepId))
134
+ },
135
+ adopt: request => {
136
+ editingStep.current = undefined
137
+ reportedStep.current = JSON.stringify([request.sectionId, request.stepId])
138
+ workbench.observeDocumentStep(request.sectionId, request.stepId)
139
+ return reportedNavigation.current = workbench.getState().documentPositionToken
140
+ },
141
+ post: value => frame.current?.contentWindow?.postMessage(value, origin),
142
+ })
143
+ playback.current = group
144
+ return () => { group.dispose(); if (playback.current === group) playback.current = undefined }
145
+ }, [src, origin, enabled, loadedVersion, loadedProject, state.snapshot?.revision, directory, blockId, workbench, t.previewFailure])
146
+ useEffect(() => {
147
+ if (editingStep.current && sectionId && stepId) editingStep.current = JSON.stringify([sectionId, stepId])
148
+ }, [sectionId, stepId])
149
+ useEffect(() => { reportedStep.current = undefined; reportedNavigation.current = undefined; frameReady.current = false }, [src, enabled])
150
+ useEffect(() => {
151
+ if (!enabled || !frameReady.current || state.status !== 'ready' || !sectionId || !stepId || reportedStep.current === JSON.stringify([sectionId, stepId]) && reportedNavigation.current === navigation) return
152
+ const initial = reportedNavigation.current === undefined
153
+ reportedStep.current = JSON.stringify([sectionId, stepId])
154
+ reportedNavigation.current = navigation
155
+ command(!initial && navigationIntent === 'pause' ? 'pause' : 'seek', 'end')
156
+ }, [state.status, sectionId, stepId, navigation, navigationIntent, command, enabled, src, readiness])
157
+ useEffect(() => { frame.current?.toggleAttribute('inert', !frameReady.current || state.status !== 'ready') }, [readiness, state.status, enabled, src])
158
+ useEffect(() => {
159
+ if (!src || !origin || !enabled || !state.snapshot) return
160
+ const expected = state.snapshot.revision
161
+ const generation = runtimeGeneration.current
162
+ const listener = (event: MessageEvent) => {
163
+ if (generation !== runtimeGeneration.current || event.source !== frame.current?.contentWindow || event.origin !== origin) return
164
+ const value = event.data
165
+ if (!value || !['presmium:block-preview', 'presmium:block-preview-position', 'presmium:block-preview-playback-request', 'presmium:block-preview-playback-cancelled'].includes(value.type) || value.blockId !== blockId || value.document !== directory) return
166
+ if (value.revision !== expected) {
167
+ if (mismatches.current++ < 3) setAttempt(value => value + 1)
168
+ else fail('display')
169
+ return
170
+ }
171
+ const cancelled = toBlockPreviewPlaybackCancelled(value)
172
+ if (cancelled) {
173
+ playback.current?.cancelled(cancelled.request)
174
+ return
175
+ }
176
+ const request = toBlockPreviewPlaybackRequest(value)
177
+ if (request) {
178
+ playback.current?.request(request)
179
+ return
180
+ }
181
+ if (value.type !== 'presmium:block-preview' && value.type !== 'presmium:block-preview-position') return
182
+ if (value.type === 'presmium:block-preview-position') {
183
+ const current = workbench.getState()
184
+ if (current.document !== directory || current.authoring?.version !== loadedVersion || (current.authoring?.projectRevision ?? 0) !== loadedProject) return
185
+ const position = toBlockPreviewPosition(value), document = current.authoring?.content
186
+ if (position && position.navigation === current.documentPositionToken && document
187
+ && documentBlockSection(document, blockId) === position.sectionId && document.sections[position.sectionId]?.stepOrder.includes(position.stepId)) {
188
+ // A position posted just before pause must not replace an active field's target.
189
+ if (frame.current && frame.current.ownerDocument.activeElement === frame.current) editingStep.current = undefined
190
+ if (editingStep.current && editingStep.current !== JSON.stringify([position.sectionId, position.stepId])) return
191
+ reportedStep.current = JSON.stringify([position.sectionId, position.stepId])
192
+ workbench.observeDocumentStep(position.sectionId, position.stepId)
193
+ reportedNavigation.current = workbench.getState().documentPositionToken
194
+ const accepted: BlockPreviewCommand = { type: 'presmium:block-preview-command', action: 'sync', document: directory ?? '', blockId, revision: expected, sectionId: position.sectionId, stepId: position.stepId, navigation: reportedNavigation.current }
195
+ frame.current?.contentWindow?.postMessage(accepted, origin)
196
+ }
197
+ return
198
+ }
199
+ if (typeof value.height === 'number' && Number.isFinite(value.height)) setHeight(Math.max(1, Math.min(value.height, 4096)))
200
+ if (value.status === 'failed') fail('display')
201
+ else if (value.status === 'ready') {
202
+ if (!frameReady.current) { frameReady.current = true; setReadiness(value => value + 1) }
203
+ mismatches.current = 0; setState(before => ({ ...before, status: 'ready' }))
204
+ }
205
+ }
206
+ window.addEventListener('message', listener)
207
+ return () => { window.removeEventListener('message', listener) }
208
+ }, [src, origin, enabled, state.snapshot?.revision, blockId, directory, loadedVersion, loadedProject, workbench, fail])
209
+ useEffect(() => {
210
+ if (!src || !enabled || !exposed || !pageVisible || state.status !== 'loading') return
211
+ const generation = runtimeGeneration.current
212
+ const timeout = setTimeout(() => { if (generation === runtimeGeneration.current && currentState.current.status === 'loading') fail('display') }, 30_000)
213
+ return () => clearTimeout(timeout)
214
+ }, [src, enabled, exposed, pageVisible, state.status, fail])
215
+ const old = source?.dirty || source?.saving || source?.error || source?.restored || loadedVersion !== source?.version || loadedProject !== (source?.projectRevision ?? 0)
216
+ return <section ref={root} className="pm-ui-block-preview" data-block-preview={blockId} data-status={state.status} contentEditable={false}>
217
+ <header className="pm-ui-block-preview__header"><span className="pm-ui-block-preview__label" role={state.status === 'failed' ? 'status' : undefined}><PmIcon name="slides" />{state.status === 'failed' ? t.previewFailure : old && state.snapshot ? t.previewPrevious : state.status === 'preparing' ? p.preparing : state.status === 'loading' ? t.previewLoading : t.previewSnapshot}</span>
218
+ <span className="pm-ui-block-preview__actions">
219
+ {source?.content?.blocks[blockId]?.type === 'component' && <PmIconButton small icon="sliders" label={COMPONENT_PROPERTY_MESSAGES[locale].properties}
220
+ data-component-properties={blockId} disabled={!source.identity || workbench.client.capabilities?.readOnly} onClick={() => { if (source.identity && !workbench.client.capabilities?.readOnly) setProperties({ identity: source.identity, stepId }) }} />}
221
+ {workbench.client.capabilities?.narration !== false && <PmIconButton small icon="captions" label={t.stepCaptions} aria-expanded={narrationOpen} aria-controls={narrationOpen ? narrationPanel : undefined} aria-pressed={narrationOpen} disabled={!stepId} onClick={() => {
222
+ pause()
223
+ if (sectionId && stepId) workbench.observeDocumentStep(sectionId, stepId)
224
+ setNarrationOpen(value => !value)
225
+ }} />}
226
+ <PmIconButton small icon="refresh" label={state.status === 'failed' ? t.previewRetry : t.previewRefresh} hint={state.status === 'loading' ? t.previewLoading : state.status === 'preparing' ? p.preparing : state.status === 'failed' ? t.previewRetry : t.previewRefresh}
227
+ loading={state.status === 'loading' || state.status === 'preparing'} onClick={() => { pause(); if (!source?.fingerprint) void workbench.retryDocument(); setAttempt(value => value + 1) }} disabled={!enabled && state.status !== 'failed'} />
228
+ </span></header>
229
+ <div ref={viewport} className="pm-ui-block-preview__viewport" role="region" aria-label={t.previewTitle} tabIndex={enabled && src ? -1 : 0}
230
+ style={height === undefined ? undefined : { '--pm-block-content-height': `${height}px` } as CSSProperties}
231
+ onKeyDownCapture={event => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) event.stopPropagation() }}>
232
+ {enabled && src ? <iframe key={src} ref={mountFrame} className="pm-ui-block-preview__frame" src={src} title={t.previewTitle} sandbox="allow-scripts allow-same-origin" onFocus={() => { editingStep.current = undefined }} /> : fallback}
233
+ {enabled && !src && (state.status === 'loading' || state.status === 'preparing') && <div className="pm-ui-block-preview__pending"><PmSkeleton shape="preview" label={state.status === 'preparing' ? p.preparingDetail : t.previewLoading} /></div>}
234
+ {state.status === 'failed' && <p className="pm-ui-block-preview__pending">{state.failure === 'build' ? p.buildFailure : p.displayFailure}</p>}
235
+ </div>
236
+ {sectionId && stepId && <DocumentStepNarration blockId={blockId} sectionId={sectionId} stepId={stepId} expanded={narrationOpen} panelId={narrationPanel} onPause={pause} />}
237
+ {properties && <ComponentPropertiesDialog identity={properties.identity} blockId={blockId} stepId={properties.stepId} onClose={() => setProperties(undefined)} />}
238
+ </section>
239
+ }
@@ -0,0 +1,282 @@
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
+ import type { FocusEvent, ReactNode } from 'react'
3
+ import { CommentEditorSubmitExtension, CommentsExtension } from '@blocknote/core/comments'
4
+ import { components } from '@blocknote/mantine'
5
+ import { FloatingThreadController, getReferenceText, Thread, useBlockNoteEditor, useCreateBlockNote, useEditorState, useExtension, useExtensionState, useThreads } from '@blocknote/react'
6
+ import type { ComponentProps, ThreadProps } from '@blocknote/react'
7
+ import type { Locale } from '../messages.ts'
8
+ import { PmButton, PmEmpty, PmNotice, PmSelect } from '../components.tsx'
9
+ import { enterSubmits } from './comments.ts'
10
+ import type { DocumentCommentEditorHandle, DocumentThreadStore } from './comments.ts'
11
+ import type { CommentDrafts, CommentDraftCandidate, CommentDraftRecord, CommentDraftReference, CommentDraftOperation } from './comment-drafts.ts'
12
+ import { isNewCommentDraft, sameCommentDraftTarget } from './comment-drafts.ts'
13
+ import { createRichProjection, nativeRichTextToProduct, productRichTextToNative } from './rich-text.ts'
14
+ import { commentEditorSchema } from './schema.tsx'
15
+ import type { DocumentEditorBlock } from './schema.tsx'
16
+ import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
17
+ import { commentFloating } from './popover.tsx'
18
+
19
+ export const COMMENT_EDITOR_CONTEXT = createContext<{ store: DocumentThreadStore, locale: Locale, sidebar: boolean, getPositionToken?(): string | undefined, onDraftRestore?(): void } | undefined>(undefined)
20
+ const THREAD_CONTEXT = createContext<string | undefined>(undefined)
21
+ const emptySubscribe = () => () => {}
22
+ const emptyState = { candidates: [], pending: false, failed: false, reading: false } as const
23
+ const referenceKey = (value: CommentDraftReference) => JSON.stringify([value.id, value.revision, value.token])
24
+ const MantineCommentEditor = components.Comments.Editor
25
+ // A native Thread retains its reply editor while another thread is selected.
26
+ const composerDrafts = new WeakMap<object, { owner: CommentDrafts | undefined, current?: CommentDraftRecord, rich: ReturnType<typeof createRichProjection> }>()
27
+
28
+ /** The public Thread boundary supplies identity; an editor never guesses the selected thread. */
29
+ export function DocumentCommentThread(props: ThreadProps) {
30
+ return <THREAD_CONTEXT.Provider value={props.thread.id}><Thread {...props} /></THREAD_CONTEXT.Provider>
31
+ }
32
+
33
+ /** Keep the native controller and reply editor while body selection owns the pointer. */
34
+ export function DocumentFloatingThreadController() {
35
+ const editor = useBlockNoteEditor(), { selectedThreadId } = useExtensionState(CommentsExtension)
36
+ const empty = useEditorState({ editor, on: 'selection', selector: ({ editor }) => editor.prosemirrorState.selection.empty })
37
+ const [selecting, setSelecting] = useState(false)
38
+ const blocked = selecting || !empty
39
+ const shown = useRef(false), thread = useRef(selectedThreadId)
40
+ shown.current = Boolean(selectedThreadId) && !blocked
41
+ thread.current = selectedThreadId
42
+ // The pointer that last pressed commented text, and the line of the thread chosen from it (the first line without one).
43
+ const point = useRef<{ x: number, y: number }>(), line = useRef<{ thread: string, index: number }>()
44
+ useEffect(() => {
45
+ // The view is read at event time: the one present when this effect runs can be replaced when the editor mounts.
46
+ const view = () => editor.prosemirrorView
47
+ let pressed = false
48
+ const down = (event: PointerEvent) => {
49
+ const current = view()
50
+ if (event.target instanceof Element && current?.dom.contains(event.target) && event.target.closest('[data-bn-thread-id]')) {
51
+ point.current = { x: event.clientX, y: event.clientY }; line.current = undefined
52
+ }
53
+ if (event.button !== 0 || event.isPrimary === false || !current?.editable || current.composing
54
+ || !(event.target instanceof Element) || !current.dom.contains(event.target) || event.target.closest('[contenteditable="false"]')) return
55
+ pressed = true
56
+ // A card already on screen stays through a plain click; a card the press would open waits for the click.
57
+ if (!shown.current) setSelecting(true)
58
+ }
59
+ const move = (event: PointerEvent) => {
60
+ // Only the native collapse flag gates a popover; ranges and content stay editor-owned.
61
+ if (pressed && event.buttons & 1 && document.getSelection()?.isCollapsed === false) setSelecting(true)
62
+ }
63
+ const click = () => {
64
+ if (!pressed) return
65
+ pressed = false
66
+ // PM may still report the previous caret at the end of a fast pointer selection.
67
+ setSelecting(document.getSelection()?.isCollapsed === false)
68
+ }
69
+ const key = (event: KeyboardEvent) => {
70
+ if (!(event.target instanceof Node) || !view()?.dom.contains(event.target)) return
71
+ pressed = false; setSelecting(false)
72
+ point.current = undefined; line.current = undefined
73
+ }
74
+ const cancel = () => { if (pressed) { pressed = false; setSelecting(true) } }
75
+ document.addEventListener('pointerdown', down, true)
76
+ document.addEventListener('pointermove', move, true)
77
+ document.addEventListener('keydown', key, true)
78
+ document.addEventListener('click', click)
79
+ document.addEventListener('pointercancel', cancel, true)
80
+ window.addEventListener('blur', cancel)
81
+ return () => {
82
+ pressed = false
83
+ document.removeEventListener('pointerdown', down, true)
84
+ document.removeEventListener('pointermove', move, true)
85
+ document.removeEventListener('keydown', key, true)
86
+ document.removeEventListener('click', click)
87
+ document.removeEventListener('pointercancel', cancel, true)
88
+ window.removeEventListener('blur', cancel)
89
+ }
90
+ }, [editor])
91
+ const floating = useMemo(() => commentFloating(() => {
92
+ const id = thread.current, body = editor.prosemirrorView?.dom
93
+ if (!id || !body) return
94
+ const rects = [...body.querySelectorAll(`[data-bn-thread-id="${CSS.escape(id)}"]:not([data-orphan="true"])`)]
95
+ .flatMap(mark => [...mark.getClientRects()]).filter(rect => rect.width > 0 && rect.height > 0)
96
+ if (!rects.length) return
97
+ if (line.current?.thread !== id) {
98
+ const at = point.current, distance = (rect: DOMRect) => at ? Math.hypot(Math.max(rect.left - at.x, 0, at.x - rect.right), Math.max(rect.top - at.y, 0, at.y - rect.bottom)) : 0
99
+ line.current = { thread: id, index: rects.reduce((best, rect, index) => distance(rect) < distance(rects[best]!) ? index : best, 0) }
100
+ }
101
+ return rects[Math.min(line.current.index, rects.length - 1)]
102
+ }), [editor])
103
+ return <FloatingThreadController floatingThread={DocumentCommentThread} floatingUIOptions={{
104
+ useFloatingOptions: { ...floating, open: Boolean(selectedThreadId) && !blocked },
105
+ elementProps: { className: blocked ? 'pm-ui-comment-hidden' : undefined },
106
+ }} />
107
+ }
108
+
109
+ /** Native Thread composition, with the same public focus/blur and position ordering as the sidebar. */
110
+ export function DocumentThreadsSidebar({ children }: { children?: ReactNode }) {
111
+ const editor = useBlockNoteEditor(), comments = useExtension(CommentsExtension), threads = useThreads()
112
+ const { selectedThreadId, threadPositions } = useExtensionState(CommentsExtension)
113
+ const ordered = [...threads.values()].sort((a, b) => (threadPositions.get(a.id)?.from ?? Number.MAX_VALUE) - (threadPositions.get(b.id)?.from ?? Number.MAX_VALUE))
114
+ const focus = (id: string, event: FocusEvent) => { if (!(event.target as HTMLElement).closest('.bn-action-toolbar')) comments.selectThread(id) }
115
+ const blur = (event: FocusEvent) => {
116
+ const next = event.relatedTarget
117
+ if (!next || next.closest('.bn-action-toolbar')) return
118
+ const parent = next.closest('.bn-thread')
119
+ if (!parent || !(event.target instanceof Node) || !parent.contains(event.target)) comments.selectThread(undefined)
120
+ }
121
+ const context = useContext(COMMENT_EDITOR_CONTEXT), t = DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en']
122
+ return <div className="bn-threads-sidebar">{children}<CommentDraftRetrieval />{!ordered.length && <PmEmpty icon="comment">{t.commentsEmpty}</PmEmpty>}{ordered.map(thread => <DocumentCommentThread key={thread.id} thread={thread}
123
+ selected={selectedThreadId === thread.id} orphaned={!threadPositions.has(thread.id)} referenceText={getReferenceText(editor, threadPositions.get(thread.id))}
124
+ onFocus={event => focus(thread.id, event)} onBlur={blur} tabIndex={0} />)}</div>
125
+ }
126
+
127
+ function DraftPreview({ candidate }: { candidate: CommentDraftCandidate }) {
128
+ const projection = useRef(createRichProjection())
129
+ const editor = useCreateBlockNote({ schema: commentEditorSchema, trailingBlock: false, initialContent: productRichTextToNative(candidate.content, projection.current) }, [candidate.id, candidate.revision, candidate.token])
130
+ return <MantineCommentEditor editor={editor} editable={false} autoFocus={false} />
131
+ }
132
+
133
+ export function DraftChoices({ candidates, disabled, onRecover, onDiscard, preview = false, canRecover, unavailableMessage }: {
134
+ candidates: readonly CommentDraftCandidate[], disabled?: boolean, onRecover?(candidate: CommentDraftCandidate): Promise<void>,
135
+ onDiscard(candidate: CommentDraftCandidate): Promise<void>, preview?: boolean, canRecover?(candidate: CommentDraftCandidate): boolean, unavailableMessage?: string,
136
+ }) {
137
+ const context = useContext(COMMENT_EDITOR_CONTEXT), t = DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en']
138
+ const [selected, setSelected] = useState(''), [busy, setBusy] = useState(false)
139
+ const candidate = candidates.find(value => referenceKey(value) === selected)
140
+ const act = async (action: (candidate: CommentDraftCandidate) => Promise<void>) => {
141
+ if (!candidate || busy || disabled) return
142
+ setBusy(true)
143
+ try { await action(candidate) } finally { setBusy(false) }
144
+ }
145
+ if (!candidates.length) return null
146
+ return <PmNotice tone="accent"><PmSelect label={t.commentDrafts} value={candidate ? selected : ''} disabled={disabled || busy} onChange={event => setSelected(event.target.value)}>
147
+ <option value="">{t.commentChooseDraft}</option>{candidates.map(value => <option key={referenceKey(value)} value={referenceKey(value)}>{new Date(value.updatedAt).toLocaleString(context?.locale)} · {isNewCommentDraft(value.target) ? t.commentNewDraft : value.target.commentId ? t.commentEditDraft : t.commentReplyDraft}{value.storage === 'session' ? ` · ${t.commentSessionOnly}` : ''}</option>)}
148
+ </PmSelect>{candidate && preview && <DraftPreview candidate={candidate} />}
149
+ {candidate && unavailableMessage && canRecover && !canRecover(candidate) && <PmNotice tone="warn">{unavailableMessage}</PmNotice>}
150
+ <span className="pmw-notice__actions">{onRecover && <PmButton small variant="primary" disabled={!candidate || disabled || busy || candidate.storage === 'session' || canRecover && !canRecover(candidate)} loading={busy} onClick={() => void act(onRecover)}>{t.commentRecover}</PmButton>}
151
+ <PmButton small disabled={!candidate || disabled || busy} onClick={() => void act(onDiscard)}>{t.commentDiscard}</PmButton></span>
152
+ </PmNotice>
153
+ }
154
+
155
+ function CommentDraftRetrieval() {
156
+ const context = useContext(COMMENT_EDITOR_CONTEXT), drafts = context?.store.drafts
157
+ const state = useSyncExternalStore(drafts?.subscribe ?? emptySubscribe, drafts?.snapshot ?? (() => emptyState))
158
+ const candidates = state.candidates.filter(value => !isNewCommentDraft(value.target) && (value.storage === 'session' || context?.store.draftTargetState(value.target) === 'unavailable'))
159
+ if (!drafts) return null
160
+ return <>{state.failed && <PmNotice tone="warn">{DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en'].commentBackupFailed}</PmNotice>}<DraftChoices candidates={candidates} preview onDiscard={async candidate => { await drafts.discard(candidate) }} /></>
161
+ }
162
+
163
+ export function DocumentCommentEditor(props: ComponentProps['Comments']['Editor']) {
164
+ const context = useContext(COMMENT_EDITOR_CONTEXT), threadId = useContext(THREAD_CONTEXT)
165
+ const [handle, setHandle] = useState<DocumentCommentEditorHandle>(), [, setRevision] = useState(0), [recoveryFailure, setRecoveryFailure] = useState(false)
166
+ const currentHandle = useRef(handle), recoveryOwner = useRef(0)
167
+ currentHandle.current = handle
168
+ const current = useRef(props), active = useRef(true), input = useRef(0), composing = useRef(false), submitting = useRef(false), suppress = useRef(false)
169
+ current.current = props
170
+ const drafts = context?.store.drafts
171
+ const record = useMemo(() => {
172
+ let value = composerDrafts.get(props.editor)
173
+ if (!value || value.owner !== drafts) { value = { owner: drafts, rich: createRichProjection() }; composerDrafts.set(props.editor, value) }
174
+ return value
175
+ }, [props.editor, drafts])
176
+ const rich = useMemo(() => ({ current: record.rich }), [record])
177
+ const composerId = useRef(crypto.randomUUID())
178
+ const state = useSyncExternalStore(drafts?.subscribe ?? emptySubscribe, drafts?.snapshot ?? (() => emptyState))
179
+ useLayoutEffect(() => {
180
+ if (!context || !threadId) return
181
+ active.current = true; ++input.current
182
+ if (record.current && drafts && !drafts.owns(record.current)) record.current = undefined
183
+ const value = context.store.bindEditor(props.editor, props.editable, threadId)
184
+ setHandle(value)
185
+ const original = props.editor.getExtension(CommentEditorSubmitExtension)
186
+ let guarded: typeof original, stopEnter = () => {}
187
+ if (original && value.target && drafts) {
188
+ props.editor.replaceExtension(original, CommentEditorSubmitExtension({ onSubmit: async () => {
189
+ if (composing.current || !active.current) return
190
+ const target = value.target!, before = context.store.threadSnapshot(target.threadId), ids = new Set(before?.comments.map(comment => comment.id))
191
+ const own = record.current
192
+ submitting.current = true
193
+ try {
194
+ const result = original.submit(), thread = context.store.threadSnapshot(target.threadId)
195
+ const comment = target.commentId ? thread?.comments.find(comment => comment.id === target.commentId) : thread?.comments.find(comment => !ids.has(comment.id))
196
+ if (comment && thread !== before) { drafts.submitted(own, comment); record.current = undefined; setRevision(value => value + 1) }
197
+ await result
198
+ } finally { submitting.current = false }
199
+ } }))
200
+ guarded = props.editor.getExtension(CommentEditorSubmitExtension)
201
+ const extension = guarded
202
+ stopEnter = enterSubmits(props.editor, () => extension?.submit())
203
+ context.store.refreshEditorCallbacks()
204
+ }
205
+ const stop = props.editor.onChange(() => {
206
+ if (!active.current || !current.current.editable || suppress.current || submitting.current) return
207
+ ++input.current; setRecoveryFailure(false)
208
+ const version = context.store.version
209
+ if (!value.target || !drafts || !version) return
210
+ // Empty reply and native Cancel are not new unpublished content.
211
+ if (props.editor.isEmpty && !value.target.commentId) { if (record.current) { void drafts.clearOwn(record.current); record.current = undefined }; return }
212
+ const content = nativeRichTextToProduct(props.editor.document as DocumentEditorBlock[], rich.current)
213
+ record.current = drafts.write(record.current?.target ?? value.target, record.current?.version ?? version, content, composing.current, record.current) ?? record.current
214
+ setRevision(value => value + 1)
215
+ })
216
+ return () => {
217
+ active.current = false; ++input.current; stop(); stopEnter()
218
+ if (original && guarded && !props.editor.prosemirrorView.isDestroyed && props.editor.getExtension(CommentEditorSubmitExtension) === guarded) props.editor.replaceExtension(guarded, original)
219
+ value.close()
220
+ }
221
+ }, [context?.store, props.editor, threadId])
222
+ const editable = useRef(props.editable)
223
+ useLayoutEffect(() => {
224
+ handle?.setEditable(props.editable)
225
+ if (editable.current && !props.editable && record.current && drafts) { const value = record.current; record.current = undefined; void drafts.clearOwn(value) }
226
+ editable.current = props.editable
227
+ }, [handle, props.editable, drafts])
228
+ useLayoutEffect(() => { recoveryOwner.current++; setRecoveryFailure(false) }, [handle, props.editable])
229
+ const failure = useSyncExternalStore(handle?.subscribe ?? emptySubscribe, handle?.failure ?? (() => undefined))
230
+ const target = handle?.target, t = DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en']
231
+ const candidates = target ? state.candidates.filter(value => sameCommentDraftTarget(value.target, target) && value.id !== record.current?.id) : []
232
+ const recover = async (candidate: CommentDraftCandidate, operation?: CommentDraftOperation): Promise<CommentDraftRecord | undefined> => {
233
+ if (!drafts || !target || !context?.store.version || !props.editable || composing.current || submitting.current || props.editor.prosemirrorView.composing) return
234
+ context.store.beforeAction?.()
235
+ const before = input.current, version = context.store.version, native = props.editor.prosemirrorState, position = context.getPositionToken?.()
236
+ const own = record.current
237
+ const owns = () => {
238
+ operation?.signal?.throwIfAborted(); operation?.guard?.()
239
+ context.store.beforeAction?.()
240
+ if (!active.current || input.current !== before || context.store.version !== version || composing.current || submitting.current || !current.current.editable
241
+ || props.editor.prosemirrorView.isDestroyed || props.editor.prosemirrorView.composing || props.editor.prosemirrorState.doc !== native.doc
242
+ || props.editor.prosemirrorState.selection !== native.selection || context.getPositionToken?.() !== position || record.current !== own
243
+ || context.store.draftTargetState(target) === 'unavailable') throw new Error('client-comment-composer-changed')
244
+ }
245
+ if (own && !await drafts.confirm(own)) return
246
+ owns()
247
+ const value = await drafts.recover(candidate, target, version, { signal: operation?.signal, guard: owns })
248
+ if (!value) return
249
+ owns()
250
+ suppress.current = true
251
+ try {
252
+ props.editor.replaceBlocks(props.editor.document, productRichTextToNative(value.content, rich.current))
253
+ const actual = nativeRichTextToProduct(props.editor.document as DocumentEditorBlock[], rich.current)
254
+ if (JSON.stringify(actual) !== JSON.stringify(value.content)) throw new Error('client-comment-native-unverified')
255
+ record.current = value; handle?.restoreBaseline(!isNewCommentDraft(candidate.target) ? candidate.target.baseline : undefined)
256
+ ++input.current; setRevision(value => value + 1)
257
+ } finally { suppress.current = false }
258
+ if (!operation) await drafts.discard(candidate)
259
+ return value
260
+ }
261
+ useLayoutEffect(() => {
262
+ if (!drafts || !target || !props.editable) return
263
+ return drafts.registerComposer({ id: composerId.current, target,
264
+ snapshot: () => { const available = active.current && current.current.editable && !submitting.current && !props.editor.prosemirrorView.isDestroyed; return { record: record.current, composing: composing.current || available && props.editor.prosemirrorView.composing, available } },
265
+ targetState: () => context?.store.draftTargetState(target) ?? 'unavailable', restore: recover })
266
+ }, [drafts, handle, props.editor, props.editable])
267
+ const composition = (value: boolean) => {
268
+ composing.current = value
269
+ if (record.current && target && drafts && context?.store.version) {
270
+ record.current = drafts.write(record.current.target, record.current.version, nativeRichTextToProduct(props.editor.document as DocumentEditorBlock[], rich.current), value, record.current) ?? record.current
271
+ setRevision(value => value + 1)
272
+ }
273
+ }
274
+ return <div data-comment-draft-editor={target ? JSON.stringify([target.threadId, target.commentId ?? null]) : undefined} aria-busy={state.pending || undefined}
275
+ onCompositionStart={() => composition(true)} onCompositionEnd={() => composition(false)}>
276
+ <MantineCommentEditor {...props} />
277
+ {recoveryFailure && <PmNotice tone="warn">{t.commentRecoveryChanged}</PmNotice>}
278
+ {failure && <PmNotice tone="danger">{failure === 'changed' ? t.commentChanged : t.commentUnavailable}</PmNotice>}
279
+ {drafts && props.editable && <DraftChoices candidates={candidates} disabled={composing.current || submitting.current} onRecover={async candidate => { const captured = handle, owner = recoveryOwner.current; setRecoveryFailure(false); try { await recover(candidate) } catch { if (active.current && current.current.editable && currentHandle.current === captured && recoveryOwner.current === owner) setRecoveryFailure(true) } }} onDiscard={async candidate => { await drafts.discard(candidate) }} />}
280
+ {drafts && props.editable && state.failed && !context?.sidebar && <PmNotice tone="warn">{t.commentBackupFailed}</PmNotice>}
281
+ </div>
282
+ }