@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,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
+ }