@lincyaw/workbench 0.1.0 → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,410 @@
1
+ import { CommentEditorSubmitExtension, CommentsExtension, DefaultThreadStoreAuth, ThreadStore, ThreadStoreAuth } from '@blocknote/core/comments'
2
+ import { createExtension, createUserStore } from '@blocknote/core'
3
+ import type { BlockNoteEditor } from '@blocknote/core'
4
+ import type { CommentData, ThreadData } from '@blocknote/core/comments'
5
+ import { AddMarkStep, RemoveMarkStep } from '@tiptap/pm/transform'
6
+ import { Plugin } from '@tiptap/pm/state'
7
+ import { applyDocumentCommentAction, hasRole, LOCAL_AUTHOR, validateDocumentCommentAnchor } from '@lincyaw/kernel'
8
+ import type { DocumentComment, DocumentCommentAction, DocumentThread, Member, RichTextNode, TeachingDocument } from '@lincyaw/kernel'
9
+ import { createRichProjection, nativeRichTextToProduct, productRichTextToNative } from './rich-text.ts'
10
+ import type { RichProjection } from './rich-text.ts'
11
+ import type { Transaction } from '@tiptap/pm/state'
12
+ import type { DocumentEditorBlock, NativeDocumentEditor } from './schema.tsx'
13
+ import { commentEditorSchema } from './schema.tsx'
14
+ import { CommentDrafts, commentDraftBaseline, isNewCommentDraft, newCommentDraftSignature } from './comment-drafts.ts'
15
+ import type { CommentComposerTarget, CommentDraftTarget, NewCommentDraftTarget } from './comment-drafts.ts'
16
+ import { nativeProductText, nativeTextPoint, nativeTextPosition, nativeTextPositionInDocument } from './selection.ts'
17
+
18
+ type NewComment = { body: unknown, metadata?: unknown }
19
+ type Threads = NonNullable<TeachingDocument['comments']>
20
+ const date = (value: string) => new Date(value)
21
+ const jsonObject = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value) ? structuredClone(value) as DocumentThread['metadata'] : undefined
22
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
23
+
24
+ type CommentEditor = BlockNoteEditor<any, any, any>
25
+ type EditorFailure = 'changed' | 'unavailable'
26
+ interface CommentEditing {
27
+ threadId?: string
28
+ commentId?: string
29
+ baseline?: string
30
+ body?: unknown
31
+ editable: boolean
32
+ closed: boolean
33
+ failure?: EditorFailure
34
+ listeners: Set<() => void>
35
+ }
36
+ export interface DocumentCommentEditorHandle {
37
+ setEditable(value: boolean): void
38
+ failure(): EditorFailure | undefined
39
+ subscribe(listener: () => void): () => void
40
+ close(): void
41
+ target?: CommentDraftTarget
42
+ restoreBaseline(baseline: string | undefined): void
43
+ }
44
+ const commentBase = (comment: DocumentComment) => JSON.stringify({ userId: comment.userId, content: comment.content })
45
+
46
+ /** Native visibility and the shared reducer enforce the same trusted member policy. */
47
+ class DocumentThreadAuth extends ThreadStoreAuth {
48
+ readonly #member?: Member
49
+ readonly #native: DefaultThreadStoreAuth
50
+ constructor(member?: Member) { super(); this.#member = member; this.#native = new DefaultThreadStoreAuth(member?.id ?? '', 'editor') }
51
+ #editable = () => Boolean(this.#member?.id && hasRole(this.#member, 'editor'))
52
+ canCreateThread = () => this.#editable()
53
+ canAddComment = () => this.#editable()
54
+ canUpdateComment = (comment: CommentData) => this.#editable() && (hasRole(this.#member!, 'owner') || this.#native.canUpdateComment(comment))
55
+ canDeleteComment = (comment: CommentData) => this.#editable() && this.#native.canDeleteComment(comment)
56
+ canDeleteThread = () => this.#editable()
57
+ canResolveThread = () => this.#editable()
58
+ canUnresolveThread = () => this.#editable()
59
+ canAddReaction = (comment: CommentData, emoji?: string) => this.#editable() && this.#native.canAddReaction(comment, emoji)
60
+ canDeleteReaction = (comment: CommentData, emoji?: string) => this.#editable() && this.#native.canDeleteReaction(comment, emoji)
61
+ }
62
+
63
+ /** Native actions resolve immediately so BlockNote attaches their marks to its own selection. */
64
+ export class DocumentThreadStore extends ThreadStore {
65
+ addThreadToDocument: ThreadStore['addThreadToDocument'] = undefined
66
+ beforeAction?: () => void
67
+ readonly member?: Member
68
+ readonly drafts?: CommentDrafts
69
+ readonly #ownsDrafts: boolean
70
+ #threads = new Map<string, ThreadData>()
71
+ #canonical: Threads = {}
72
+ #version?: string
73
+ #document: TeachingDocument
74
+ #synchronizing = false
75
+ readonly #edits = new Map<string, CommentEditing>()
76
+ #submitting?: CommentEditing
77
+ readonly #listeners = new Set<(threads: Map<string, ThreadData>) => void>()
78
+ readonly #rich: RichProjection = createRichProjection()
79
+ readonly #anchors = new Map<string, DocumentThread['anchor']>()
80
+ readonly #initialBodies = new Map<string, { body: unknown, content: RichTextNode[] }>()
81
+ constructor(document: TeachingDocument, member?: Member, version?: string, identity?: string, drafts?: CommentDrafts) {
82
+ super(new DocumentThreadAuth(member)); this.member = member; this.#version = version; this.#document = document; this.#replace(document.comments ?? {})
83
+ this.#ownsDrafts = !drafts
84
+ if (drafts || identity && member?.id) { this.drafts = drafts ?? new CommentDrafts(identity!, member!.id) }
85
+ }
86
+ get version(): string | undefined { return this.#version }
87
+ threadSnapshot(threadId: string): DocumentThread | undefined { return this.#canonical[threadId] }
88
+ refreshEditorCallbacks(): void { this.#publishView() }
89
+ draftTargetState(target: CommentComposerTarget): 'current' | 'changed' | 'unavailable' {
90
+ if (isNewCommentDraft(target)) {
91
+ if (target.documentId !== this.#document.id || !target.anchor || !target.signature) return 'unavailable'
92
+ const signature = newCommentDraftSignature(this.#document, target.anchor)
93
+ return signature === undefined ? 'unavailable' : signature === target.signature ? 'current' : 'changed'
94
+ }
95
+ const thread = this.#canonical[target.threadId], comment = thread?.comments.find(value => value.id === target.commentId)
96
+ if (!thread || thread.deletedAt || thread.createdAt !== target.threadCreatedAt || target.commentId && (!comment || comment.deletedAt || comment.createdAt !== target.commentCreatedAt)) return 'unavailable'
97
+ return comment && target.baseline !== commentDraftBaseline(comment) ? 'changed' : 'current'
98
+ }
99
+ newDraftTarget(editor: NativeDocumentEditor, intentId = crypto.randomUUID()): NewCommentDraftTarget {
100
+ this.beforeAction?.()
101
+ const target: NewCommentDraftTarget = { kind: 'create', intentId, documentId: this.#document.id }
102
+ const selection = editor.prosemirrorState.selection
103
+ if (selection.empty) return target
104
+ const from = nativeTextPoint(editor, selection.from), to = nativeTextPoint(editor, selection.to)
105
+ if (!from || !to) return target
106
+ const anchor = { from: { blockId: from.blockId, offset: from.offset }, to: { blockId: to.blockId, offset: to.offset } }
107
+ const signature = newCommentDraftSignature(this.#document, anchor)
108
+ return signature ? { ...target, anchor, signature } : target
109
+ }
110
+ /** Verify both the canonical range and the actual native projection before a selection command. */
111
+ newDraftPositions(target: NewCommentDraftTarget, editor: NativeDocumentEditor): { from: number, to: number } | undefined {
112
+ if (editor.prosemirrorView.isDestroyed || this.draftTargetState(target) !== 'current' || !target.anchor || !target.signature) return
113
+ const positions = (point: { blockId: string, offset: number }) => {
114
+ const value = nativeTextPosition(editor, point)
115
+ if (value === undefined) return
116
+ const actual = nativeTextPoint(editor, value)
117
+ return actual?.blockId === point.blockId && actual.offset === point.offset ? value : undefined
118
+ }
119
+ const from = positions(target.anchor.from), to = positions(target.anchor.to)
120
+ if (from === undefined || to === undefined || from >= to) return
121
+ const entities: { id: string, text: string }[] = JSON.parse(target.signature)
122
+ for (const entity of entities) {
123
+ const start = positions({ blockId: entity.id, offset: 0 }), end = positions({ blockId: entity.id, offset: entity.text.length })
124
+ if (start === undefined || end === undefined || nativeProductText(editor.prosemirrorState.doc, start, end) !== entity.text) return
125
+ }
126
+ return { from, to }
127
+ }
128
+ close(): void { if (this.#ownsDrafts) this.drafts?.close() }
129
+ get synchronizing(): boolean { return this.#synchronizing }
130
+ getThread = (threadId: string): ThreadData => { const thread = this.#threads.get(threadId); if (!thread) throw new Error('document-thread-missing'); return thread }
131
+ getThreads = (): Map<string, ThreadData> => this.#threads
132
+ subscribe = (listener: (threads: Map<string, ThreadData>) => void): (() => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener) }
133
+ #notify(): void { for (const listener of this.#listeners) listener(this.#threads) }
134
+ #replace(threads: Threads, nativeBody?: { id: string, body: unknown }, keepEditing = false): void {
135
+ const previous = this.#canonical
136
+ this.#canonical = structuredClone(threads)
137
+ this.#anchors.clear()
138
+ this.#threads = new Map(Object.values(threads).map(thread => {
139
+ this.#anchors.set(thread.id, thread.anchor)
140
+ const old = this.#threads.get(thread.id)
141
+ return [thread.id, {
142
+ ...thread, createdAt: date(thread.createdAt), updatedAt: date(thread.updatedAt),
143
+ resolvedUpdatedAt: thread.resolvedUpdatedAt ? date(thread.resolvedUpdatedAt) : undefined,
144
+ deletedAt: thread.deletedAt ? date(thread.deletedAt) : undefined,
145
+ comments: thread.comments.map(comment => {
146
+ const oldComment = old?.comments.find(value => value.id === comment.id), oldValue = previous[thread.id]?.comments.find(value => value.id === comment.id)
147
+ const editing = keepEditing ? this.#edits.get(comment.id) : undefined
148
+ if (editing && editing.baseline !== commentBase(comment)) this.#failEdit(editing, 'changed')
149
+ const body = comment.deletedAt ? undefined : editing ? editing.body : nativeBody?.id === comment.id ? nativeBody.body
150
+ : oldComment && same(oldValue?.content, comment.content) ? oldComment.body : productRichTextToNative(comment.content ?? [], this.#rich)
151
+ this.#initialBodies.set(comment.id, { body, content: structuredClone(comment.content ?? []) })
152
+ return { ...comment, createdAt: date(comment.createdAt), updatedAt: date(comment.updatedAt), deletedAt: comment.deletedAt ? date(comment.deletedAt) : undefined,
153
+ body, reactions: comment.reactions.map(reaction => ({ ...reaction, createdAt: date(reaction.createdAt) })),
154
+ } as CommentData
155
+ }),
156
+ } as ThreadData]
157
+ }))
158
+ for (const editing of this.#edits.values()) if (keepEditing && (!editing.threadId || !threads[editing.threadId]?.comments.some(comment => comment.id === editing.commentId && !comment.deletedAt))) this.#failEdit(editing, 'unavailable')
159
+ const live = new Set(Object.values(threads).flatMap(thread => thread.comments.map(comment => comment.id)))
160
+ for (const id of this.#initialBodies.keys()) if (!live.has(id)) this.#initialBodies.delete(id)
161
+ }
162
+ #content(comment: NewComment): RichTextNode[] { return nativeRichTextToProduct(comment.body as DocumentEditorBlock[], this.#rich) }
163
+ #act(action: DocumentCommentAction, nativeBody?: unknown) {
164
+ this.beforeAction?.()
165
+ const answer = applyDocumentCommentAction(this.#canonical, action, { member: this.member, now: new Date().toISOString(), allocate: () => crypto.randomUUID() })
166
+ if (answer.effect.changed) {
167
+ this.#replace(answer.threads, answer.effect.commentId && nativeBody !== undefined ? { id: answer.effect.commentId, body: nativeBody } : undefined)
168
+ this.#notify()
169
+ }
170
+ return answer.effect
171
+ }
172
+ createThread = async ({ initialComment, metadata }: { initialComment: NewComment, metadata?: unknown }): Promise<ThreadData> => {
173
+ const effect = this.#act({ type: 'comment.create', content: this.#content(initialComment), metadata: jsonObject(metadata), commentMetadata: jsonObject(initialComment.metadata) }, initialComment.body)
174
+ return this.getThread(effect.threadId)
175
+ }
176
+ addComment = async ({ threadId, comment }: { threadId: string, comment: NewComment }): Promise<CommentData> => {
177
+ const effect = this.#act({ type: 'comment.reply', threadId, content: this.#content(comment), metadata: jsonObject(comment.metadata) }, comment.body)
178
+ return this.getThread(threadId).comments.find(value => value.id === effect.commentId)!
179
+ }
180
+ updateComment = async ({ threadId, commentId, comment }: { threadId: string, commentId: string, comment: NewComment }): Promise<void> => {
181
+ this.beforeAction?.()
182
+ const editing = this.#submitting
183
+ if (!editing || editing.threadId !== threadId || editing.commentId !== commentId || !this.#canSubmit(editing)) throw new Error('comment-editor-version-changed')
184
+ this.#act({ type: 'comment.update', threadId, commentId, content: this.#content(comment), metadata: jsonObject(comment.metadata) }, comment.body)
185
+ }
186
+ deleteComment = async ({ threadId, commentId }: { threadId: string, commentId: string }): Promise<void> => { this.#act({ type: 'comment.delete', threadId, commentId }) }
187
+ deleteThread = async ({ threadId }: { threadId: string }): Promise<void> => { this.#act({ type: 'comment.delete', threadId }) }
188
+ resolveThread = async ({ threadId }: { threadId: string }): Promise<void> => { this.#act({ type: 'comment.resolve', threadId }) }
189
+ unresolveThread = async ({ threadId }: { threadId: string }): Promise<void> => { this.#act({ type: 'comment.reopen', threadId }) }
190
+ addReaction = async ({ threadId, commentId, emoji }: { threadId: string, commentId: string, emoji: string }): Promise<void> => { this.#act({ type: 'comment.react', threadId, commentId, emoji, active: true }) }
191
+ deleteReaction = async ({ threadId, commentId, emoji }: { threadId: string, commentId: string, emoji: string }): Promise<void> => { this.#act({ type: 'comment.react', threadId, commentId, emoji, active: false }) }
192
+
193
+ #failEdit(editing: CommentEditing, failure: EditorFailure): void {
194
+ if (editing.failure === failure) return
195
+ editing.failure = failure
196
+ for (const listener of editing.listeners) listener()
197
+ }
198
+ #canSubmit(editing: CommentEditing): boolean {
199
+ if (editing.closed || !editing.editable || !editing.threadId || !editing.commentId || editing.failure) return false
200
+ const thread = this.#canonical[editing.threadId], comment = thread?.comments.find(value => value.id === editing.commentId)
201
+ if (!thread || thread.deletedAt || !comment || comment.deletedAt) { this.#failEdit(editing, 'unavailable'); return false }
202
+ if (editing.baseline !== commentBase(comment)) { this.#failEdit(editing, 'changed'); return false }
203
+ return true
204
+ }
205
+ #publishView(): void {
206
+ const synchronizing = this.#synchronizing
207
+ this.#synchronizing = true
208
+ try { this.#threads = new Map(this.#threads); this.#notify() } finally { this.#synchronizing = synchronizing }
209
+ }
210
+ /** Public comment-editor boundary: native callbacks carry no base revision themselves. */
211
+ bindEditor(editor: CommentEditor, editable: boolean, threadId?: string): DocumentCommentEditorHandle {
212
+ const ids = new Set(editor.document.map(block => block.id))
213
+ const matches = Object.values(this.#canonical).filter(thread => thread.id === threadId).flatMap(thread => thread.comments.flatMap(comment => {
214
+ const body = this.#initialBodies.get(comment.id)?.body
215
+ return Array.isArray(body) && body.some(block => ids.has(block.id)) ? [{ thread, comment, body }] : []
216
+ }))
217
+ // A reply/create editor starts editable and has no existing-comment binding.
218
+ const existing = !editable, target = matches.length === 1 ? matches[0] : undefined
219
+ const editing: CommentEditing = { threadId: target?.thread.id, commentId: target?.comment.id, baseline: target ? commentBase(target.comment) : undefined, body: target?.body, editable: false, closed: false, listeners: new Set() }
220
+ const original = existing ? editor.getExtension(CommentEditorSubmitExtension) : undefined
221
+ let guarded: typeof original, stopEnter = () => {}
222
+ if (original) {
223
+ editor.replaceExtension(original, CommentEditorSubmitExtension({ onSubmit: async () => {
224
+ try { this.beforeAction?.() } catch { this.#failEdit(editing, 'unavailable'); return }
225
+ if (!this.#canSubmit(editing)) { if (!editing.failure) this.#failEdit(editing, 'unavailable'); return }
226
+ const previous = this.#submitting
227
+ let result: Promise<void>
228
+ try { this.#submitting = editing; result = original.submit() } finally { this.#submitting = previous }
229
+ await result
230
+ } }))
231
+ guarded = editor.getExtension(CommentEditorSubmitExtension)
232
+ const extension = guarded
233
+ stopEnter = enterSubmits(editor, () => extension?.submit())
234
+ // The parent Comment reads submit during render; publish a fresh view without a source edit.
235
+ this.#publishView()
236
+ }
237
+ const setEditable = (value: boolean) => {
238
+ if (!existing || editing.closed || value === editing.editable) return
239
+ editing.editable = value
240
+ if (value) {
241
+ if (editing.commentId) this.#edits.set(editing.commentId, editing)
242
+ if (!target) this.#failEdit(editing, 'unavailable')
243
+ } else {
244
+ if (editing.commentId && this.#edits.get(editing.commentId) === editing) this.#edits.delete(editing.commentId)
245
+ const comment = editing.threadId ? this.#canonical[editing.threadId]?.comments.find(value => value.id === editing.commentId) : undefined
246
+ if (comment && editing.failure) {
247
+ this.#replace(this.#canonical, { id: comment.id, body: productRichTextToNative(comment.content ?? [], this.#rich) }, true)
248
+ this.#publishView()
249
+ }
250
+ }
251
+ }
252
+ setEditable(editable)
253
+ const thread = threadId ? this.#canonical[threadId] : undefined
254
+ const draftTarget: CommentDraftTarget | undefined = thread && (!existing || target) ? { threadId: thread.id, threadCreatedAt: thread.createdAt, ...existing && target ? { commentId: target.comment.id, commentCreatedAt: target.comment.createdAt, baseline: commentBase(target.comment) } : {} } : undefined
255
+ return {
256
+ target: draftTarget,
257
+ restoreBaseline: baseline => { if (existing && editing.editable && !editing.closed) { editing.baseline = baseline; if (!this.#canSubmit(editing) && !editing.failure) this.#failEdit(editing, 'unavailable') } },
258
+ setEditable, failure: () => editing.failure,
259
+ subscribe: listener => { editing.listeners.add(listener); return () => editing.listeners.delete(listener) },
260
+ close: () => {
261
+ if (editing.closed) return
262
+ setEditable(false); editing.closed = true; editing.listeners.clear(); stopEnter()
263
+ if (original && guarded && !editor.prosemirrorView.isDestroyed && editor.getExtension(CommentEditorSubmitExtension) === guarded) editor.replaceExtension(guarded, original)
264
+ },
265
+ }
266
+ }
267
+
268
+ /** Source adoption is not another native edit; preserve selected thread and unchanged comment bodies. */
269
+ synchronize(document: TeachingDocument, version: string | undefined, editor: NativeDocumentEditor, transaction?: Transaction, acknowledged = false): void {
270
+ if (this.#synchronizing) return
271
+ this.#document = document
272
+ this.drafts?.synchronize(document, version, acknowledged)
273
+ if (version === undefined || version === this.#version) return
274
+ this.#version = version
275
+ const threads = document.comments
276
+ if (same(threads ?? {}, this.#canonical)) return
277
+ this.#synchronizing = true
278
+ try {
279
+ this.#replace(threads ?? {}, undefined, true)
280
+ this.restoreAnchors(editor, transaction)
281
+ this.#notify()
282
+ const extension = editor.getExtension(CommentsExtension)!, selected = extension.store.state.selectedThreadId
283
+ if (selected && (!this.#threads.has(selected) || this.#threads.get(selected)!.resolved || this.#threads.get(selected)!.deletedAt)) extension.selectThread(undefined, false)
284
+ } finally { this.#synchronizing = false }
285
+ }
286
+ restoreAnchors(editor: NativeDocumentEditor, transaction?: Transaction): void {
287
+ const extension = editor.getExtension(CommentsExtension)!, name = extension.tiptapExtensions[0].name
288
+ const ranges = [...this.#anchors].flatMap(([threadId, anchor]) => {
289
+ if (!anchor) return []
290
+ try { validateDocumentCommentAnchor(this.#document, anchor) } catch { return [] }
291
+ const position = (point: { blockId: string, offset: number }, association: number) => {
292
+ if (!transaction) return nativeTextPosition(editor, point)
293
+ const before = nativeTextPositionInDocument(transaction.before, point)
294
+ return before === undefined ? undefined : transaction.mapping.map(before, association)
295
+ }
296
+ const from = position(anchor.from, 1), to = position(anchor.to, -1)
297
+ return from !== undefined && to !== undefined && from < to ? [{ threadId, from, to, orphan: Boolean(this.#threads.get(threadId)?.resolved || this.#threads.get(threadId)?.deletedAt) }] : []
298
+ })
299
+ editor.transact(transaction => {
300
+ const mark = transaction.doc.type.schema.marks[name]!
301
+ transaction.removeMark(0, transaction.doc.content.size, mark)
302
+ for (const { threadId, from, to, orphan } of ranges) transaction.addMark(from, to, mark.create({ threadId, orphan }))
303
+ transaction.setMeta('addToHistory', false)
304
+ })
305
+ }
306
+ snapshot(editor: NativeDocumentEditor): Threads {
307
+ const positions = editor.getExtension(CommentsExtension)!.store.state.threadPositions
308
+ const result = Object.fromEntries([...this.#threads.values()].map(thread => {
309
+ const position = positions.get(thread.id)
310
+ const from = position ? nativeTextPoint(editor, position.from) : undefined, to = position ? nativeTextPoint(editor, position.to) : undefined
311
+ const anchor = from && to ? { from: { blockId: from.blockId, offset: from.offset }, to: { blockId: to.blockId, offset: to.offset } } : undefined
312
+ const value: DocumentThread = {
313
+ id: thread.id, type: 'thread', createdAt: thread.createdAt.toISOString(), updatedAt: thread.updatedAt.toISOString(), resolved: thread.resolved,
314
+ ...thread.resolvedUpdatedAt ? { resolvedUpdatedAt: thread.resolvedUpdatedAt.toISOString() } : {}, ...thread.resolvedBy ? { resolvedBy: thread.resolvedBy } : {},
315
+ ...thread.deletedAt ? { deletedAt: thread.deletedAt.toISOString() } : {}, ...jsonObject(thread.metadata) ? { metadata: jsonObject(thread.metadata) } : {}, ...anchor ? { anchor } : {},
316
+ comments: thread.comments.map(comment => ({
317
+ id: comment.id, type: 'comment', userId: comment.userId, createdAt: comment.createdAt.toISOString(), updatedAt: comment.updatedAt.toISOString(),
318
+ ...comment.deletedAt ? { deletedAt: comment.deletedAt.toISOString() } : { content: this.#initialBodies.get(comment.id)?.body === comment.body ? structuredClone(this.#initialBodies.get(comment.id)!.content) : nativeRichTextToProduct(comment.body as DocumentEditorBlock[], this.#rich) },
319
+ ...jsonObject(comment.metadata) ? { metadata: jsonObject(comment.metadata) } : {}, reactions: comment.reactions.map(reaction => ({ ...reaction, createdAt: reaction.createdAt.toISOString() })),
320
+ } satisfies DocumentComment)),
321
+ }
322
+ return [value.id, value]
323
+ }))
324
+ this.#canonical = result
325
+ return result
326
+ }
327
+ }
328
+
329
+ /** A member's own display name; the local author's built-in name is a placeholder, not one the author chose. */
330
+ function displayName(member: Member | undefined): string | undefined {
331
+ const name = member?.name.trim()
332
+ return name && !(member!.id === LOCAL_AUTHOR.id && member!.name === LOCAL_AUTHOR.name) ? name : undefined
333
+ }
334
+
335
+ /**
336
+ * Plain Enter submits through `submit`. A submit extension put in place with `replaceExtension` lands after the
337
+ * editor's default keymap, which splits the block on Enter first, so only its Mod-Enter shortcut would ever run.
338
+ * Same conditions as BlockNote's own Enter shortcut; returns the cleanup.
339
+ */
340
+ export function enterSubmits(editor: CommentEditor, submit: () => unknown): () => void {
341
+ const dom = editor.prosemirrorView.dom
342
+ const onKeyDown = (event: KeyboardEvent) => {
343
+ const view = editor.prosemirrorView
344
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
345
+ if (!view.editable || view.composing || event.isComposing || event.keyCode === 229) return
346
+ event.preventDefault(); event.stopPropagation()
347
+ if (!event.repeat) void submit()
348
+ }
349
+ dom.addEventListener('keydown', onKeyDown, true)
350
+ return () => dom.removeEventListener('keydown', onKeyDown, true)
351
+ }
352
+
353
+ export function nativeComments(store: DocumentThreadStore, username: string) {
354
+ const resolve = (ids: string[]) => ids.map(id => ({ id, avatarUrl: '',
355
+ username: id === store.member?.id ? displayName(store.member) ?? username : id === 'local-author' || id === LOCAL_AUTHOR.id ? username : id }))
356
+ const users = createUserStore(async ids => resolve(ids)), identities = new Set<string>(store.member ? [store.member.id] : [])
357
+ for (const thread of store.getThreads().values()) {
358
+ if (thread.resolvedBy) identities.add(thread.resolvedBy)
359
+ for (const comment of thread.comments) {
360
+ identities.add(comment.userId)
361
+ for (const reaction of comment.reactions) for (const id of reaction.userIds) identities.add(id)
362
+ }
363
+ }
364
+ users.setUser(resolve([...identities]))
365
+ return CommentsExtension({ threadStore: store, schema: commentEditorSchema, resolveUsers: users })
366
+ }
367
+
368
+ /** Select the thread before the browser places its native mouse caret. */
369
+ export const nativeCommentSelection = createExtension(({ editor }: { editor: NativeDocumentEditor }) => {
370
+ let pressed: string | undefined
371
+ const cancel = () => { pressed = undefined; return false }
372
+ return {
373
+ key: 'presmium-native-comment-selection',
374
+ runsBefore: ['comments'],
375
+ mount: () => { cancel(); return () => { cancel() } },
376
+ prosemirrorPlugins: [new Plugin({ props: {
377
+ handleDOMEvents: {
378
+ mousedown(view, event) {
379
+ cancel()
380
+ const comments = editor.getExtension(CommentsExtension)
381
+ if (event.button !== 0 || event.defaultPrevented || !view.editable || view.composing || !comments || comments.store.state.pendingComment) return false
382
+ const mark = event.target instanceof Element ? event.target.closest('[data-bn-thread-id]') : null
383
+ if (!mark || !view.dom.contains(mark) || mark.hasAttribute('data-orphan')) return false
384
+ const id = mark.getAttribute('data-bn-thread-id'), thread = id ? comments.threadStore.getThreads().get(id) : undefined
385
+ if (id && thread && !thread.resolved && !thread.deletedAt && comments.store.state.threadPositions.has(id) && comments.store.state.selectedThreadId !== id) {
386
+ pressed = id
387
+ comments.selectThread(id, false)
388
+ }
389
+ return false
390
+ },
391
+ pointercancel: cancel, dragstart: cancel, blur: cancel, keydown: cancel,
392
+ },
393
+ handleClick(view, position, event) {
394
+ const id = pressed, comments = editor.getExtension(CommentsExtension)
395
+ cancel()
396
+ // Native comments consumes the first thread click before following links.
397
+ return Boolean(id && comments?.store.state.selectedThreadId === id && event.button === 0 && !event.defaultPrevented && view.editable && !view.composing
398
+ && view.state.doc.nodeAt(position)?.marks.some(mark => mark.type.name === comments.tiptapExtensions[0].name && mark.attrs.threadId === id && mark.attrs.orphan !== true))
399
+ },
400
+ } })],
401
+ }
402
+ })
403
+
404
+ /** Thread entities own their state; pure comment marks must not consume body undo. */
405
+ export function excludeCommentMarkHistory(editor: NativeDocumentEditor): () => void {
406
+ const name = editor.getExtension(CommentsExtension)!.tiptapExtensions[0].name
407
+ return editor.onBeforeChange(({ tr }) => {
408
+ if (tr.steps.length && tr.steps.every(step => (step instanceof AddMarkStep || step instanceof RemoveMarkStep) && step.mark.type.name === name)) tr.setMeta('addToHistory', false)
409
+ })
410
+ }
@@ -0,0 +1,71 @@
1
+ import { createExtension, getBlockInfoNearPos, getNodeId } from '@blocknote/core'
2
+ import { FileHandler } from '@tiptap/extension-file-handler'
3
+ import { DOCUMENT_IMAGE_MEDIA_TYPES } from '@lincyaw/kernel'
4
+ import { isObservedWorkspaceFailure, WorkspaceServerError } from '../workspace-client.ts'
5
+ import type { NativeDocumentEditor } from './schema.tsx'
6
+
7
+ export type ImageImportFailureKind = 'unsupported' | 'format' | 'size' | 'dimensions' | 'failed'
8
+ export interface ImageImportFailure { name: string, kind: ImageImportFailureKind }
9
+ interface Options { onStart(): void, onFailure(failure: ImageImportFailure): void }
10
+
11
+ const supported = (file: File) => (DOCUMENT_IMAGE_MEDIA_TYPES as readonly string[]).includes(file.type)
12
+ || !file.type && /\.(?:png|jpe?g|gif|webp|svg|bmp)$/i.test(file.name)
13
+ const failureKind = (error: unknown): ImageImportFailureKind => {
14
+ if (!(error instanceof WorkspaceServerError)) return 'failed'
15
+ if (error.answer.error === 'document-image-format') return 'format'
16
+ if (error.answer.error === 'document-image-size') return 'size'
17
+ if (error.answer.error === 'document-image-dimensions') return 'dimensions'
18
+ return 'failed'
19
+ }
20
+
21
+ /** FileHandler snapshots native files before the browser closes the drop data store. */
22
+ export const imageFileInput = createExtension(({ editor, options }: { editor: NativeDocumentEditor, options: Options }) => {
23
+ let mounted: AbortSignal | undefined
24
+ const insert = (files: File[], position = editor.prosemirrorState.selection.head): void => {
25
+ const owner = mounted
26
+ const active = () => owner !== undefined && owner === mounted && !owner.aborted && !editor.prosemirrorView.isDestroyed
27
+ const fail = (file: File, kind: ImageImportFailureKind) => { if (active()) options.onFailure({ name: file.name, kind }) }
28
+ if (!active() || !editor.isEditable || !editor.uploadFile || !files.length) return
29
+ options.onStart()
30
+ const accepted = files.filter(file => { if (supported(file)) return true; fail(file, 'unsupported'); return false })
31
+ if (!accepted.length) return
32
+ let pending: { file: File, id: string }[]
33
+ try {
34
+ const state = editor.prosemirrorState, info = getBlockInfoNearPos(state, position)
35
+ const anchor = editor.getBlock(getNodeId(info.block.node, state.doc))
36
+ if (!anchor) throw new Error('document-file-position')
37
+ const blocks = accepted.map(file => ({ id: crypto.randomUUID(), type: 'image' as const, props: { name: file.name } }))
38
+ const before = anchor.type !== 'presmiumPage' && position <= (info.contentStart ?? info.block.beforePos)
39
+ const inserted = editor.insertBlocks(blocks, anchor, before ? 'before' : 'after')
40
+ pending = inserted.map((block, index) => ({ file: accepted[index]!, id: block.id }))
41
+ } catch (error) {
42
+ for (const file of accepted) fail(file, failureKind(error))
43
+ return
44
+ }
45
+ const upload = async () => {
46
+ for (const { file, id } of pending) {
47
+ if (!active()) return
48
+ try {
49
+ const block = editor.getBlock(id)
50
+ if (block?.type !== 'image' || block.props.url) continue
51
+ const result = await editor.uploadFile!(file, id)
52
+ if (!active()) return
53
+ const current = editor.getBlock(id)
54
+ if (current?.type !== 'image' || current.props.url) continue
55
+ editor.updateBlock(id, typeof result === 'string' ? { props: { url: result } } : result)
56
+ } catch (error) {
57
+ if (!active()) return
58
+ if (error instanceof Error && error.name === 'AbortError') continue
59
+ // The existing request observer owns transport and server failures.
60
+ if (!isObservedWorkspaceFailure(error)) fail(file, failureKind(error))
61
+ }
62
+ }
63
+ }
64
+ void upload()
65
+ }
66
+ return {
67
+ key: 'presmiumImageFileInput', insert,
68
+ mount: ({ signal }: { signal: AbortSignal }) => { mounted = signal },
69
+ tiptapExtensions: [FileHandler.extend({ priority: 1000 }).configure({ onDrop: (_editor, files, position) => insert(files, position) })],
70
+ }
71
+ })
@@ -0,0 +1,54 @@
1
+ import type { EditorState, SelectionBookmark, Transaction } from '@tiptap/pm/state'
2
+ import type { EditorView } from '@tiptap/pm/view'
3
+
4
+ interface NativeFieldEditor {
5
+ readonly prosemirrorState: EditorState
6
+ readonly prosemirrorView: EditorView
7
+ transact<T>(callback: (transaction: Transaction) => T): T
8
+ focus(): void
9
+ }
10
+ export interface DocumentFieldBookmark { content: string, document: unknown, selection: SelectionBookmark }
11
+ export interface DocumentHistoryEditor {
12
+ readonly prosemirrorState: EditorState
13
+ readonly prosemirrorView: EditorView
14
+ undo(): boolean
15
+ redo(): boolean
16
+ focus(): void
17
+ onChange(callback: () => void): () => void
18
+ }
19
+ export interface DocumentFieldFocus {
20
+ capture(): DocumentFieldBookmark | undefined
21
+ restore(bookmark: DocumentFieldBookmark): boolean
22
+ }
23
+ export interface DocumentFieldRegistration {
24
+ identity: string
25
+ reset: number
26
+ blockId?: string
27
+ stepId: string
28
+ narrationId: string
29
+ slot: number
30
+ focus: DocumentFieldFocus
31
+ editor?: DocumentHistoryEditor
32
+ }
33
+
34
+ /** Only transient native state is carried across a source structure reset, never persisted. */
35
+ export function nativeFieldFocus(editor: NativeFieldEditor, content: () => string): DocumentFieldFocus {
36
+ return {
37
+ capture: () => {
38
+ if (!editor.prosemirrorView.hasFocus()) return undefined
39
+ const state = editor.prosemirrorState
40
+ return { content: content(), document: state.doc.toJSON(), selection: state.selection.getBookmark() }
41
+ },
42
+ restore: bookmark => {
43
+ if (bookmark.content !== content()) return false
44
+ const document = editor.prosemirrorState.schema.nodeFromJSON(bookmark.document)
45
+ editor.transact(transaction => {
46
+ transaction.replaceWith(0, transaction.doc.content.size, document.content)
47
+ transaction.setSelection(bookmark.selection.resolve(transaction.doc))
48
+ transaction.setMeta('addToHistory', false)
49
+ })
50
+ editor.focus()
51
+ return true
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,104 @@
1
+ import { isDocumentTextLink } from '@lincyaw/kernel'
2
+ import type { InlineContent, StyledText, Styles } from '@blocknote/core'
3
+ import type { InlineNode, TextAlignment, TextMark, TextStyle } from '@lincyaw/kernel'
4
+ import type { documentEditorSchema } from './schema.tsx'
5
+
6
+ type NativeStyles = Styles<typeof documentEditorSchema.styleSchema>
7
+ export type NativeInline = InlineContent<typeof documentEditorSchema.inlineContentSchema, typeof documentEditorSchema.styleSchema>
8
+ type NativeText = StyledText<typeof documentEditorSchema.styleSchema>
9
+
10
+ export interface InlineProjectionContext {
11
+ opaqueInline: Map<string, InlineNode>
12
+ newInlineId(): string
13
+ }
14
+
15
+ export interface NativeTextProperties {
16
+ textAlignment: TextAlignment
17
+ textColor: string
18
+ backgroundColor: string
19
+ }
20
+
21
+ const BOOLEAN_MARKS = ['bold', 'italic', 'underline', 'strike', 'code'] as const
22
+
23
+ function nativeStyles(marks: readonly TextMark[] | undefined): NativeStyles {
24
+ const styles: NativeStyles = {}
25
+ for (const mark of marks ?? []) {
26
+ if (mark.type === 'color') styles.textColor = mark.value
27
+ else if (mark.type === 'background') styles.backgroundColor = mark.value
28
+ else styles[mark.type] = true
29
+ }
30
+ return styles
31
+ }
32
+
33
+ function productMarks(styles: NativeStyles): TextMark[] | undefined {
34
+ const marks: TextMark[] = []
35
+ for (const type of BOOLEAN_MARKS) if (styles[type]) marks.push({ type })
36
+ if (styles.textColor !== undefined) marks.push({ type: 'color', value: styles.textColor })
37
+ if (styles.backgroundColor !== undefined) marks.push({ type: 'background', value: styles.backgroundColor })
38
+ return marks.length ? marks : undefined
39
+ }
40
+
41
+ function label(node: InlineNode): string {
42
+ if (node.type === 'text') return node.text
43
+ if (node.type === 'math') return node.value
44
+ if (node.type === 'break') return '\n'
45
+ if (node.type === 'extension') return node.fallback
46
+ return node.children.map(label).join('') || node.href
47
+ }
48
+
49
+ function opaque(node: InlineNode, context: InlineProjectionContext): NativeInline {
50
+ const entityId = context.newInlineId()
51
+ context.opaqueInline.set(entityId, structuredClone(node))
52
+ return { type: 'presmiumInline', props: { entityId, kind: node.type, label: label(node) }, content: undefined }
53
+ }
54
+
55
+ function nativeText(node: Extract<InlineNode, { type: 'text' | 'break' }>): NativeText {
56
+ return { type: 'text', text: node.type === 'break' ? '\n' : node.text, styles: node.type === 'text' ? nativeStyles(node.marks) : {} }
57
+ }
58
+
59
+ /** Use native links only when their children fit BlockNote's public styled-text link content. */
60
+ export function productInlineToNative(nodes: readonly InlineNode[], context: InlineProjectionContext): NativeInline[] {
61
+ return nodes.map(node => {
62
+ if (node.type === 'text' || node.type === 'break') return nativeText(node)
63
+ if (node.type === 'math') return { type: 'presmiumMathInline', props: {}, content: node.value }
64
+ if (isDocumentTextLink(node)) {
65
+ return { type: 'link', href: node.href, content: node.children.map(child => nativeText(child as Extract<InlineNode, { type: 'text' | 'break' }>)) }
66
+ }
67
+ return opaque(node, context)
68
+ })
69
+ }
70
+
71
+ function productText(node: NativeText): InlineNode[] {
72
+ const marks = productMarks(node.styles)
73
+ const parts = node.text.split('\n')
74
+ const content: InlineNode[] = []
75
+ parts.forEach((text, index) => {
76
+ if (index) content.push({ type: 'break' })
77
+ if (text) content.push({ type: 'text', text, ...marks ? { marks } : {} })
78
+ })
79
+ return content
80
+ }
81
+
82
+ /** Unknown atoms are resolved from the projection registry, never flattened to their visible labels. */
83
+ export function nativeInlineToProduct(nodes: readonly NativeInline[], context: InlineProjectionContext): InlineNode[] {
84
+ return nodes.flatMap(node => {
85
+ if (node.type === 'text') return productText(node)
86
+ if (node.type === 'link') return [{ type: 'link', href: node.href, children: node.content.flatMap(productText) } satisfies InlineNode]
87
+ if (node.type === 'presmiumMathInline') return [{ type: 'math', value: node.content } satisfies InlineNode]
88
+ const original = context.opaqueInline.get(node.props.entityId)
89
+ if (!original) throw new Error('document-inline-reference')
90
+ return [structuredClone(original)]
91
+ })
92
+ }
93
+
94
+ export function productTextStyleToNative(style?: TextStyle): NativeTextProperties {
95
+ return { textAlignment: style?.alignment ?? 'left', textColor: style?.color ?? 'default', backgroundColor: style?.background ?? 'default' }
96
+ }
97
+
98
+ export function nativeTextStyleToProduct(props: Partial<NativeTextProperties>): TextStyle | undefined {
99
+ const style: TextStyle = {}
100
+ if (props.textAlignment && props.textAlignment !== 'left') style.alignment = props.textAlignment
101
+ if (props.textColor && props.textColor !== 'default') style.color = props.textColor
102
+ if (props.backgroundColor && props.backgroundColor !== 'default') style.background = props.backgroundColor
103
+ return Object.keys(style).length ? style : undefined
104
+ }