@lincyaw/workbench 0.1.0 → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/package.json +27 -22
- 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 +547 -90
- 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 +33 -43
- 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 +751 -203
- 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 +456 -44
- 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 +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- 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 +757 -361
- 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
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- 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
|
+
}
|