@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.
- 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,192 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import type { Transaction } from '@tiptap/pm/state'
|
|
3
|
+
import { closeHistory } from '@tiptap/pm/history'
|
|
4
|
+
import { allocateDocumentPage } from '@lincyaw/kernel'
|
|
5
|
+
import type { Member, TeachingDocument } from '@lincyaw/kernel'
|
|
6
|
+
import type { Locale } from '../messages.ts'
|
|
7
|
+
import { BlockNoteSurface } from './BlockNoteSurface.tsx'
|
|
8
|
+
import type { CommentDrafts } from './comment-drafts.ts'
|
|
9
|
+
import { DocumentThreadStore } from './comments.ts'
|
|
10
|
+
import { createDocumentProjection, nativeDocumentToProduct, nativePage, pageId, rememberComponentConfiguration, rememberDocumentStructure } from './projection.ts'
|
|
11
|
+
import type { NativePageCreation } from './projection.ts'
|
|
12
|
+
import { captureRichBaseline } from './rich-text.ts'
|
|
13
|
+
import { captureTableIdentities, seedTableIdentities } from './table-identities.ts'
|
|
14
|
+
import type { DocumentNodeViews, NativeDocumentEditor } from './schema.tsx'
|
|
15
|
+
import { nativeEditingCursor } from './selection.ts'
|
|
16
|
+
import type { NativeDocumentSelection } from './selection.ts'
|
|
17
|
+
|
|
18
|
+
export interface DocumentEditingPosition {
|
|
19
|
+
nativeBlockId: string
|
|
20
|
+
sectionId: string
|
|
21
|
+
position: number
|
|
22
|
+
}
|
|
23
|
+
export interface DocumentEditorCommands {
|
|
24
|
+
insertPage(after: string | undefined, title: string): NativePageCreation
|
|
25
|
+
rememberComponentConfiguration(document: TeachingDocument): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DocumentEditorProps {
|
|
29
|
+
identity: string
|
|
30
|
+
document: TeachingDocument
|
|
31
|
+
version?: string
|
|
32
|
+
member?: Member
|
|
33
|
+
commentDrafts?: CommentDrafts
|
|
34
|
+
/** The latest actor version, including before the next React render. */
|
|
35
|
+
getDocumentVersion?(): string | undefined
|
|
36
|
+
/** Only a clean source snapshot confirms a submitted comment backup. */
|
|
37
|
+
acknowledged?: boolean
|
|
38
|
+
getDocumentAcknowledged?(): boolean
|
|
39
|
+
/** Read sibling field edits before their React render has committed. */
|
|
40
|
+
getDocument?(): TeachingDocument | undefined
|
|
41
|
+
/** Ownership of a deferred native focus notification. */
|
|
42
|
+
getPositionToken?(): string
|
|
43
|
+
/** Display-only URL resolution; native block URLs keep their canonical source locations. */
|
|
44
|
+
resolveAssetURL?(location: string): Promise<string>
|
|
45
|
+
uploadImage?(file: File, signal: AbortSignal): Promise<string>
|
|
46
|
+
reset?: number
|
|
47
|
+
locale: Locale
|
|
48
|
+
readOnly?: boolean
|
|
49
|
+
nodeViews?: DocumentNodeViews
|
|
50
|
+
onChange(document: TeachingDocument): void
|
|
51
|
+
onSelection?(selection: NativeDocumentSelection | undefined): void
|
|
52
|
+
onEditingPosition?(position: DocumentEditingPosition, editor: NativeDocumentEditor): void
|
|
53
|
+
onReady?(editor: NativeDocumentEditor | undefined, commands?: DocumentEditorCommands): void
|
|
54
|
+
onComposition?(composing: boolean): void
|
|
55
|
+
onSectionLink?(sectionId: string): boolean
|
|
56
|
+
onError?(error: Error): void
|
|
57
|
+
commentsPortal?: HTMLElement | null
|
|
58
|
+
onCommentDraftRestore?(): void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A source version reset replaces the editor; its own save receipts never replace it. */
|
|
62
|
+
export function DocumentEditor(props: DocumentEditorProps) {
|
|
63
|
+
const projection = useMemo(() => createDocumentProjection(props.document), [props.identity, props.reset])
|
|
64
|
+
useEffect(() => rememberComponentConfiguration(projection, props.document), [projection, props.version])
|
|
65
|
+
const comments = useMemo(() => new DocumentThreadStore(props.document, props.member, props.version, props.identity, props.commentDrafts), [props.identity, props.reset])
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const latest = current.current, document = latest.getDocument ? latest.getDocument() : latest.document
|
|
68
|
+
const version = latest.getDocumentVersion ? latest.getDocumentVersion() : latest.version
|
|
69
|
+
if (document && version !== undefined) {
|
|
70
|
+
comments.drafts?.synchronize(document, version, latest.getDocumentAcknowledged ? latest.getDocumentAcknowledged() : latest.acknowledged ?? false)
|
|
71
|
+
comments.drafts?.start()
|
|
72
|
+
}
|
|
73
|
+
return () => comments.close()
|
|
74
|
+
}, [comments])
|
|
75
|
+
const current = useRef(props), editor = useRef<NativeDocumentEditor>(), unsubscribe = useRef<() => void>()
|
|
76
|
+
const projectedDocument = useRef<NativeDocumentEditor['prosemirrorState']['doc']>()
|
|
77
|
+
const pendingTransaction = useRef<Transaction>()
|
|
78
|
+
const focusFrame = useRef<number>()
|
|
79
|
+
const binding = useRef(projection)
|
|
80
|
+
const active = useRef(projection)
|
|
81
|
+
active.current = projection
|
|
82
|
+
current.current = props
|
|
83
|
+
const cancelFocus = () => { if (focusFrame.current !== undefined) cancelAnimationFrame(focusFrame.current); focusFrame.current = undefined }
|
|
84
|
+
const reportPosition = () => {
|
|
85
|
+
const value = editor.current
|
|
86
|
+
if (!value || current.current.readOnly || !current.current.onEditingPosition || binding.current !== projection || active.current !== projection || value.prosemirrorView.isDestroyed || value.prosemirrorState.doc !== projectedDocument.current) return
|
|
87
|
+
try {
|
|
88
|
+
const cursor = nativeEditingCursor(value)
|
|
89
|
+
if (!cursor) return
|
|
90
|
+
const owner = projection.owners.get(cursor.nativeBlockId)
|
|
91
|
+
const sectionId = projection.pages.get(cursor.nativeBlockId) ?? (owner ? projection.blockSections.get(owner) : undefined)
|
|
92
|
+
const latest = current.current.getDocument ? current.current.getDocument() : current.current.document
|
|
93
|
+
if (sectionId && latest?.sections[sectionId]) current.current.onEditingPosition({ ...cursor, sectionId }, value)
|
|
94
|
+
} catch (error) { current.current.onError?.(error instanceof Error ? error : new Error(String(error))) }
|
|
95
|
+
}
|
|
96
|
+
const syncComments = () => {
|
|
97
|
+
const value = editor.current, latest = current.current.getDocument ? current.current.getDocument() : current.current.document
|
|
98
|
+
const version = current.current.getDocumentVersion ? current.current.getDocumentVersion() : current.current.version
|
|
99
|
+
if (!value || !latest || binding.current !== projection || active.current !== projection || value.prosemirrorView.isDestroyed) return
|
|
100
|
+
const transaction = pendingTransaction.current
|
|
101
|
+
comments.synchronize(latest, version, value, transaction?.doc === value.prosemirrorState.doc && transaction.before === projectedDocument.current ? transaction : undefined, current.current.getDocumentAcknowledged ? current.current.getDocumentAcknowledged() : current.current.acknowledged ?? false)
|
|
102
|
+
if (version !== undefined) comments.drafts?.start()
|
|
103
|
+
projectedDocument.current = value.prosemirrorState.doc
|
|
104
|
+
}
|
|
105
|
+
comments.beforeAction = () => { if (current.current.getDocumentVersion && current.current.getDocumentVersion() === undefined) throw new Error('document-context-changed'); syncComments() }
|
|
106
|
+
useEffect(syncComments, [props.version, props.document.comments, props.acknowledged, comments])
|
|
107
|
+
const emit = () => {
|
|
108
|
+
if (!editor.current || binding.current !== projection || comments.synchronizing) return
|
|
109
|
+
try {
|
|
110
|
+
captureTableIdentities(editor.current, projection)
|
|
111
|
+
if (projection !== active.current) return
|
|
112
|
+
const latest = current.current.getDocument ? current.current.getDocument() : current.current.document
|
|
113
|
+
if (!latest || current.current.getDocumentVersion && current.current.getDocumentVersion() === undefined) return
|
|
114
|
+
syncComments()
|
|
115
|
+
const document = nativeDocumentToProduct(editor.current.document, projection, latest)
|
|
116
|
+
const threads = comments.snapshot(editor.current)
|
|
117
|
+
if (Object.keys(threads).length) document.comments = threads
|
|
118
|
+
else delete document.comments
|
|
119
|
+
current.current.onChange(document)
|
|
120
|
+
projectedDocument.current = editor.current.prosemirrorState.doc
|
|
121
|
+
pendingTransaction.current = undefined
|
|
122
|
+
cancelFocus()
|
|
123
|
+
reportPosition()
|
|
124
|
+
} catch (error) { current.current.onError?.(error instanceof Error ? error : new Error(String(error))) }
|
|
125
|
+
}
|
|
126
|
+
return <BlockNoteSurface identity={props.identity} reset={props.reset} initialContent={projection.content} locale={props.locale} contentLang={props.document.metadata.lang || 'zh-CN'} readOnly={props.readOnly}
|
|
127
|
+
resolveAssetURL={props.resolveAssetURL} uploadImage={props.uploadImage}
|
|
128
|
+
comments={comments} commentsPortal={props.commentsPortal} onCommentDraftRestore={() => current.current.onCommentDraftRestore?.()} nodeViews={props.nodeViews} onComposition={props.onComposition} onSelection={props.onSelection} onSectionLink={sectionId => {
|
|
129
|
+
if (!current.current.onSectionLink?.(sectionId)) return false
|
|
130
|
+
// The click placed the caret in the link; it follows the reader to the page they asked for.
|
|
131
|
+
const value = editor.current
|
|
132
|
+
if (value && !value.prosemirrorView.isDestroyed && value.getBlock(pageId(sectionId))) value.setTextCursorPosition(pageId(sectionId), 'start')
|
|
133
|
+
return true
|
|
134
|
+
}}
|
|
135
|
+
onChange={emit} onReady={value => {
|
|
136
|
+
if (projection !== active.current) return
|
|
137
|
+
unsubscribe.current?.(); unsubscribe.current = undefined
|
|
138
|
+
cancelFocus()
|
|
139
|
+
editor.current = undefined
|
|
140
|
+
projectedDocument.current = undefined
|
|
141
|
+
pendingTransaction.current = undefined
|
|
142
|
+
if (value) {
|
|
143
|
+
seedTableIdentities(value, projection)
|
|
144
|
+
captureTableIdentities(value, projection)
|
|
145
|
+
captureRichBaseline(value.document, projection)
|
|
146
|
+
rememberDocumentStructure(value.document, projection)
|
|
147
|
+
comments.restoreAnchors(value)
|
|
148
|
+
binding.current = projection
|
|
149
|
+
editor.current = value
|
|
150
|
+
projectedDocument.current = value.prosemirrorState.doc
|
|
151
|
+
const stopBefore = value.onBeforeChange(({ tr }) => { if (!comments.synchronizing && tr.docChanged) pendingTransaction.current = tr })
|
|
152
|
+
const stopComments = comments.subscribe(emit)
|
|
153
|
+
const stopSelection = value.onSelectionChange(() => { cancelFocus(); reportPosition() })
|
|
154
|
+
const stopFocus = value.onFocusChange((_, { focused }) => {
|
|
155
|
+
cancelFocus()
|
|
156
|
+
if (!focused) return
|
|
157
|
+
const token = current.current.getPositionToken?.()
|
|
158
|
+
focusFrame.current = requestAnimationFrame(() => {
|
|
159
|
+
focusFrame.current = undefined
|
|
160
|
+
if (token === current.current.getPositionToken?.()) reportPosition()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
unsubscribe.current = () => { stopBefore(); stopComments(); stopSelection(); stopFocus(); cancelFocus() }
|
|
164
|
+
}
|
|
165
|
+
const commands: DocumentEditorCommands | undefined = value ? { rememberComponentConfiguration: document => {
|
|
166
|
+
if (editor.current === value && active.current === projection && binding.current === projection && !value.prosemirrorView.isDestroyed) rememberComponentConfiguration(projection, document)
|
|
167
|
+
}, insertPage: (after, title) => {
|
|
168
|
+
if (current.current.readOnly || editor.current !== value || active.current !== projection || binding.current !== projection || value.prosemirrorView.isDestroyed) throw new Error('page-context-changed')
|
|
169
|
+
const blocks = value.document, index = after ? blocks.findIndex(block => block.type === 'presmiumPage' && block.props.sectionId === after) : blocks.length - 1
|
|
170
|
+
if (index < 0) throw new Error('page-boundary')
|
|
171
|
+
const next = after ? blocks.slice(index + 1).find(block => block.type === 'presmiumPage') : undefined
|
|
172
|
+
const latest = current.current.getDocument ? current.current.getDocument() : current.current.document
|
|
173
|
+
if (!latest) throw new Error('page-context-changed')
|
|
174
|
+
const creation = allocateDocumentPage(latest, () => crypto.randomUUID())
|
|
175
|
+
projection.createdPages.set(creation.sectionId, creation)
|
|
176
|
+
try {
|
|
177
|
+
value.transact(transaction => {
|
|
178
|
+
closeHistory(transaction)
|
|
179
|
+
value.insertBlocks([nativePage(creation.sectionId, title), { id: creation.nodeId, type: 'paragraph', content: [] }], next ?? blocks.at(-1)!, next ? 'before' : 'after')
|
|
180
|
+
})
|
|
181
|
+
// The first keystroke belongs to its own native history event.
|
|
182
|
+
value.transact(transaction => closeHistory(transaction))
|
|
183
|
+
return creation
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!value.getBlock(`page:${creation.sectionId}`)) projection.createdPages.delete(creation.sectionId)
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
} } : undefined
|
|
189
|
+
current.current.onReady?.(value, commands)
|
|
190
|
+
if (value) reportPosition()
|
|
191
|
+
}} getPositionToken={() => current.current.getPositionToken?.()} />
|
|
192
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { NarrationNode, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { PmIconButton } from '../components.tsx'
|
|
4
|
+
import { useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
5
|
+
import { isAgentChanging } from '../workbench.ts'
|
|
6
|
+
import { NarrationEditor } from './NarrationEditor.tsx'
|
|
7
|
+
import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
|
|
8
|
+
import { narrationFieldFocus } from './narration.ts'
|
|
9
|
+
import type { NativeNarrationEditor } from './narration-schema.tsx'
|
|
10
|
+
import { StepProductionEditor } from '../StepProductionEditor.tsx'
|
|
11
|
+
|
|
12
|
+
/** Content ownership determines the page, even when the author is reading another page. */
|
|
13
|
+
export function documentBlockSection(document: TeachingDocument, blockId: string): string | undefined {
|
|
14
|
+
const contains = (id: string): boolean => {
|
|
15
|
+
if (id === blockId) return true
|
|
16
|
+
const block = document.blocks[id]
|
|
17
|
+
return (block?.type === 'container' || block?.type === 'component') && (block.children ?? []).some(contains)
|
|
18
|
+
}
|
|
19
|
+
return document.sectionOrder.find(id => document.sections[id]?.children.some(contains))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function narrationDisplay(content: readonly NarrationNode[]): string {
|
|
23
|
+
return content.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function NarrationField({ identity, reset, blockId, stepId, slot, narrationId, content, readOnly, advanced, onPause }: {
|
|
27
|
+
identity: string, reset: number, blockId?: string, stepId: string, slot: number, narrationId?: string, content: NarrationNode[], readOnly: boolean, advanced: boolean, onPause(): void,
|
|
28
|
+
}) {
|
|
29
|
+
const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale)
|
|
30
|
+
// Reserving an identity does not create an entity. The first actual input commits it.
|
|
31
|
+
const [reservedId] = useState(() => {
|
|
32
|
+
const pending = workbench.pendingDocumentField(blockId, identity)
|
|
33
|
+
return narrationId ?? (pending?.stepId === stepId ? pending.narrationId : undefined) ?? crypto.randomUUID()
|
|
34
|
+
})
|
|
35
|
+
const id = narrationId ?? reservedId
|
|
36
|
+
const target = () => narrationId ?? workbench.getDocument(identity)?.steps[stepId]?.narrationIds[slot] ?? id
|
|
37
|
+
const registration = useRef<() => void>()
|
|
38
|
+
const fieldEditor = useRef<NativeNarrationEditor>()
|
|
39
|
+
const owner = `narration:${identity}:${reset}:${JSON.stringify(blockId ?? null)}:${stepId}:${slot}:${id}`
|
|
40
|
+
return <NarrationEditor identity={identity} reset={reset} narrationId={id} content={content} locale={locale} readOnly={readOnly} advanced={advanced}
|
|
41
|
+
getContent={() => { const document = workbench.getDocument(identity); return document ? document.narrations[target()]?.content ?? [] : undefined }}
|
|
42
|
+
onReady={editor => {
|
|
43
|
+
registration.current?.()
|
|
44
|
+
fieldEditor.current = editor
|
|
45
|
+
registration.current = editor ? workbench.registerDocumentField({ identity, reset, blockId, stepId, narrationId: id, slot, focus: narrationFieldFocus(editor), editor }) : undefined
|
|
46
|
+
}}
|
|
47
|
+
onFocus={() => { workbench.documentInteraction(); if (fieldEditor.current) workbench.activateDocumentHistory(fieldEditor.current); onPause() }} onChange={value => workbench.setDocumentNarration(stepId, target(), value, identity)}
|
|
48
|
+
onComposition={composing => workbench.documentComposition(composing, owner, identity)}
|
|
49
|
+
onError={error => workbench.documentError(error)} />
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Captions are a view of the current step's canonical narration, including unsaved input. */
|
|
53
|
+
export function DocumentStepNarration({ blockId, sectionId, stepId, expanded, advanced = false, showLabel = true, panelId, onPause }: {
|
|
54
|
+
blockId?: string, sectionId: string, stepId: string, expanded: boolean, advanced?: boolean, showLabel?: boolean, panelId: string, onPause(): void,
|
|
55
|
+
}) {
|
|
56
|
+
const workbench = useWorkbench(), source = useWorkbenchState(state => state.authoring)
|
|
57
|
+
const locale = useWorkbenchState(state => state.locale), t = DOCUMENT_EDITOR_MESSAGES[locale]
|
|
58
|
+
const readOnly = useWorkbenchState(state => workbench.client.capabilities?.readOnly === true || Boolean(state.authoring?.loading) || isAgentChanging(state))
|
|
59
|
+
const timingStorageFailure = useWorkbenchState(state => state.documentTimingDraftStorageFailure)
|
|
60
|
+
if (workbench.client.capabilities?.narration === false) return null
|
|
61
|
+
const document = source?.content, section = document?.sections[sectionId], step = document?.steps[stepId]
|
|
62
|
+
if (!source?.identity || !document || !section || !step) return null
|
|
63
|
+
const index = section.stepOrder.indexOf(stepId), ids = step.narrationIds
|
|
64
|
+
const label = t.narrationStep(index + 1, section.stepOrder.length, step.title ?? ''), fieldLabel = advanced ? t.stepNarration : t.stepCaptions
|
|
65
|
+
const text = ids.map(id => narrationDisplay(document.narrations[id]?.content ?? [])).filter(Boolean).join('\n')
|
|
66
|
+
const navigate = (offset: number) => {
|
|
67
|
+
const id = section.stepOrder[index + offset]
|
|
68
|
+
if (id) { onPause(); workbench.selectDocumentStep(sectionId, id) }
|
|
69
|
+
}
|
|
70
|
+
return <section className="pm-ui-block-preview__narration" aria-label={fieldLabel} data-step-narration={stepId}>
|
|
71
|
+
<header className="pm-ui-block-preview__narration-header">
|
|
72
|
+
<span className="pm-ui-block-preview__step-label">{label}</span>
|
|
73
|
+
<span className="pm-ui-block-preview__actions">
|
|
74
|
+
<PmIconButton small icon="chevron-left" label={t.previousStep} disabled={index <= 0} onClick={() => navigate(-1)} />
|
|
75
|
+
<PmIconButton small icon="chevron-right" label={t.nextStep} disabled={index >= section.stepOrder.length - 1} onClick={() => navigate(1)} />
|
|
76
|
+
</span>
|
|
77
|
+
</header>
|
|
78
|
+
{!expanded && text && <p className="pm-ui-block-preview__caption" data-step-caption={stepId}>{text}</p>}
|
|
79
|
+
{expanded && <div id={panelId} className="pm-ui-block-preview__narration-fields">
|
|
80
|
+
{showLabel && <span className="pm-ui-block-preview__step-label">{fieldLabel}</span>}
|
|
81
|
+
{(ids.length ? ids : [undefined]).map((id, slot) => <NarrationField key={`${source.identity}:${source.reset}:${stepId}:${slot}`}
|
|
82
|
+
identity={source.identity!} reset={source.reset} blockId={blockId} stepId={stepId} slot={slot} narrationId={id}
|
|
83
|
+
content={id ? document.narrations[id]?.content ?? [] : []} readOnly={readOnly} advanced={advanced} onPause={onPause} />)}
|
|
84
|
+
<StepProductionEditor key={`${source.identity}:${source.reset}:${stepId}`} document={document} stepId={stepId} locale={locale} readOnly={readOnly} advanced={advanced}
|
|
85
|
+
onPause={onPause} onInteraction={() => workbench.documentInteraction()}
|
|
86
|
+
onChange={patch => workbench.setDocumentStepProduction(stepId, patch, source.identity)}
|
|
87
|
+
drafts={{ minimumHoldMs: workbench.documentTimingDraft(stepId, 'minimumHoldMs', source.identity, source.reset), trailingPauseMs: workbench.documentTimingDraft(stepId, 'trailingPauseMs', source.identity, source.reset) }}
|
|
88
|
+
draftStorageFailure={timingStorageFailure}
|
|
89
|
+
onDraft={(key, draft) => workbench.setDocumentTimingDraft(stepId, key, draft, source.identity!, source.reset)}
|
|
90
|
+
onComposition={(key, composing) => workbench.documentComposition(composing, `production:${source.identity}:${source.reset}:${blockId}:${stepId}:${key}`, source.identity)} />
|
|
91
|
+
</div>}
|
|
92
|
+
</section>
|
|
93
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import type { NarrationNode } from '@lincyaw/kernel'
|
|
4
|
+
import { blockNoteDictionary } from './blocknote-messages.ts'
|
|
5
|
+
import { BlockNoteView } from '@blocknote/mantine'
|
|
6
|
+
import { useCreateBlockNote } from '@blocknote/react'
|
|
7
|
+
import { PmButton, PmDialog, PmInput } from '../components.tsx'
|
|
8
|
+
import type { Locale } from '../messages.ts'
|
|
9
|
+
import { DOCUMENT_EDITOR_THEME } from './BlockNoteSurface.tsx'
|
|
10
|
+
import { narrationToNative, nativeToNarration } from './narration.ts'
|
|
11
|
+
import { NARRATION_CONTEXT, NARRATION_MESSAGES, narrationExtensions, narrationSchema } from './narration-schema.tsx'
|
|
12
|
+
import type { NativeNarrationEditor } from './narration-schema.tsx'
|
|
13
|
+
|
|
14
|
+
export interface NarrationEditorProps {
|
|
15
|
+
identity: string
|
|
16
|
+
narrationId: string
|
|
17
|
+
content: NarrationNode[]
|
|
18
|
+
getContent?(): NarrationNode[] | undefined
|
|
19
|
+
locale: Locale
|
|
20
|
+
reset?: number
|
|
21
|
+
readOnly?: boolean
|
|
22
|
+
advanced?: boolean
|
|
23
|
+
onChange(content: NarrationNode[]): void
|
|
24
|
+
onComposition?(composing: boolean): void
|
|
25
|
+
onFocus?(): void
|
|
26
|
+
onError?(error: Error): void
|
|
27
|
+
onReady?(editor: NativeNarrationEditor | undefined): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A native field can live inside an opaque body node without sharing its DOM event root. */
|
|
31
|
+
export function NarrationEditor(props: NarrationEditorProps) {
|
|
32
|
+
const mount = useRef<HTMLDivElement>(null), root = useRef<ReturnType<typeof createRoot>>(), current = useRef(props)
|
|
33
|
+
current.current = props
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const host = mount.current!, shadow = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
|
|
36
|
+
// Public stylesheet entries are shared; typography and tokens keep their single source.
|
|
37
|
+
const styles = [...document.head.querySelectorAll('link[rel="stylesheet"], style')].map(node => node.cloneNode(true))
|
|
38
|
+
const element = document.createElement('div')
|
|
39
|
+
shadow.append(...styles, element)
|
|
40
|
+
const field = createRoot(element)
|
|
41
|
+
root.current = field
|
|
42
|
+
const stop = (event: Event) => event.stopPropagation()
|
|
43
|
+
const events = ['keydown', 'keyup', 'keypress', 'beforeinput', 'input', 'compositionstart', 'compositionupdate', 'compositionend', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'copy', 'cut', 'paste', 'dragstart', 'dragover', 'drop']
|
|
44
|
+
for (const event of events) element.addEventListener(event, stop)
|
|
45
|
+
field.render(<NarrationSurface {...current.current} />)
|
|
46
|
+
return () => {
|
|
47
|
+
root.current = undefined
|
|
48
|
+
for (const event of events) element.removeEventListener(event, stop)
|
|
49
|
+
queueMicrotask(() => { field.unmount(); element.remove(); styles.forEach(node => node.parentNode?.removeChild(node)) })
|
|
50
|
+
}
|
|
51
|
+
}, [])
|
|
52
|
+
useEffect(() => { root.current?.render(<NarrationSurface {...props} />) })
|
|
53
|
+
return <div ref={mount} contentEditable={false} data-narration-editor={props.narrationId} />
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function NarrationSurface(props: NarrationEditorProps) {
|
|
57
|
+
const current = useRef(props), pending = useRef<string[]>([])
|
|
58
|
+
current.current = props
|
|
59
|
+
const canonical = props.getContent?.() ?? props.content
|
|
60
|
+
const incoming = JSON.stringify(canonical), sourceKey = JSON.stringify([props.identity, props.narrationId, props.reset])
|
|
61
|
+
const version = useRef({ key: sourceKey, incoming, epoch: 0 })
|
|
62
|
+
if (version.current.key !== sourceKey) {
|
|
63
|
+
version.current.epoch++; version.current.key = sourceKey; pending.current = []
|
|
64
|
+
} else if (version.current.incoming !== incoming) {
|
|
65
|
+
const receipt = pending.current.lastIndexOf(incoming)
|
|
66
|
+
if (receipt >= 0) pending.current.splice(0, receipt + 1)
|
|
67
|
+
else { version.current.epoch++; pending.current = [] }
|
|
68
|
+
}
|
|
69
|
+
version.current.incoming = incoming
|
|
70
|
+
const instance = useMemo(() => ({ content: structuredClone(canonical), emitted: incoming }), [sourceKey, version.current.epoch])
|
|
71
|
+
const active = useRef(instance); active.current = instance
|
|
72
|
+
const t = NARRATION_MESSAGES[props.locale]
|
|
73
|
+
const editor = useCreateBlockNote({ schema: narrationSchema, initialContent: narrationToNative(props.narrationId, instance.content),
|
|
74
|
+
trailingBlock: false,
|
|
75
|
+
dictionary: { ...blockNoteDictionary(props.locale), placeholders: { ...blockNoteDictionary(props.locale).placeholders, default: props.advanced === false ? t.emptyCaptions : t.empty } },
|
|
76
|
+
extensions: narrationExtensions, domAttributes: { editor: { 'aria-label': props.advanced === false ? t.captions : t.field } },
|
|
77
|
+
}, [instance, props.advanced])
|
|
78
|
+
const baseline = useMemo(() => JSON.stringify(editor.document), [editor])
|
|
79
|
+
const [open, setOpen] = useState(false), [display, setDisplay] = useState(''), [spoken, setSpoken] = useState('')
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
current.current.onReady?.(editor)
|
|
82
|
+
return () => { props.onComposition?.(false); props.onReady?.(undefined) }
|
|
83
|
+
}, [editor])
|
|
84
|
+
const change = () => {
|
|
85
|
+
if (active.current !== instance) return
|
|
86
|
+
if (current.current.getContent && current.current.getContent() === undefined) return
|
|
87
|
+
try {
|
|
88
|
+
const content = JSON.stringify(editor.document) === baseline ? structuredClone(instance.content) : nativeToNarration(editor.document)
|
|
89
|
+
const serialized = JSON.stringify(content)
|
|
90
|
+
if (serialized === instance.emitted) return
|
|
91
|
+
instance.emitted = serialized; pending.current.push(serialized)
|
|
92
|
+
current.current.onChange(content)
|
|
93
|
+
} catch (error) { current.current.onError?.(error instanceof Error ? error : new Error(String(error))) }
|
|
94
|
+
}
|
|
95
|
+
return <NARRATION_CONTEXT.Provider value={{ locale: props.locale, readOnly: props.readOnly, advanced: props.advanced }}>
|
|
96
|
+
<div className="pm-ui-narration-editor pm-ui-block-editor pm-ui" onFocus={() => current.current.onFocus?.()}
|
|
97
|
+
onCompositionStart={() => current.current.onComposition?.(true)}
|
|
98
|
+
onCompositionEnd={() => queueMicrotask(() => { if (active.current === instance) current.current.onComposition?.(false) })}>
|
|
99
|
+
<BlockNoteView editor={editor} editable={!props.readOnly} theme={DOCUMENT_EDITOR_THEME} formattingToolbar={false} sideMenu={false} slashMenu={false} linkToolbar={false} comments={false} onChange={change} />
|
|
100
|
+
{props.advanced !== false && <div className="pm-ui-narration-editor__actions"><PmButton small variant="ghost" disabled={props.readOnly} onClick={() => { setDisplay(editor.getSelectedText()); setSpoken(''); setOpen(true) }}>{t.pronunciation}</PmButton></div>}
|
|
101
|
+
<PmDialog open={open} label={t.pronunciation} closeLabel={t.cancel} onClose={() => setOpen(false)} actions={<>
|
|
102
|
+
<PmButton small onClick={() => setOpen(false)}>{t.cancel}</PmButton>
|
|
103
|
+
<PmButton small variant="primary" disabled={!display} onClick={() => { editor.insertInlineContent([{ type: 'narrationAtom', props: { kind: 'pronunciation', display, spoken, cueId: '', payload: '' } }], { updateSelection: true }); setOpen(false); editor.focus() }}>{t.done}</PmButton>
|
|
104
|
+
</>}>
|
|
105
|
+
<PmInput label={t.display} value={display} onChange={event => setDisplay(event.target.value)} />
|
|
106
|
+
<PmInput label={t.spoken} value={spoken} onChange={event => setSpoken(event.target.value)} />
|
|
107
|
+
</PmDialog>
|
|
108
|
+
</div>
|
|
109
|
+
</NARRATION_CONTEXT.Provider>
|
|
110
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { CommentEditorSubmitExtension, CommentsExtension } from '@blocknote/core/comments'
|
|
4
|
+
import { components } from '@blocknote/mantine'
|
|
5
|
+
import { ComponentsContext, FloatingComposer, PositionPopover, useBlockNoteEditor, useComponentsContext, useCreateBlockNote, useDictionary, useEditorState, useExtension, useExtensionState } from '@blocknote/react'
|
|
6
|
+
import type { ComponentProps } from '@blocknote/react'
|
|
7
|
+
import type { BlockNoteEditor } from '@blocknote/core'
|
|
8
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
9
|
+
import { COMMENT_EDITOR_CONTEXT, DraftChoices } from './DocumentComments.tsx'
|
|
10
|
+
import { isNewCommentDraft, sameCommentDraftTarget } from './comment-drafts.ts'
|
|
11
|
+
import { enterSubmits } from './comments.ts'
|
|
12
|
+
import type { CommentDraftCandidate, CommentDraftRecord, CommentDraftReference, NewCommentDraftTarget } from './comment-drafts.ts'
|
|
13
|
+
import { createRichProjection, nativeRichTextToProduct, productRichTextToNative } from './rich-text.ts'
|
|
14
|
+
import type { DocumentEditorBlock, NativeDocumentEditor } from './schema.tsx'
|
|
15
|
+
import { PmNotice } from '../components.tsx'
|
|
16
|
+
import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
|
|
17
|
+
import { commentFloating } from './popover.tsx'
|
|
18
|
+
|
|
19
|
+
type CommentEditor = BlockNoteEditor<any, any, any>
|
|
20
|
+
interface ComposerBinding {
|
|
21
|
+
target: NewCommentDraftTarget
|
|
22
|
+
editor: CommentEditor
|
|
23
|
+
record?: CommentDraftRecord
|
|
24
|
+
origin?: CommentDraftReference
|
|
25
|
+
input: number
|
|
26
|
+
composing: boolean
|
|
27
|
+
active: boolean
|
|
28
|
+
}
|
|
29
|
+
interface PreparedDraft { record: CommentDraftRecord, origin: CommentDraftReference, doc: NativeDocumentEditor['prosemirrorState']['doc'], selection: NativeDocumentEditor['prosemirrorState']['selection'], version: string }
|
|
30
|
+
interface NewDraftOwner {
|
|
31
|
+
editor: NativeDocumentEditor
|
|
32
|
+
latest: { current: ComposerBinding | undefined }
|
|
33
|
+
prepared: { current: PreparedDraft | undefined }
|
|
34
|
+
restore(candidate: CommentDraftCandidate, current?: () => boolean): Promise<void>
|
|
35
|
+
failure?: boolean
|
|
36
|
+
}
|
|
37
|
+
const NEW_DRAFT_OWNER = createContext<NewDraftOwner | undefined>(undefined)
|
|
38
|
+
const emptySubscribe = () => () => {}
|
|
39
|
+
const emptyState = { candidates: [], pending: false, failed: false, reading: false } as const
|
|
40
|
+
|
|
41
|
+
/** One owner connects the public composer instance to a source-bound creation intent. */
|
|
42
|
+
export function NewCommentDraftProvider({ children }: { children?: ReactNode }) {
|
|
43
|
+
const context = useContext(COMMENT_EDITOR_CONTEXT), editor = useBlockNoteEditor() as NativeDocumentEditor, comments = useExtension(CommentsExtension)
|
|
44
|
+
const latest = useRef<ComposerBinding>(), prepared = useRef<PreparedDraft>(), active = useRef(true), operation = useRef(0)
|
|
45
|
+
const [failure, setFailure] = useState(false)
|
|
46
|
+
useEffect(() => { active.current = true; return () => { active.current = false; ++operation.current; prepared.current = undefined; latest.current = undefined } }, [editor])
|
|
47
|
+
const restore = async (candidate: CommentDraftCandidate, current = () => true) => {
|
|
48
|
+
const store = context?.store, drafts = store?.drafts, target = candidate.target
|
|
49
|
+
if (!store || !drafts || !isNewCommentDraft(target) || candidate.storage !== 'durable' || editor.prosemirrorView.isDestroyed || editor.prosemirrorView.composing) return
|
|
50
|
+
const binding = latest.current
|
|
51
|
+
if (binding?.record && !drafts.owns(binding.record)) binding.record = undefined
|
|
52
|
+
const request = ++operation.current, input = binding?.input, record = binding?.record
|
|
53
|
+
try {
|
|
54
|
+
store.beforeAction?.()
|
|
55
|
+
if (!store.auth.canCreateThread() || store.draftTargetState(target) !== 'current' || !store.newDraftPositions(target, editor)) { setFailure(true); return }
|
|
56
|
+
const version = store.version, native = editor.prosemirrorState, extensionState = comments.store.state, position = context.getPositionToken?.()
|
|
57
|
+
if (!version || binding?.composing || binding?.editor.prosemirrorView.composing) return
|
|
58
|
+
const owns = () => active.current && current() && request === operation.current && !editor.prosemirrorView.isDestroyed && !editor.prosemirrorView.composing && store.version === version
|
|
59
|
+
&& context.getPositionToken?.() === position
|
|
60
|
+
&& editor.prosemirrorState.doc === native.doc && editor.prosemirrorState.selection === native.selection && comments.store.state === extensionState
|
|
61
|
+
&& (!binding || latest.current === binding && binding.input === input && binding.record === record && !binding.composing)
|
|
62
|
+
if (record && !await drafts.confirm(record) || !owns()) return
|
|
63
|
+
const nextTarget = { ...target, intentId: crypto.randomUUID() }, next = await drafts.recover(candidate, nextTarget, version)
|
|
64
|
+
store.beforeAction?.()
|
|
65
|
+
if (!next || !owns()) return
|
|
66
|
+
const positions = store.newDraftPositions(nextTarget, editor)
|
|
67
|
+
if (!positions) { setFailure(true); return }
|
|
68
|
+
comments.stopPendingComment()
|
|
69
|
+
// ProseMirror scrolls to a selection only while the DOM selection is in the editor, and the pressed button is gone.
|
|
70
|
+
editor.focus()
|
|
71
|
+
editor.transact(transaction => transaction.setSelection(TextSelection.create(transaction.doc, positions.from, positions.to)).scrollIntoView())
|
|
72
|
+
// The public controller creates the actual editor; this is only its strictly committed handoff.
|
|
73
|
+
prepared.current = { record: next, origin: candidate, doc: editor.prosemirrorState.doc, selection: editor.prosemirrorState.selection, version }
|
|
74
|
+
comments.startPendingComment()
|
|
75
|
+
prepared.current.selection = editor.prosemirrorState.selection
|
|
76
|
+
setFailure(false)
|
|
77
|
+
context.onDraftRestore?.()
|
|
78
|
+
} catch { if (active.current && request === operation.current) setFailure(true) }
|
|
79
|
+
}
|
|
80
|
+
return <NEW_DRAFT_OWNER.Provider value={{ editor, latest, prepared, restore, failure }}>{children}</NEW_DRAFT_OWNER.Provider>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* BlockNote's FloatingComposerController, assembled from the same public parts but without its modal focus manager.
|
|
85
|
+
* That manager marks everything outside the form with `aria-hidden`, and when the document holds a live region (a
|
|
86
|
+
* block's notice) it walks into the document and marks the blocks themselves: ProseMirror reads those attributes as
|
|
87
|
+
* an edit and redraws every block, which collapses the canvas (it scrolls away) and takes the focus from the form.
|
|
88
|
+
* The comment editor focuses itself (`autoFocus`, without scrolling), as in a thread's reply form.
|
|
89
|
+
* Closing hides the native form; permanent discard is an exact backup action.
|
|
90
|
+
*/
|
|
91
|
+
export function DocumentNewCommentController() {
|
|
92
|
+
const owner = useContext(NEW_DRAFT_OWNER), context = useContext(COMMENT_EDITOR_CONTEXT), comments = useExtension(CommentsExtension)
|
|
93
|
+
const editor = useBlockNoteEditor(), dict = useDictionary()
|
|
94
|
+
const pendingComment = useExtensionState(CommentsExtension, { editor, selector: state => state.pendingComment })
|
|
95
|
+
const newCommentEditor = useCreateBlockNote({
|
|
96
|
+
trailingBlock: false,
|
|
97
|
+
dictionary: { ...dict, placeholders: { ...dict.placeholders, emptyDocument: dict.placeholders.new_comment } },
|
|
98
|
+
schema: comments.commentEditorSchema,
|
|
99
|
+
extensions: [CommentEditorSubmitExtension({ submitOnEnter: comments.submitOnEnter, onSubmit: async commentEditor => {
|
|
100
|
+
await comments.createThread({ initialComment: { body: commentEditor.document } })
|
|
101
|
+
comments.stopPendingComment()
|
|
102
|
+
editor.transact(transaction => { transaction.setSelection(TextSelection.create(transaction.doc, transaction.selection.to)) })
|
|
103
|
+
editor.focus()
|
|
104
|
+
} })],
|
|
105
|
+
}, [pendingComment])
|
|
106
|
+
const position = useEditorState({ editor, selector: ({ editor }) => pendingComment ? { from: editor.prosemirrorState.selection.from, to: editor.prosemirrorState.selection.to } : undefined })
|
|
107
|
+
const floating = useMemo(() => commentFloating(), [])
|
|
108
|
+
return <PositionPopover position={position} focusManagerProps={{ disabled: true }} elementProps={{ style: { zIndex: COMPOSER_LAYER } }}
|
|
109
|
+
useFloatingOptions={{ placement: 'bottom', ...floating, open: pendingComment, onOpenChange(open) {
|
|
110
|
+
if (open || !owner || owner.latest.current?.composing || owner.latest.current?.editor.prosemirrorView.composing) return
|
|
111
|
+
try { context?.store.beforeAction?.() } catch { return }
|
|
112
|
+
comments.stopPendingComment()
|
|
113
|
+
if (!owner.editor.prosemirrorView.isDestroyed) owner.editor.focus()
|
|
114
|
+
} }}>
|
|
115
|
+
<DocumentNewCommentComposer newCommentEditor={newCommentEditor} />
|
|
116
|
+
</PositionPopover>
|
|
117
|
+
}
|
|
118
|
+
// BlockNote's layer for the new-comment form, above its toolbars and thread cards.
|
|
119
|
+
const COMPOSER_LAYER = 60
|
|
120
|
+
|
|
121
|
+
function DocumentNewCommentComposer({ newCommentEditor }: { newCommentEditor: CommentEditor }) {
|
|
122
|
+
const owner = useContext(NEW_DRAFT_OWNER), context = useContext(COMMENT_EDITOR_CONTEXT), nativeComponents = useComponentsContext()!
|
|
123
|
+
const [binding, setBinding] = useState<ComposerBinding>(), [, render] = useState(0)
|
|
124
|
+
useLayoutEffect(() => {
|
|
125
|
+
if (!owner || !context) return
|
|
126
|
+
try {
|
|
127
|
+
context.store.beforeAction?.()
|
|
128
|
+
const restored = owner.prepared.current, prepared = restored && restored.version === context.store.version
|
|
129
|
+
&& restored.doc === owner.editor.prosemirrorState.doc && restored.selection === owner.editor.prosemirrorState.selection && newCommentEditor.isEmpty
|
|
130
|
+
&& isNewCommentDraft(restored.record.target) && context.store.newDraftPositions(restored.record.target, owner.editor) ? restored : undefined
|
|
131
|
+
const target = prepared && isNewCommentDraft(prepared.record.target) ? prepared.record.target : context.store.newDraftTarget(owner.editor)
|
|
132
|
+
const value: ComposerBinding = { target, editor: newCommentEditor, record: prepared?.record, origin: prepared?.origin, input: 0, composing: false, active: true }
|
|
133
|
+
if (prepared) owner.prepared.current = undefined
|
|
134
|
+
owner.latest.current = value; setBinding(value)
|
|
135
|
+
return () => { value.active = false }
|
|
136
|
+
} catch { /* The unchanged native form remains available when no authoritative target can be confirmed. */ }
|
|
137
|
+
}, [newCommentEditor, context?.store, owner?.editor])
|
|
138
|
+
const value = useMemo(() => ({ ...nativeComponents, Comments: { ...nativeComponents.Comments, Editor: NewCommentEditor } }), [nativeComponents])
|
|
139
|
+
return <NEW_COMPOSER_BINDING.Provider value={{ binding, ready: () => render(value => value + 1) }}><ComponentsContext.Provider value={value}>
|
|
140
|
+
<FloatingComposer newCommentEditor={newCommentEditor} />
|
|
141
|
+
</ComponentsContext.Provider></NEW_COMPOSER_BINDING.Provider>
|
|
142
|
+
}
|
|
143
|
+
const NEW_COMPOSER_BINDING = createContext<{ binding?: ComposerBinding, ready(): void } | undefined>(undefined)
|
|
144
|
+
|
|
145
|
+
function NewCommentEditor(props: ComponentProps['Comments']['Editor']) {
|
|
146
|
+
const context = useContext(COMMENT_EDITOR_CONTEXT), owner = useContext(NEW_DRAFT_OWNER), composer = useContext(NEW_COMPOSER_BINDING), binding = composer?.binding
|
|
147
|
+
const rich = useMemo(() => createRichProjection(), [props.editor]), suppress = useRef(false), submitting = useRef(false)
|
|
148
|
+
const [failure, setFailure] = useState(false)
|
|
149
|
+
const drafts = context?.store.drafts, state = useSyncExternalStore(drafts?.subscribe ?? emptySubscribe, drafts?.snapshot ?? (() => emptyState))
|
|
150
|
+
useLayoutEffect(() => {
|
|
151
|
+
if (!binding || !drafts || !context || !owner) return
|
|
152
|
+
if (binding.record && props.editor.isEmpty) {
|
|
153
|
+
props.editor.replaceBlocks(props.editor.document, productRichTextToNative(binding.record.content, rich))
|
|
154
|
+
if (binding.origin) { void drafts.discard(binding.origin); binding.origin = undefined }
|
|
155
|
+
}
|
|
156
|
+
const original = props.editor.getExtension(CommentEditorSubmitExtension)
|
|
157
|
+
let guarded: typeof original, stopEnter = () => {}
|
|
158
|
+
if (original) {
|
|
159
|
+
props.editor.replaceExtension(original, CommentEditorSubmitExtension({ onSubmit: async () => {
|
|
160
|
+
if (!binding.active || binding.composing || props.editor.prosemirrorView.composing) return
|
|
161
|
+
try {
|
|
162
|
+
context.store.beforeAction?.()
|
|
163
|
+
const positions = context.store.newDraftPositions(binding.target, owner.editor), selection = owner.editor.prosemirrorState.selection
|
|
164
|
+
if (!context.store.auth.canCreateThread() || !positions || selection.from !== positions.from || selection.to !== positions.to) { setFailure(true); return }
|
|
165
|
+
const before = new Set(context.store.getThreads().keys()), record = binding.record
|
|
166
|
+
submitting.current = true
|
|
167
|
+
try {
|
|
168
|
+
const result = original.submit(), thread = [...context.store.getThreads().values()].find(thread => !before.has(thread.id))
|
|
169
|
+
const actual = thread && context.store.threadSnapshot(thread.id)
|
|
170
|
+
if (actual) { drafts.submitted(record, actual.comments[0], actual); binding.record = undefined }
|
|
171
|
+
await result
|
|
172
|
+
} finally { submitting.current = false }
|
|
173
|
+
} catch { setFailure(true) }
|
|
174
|
+
} }))
|
|
175
|
+
guarded = props.editor.getExtension(CommentEditorSubmitExtension); composer.ready()
|
|
176
|
+
const extension = guarded
|
|
177
|
+
stopEnter = enterSubmits(props.editor, () => extension?.submit())
|
|
178
|
+
}
|
|
179
|
+
const stop = props.editor.onChange(() => {
|
|
180
|
+
if (!binding.active || suppress.current || submitting.current || !context.store.version) return
|
|
181
|
+
++binding.input
|
|
182
|
+
if (props.editor.isEmpty) { if (binding.record) { void drafts.clearOwn(binding.record); binding.record = undefined }; return }
|
|
183
|
+
binding.record = drafts.write(binding.record?.target ?? binding.target, binding.record?.version ?? context.store.version,
|
|
184
|
+
nativeRichTextToProduct(props.editor.document as DocumentEditorBlock[], rich), binding.composing, binding.record) ?? binding.record
|
|
185
|
+
})
|
|
186
|
+
return () => { stop(); stopEnter(); if (original && guarded && !props.editor.prosemirrorView.isDestroyed && props.editor.getExtension(CommentEditorSubmitExtension) === guarded) props.editor.replaceExtension(guarded, original) }
|
|
187
|
+
}, [binding, drafts, props.editor])
|
|
188
|
+
const composerId = useRef(crypto.randomUUID())
|
|
189
|
+
useLayoutEffect(() => {
|
|
190
|
+
if (!binding || !drafts || !context) return
|
|
191
|
+
return drafts.registerComposer({ id: composerId.current, target: binding.target,
|
|
192
|
+
snapshot: () => { const available = binding.active && !submitting.current && !props.editor.prosemirrorView.isDestroyed; return { record: binding.record, composing: binding.composing || available && props.editor.prosemirrorView.composing, available } },
|
|
193
|
+
targetState: () => context.store.draftTargetState(binding.target) })
|
|
194
|
+
}, [binding, drafts, props.editor])
|
|
195
|
+
const t = DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en']
|
|
196
|
+
const composition = (value: boolean) => {
|
|
197
|
+
if (!binding) return
|
|
198
|
+
binding.composing = value
|
|
199
|
+
if (binding.record && drafts) binding.record = drafts.write(binding.record.target, binding.record.version,
|
|
200
|
+
nativeRichTextToProduct(props.editor.document as DocumentEditorBlock[], rich), value, binding.record) ?? binding.record
|
|
201
|
+
}
|
|
202
|
+
return <div onCompositionStart={() => composition(true)} onCompositionEnd={() => composition(false)}>
|
|
203
|
+
<components.Comments.Editor {...props} />
|
|
204
|
+
{failure && <PmNotice tone="warn">{t.commentTargetUnavailable}</PmNotice>}
|
|
205
|
+
{state.failed && <PmNotice tone="warn">{t.commentBackupFailed}</PmNotice>}
|
|
206
|
+
</div>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function DocumentNewCommentRecovery() {
|
|
210
|
+
const context = useContext(COMMENT_EDITOR_CONTEXT), owner = useContext(NEW_DRAFT_OWNER), drafts = context?.store.drafts
|
|
211
|
+
const active = useRef(true)
|
|
212
|
+
useLayoutEffect(() => { active.current = true; return () => { active.current = false } }, [owner?.editor])
|
|
213
|
+
const state = useSyncExternalStore(drafts?.subscribe ?? emptySubscribe, drafts?.snapshot ?? (() => emptyState)), t = DOCUMENT_EDITOR_MESSAGES[context?.locale ?? 'en']
|
|
214
|
+
const candidates = state.candidates.filter(value => isNewCommentDraft(value.target))
|
|
215
|
+
if (!drafts || !owner) return null
|
|
216
|
+
return <>{owner.failure && <PmNotice tone="warn">{t.commentTargetUnavailable}</PmNotice>}<DraftChoices candidates={candidates} preview
|
|
217
|
+
canRecover={candidate => isNewCommentDraft(candidate.target) && Boolean(context?.store.newDraftPositions(candidate.target, owner.editor))}
|
|
218
|
+
unavailableMessage={t.commentTargetUnavailable} onRecover={candidate => owner.restore(candidate, () => active.current)} onDiscard={async candidate => {
|
|
219
|
+
const binding = owner.latest.current, record = binding?.record, input = binding?.input
|
|
220
|
+
const { token, storage, ...value } = candidate
|
|
221
|
+
if (await drafts.discard(candidate) && binding && owner.latest.current === binding && binding.input === input && binding.record === record
|
|
222
|
+
&& record && JSON.stringify(record) === JSON.stringify(value)) binding.record = undefined
|
|
223
|
+
}} /></>
|
|
224
|
+
}
|