@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +66 -2
- package/package.json +68 -3
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +631 -0
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +36 -0
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +781 -0
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +462 -0
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +1025 -0
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
|
@@ -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
|
+
}
|