@lincyaw/workbench 0.1.0 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,423 @@
1
+ import { documentPageDefaults, DOCUMENT_PAGE_DEFAULT_TIMING } from '@lincyaw/kernel'
2
+ import type { DocumentBlock, DocumentPageIds, NarrationNode, RichTextNode, Section, TeachingDocument, TextBlock } from '@lincyaw/kernel'
3
+ import { productInlineToNative } from './inline.ts'
4
+ import { captureRichBaseline, createRichProjection, nativeRichTextToProduct, productRichTextToNative } from './rich-text.ts'
5
+ import type { RichProjection } from './rich-text.ts'
6
+ import type { DocumentEditorBlock, DocumentEditorInput } from './schema.tsx'
7
+
8
+ export interface DocumentProjection extends RichProjection {
9
+ owners: Map<string, string>
10
+ pages: Map<string, string>
11
+ blockSections: Map<string, string>
12
+ document: TeachingDocument
13
+ content: DocumentEditorInput[]
14
+ structure?: string
15
+ structures: Map<string, Pick<DocumentProjection, 'document' | 'rich' | 'owners' | 'pages' | 'baseline'>>
16
+ createdPages: Map<string, NativePageCreation>
17
+ componentConfiguration: Map<string, string>
18
+ }
19
+ export type NativePageCreation = DocumentPageIds
20
+ const newId = () => crypto.randomUUID()
21
+ export const pageId = (sectionId: string) => `page:${sectionId}`
22
+ function plain(nodes: readonly { type: string, text?: string, children?: readonly unknown[] }[]): string {
23
+ return nodes.map(node => node.type === 'text' ? node.text ?? '' : node.type === 'break' ? '\n' : node.children ? plain(node.children as Parameters<typeof plain>[0]) : '').join('')
24
+ }
25
+ /**
26
+ * Code the published document draws as a picture is generated content: authors see its preview, not its source.
27
+ * The viewer draws Mermaid; the content add-on's compiler pass turns PlantUML into an image.
28
+ */
29
+ export function isDiagram(block: DocumentBlock, document: TeachingDocument): boolean {
30
+ if (block.type !== 'code') return false
31
+ return block.language === 'mermaid' || /^(?:plantuml|puml)$/i.test(block.language) && Boolean(document.metadata.plugins?.includes('@lincyaw/addon-content'))
32
+ }
33
+ /** Blocks the editor shows through their read-only runtime preview. */
34
+ export function hasBlockPreview(block: DocumentBlock, document: TeachingDocument): boolean {
35
+ return block.type === 'component' || isDiagram(block, document)
36
+ }
37
+ function register(blocks: readonly DocumentEditorInput[], owner: string, context: DocumentProjection): void {
38
+ for (const block of blocks) { if (block.id) context.owners.set(block.id, owner); if (block.children) register(block.children, owner, context) }
39
+ }
40
+ function productBlock(blockId: string, context: DocumentProjection, sectionId: string): DocumentEditorInput[] {
41
+ const document = context.document, block = document.blocks[blockId]
42
+ if (!block) throw new Error('document-block-reference')
43
+ context.blockSections.set(blockId, sectionId)
44
+ if (block.type === 'text') {
45
+ const native = productRichTextToNative(block.content, context)
46
+ if (!native.length) native.push({ id: newId(), type: 'paragraph', content: [] })
47
+ register(native, blockId, context)
48
+ return native
49
+ }
50
+ context.owners.set(block.id, block.id)
51
+ if (block.type === 'container') return [{ id: block.id, type: 'presmiumContainer', props: { entityId: block.id, layout: block.layout, title: block.properties.title ?? '' }, children: block.children.flatMap(child => productBlock(child, context, sectionId)) }]
52
+ if (block.type === 'image') return [{ id: block.id, type: 'image', props: { url: block.assetId ? document.assets[block.assetId]?.location ?? '' : '', caption: plain(block.caption ?? []), name: block.alt, textAlignment: block.alignment ?? 'left', ...block.width !== undefined ? { previewWidth: block.width } : {} } }]
53
+ if (block.type === 'code' && block.value !== undefined && !isDiagram(block, document)) return [{ id: block.id, type: 'codeBlock', props: { language: block.language }, content: block.value }]
54
+ return [{ id: block.id, type: 'presmiumAtom', props: { entityId: block.id, kind: block.type, label: block.type === 'code' ? block.title ?? block.origin?.anchor ?? block.language : document.definitions[block.definitionId]?.kind ?? block.definitionId }, ...block.type === 'component' && block.children ? { children: block.children.flatMap(child => productBlock(child, context, sectionId)) } : {} }]
55
+ }
56
+
57
+ export function createDocumentProjection(document: TeachingDocument): DocumentProjection {
58
+ const context: DocumentProjection = { ...createRichProjection(), owners: new Map(), pages: new Map(), blockSections: new Map(), structures: new Map(), createdPages: new Map(), componentConfiguration: new Map(), document: structuredClone(document), content: [] }
59
+ context.content = document.sectionOrder.flatMap(sectionId => {
60
+ const section = document.sections[sectionId]!
61
+ context.pages.set(pageId(sectionId), sectionId)
62
+ return [{ id: pageId(sectionId), type: 'presmiumPage' as const, props: { sectionId, level: section.level ?? 1 }, content: [{ type: 'text' as const, text: section.title, styles: {} }] }, ...section.children.flatMap(blockId => productBlock(blockId, context, sectionId))]
63
+ })
64
+ return context
65
+ }
66
+
67
+ function structureOf(blocks: readonly DocumentEditorBlock[], context: DocumentProjection): string {
68
+ const tree = (blocks: readonly DocumentEditorBlock[]): unknown[] => blocks.map(block => [block.id, block.type,
69
+ block.type === 'presmiumPage' ? block.props.sectionId : block.type === 'presmiumAtom' ? [block.props.entityId, block.props.kind] : undefined, tree(block.children)])
70
+ return JSON.stringify([tree(blocks), [...context.tables]])
71
+ }
72
+
73
+ /** Canonical structural snapshots accompany native history; no editor envelope is persisted. */
74
+ export function rememberDocumentStructure(blocks: readonly DocumentEditorBlock[], context: DocumentProjection): void {
75
+ const structure = structureOf(blocks, context)
76
+ context.structure = structure
77
+ if (!context.structures.has(structure)) context.structures.set(structure, {
78
+ document: context.document, rich: new Map(context.rich), owners: new Map(context.owners), pages: new Map(context.pages), baseline: new Map(context.baseline),
79
+ })
80
+ }
81
+
82
+ /** Source-owned component configuration also accompanies a missing node in native structural history. */
83
+ export function rememberComponentConfiguration(context: DocumentProjection, source: TeachingDocument): void {
84
+ const changed = new Set<string>()
85
+ const overrides = new Map<string, unknown[]>()
86
+ for (const step of Object.values(source.steps)) for (const visual of step.visuals) if (visual.parameters) {
87
+ let values = overrides.get(visual.blockId)
88
+ if (!values) overrides.set(visual.blockId, values = [])
89
+ values.push([step.id, visual.parameters])
90
+ }
91
+ for (const block of Object.values(source.blocks)) {
92
+ if (block.type !== 'component') continue
93
+ const signature = JSON.stringify([block.definitionId, block.parameters, overrides.get(block.id) ?? []])
94
+ if (context.componentConfiguration.get(block.id) === signature) continue
95
+ context.componentConfiguration.set(block.id, signature); changed.add(block.id)
96
+ }
97
+ if (!changed.size) return
98
+ for (const snapshot of context.structures.values()) {
99
+ for (const id of changed) {
100
+ const current = source.blocks[id]!
101
+ const previous = snapshot.document.blocks[id]
102
+ if (current.type !== 'component' || previous?.type !== 'component') continue
103
+ snapshot.document = { ...snapshot.document, blocks: { ...snapshot.document.blocks, [id]: { ...previous, definitionId: current.definitionId, parameters: structuredClone(current.parameters) } } }
104
+ }
105
+ for (const [id, current] of Object.entries(source.steps)) {
106
+ const previous = snapshot.document.steps[id]
107
+ if (!previous || !previous.visuals.some(visual => changed.has(visual.blockId)) && !current.visuals.some(visual => changed.has(visual.blockId) && visual.parameters)) continue
108
+ const visuals = previous.visuals.map(visual => {
109
+ if (!changed.has(visual.blockId)) return visual
110
+ const configured = current.visuals.find(value => value.blockId === visual.blockId)
111
+ const { parameters: _parameters, ...preserved } = visual
112
+ return { ...preserved, ...configured?.parameters ? { parameters: structuredClone(configured.parameters) } : {} }
113
+ })
114
+ for (const visual of current.visuals) if (changed.has(visual.blockId) && visual.parameters && !visuals.some(value => value.blockId === visual.blockId) && snapshot.document.blocks[visual.blockId]?.type === 'component') visuals.push(structuredClone(visual))
115
+ snapshot.document = { ...snapshot.document, steps: { ...snapshot.document.steps, [id]: { ...previous, visuals } } }
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Share unchanged JSON branches across the editor's in-memory structural history. */
121
+ function share(before: unknown, after: unknown): unknown {
122
+ if (before === after || !before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after)) return after
123
+ if (Array.isArray(after)) {
124
+ const prior = before as unknown[]
125
+ let same = prior.length === after.length
126
+ after.forEach((value, index) => { after[index] = share(prior[index], value); same &&= after[index] === prior[index] })
127
+ return same ? before : after
128
+ }
129
+ const prior = before as Record<string, unknown>, next = after as Record<string, unknown>, keys = Object.keys(next)
130
+ let same = Object.keys(prior).length === keys.length
131
+ for (const key of keys) { next[key] = share(prior[key], next[key]); same &&= Object.hasOwn(prior, key) && next[key] === prior[key] }
132
+ return same ? before : after
133
+ }
134
+
135
+ function rememberRich(nodes: readonly RichTextNode[], context: DocumentProjection): void {
136
+ for (const node of nodes) {
137
+ context.rich.set(node.id, { ...context.rich.get(node.id), original: node })
138
+ if (node.type === 'list') for (const item of node.children) rememberRich(item.children, context)
139
+ else if (node.type === 'blockquote') rememberRich(node.children, context)
140
+ else if (node.type === 'extension') rememberRich(node.fallback, context)
141
+ }
142
+ }
143
+
144
+ function nativePlain(block: DocumentEditorBlock): string {
145
+ if (!Array.isArray(block.content)) return ''
146
+ return plain(block.content as Parameters<typeof plain>[0])
147
+ }
148
+ function isEntity(block: DocumentEditorBlock, context: DocumentProjection): boolean {
149
+ if (block.type === 'presmiumAtom') return !block.props.kind.startsWith('rich:')
150
+ if (block.type === 'presmiumContainer' || block.type === 'image') return true
151
+ return block.type === 'codeBlock' && context.document.blocks[context.owners.get(block.id) ?? block.id]?.type !== 'text' && !context.rich.has(block.id)
152
+ }
153
+ function nativeChildren(native: readonly DocumentEditorBlock[], context: DocumentProjection, next: TeachingDocument, usedListIds: Set<string>): string[] {
154
+ const children: string[] = []
155
+ let buffer: DocumentEditorBlock[] = [], owner: string | undefined
156
+ const flush = () => {
157
+ if (!buffer.length) return
158
+ const blockId = owner && !next.blocks[owner] ? owner : newId()
159
+ const original = context.document.blocks[owner ?? '']
160
+ const block: TextBlock = { ...original?.type === 'text' ? structuredClone(original) : {}, id: blockId, type: 'text', content: nativeRichTextToProduct(buffer, context, usedListIds) }
161
+ if (block.reveal) { const ids = richNodeIds(block.content); block.reveal.order = block.reveal.order.filter(id => ids.has(id)) }
162
+ next.blocks[blockId] = block
163
+ children.push(blockId)
164
+ register(buffer, blockId, context)
165
+ buffer = []; owner = undefined
166
+ }
167
+ for (const block of native) {
168
+ if (block.type === 'presmiumPage') throw new Error('document-page-nesting')
169
+ if (!isEntity(block, context)) {
170
+ const knownOwner = context.owners.get(block.id)
171
+ if (buffer.length && knownOwner && owner && knownOwner !== owner) flush()
172
+ owner ??= knownOwner
173
+ buffer.push(block)
174
+ continue
175
+ }
176
+ flush()
177
+ const original = context.document.blocks[block.id] ?? (block.type === 'presmiumAtom' || block.type === 'presmiumContainer' ? context.document.blocks[block.props.entityId] : undefined)
178
+ let product: DocumentBlock
179
+ if (block.type === 'presmiumContainer') {
180
+ const children = nativeChildren(block.children, context, next, usedListIds)
181
+ const properties = { ...original?.type === 'container' ? original.properties : {}, ...block.props.title ? { title: block.props.title } : {} }
182
+ if (original?.type === 'container') {
183
+ if (properties.columnWidths) properties.columnWidths = children.map(child => original.properties.columnWidths?.[original.children.indexOf(child)] ?? 1)
184
+ if (properties.tabTitles) properties.tabTitles = children.map(child => original.properties.tabTitles?.[original.children.indexOf(child)] ?? '')
185
+ }
186
+ product = { ...original?.type === 'container' ? structuredClone(original) : {}, id: block.id, type: 'container', layout: block.props.layout, properties, children }
187
+ }
188
+ else if (block.type === 'image') {
189
+ if (original?.type === 'image' && context.baseline.get(block.id) === JSON.stringify(block)) { next.blocks[block.id] = structuredClone(original); children.push(block.id); continue }
190
+ // Native pickers create an empty image first. Persist that intent without
191
+ // inventing an invalid asset. Replacements change this instance's binding.
192
+ const prior = original?.type === 'image' ? original.assetId : undefined
193
+ const assetId = !block.props.url ? undefined : prior && next.assets[prior]?.location === block.props.url ? prior
194
+ : Object.values(next.assets).find(asset => asset.kind === 'image' && asset.location === block.props.url)?.id ?? newId()
195
+ if (assetId && !next.assets[assetId]) next.assets[assetId] = { id: assetId, kind: 'image', location: block.props.url }
196
+ const previousCaption = original?.type === 'image' ? original.caption : undefined
197
+ const caption = plain(previousCaption ?? []) === block.props.caption ? previousCaption : block.props.caption ? [{ type: 'text' as const, text: block.props.caption }] : undefined
198
+ product = { ...original?.type === 'image' ? structuredClone(original) : {}, id: block.id, type: 'image', ...assetId ? { assetId } : {}, alt: block.props.name, ...caption ? { caption } : {}, width: block.props.previewWidth, alignment: block.props.textAlignment }
199
+ if (!assetId) delete product.assetId
200
+ if (!caption) delete product.caption
201
+ } else if (block.type === 'codeBlock') product = { ...original?.type === 'code' ? structuredClone(original) : {}, id: block.id, type: 'code', language: block.props.language, value: nativePlain(block) }
202
+ else {
203
+ if (!original) throw new Error('document-opaque-reference')
204
+ product = structuredClone(original)
205
+ product.id = block.id
206
+ if (product.type === 'component' && (product.children || block.children.length)) product.children = nativeChildren(block.children, context, next, usedListIds)
207
+ }
208
+ next.blocks[product.id] = product
209
+ context.owners.set(block.id, product.id)
210
+ children.push(product.id)
211
+ }
212
+ flush()
213
+ return children
214
+ }
215
+
216
+ /** A plain text transaction keeps the teaching graph and untouched rich nodes by identity. */
217
+ function projectTextEdits(blocks: readonly DocumentEditorBlock[], context: DocumentProjection, latest: TeachingDocument): TeachingDocument | undefined {
218
+ const changed: { native: DocumentEditorBlock, serialized: string, owner: TextBlock }[] = []
219
+ const visit = (blocks: readonly DocumentEditorBlock[]): boolean => blocks.every(block => {
220
+ const serialized = JSON.stringify(block)
221
+ if (context.baseline.get(block.id) === serialized) return true
222
+ const owner = latest.blocks[context.owners.get(block.id) ?? '']
223
+ if (block.children.length || (block.type !== 'paragraph' && block.type !== 'heading') || owner?.type !== 'text'
224
+ || !owner.content.some(node => node.id === block.id && (node.type === 'paragraph' || node.type === 'heading'))) return false
225
+ changed.push({ native: block, serialized, owner })
226
+ return true
227
+ })
228
+ if (!visit(blocks)) return undefined
229
+ const next = { ...latest, ...changed.length ? { blocks: { ...latest.blocks } } : {} }
230
+ for (const { native, serialized, owner } of changed) {
231
+ const content = nativeRichTextToProduct([native], context)[0]!
232
+ const current = next.blocks[owner.id] as TextBlock
233
+ next.blocks[owner.id] = { ...current, content: current.content.map(node => node.id === native.id ? content : node) }
234
+ context.rich.set(native.id, { ...context.rich.get(native.id), original: content })
235
+ context.baseline.set(native.id, serialized)
236
+ }
237
+ context.document = next
238
+ return next
239
+ }
240
+
241
+ /** Reconstruct the normalized product tree without visiting or reparsing Markdown. */
242
+ export function nativeDocumentToProduct(blocks: readonly DocumentEditorBlock[], context: DocumentProjection, latest = context.document): TeachingDocument {
243
+ const targetStructure = structureOf(blocks, context)
244
+ if (targetStructure === context.structure) {
245
+ const edited = projectTextEdits(blocks, context, latest)
246
+ if (edited) return edited
247
+ }
248
+ if (context.structure && targetStructure !== context.structure) context.structures.set(context.structure, {
249
+ document: structuredClone(latest), rich: new Map(context.rich), owners: new Map(context.owners), pages: new Map(context.pages), baseline: new Map(context.baseline),
250
+ })
251
+ const restored = targetStructure === context.structure ? undefined : context.structures.get(targetStructure)
252
+ if (restored) {
253
+ context.rich = new Map(restored.rich); context.owners = new Map(restored.owners); context.pages = new Map(restored.pages); context.baseline = new Map(restored.baseline)
254
+ }
255
+ const snapshot = restored?.document
256
+ // Source fields are current; a body history snapshot only supplies missing structural entities.
257
+ context.document = snapshot ? structuredClone({ ...latest,
258
+ blocks: { ...snapshot.blocks, ...latest.blocks }, sections: { ...snapshot.sections, ...latest.sections },
259
+ }) : structuredClone(latest)
260
+ // Creation identities accompany native history, while all edited values stay in the document.
261
+ for (const block of blocks) if (block.type === 'presmiumPage') {
262
+ const created = context.createdPages.get(block.props.sectionId)
263
+ if (!created) continue
264
+ const { sectionId, blockId, nodeId, stepId } = created
265
+ context.owners.set(nodeId, blockId)
266
+ context.pages.set(block.id, sectionId)
267
+ if (latest.sections[sectionId]) continue
268
+ const defaults = documentPageDefaults(created, nativePlain(block))
269
+ context.document.sections[sectionId] ??= defaults.section
270
+ context.document.blocks[blockId] ??= defaults.block
271
+ context.document.steps[stepId] ??= defaults.step
272
+ context.document.production.stepOverrides[stepId] ??= defaults.timing
273
+ }
274
+ if (snapshot) for (const id of snapshot.sectionOrder) {
275
+ if (latest.sections[id]) continue
276
+ const returned = context.document.sections[id]!
277
+ for (const other of Object.values(context.document.sections)) if (other.id !== id) other.stepOrder = other.stepOrder.filter(step => !returned.stepOrder.includes(step))
278
+ for (const step of returned.stepOrder) if (context.document.steps[step]) context.document.steps[step]!.sectionId = id
279
+ }
280
+ const next = structuredClone(context.document)
281
+ next.blocks = {}; next.sections = {}; next.sectionOrder = []
282
+ const usedListIds = new Set<string>()
283
+ let section: Section | undefined, content: DocumentEditorBlock[] = []
284
+ const flush = () => { if (section) { section.children = nativeChildren(content, context, next, usedListIds); next.sections[section.id] = section; next.sectionOrder.push(section.id) }; content = [] }
285
+ for (const block of blocks) {
286
+ if (block.type === 'presmiumPage') {
287
+ flush()
288
+ let sectionId = context.pages.get(block.id) ?? block.props.sectionId
289
+ if (!sectionId || next.sections[sectionId]) sectionId = newId()
290
+ context.pages.set(block.id, sectionId)
291
+ const original = context.document.sections[sectionId]
292
+ section = { ...original ? structuredClone(original) : {}, id: sectionId, title: nativePlain(block), children: [], stepOrder: [...(original?.stepOrder ?? [])], layout: original?.layout ?? { type: 'stack' } }
293
+ } else {
294
+ if (!section) {
295
+ const original = context.document.sections[context.document.sectionOrder[0] ?? '']
296
+ section = original ? structuredClone(original) : { id: newId(), title: next.metadata.title, children: [], stepOrder: [], layout: { type: 'stack' } }
297
+ }
298
+ content.push(block)
299
+ }
300
+ }
301
+ flush()
302
+ if (!next.sectionOrder.length) throw new Error('document-empty')
303
+ // Removing a page boundary merges its teaching sequence into the preceding page.
304
+ let destination = next.sectionOrder[0]!
305
+ for (const sectionId of context.document.sectionOrder) {
306
+ if (next.sections[sectionId]) { destination = sectionId; continue }
307
+ const removed = context.document.sections[sectionId]!
308
+ const created = context.createdPages.get(sectionId)
309
+ if (created && snapshot && !snapshot.sections[sectionId] && untouchedPageCreation(context.document, created)) {
310
+ delete next.steps[created.stepId]
311
+ delete next.production.stepOverrides[created.stepId]
312
+ continue
313
+ }
314
+ for (const stepId of removed.stepOrder) {
315
+ if (!next.steps[stepId]) continue
316
+ next.steps[stepId]!.sectionId = destination
317
+ if (!next.sections[destination]!.stepOrder.includes(stepId)) next.sections[destination]!.stepOrder.push(stepId)
318
+ }
319
+ if (next.production.sectionOverrides) delete next.production.sectionOverrides[sectionId]
320
+ }
321
+ const sectionOf = new Map<string, string>()
322
+ const previousSectionOf = new Map<string, string>(), newSteps = new Set<string>()
323
+ const previous = (blockId: string, sectionId: string) => {
324
+ previousSectionOf.set(blockId, sectionId)
325
+ const block = latest.blocks[blockId]
326
+ if (block?.type === 'container' || block?.type === 'component') for (const child of block.children ?? []) previous(child, sectionId)
327
+ }
328
+ for (const section of Object.values(latest.sections)) section.children.forEach(blockId => previous(blockId, section.id))
329
+ const assign = (blockId: string, sectionId: string) => {
330
+ sectionOf.set(blockId, sectionId)
331
+ const block = next.blocks[blockId]
332
+ if (block?.type === 'container' || block?.type === 'component') for (const child of block.children ?? []) assign(child, sectionId)
333
+ }
334
+ for (const section of Object.values(next.sections)) {
335
+ section.children.forEach(blockId => assign(blockId, section.id))
336
+ if (!section.stepOrder.length) {
337
+ const stepId = newId(); section.stepOrder.push(stepId)
338
+ newSteps.add(stepId)
339
+ next.steps[stepId] = { id: stepId, sectionId: section.id, visuals: [], narrationIds: [] }
340
+ }
341
+ }
342
+ for (const step of Object.values(next.steps)) {
343
+ step.visuals = step.visuals.filter(visual => sectionOf.get(visual.blockId) === step.sectionId)
344
+ for (const [blockId, sectionId] of sectionOf) if (sectionId === step.sectionId && (previousSectionOf.get(blockId) !== sectionId || newSteps.has(step.id)) && !step.visuals.some(visual => visual.blockId === blockId)) step.visuals.push({ blockId, visible: true })
345
+ }
346
+ if (snapshot) {
347
+ const returned = new Set(Object.keys(next.blocks).filter(id => !latest.blocks[id]))
348
+ for (const step of Object.values(next.steps)) for (const visual of snapshot.steps[step.id]?.visuals ?? []) {
349
+ if (sectionOf.get(visual.blockId) !== step.sectionId || previousSectionOf.get(visual.blockId) === step.sectionId) continue
350
+ const index = step.visuals.findIndex(value => value.blockId === visual.blockId)
351
+ if (index < 0) step.visuals.push(structuredClone(visual)); else step.visuals[index] = structuredClone(visual)
352
+ }
353
+ for (const id of next.sectionOrder) if (!latest.sections[id] && snapshot.production.sectionOverrides?.[id]) {
354
+ next.production.sectionOverrides ??= {}; next.production.sectionOverrides[id] ??= structuredClone(snapshot.production.sectionOverrides[id]!)
355
+ }
356
+ const cueIds = new Set(Object.values(next.narrations).flatMap(value => value.content.flatMap(node => node.type === 'cue' ? [node.id] : [])))
357
+ for (const narration of Object.values(snapshot.narrations)) {
358
+ const current = next.narrations[narration.id]
359
+ if (!current) continue
360
+ let offset = 0
361
+ for (const node of narration.content) {
362
+ if (node.type === 'text') offset += node.text.length
363
+ else if (node.type === 'pronunciation') offset += node.display.length
364
+ else if (node.target && returned.has(node.target.blockId) && !cueIds.has(node.id)) {
365
+ current.content = insertCue(current.content, structuredClone(node), offset); cueIds.add(node.id)
366
+ if (snapshot.production.cueTimings?.[node.id]) { next.production.cueTimings ??= {}; next.production.cueTimings[node.id] ??= structuredClone(snapshot.production.cueTimings[node.id]!) }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ for (const narration of Object.values(next.narrations)) narration.content = narration.content.filter(node => {
372
+ if (node.type !== 'cue' || !node.target || next.blocks[node.target.blockId]) return true
373
+ if (next.production.cueTimings) delete next.production.cueTimings[node.id]
374
+ return false
375
+ })
376
+ context.document = share(context.document, structuredClone(next)) as TeachingDocument
377
+ context.blockSections = sectionOf
378
+ for (const block of Object.values(context.document.blocks)) if (block.type === 'text') rememberRich(block.content, context)
379
+ context.baseline.clear(); captureRichBaseline(blocks, context)
380
+ rememberDocumentStructure(blocks, context)
381
+ return next
382
+ }
383
+
384
+ /** Only an untouched, unreferenced creation default is removed with its native insertion. */
385
+ function untouchedPageCreation(document: TeachingDocument, created: NativePageCreation): boolean {
386
+ const { sectionId, blockId, stepId } = created, section = document.sections[sectionId]
387
+ if (section?.stepOrder.length !== 1 || section.stepOrder[0] !== stepId
388
+ || JSON.stringify(document.steps[stepId]) !== JSON.stringify(documentPageDefaults(created, '').step)
389
+ || JSON.stringify(document.production.stepOverrides[stepId]) !== JSON.stringify(DOCUMENT_PAGE_DEFAULT_TIMING)
390
+ || document.production.sectionOverrides?.[sectionId]
391
+ || document.production.mediaClips.some(clip => clip.attachment.stepId === stepId)
392
+ || Object.values(document.steps).some(step => step.inherits === stepId || step.id !== stepId && step.visuals.some(visual => visual.blockId === blockId))
393
+ || Object.values(document.narrations).some(narration => narration.content.some(node => node.type === 'cue' && node.target?.blockId === blockId))) return false
394
+ return true
395
+ }
396
+
397
+ /** A restored cue keeps its written offset without replacing later narration text. */
398
+ function insertCue(content: NarrationNode[], cue: Extract<NarrationNode, { type: 'cue' }>, offset: number): NarrationNode[] {
399
+ const next = structuredClone(content)
400
+ for (let index = 0; index < next.length; index++) {
401
+ const node = next[index]!
402
+ if (node.type === 'cue') continue
403
+ const length = node.type === 'text' ? node.text.length : node.display.length
404
+ if (offset <= length) {
405
+ if (node.type === 'text') next.splice(index, 1, ...offset ? [{ type: 'text' as const, text: node.text.slice(0, offset) }] : [], cue, ...offset < length ? [{ type: 'text' as const, text: node.text.slice(offset) }] : [])
406
+ else next.splice(index + (offset ? 1 : 0), 0, cue)
407
+ return next
408
+ }
409
+ offset -= length
410
+ }
411
+ next.push(cue); return next
412
+ }
413
+
414
+ export function nativePage(sectionId: string, title: string): DocumentEditorInput {
415
+ return { id: pageId(sectionId), type: 'presmiumPage', props: { sectionId }, content: productInlineToNative([{ type: 'text', text: title }], createRichProjection()) }
416
+ }
417
+
418
+ export function richNodeIds(nodes: readonly RichTextNode[]): Set<string> {
419
+ const ids = new Set<string>()
420
+ const visit = (node: RichTextNode | { id: string, type: string, children?: unknown[] }) => { ids.add(node.id); if ('children' in node) for (const child of node.children ?? []) if (typeof child === 'object' && child && 'id' in child) visit(child as RichTextNode) }
421
+ nodes.forEach(visit)
422
+ return ids
423
+ }
@@ -0,0 +1,152 @@
1
+ import type { InlineNode, JsonValue, ListItemNode, RichTextNode, TableNode } from '@lincyaw/kernel'
2
+ import type { TableCellProps } from '@blocknote/core'
3
+ import { nativeInlineToProduct, nativeTextStyleToProduct, productInlineToNative, productTextStyleToNative } from './inline.ts'
4
+ import type { InlineProjectionContext, NativeInline } from './inline.ts'
5
+ import type { DocumentEditorBlock, DocumentEditorInput } from './schema.tsx'
6
+
7
+ export function fallbackText(value: unknown): string {
8
+ if (!value || typeof value !== 'object') return ''
9
+ if (Array.isArray(value)) return value.map(fallbackText).filter(Boolean).join('\n')
10
+ const node = value as { type?: string, value?: string, text?: string, alt?: string, children?: JsonValue[], fallback?: unknown }
11
+ if (node.type === 'text') return node.text ?? node.value ?? ''
12
+ if (node.type === 'image') return node.alt ?? ''
13
+ if (node.type === 'math' || node.type === 'code') return node.value ?? ''
14
+ if (node.type === 'extension') return typeof node.fallback === 'string' ? node.fallback : fallbackText(node.fallback)
15
+ return (node.children ?? []).map(fallbackText).join(node.type === 'root' ? '\n' : '')
16
+ }
17
+
18
+ interface RichIdentity {
19
+ original?: RichTextNode
20
+ listId?: string
21
+ itemId?: string
22
+ paragraphId?: string
23
+ }
24
+ export interface RichProjection extends InlineProjectionContext {
25
+ rich: Map<string, RichIdentity>
26
+ baseline: Map<string, string>
27
+ tables: Map<string, { id: string, cells: string[] }[]>
28
+ }
29
+ export function createRichProjection(): RichProjection {
30
+ return { rich: new Map(), baseline: new Map(), tables: new Map(), opaqueInline: new Map(), newInlineId: () => crypto.randomUUID() }
31
+ }
32
+ export function captureRichBaseline(blocks: readonly DocumentEditorBlock[], context: RichProjection): void {
33
+ for (const block of blocks) { context.baseline.set(block.id, JSON.stringify(block)); captureRichBaseline(block.children, context) }
34
+ }
35
+ const id = () => crypto.randomUUID()
36
+ const atom = (node: RichTextNode): DocumentEditorInput => ({ id: node.id, type: 'presmiumAtom', props: { entityId: node.id, kind: `rich:${node.type}`, label: node.type === 'extension' ? fallbackText(node.fallback) : fallbackText(node) } })
37
+
38
+ /** The projection records structural identities; the saved document owns all content. */
39
+ export function productRichTextToNative(nodes: readonly RichTextNode[], context: RichProjection): DocumentEditorInput[] {
40
+ return nodes.flatMap((node): DocumentEditorInput[] => {
41
+ context.rich.set(node.id, { original: structuredClone(node) })
42
+ if (node.type === 'paragraph' || node.type === 'heading') return [{ id: node.id, type: node.type, props: { ...productTextStyleToNative(node.style), ...node.type === 'heading' ? { level: node.level } : {} }, content: productInlineToNative(node.children, context) } as DocumentEditorInput]
43
+ if (node.type === 'code') return [{ id: node.id, type: 'codeBlock', props: { language: node.language }, content: node.value }]
44
+ if (node.type === 'math') return [{ id: node.id, type: 'presmiumMath', content: node.value }]
45
+ if (node.type === 'extension') return [atom(node)]
46
+ if (node.type === 'blockquote') {
47
+ const [first, ...rest] = node.children
48
+ if (!first || first.type !== 'paragraph') return [{ ...atom(node), children: productRichTextToNative(node.children, context) }]
49
+ context.rich.set(node.id, { original: structuredClone(node), paragraphId: first.id })
50
+ return [{ id: node.id, type: 'quote', props: productTextStyleToNative(first.style), content: productInlineToNative(first.children, context), children: productRichTextToNative(rest, context) }]
51
+ }
52
+ if (node.type === 'list') return node.children.map((item, index) => {
53
+ const [first, ...rest] = item.children
54
+ const paragraph = first?.type === 'paragraph' ? first : undefined
55
+ context.rich.set(item.id, { listId: node.id, itemId: item.id, paragraphId: paragraph?.id })
56
+ return {
57
+ id: item.id, type: item.checked !== undefined ? 'checkListItem' : node.ordered ? 'numberedListItem' : 'bulletListItem',
58
+ props: { ...productTextStyleToNative(paragraph?.style), ...item.checked !== undefined ? { checked: item.checked } : {}, ...node.ordered && index === 0 && node.start !== undefined ? { start: node.start } : {} },
59
+ content: productInlineToNative(paragraph?.children ?? [], context), children: productRichTextToNative(paragraph ? rest : item.children, context),
60
+ } as DocumentEditorInput
61
+ })
62
+ return [{ id: node.id, type: 'table', content: {
63
+ type: 'tableContent', columnWidths: node.columnWidths ?? [], headerRows: node.headerRows ?? implicitHeaderRows(node.children.length), headerCols: node.headerColumns,
64
+ rows: node.children.map(row => ({ cells: row.children.map(cell => ({ type: 'tableCell' as const, props: { ...productTextStyleToNative(cell.style), colspan: cell.colspan, rowspan: cell.rowspan }, content: productInlineToNative(cell.children, context) })) })),
65
+ } }]
66
+ })
67
+ }
68
+
69
+ // The viewer reads a table without `headerRows` as having one header row, as in Markdown.
70
+ const implicitHeaderRows = (rows: number) => Math.min(1, rows)
71
+ function tableHeaderRows(headerRows: number, rows: number, original: TableNode | undefined): { headerRows?: number } {
72
+ return original && original.headerRows === undefined && headerRows === implicitHeaderRows(rows) ? {} : { headerRows }
73
+ }
74
+
75
+ function inline(block: DocumentEditorBlock, context: RichProjection): InlineNode[] {
76
+ return Array.isArray(block.content) ? nativeInlineToProduct(block.content as NativeInline[], context) : []
77
+ }
78
+ function paragraph(block: DocumentEditorBlock, context: RichProjection, paragraphId = block.id): RichTextNode {
79
+ const style = nativeTextStyleToProduct('textAlignment' in block.props ? block.props : {})
80
+ return { id: paragraphId, type: 'paragraph', children: inline(block, context), ...style ? { style } : {} }
81
+ }
82
+ function listType(block: DocumentEditorBlock): boolean { return block.type === 'bulletListItem' || block.type === 'numberedListItem' || block.type === 'checkListItem' }
83
+
84
+ function rememberListIds(node: RichTextNode, used?: Set<string>): Set<string> | undefined {
85
+ if (node.type === 'list') { used ??= new Set(); used.add(node.id); for (const item of node.children) for (const child of item.children) used = rememberListIds(child, used) }
86
+ else if (node.type === 'blockquote') for (const child of node.children) used = rememberListIds(child, used)
87
+ else if (node.type === 'extension') for (const child of node.fallback) used = rememberListIds(child, used)
88
+ return used
89
+ }
90
+
91
+ /** Native text, table and list edits are read through BlockNote's public block API. */
92
+ export function nativeRichTextToProduct(blocks: readonly DocumentEditorBlock[], context: RichProjection, usedListIds?: Set<string>): RichTextNode[] {
93
+ const result: RichTextNode[] = []
94
+ let groupOrigin: string | undefined
95
+ for (const block of blocks) {
96
+ const identity = context.rich.get(block.id)
97
+ if (identity?.original && context.baseline.get(block.id) === JSON.stringify(block)) { usedListIds = rememberListIds(identity.original, usedListIds); result.push(structuredClone(identity.original)); groupOrigin = undefined; continue }
98
+ if (listType(block)) {
99
+ usedListIds ??= new Set()
100
+ const ordered = block.type === 'numberedListItem'
101
+ const start = block.type === 'numberedListItem' ? block.props.start : undefined
102
+ let previous = result.at(-1)
103
+ if (previous?.type !== 'list' || previous.ordered !== ordered || identity?.listId && identity.listId !== groupOrigin) {
104
+ const listId = identity?.listId && !usedListIds.has(identity.listId) ? identity.listId : id()
105
+ groupOrigin = identity?.listId ?? listId
106
+ previous = { id: listId, type: 'list', ordered, ...start !== undefined ? { start } : {}, children: [] }
107
+ usedListIds.add(listId)
108
+ result.push(previous)
109
+ }
110
+ const item: ListItemNode = {
111
+ id: identity?.itemId ?? block.id, type: 'listItem',
112
+ children: [paragraph(block, context, identity?.paragraphId ?? id()), ...nativeRichTextToProduct(block.children, context, usedListIds)],
113
+ ...block.type === 'checkListItem' ? { checked: block.props.checked } : {},
114
+ }
115
+ previous.children.push(item)
116
+ context.rich.set(block.id, { ...identity, original: undefined, listId: previous.id, itemId: item.id, paragraphId: item.children[0]!.id })
117
+ continue
118
+ }
119
+ groupOrigin = undefined
120
+ if (block.type === 'paragraph') result.push(paragraph(block, context))
121
+ else if (block.type === 'heading') {
122
+ const style = nativeTextStyleToProduct(block.props)
123
+ result.push({ id: block.id, type: 'heading', level: Math.max(1, Math.min(6, block.props.level)) as 1 | 2 | 3 | 4 | 5 | 6, children: inline(block, context), ...style ? { style } : {} })
124
+ } else if (block.type === 'quote') result.push({ id: block.id, type: 'blockquote', children: [paragraph(block, context, identity?.paragraphId ?? id()), ...nativeRichTextToProduct(block.children, context, usedListIds ??= new Set())] })
125
+ else if (block.type === 'presmiumMath') result.push({ id: block.id, type: 'math', value: block.content.map(part => part.text).join('') })
126
+ else if (block.type === 'codeBlock') result.push({ id: block.id, type: 'code', language: block.props.language, value: block.content.map(part => part.text).join('') })
127
+ else if (block.type === 'table') {
128
+ const original = identity?.original?.type === 'table' ? identity.original : undefined
129
+ const table: TableNode = {
130
+ id: block.id, type: 'table', ...tableHeaderRows(block.content.headerRows ?? 0, block.content.rows.length, original),
131
+ ...block.content.headerCols ? { headerColumns: block.content.headerCols } : {},
132
+ ...block.content.columnWidths.length && block.content.columnWidths.every(width => typeof width === 'number' && Number.isFinite(width) && width > 0) ? { columnWidths: block.content.columnWidths as number[] } : {},
133
+ children: block.content.rows.map((row, rowIndex) => ({
134
+ id: context.tables.get(block.id)?.[rowIndex]?.id ?? original?.children[rowIndex]?.id ?? id(), type: 'tableRow', children: row.cells.map((cell, cellIndex) => {
135
+ const props: Partial<TableCellProps> = Array.isArray(cell) ? {} : cell.props, style = nativeTextStyleToProduct(props)
136
+ return { id: context.tables.get(block.id)?.[rowIndex]?.cells[cellIndex] ?? original?.children[rowIndex]?.children[cellIndex]?.id ?? id(), type: 'tableCell', children: nativeInlineToProduct(Array.isArray(cell) ? cell : cell.content, context), ...style ? { style } : {}, ...props.colspan && props.colspan !== 1 ? { colspan: props.colspan } : {}, ...props.rowspan && props.rowspan !== 1 ? { rowspan: props.rowspan } : {} }
137
+ }),
138
+ })),
139
+ }
140
+ context.rich.set(block.id, { original: structuredClone(table) })
141
+ result.push(table)
142
+ } else if (block.type === 'presmiumAtom' && block.props.kind.startsWith('rich:')) {
143
+ if (!identity?.original) throw new Error('document-rich-reference')
144
+ const original = structuredClone(identity.original)
145
+ if (original.type === 'blockquote') original.children = nativeRichTextToProduct(block.children, context, usedListIds ??= new Set())
146
+ else usedListIds = rememberListIds(original, usedListIds)
147
+ result.push(original)
148
+ } else throw new Error(`document-rich-block:${block.type}`)
149
+ if (block.children.length && block.type !== 'quote' && block.type !== 'presmiumAtom') result.push(...nativeRichTextToProduct(block.children, context, usedListIds ??= new Set()))
150
+ }
151
+ return result
152
+ }