@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,117 @@
1
+ import { createContext, createElement, useContext } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs, defaultStyleSpecs } from '@blocknote/core'
4
+ import type { Block, BlockNoteEditor, PartialBlock, StyleConfig, StyleSpec } from '@blocknote/core'
5
+ import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/react'
6
+ import { DOCUMENT_IMAGE_MEDIA_TYPES } from '@lincyaw/kernel'
7
+ import { tableIdentityExtension } from './table-identities.ts'
8
+ import { documentColorStyles } from './colors.ts'
9
+ import { mathBlock, mathInline } from './math.tsx'
10
+
11
+ export interface DocumentNodeViews {
12
+ renderBlock?(id: string, kind: string): ReactNode
13
+ renderInline?(id: string, kind: string, label: string): ReactNode
14
+ }
15
+ export const DOCUMENT_NODE_VIEWS = createContext<DocumentNodeViews>({})
16
+
17
+ const page = createReactBlockSpec({
18
+ type: 'presmiumPage',
19
+ propSchema: { sectionId: { default: '' }, level: { default: 1, values: [1, 2, 3, 4, 5, 6] as const } },
20
+ content: 'inline',
21
+ }, {
22
+ render: ({ block, contentRef }) => createElement(`h${block.props.level}`, { className: 'pm-document-editor__page-title', 'data-document-section': block.props.sectionId, ref: contentRef }),
23
+ })()
24
+
25
+ const atom = createReactBlockSpec({
26
+ type: 'presmiumAtom',
27
+ propSchema: { entityId: { default: '' }, kind: { default: '' }, label: { default: '' } },
28
+ content: 'none',
29
+ }, {
30
+ render: ({ block }) => {
31
+ const views = useContext(DOCUMENT_NODE_VIEWS)
32
+ return <div className="pm-document-editor__atom" contentEditable={false}>
33
+ {views.renderBlock?.(block.props.entityId, block.props.kind) ?? (block.props.label || block.props.kind)}
34
+ </div>
35
+ },
36
+ })()
37
+
38
+ const container = createReactBlockSpec({
39
+ type: 'presmiumContainer',
40
+ propSchema: { entityId: { default: '' }, layout: { default: 'stack', values: ['stack', 'columns', 'tabs'] as const }, title: { default: '' } },
41
+ content: 'none',
42
+ }, {
43
+ render: ({ block }) => <div className="pm-document-editor__container-label" contentEditable={false}>{block.props.title || block.props.layout}</div>,
44
+ })()
45
+
46
+ const inline = createReactInlineContentSpec({
47
+ type: 'presmiumInline',
48
+ propSchema: { entityId: { default: '' }, kind: { default: '' }, label: { default: '' } },
49
+ content: 'none',
50
+ }, {
51
+ render: ({ inlineContent }) => {
52
+ const views = useContext(DOCUMENT_NODE_VIEWS)
53
+ const { entityId, kind, label } = inlineContent.props
54
+ return <span className="pm-document-editor__inline" contentEditable={false}>
55
+ {views.renderInline?.(entityId, kind, label) ?? label}
56
+ </span>
57
+ },
58
+ })
59
+
60
+ const table = { ...defaultBlockSpecs.table, extensions: [...defaultBlockSpecs.table.extensions ?? [], tableIdentityExtension] }
61
+ const image: typeof defaultBlockSpecs.image = { ...defaultBlockSpecs.image, implementation: { ...defaultBlockSpecs.image.implementation,
62
+ meta: { ...defaultBlockSpecs.image.implementation.meta, fileBlockAccept: [...DOCUMENT_IMAGE_MEDIA_TYPES] },
63
+ render(block, editor) {
64
+ const nativeRender = defaultBlockSpecs.image.implementation.render!
65
+ if (block.props.url) return nativeRender.call(this, block, editor)
66
+ const host = document.createElement('div')
67
+ let rendered = nativeRender.call(this, block, editor)
68
+ host.appendChild(rendered.dom)
69
+ // The native empty-image view starts a loader but does not restore its
70
+ // picker after failure. Own this render boundary, retaining native controls.
71
+ const stopUpload = editor.onUploadEnd(blockId => {
72
+ if (blockId !== block.id) return
73
+ const current = editor.getBlock(block.id)
74
+ if (!current || current.type !== 'image' || current.props.url) return
75
+ rendered.destroy?.()
76
+ rendered = nativeRender.call(this, { ...block, props: current.props }, editor)
77
+ host.replaceChildren(rendered.dom)
78
+ })
79
+ return { dom: host, destroy: () => { stopUpload(); rendered.destroy?.() } }
80
+ },
81
+ } }
82
+
83
+ function clipboardStyle<Config extends StyleConfig>(spec: StyleSpec<Config>): StyleSpec<Config> {
84
+ return { ...spec, implementation: { ...spec.implementation, mark: spec.implementation.mark.extend({ addPasteRules: () => [] }) } }
85
+ }
86
+
87
+ // HTML already carries formatting; its literal text must not run through inline Markdown paste rules again.
88
+ const clipboardStyles = {
89
+ ...defaultStyleSpecs, ...documentColorStyles,
90
+ bold: clipboardStyle(defaultStyleSpecs.bold), italic: clipboardStyle(defaultStyleSpecs.italic),
91
+ strike: clipboardStyle(defaultStyleSpecs.strike), code: clipboardStyle(defaultStyleSpecs.code),
92
+ }
93
+
94
+ /** An editing projection. The product document schema remains owned by the kernel. */
95
+ export const documentEditorSchema = BlockNoteSchema.create({
96
+ blockSpecs: {
97
+ paragraph: defaultBlockSpecs.paragraph, heading: defaultBlockSpecs.heading,
98
+ bulletListItem: defaultBlockSpecs.bulletListItem, numberedListItem: defaultBlockSpecs.numberedListItem,
99
+ checkListItem: defaultBlockSpecs.checkListItem, quote: defaultBlockSpecs.quote,
100
+ codeBlock: defaultBlockSpecs.codeBlock, table, image,
101
+ presmiumPage: page, presmiumAtom: atom, presmiumContainer: container, presmiumMath: mathBlock,
102
+ },
103
+ inlineContentSpecs: { ...defaultInlineContentSpecs, presmiumInline: inline, presmiumMathInline: mathInline },
104
+ styleSpecs: clipboardStyles,
105
+ })
106
+ export const commentEditorSchema = BlockNoteSchema.create({
107
+ blockSpecs: {
108
+ paragraph: defaultBlockSpecs.paragraph, heading: defaultBlockSpecs.heading,
109
+ bulletListItem: defaultBlockSpecs.bulletListItem, numberedListItem: defaultBlockSpecs.numberedListItem,
110
+ checkListItem: defaultBlockSpecs.checkListItem, quote: defaultBlockSpecs.quote,
111
+ codeBlock: defaultBlockSpecs.codeBlock, table, presmiumAtom: atom, presmiumMath: mathBlock,
112
+ },
113
+ inlineContentSpecs: { ...defaultInlineContentSpecs, presmiumInline: inline, presmiumMathInline: mathInline }, styleSpecs: { ...defaultStyleSpecs, ...documentColorStyles },
114
+ })
115
+ export type DocumentEditorBlock = Block<typeof documentEditorSchema.blockSchema, typeof documentEditorSchema.inlineContentSchema, typeof documentEditorSchema.styleSchema>
116
+ export type DocumentEditorInput = PartialBlock<typeof documentEditorSchema.blockSchema, typeof documentEditorSchema.inlineContentSchema, typeof documentEditorSchema.styleSchema>
117
+ export type NativeDocumentEditor = BlockNoteEditor<typeof documentEditorSchema.blockSchema, typeof documentEditorSchema.inlineContentSchema, typeof documentEditorSchema.styleSchema>
@@ -0,0 +1,152 @@
1
+ import { getBlockInfoFromNode, getBlockInfoNearPos, getNodeId } from '@blocknote/core'
2
+ import { NodeSelection } from '@tiptap/pm/state'
3
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
+ import type { NativeDocumentEditor } from './schema.tsx'
5
+ import { TABLE_ENTITY_ATTRIBUTE } from './table-identities.ts'
6
+
7
+ /** Ephemeral native coordinates, converted to product entity ranges by the document adapter. */
8
+ export interface NativeTextPoint {
9
+ blockId: string
10
+ offset: number
11
+ contentKind: 'inline' | 'plain' | 'table'
12
+ }
13
+ export interface NativeDocumentSelection {
14
+ blockIds: string[]
15
+ text: string
16
+ from: NativeTextPoint
17
+ to: NativeTextPoint
18
+ }
19
+
20
+ export interface NativeEditingCursor {
21
+ nativeBlockId: string
22
+ position: number
23
+ objectSelected: boolean
24
+ }
25
+
26
+ /** The active endpoint owns text ranges; an object selection owns the selected object. */
27
+ export function nativeEditingCursor(editor: NativeDocumentEditor): NativeEditingCursor | undefined {
28
+ if (editor.prosemirrorView.isDestroyed || !editor.isFocused()) return undefined
29
+ const state = editor.prosemirrorState
30
+ const objectSelected = state.selection instanceof NodeSelection
31
+ const position = objectSelected ? state.selection.from : state.selection.head
32
+ const info = getBlockInfoNearPos(state, position)
33
+ return { nativeBlockId: getNodeId(info.block.node, state.doc), position, objectSelected }
34
+ }
35
+
36
+ // Product text counts a character per text character and one per other inline node (a break, a formula, a
37
+ // component), as in the kernel's comment anchors. A native inline formula holds its source as content, so native
38
+ // positions after it are further along than product offsets.
39
+ function inlineLength(content: ProseMirrorNode): number {
40
+ let length = 0
41
+ content.forEach(child => { length += child.isText ? child.nodeSize : 1 })
42
+ return length
43
+ }
44
+
45
+ function inlineOffset(content: ProseMirrorNode, position: number): number {
46
+ let offset = 0, start = 0
47
+ for (let index = 0; index < content.childCount; index++) {
48
+ const child = content.child(index)
49
+ if (child.isText && position <= start + child.nodeSize) return offset + Math.max(0, position - start)
50
+ // Inside an inline node is its start.
51
+ if (!child.isText && position < start + child.nodeSize) return offset
52
+ offset += child.isText ? child.nodeSize : 1
53
+ start += child.nodeSize
54
+ }
55
+ return offset
56
+ }
57
+
58
+ function inlinePosition(content: ProseMirrorNode, offset: number): number {
59
+ let remaining = Math.max(0, offset), start = 0
60
+ for (let index = 0; index < content.childCount; index++) {
61
+ const child = content.child(index), length = child.isText ? child.nodeSize : 1
62
+ if (remaining === 0 || child.isText && remaining <= length) return start + remaining
63
+ remaining -= length
64
+ start += child.nodeSize
65
+ }
66
+ return start
67
+ }
68
+
69
+ /** A native range as product text: breaks as line breaks, other inline nodes as one object character. */
70
+ export function nativeProductText(document: ProseMirrorNode, from: number, to: number): string {
71
+ let text = '', started = false
72
+ document.nodesBetween(from, to, (node, position) => {
73
+ if (node.isTextblock) { if (started) text += '\n'; started = true; return true }
74
+ if (node.isText) { text += node.text!.slice(Math.max(from, position) - position, to - position); return false }
75
+ if (node.isInline) { if (position >= from) text += node.type.name === 'hardBreak' ? '\n' : '\uFFFC'; return false }
76
+ return true
77
+ })
78
+ return text
79
+ }
80
+
81
+ // Native cells contain paragraphs; the product flattens their boundaries to one break.
82
+ function cellTextOffset(cell: ProseMirrorNode, position: number): number {
83
+ let offset = 0, before = 0
84
+ for (let index = 0; index < cell.childCount; index++) {
85
+ const paragraph = cell.child(index), start = before + 1
86
+ if (position <= start + paragraph.content.size) return offset + inlineOffset(paragraph, Math.max(0, position - start))
87
+ offset += inlineLength(paragraph) + 1
88
+ before += paragraph.nodeSize
89
+ }
90
+ return Math.max(0, offset - 1)
91
+ }
92
+
93
+ function cellTextPosition(cell: ProseMirrorNode, offset: number): number {
94
+ let remaining = Math.max(0, offset), before = 0, end = 1
95
+ for (let index = 0; index < cell.childCount; index++) {
96
+ const paragraph = cell.child(index), start = before + 1, length = inlineLength(paragraph)
97
+ end = start + paragraph.content.size
98
+ if (remaining <= length) return start + inlinePosition(paragraph, remaining)
99
+ remaining -= length + 1
100
+ before += paragraph.nodeSize
101
+ }
102
+ return end
103
+ }
104
+
105
+ export function nativeTextPoint(editor: NativeDocumentEditor, position: number): NativeTextPoint | undefined {
106
+ const state = editor.prosemirrorState, resolved = state.doc.resolve(position)
107
+ for (let depth = resolved.depth; depth > 0; depth--) {
108
+ const node = resolved.node(depth)
109
+ if ((node.type.name === 'tableCell' || node.type.name === 'tableHeader') && typeof node.attrs[TABLE_ENTITY_ATTRIBUTE] === 'string') {
110
+ return { blockId: node.attrs[TABLE_ENTITY_ATTRIBUTE] as string, offset: cellTextOffset(node, position - resolved.start(depth)), contentKind: 'table' }
111
+ }
112
+ }
113
+ const info = getBlockInfoNearPos(state, position)
114
+ if (!info.hasContent || info.contentKind === 'none') return undefined
115
+ const relative = Math.max(0, Math.min(position - info.contentStart, info.contentEnd - info.contentStart))
116
+ return { blockId: getNodeId(info.block.node, state.doc), offset: info.contentKind === 'inline' ? inlineOffset(info.content.node, relative) : relative, contentKind: info.contentKind }
117
+ }
118
+
119
+ export function nativeTextPosition(editor: NativeDocumentEditor, point: { blockId: string, offset: number }): number | undefined {
120
+ return nativeTextPositionInDocument(editor.prosemirrorState.doc, point)
121
+ }
122
+
123
+ /** Stable anchor lookup against a captured native transaction’s input document. */
124
+ export function nativeTextPositionInDocument(document: ProseMirrorNode, point: { blockId: string, offset: number }): number | undefined {
125
+ let position: number | undefined
126
+ document.descendants((node, beforePos) => {
127
+ if ((node.type.name === 'tableCell' || node.type.name === 'tableHeader') && node.attrs[TABLE_ENTITY_ATTRIBUTE] === point.blockId) {
128
+ position = beforePos + 1 + cellTextPosition(node, point.offset)
129
+ return false
130
+ }
131
+ if (node.attrs.id !== point.blockId) return
132
+ const info = getBlockInfoFromNode(node, beforePos)
133
+ if (info.hasContent) position = info.contentStart + (info.contentKind === 'inline' ? inlinePosition(info.content.node, point.offset) : Math.max(0, Math.min(point.offset, info.contentEnd - info.contentStart)))
134
+ return false
135
+ })
136
+ return position
137
+ }
138
+
139
+ export function documentSelection(editor: NativeDocumentEditor): NativeDocumentSelection | undefined {
140
+ const state = editor.prosemirrorState
141
+ if (state.selection.empty) return undefined
142
+ const selected = editor.getSelection()
143
+ if (!selected?.blocks.length || !editor.getSelectedText().trim()) return undefined
144
+ const from = nativeTextPoint(editor, state.selection.from), to = nativeTextPoint(editor, state.selection.to)
145
+ if (!from || !to) return undefined
146
+ return {
147
+ blockIds: selected.blocks.map(block => block.id),
148
+ text: state.doc.textBetween(state.selection.from, state.selection.to, '\n'),
149
+ from,
150
+ to,
151
+ }
152
+ }
@@ -0,0 +1,9 @@
1
+ .pm-document-editor { min-width: 0; }
2
+ .pm-document-editor__file-errors { display: flex; align-items: flex-start; gap: var(--pm-space-2); }
3
+ .pm-document-editor__file-errors ul { flex: 1; min-width: 0; margin: 0; padding-inline-start: var(--pm-space-4); overflow-wrap: anywhere; }
4
+ .pm-document-editor .pm-document-editor__page-title { margin-block: var(--pm-space-5) var(--pm-space-3); font-family: var(--pm-font-sans); font-size: var(--pm-text-title); font-weight: var(--pm-weight-semibold); line-height: var(--pm-leading-tight); }
5
+ .pm-document-editor__atom { flex: 1; width: 100%; min-width: 0; }
6
+ .pm-document-editor .bn-block-content[data-content-type='presmiumAtom'] > * { flex: 1; width: 100%; min-width: 0; }
7
+ .pm-document-editor__fallback { display: grid; gap: var(--pm-space-2); min-width: 0; }
8
+ .pm-document-editor__fallback p { margin: 0; white-space: pre-wrap; }
9
+ .pm-document-editor__comment-tools { position: sticky; top: var(--pm-space-2); z-index: var(--pm-z-site-header); width: fit-content; margin-inline-start: var(--pm-space-5); }
@@ -0,0 +1 @@
1
+ declare module '*.css'
@@ -0,0 +1,67 @@
1
+ import { createExtension, getBlockInfoNearPos, getNodeId } from '@blocknote/core'
2
+ import { Extension } from '@tiptap/core'
3
+ import { Plugin } from '@tiptap/pm/state'
4
+ import type { NativeDocumentEditor } from './schema.tsx'
5
+ import type { RichProjection } from './rich-text.ts'
6
+
7
+ export const TABLE_ENTITY_ATTRIBUTE = 'presmiumEntityId'
8
+ const ATTR = TABLE_ENTITY_ATTRIBUTE
9
+ const TYPES = ['tableRow', 'tableCell', 'tableHeader']
10
+
11
+ /** Public schema attributes give native table transactions stable product identities. */
12
+ export const tableIdentityExtension = createExtension({
13
+ key: 'presmiumTableIdentities',
14
+ tiptapExtensions: [Extension.create({
15
+ name: 'presmiumTableEntityAttributes',
16
+ addGlobalAttributes: () => [{ types: TYPES, attributes: {
17
+ [ATTR]: { default: null, parseHTML: element => element.getAttribute('data-presmium-entity-id'), renderHTML: attributes => attributes[ATTR] ? { 'data-presmium-entity-id': attributes[ATTR] } : {} },
18
+ } }],
19
+ })],
20
+ prosemirrorPlugins: [new Plugin({
21
+ appendTransaction(transactions, _before, after) {
22
+ if (!transactions.some(transaction => transaction.docChanged)) return
23
+ const transaction = after.tr, used = new Set<string>()
24
+ after.doc.descendants((node, position) => {
25
+ if (!TYPES.includes(node.type.name)) return
26
+ let id = node.attrs[ATTR] as string | null
27
+ if (!id || used.has(id)) { id = crypto.randomUUID(); transaction.setNodeMarkup(position, undefined, { ...node.attrs, [ATTR]: id }) }
28
+ used.add(id)
29
+ })
30
+ if (transaction.steps.length) return transaction.setMeta('addToHistory', false)
31
+ },
32
+ })],
33
+ })
34
+
35
+ export function seedTableIdentities(editor: NativeDocumentEditor, context: RichProjection): void {
36
+ const state = editor.prosemirrorState, updates: { position: number, id: string }[] = []
37
+ state.doc.descendants((node, position) => {
38
+ if (node.type.name !== 'table') return
39
+ const info = getBlockInfoNearPos(state, position), original = context.rich.get(getNodeId(info.block.node, state.doc))?.original
40
+ if (original?.type !== 'table') return false
41
+ node.forEach((row, rowOffset, rowIndex) => {
42
+ const source = original.children[rowIndex]
43
+ if (!source) return
44
+ updates.push({ position: position + 1 + rowOffset, id: source.id })
45
+ row.forEach((_cell, cellOffset, cellIndex) => { if (source.children[cellIndex]) updates.push({ position: position + 2 + rowOffset + cellOffset, id: source.children[cellIndex]!.id }) })
46
+ })
47
+ return false
48
+ })
49
+ if (!updates.length) return
50
+ editor.transact(transaction => {
51
+ for (const { position, id } of updates) { const node = transaction.doc.nodeAt(position)!; transaction.setNodeMarkup(position, undefined, { ...node.attrs, [ATTR]: id }) }
52
+ transaction.setMeta('addToHistory', false)
53
+ })
54
+ }
55
+
56
+ export function captureTableIdentities(editor: NativeDocumentEditor, context: RichProjection): void {
57
+ const state = editor.prosemirrorState
58
+ context.tables.clear()
59
+ state.doc.descendants((node, position) => {
60
+ if (node.type.name !== 'table') return
61
+ const info = getBlockInfoNearPos(state, position), tableId = getNodeId(info.block.node, state.doc)
62
+ const rows: { id: string, cells: string[] }[] = []
63
+ node.forEach(row => { const cells: string[] = []; row.forEach(cell => cells.push(cell.attrs[ATTR] as string)); rows.push({ id: row.attrs[ATTR] as string, cells }) })
64
+ context.tables.set(tableId, rows)
65
+ return false
66
+ })
67
+ }
@@ -0,0 +1,112 @@
1
+ import { WorkspaceServerError } from './workspace-client.ts'
2
+ import type { WorkspaceClient, WorkspaceRequestEvent } from './workspace-client.ts'
3
+ import type { IrAnswer, PreviewAnswer, PreviewRenderer } from '@lincyaw/kernel'
4
+
5
+ export interface DocumentPreview { address: string, revision: string, preview: PreviewAnswer }
6
+ export interface CompiledDocumentPreview extends DocumentPreview { ir: IrAnswer }
7
+ interface PreviewSource { directory: string, version: string, fingerprint: string, project: number }
8
+ interface Prepared { work: Promise<DocumentPreview>, preparing: boolean, listeners: Set<() => void> }
9
+ const prepared = new WeakMap<WorkspaceClient, Map<string, Prepared>>()
10
+ const PREPARING_MAX_DELAY_MS = 5000
11
+
12
+ /** The server is still building what the preview needs (the viewer app, the document's code) and asks to be asked again. */
13
+ export function isPreviewPreparing(error: unknown): error is WorkspaceServerError {
14
+ return error instanceof WorkspaceServerError && error.status === 503 && error.answer.error === 'preview-preparing'
15
+ }
16
+
17
+ /**
18
+ * The document's preview addresses. While the server is still preparing them it is asked again, sooner first, until they
19
+ * are ready, `signal` aborts or `waiting()` says the answer is no longer wanted.
20
+ */
21
+ export async function readPreviewWhenReady(client: WorkspaceClient, directory: string, renderer: PreviewRenderer, options: { signal?: AbortSignal, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner'], onPreparing?(): void, waiting?(): boolean } = {}): Promise<PreviewAnswer> {
22
+ const { signal, feedbackOwner } = options
23
+ let timeouts = 0
24
+ for (let attempt = 0; ; attempt++) {
25
+ signal?.throwIfAborted()
26
+ const request = new AbortController(), timeout = setTimeout(() => request.abort(new DOMException('Preview preparation timed out', 'TimeoutError')), 45_000)
27
+ const cancel = () => request.abort(signal?.reason)
28
+ signal?.addEventListener('abort', cancel, { once: true })
29
+ try {
30
+ return await client.readPreview(directory, renderer, request.signal, feedbackOwner)
31
+ } catch (error) {
32
+ // A busy server (building on a slow machine) may not answer in time: asked again twice before it counts as a failure.
33
+ const timedOut = request.signal.reason instanceof DOMException && request.signal.reason.name === 'TimeoutError' && !signal?.aborted
34
+ if (!isPreviewPreparing(error) && !(timedOut && ++timeouts < 3) || options.waiting?.() === false) throw error
35
+ if (!timedOut) timeouts = 0
36
+ options.onPreparing?.()
37
+ const delay = Math.min((isPreviewPreparing(error) ? error.answer.retryAfterMs ?? 1000 : 1000) * 2 ** Math.min(attempt, 3), PREPARING_MAX_DELAY_MS)
38
+ await waitForPreviewRequest(new Promise<void>(resolve => setTimeout(resolve, delay)), signal)
39
+ } finally { clearTimeout(timeout); signal?.removeEventListener('abort', cancel) }
40
+ }
41
+ }
42
+
43
+ export function waitForPreviewRequest<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
44
+ if (!signal) return work
45
+ return new Promise<T>((resolve, reject) => {
46
+ const cancel = () => { signal.removeEventListener('abort', cancel); reject(signal.reason) }
47
+ if (signal.aborted) { work.catch(() => {}); cancel(); return }
48
+ signal.addEventListener('abort', cancel, { once: true })
49
+ work.then(value => { signal.removeEventListener('abort', cancel); resolve(value) }, error => { signal.removeEventListener('abort', cancel); reject(error) })
50
+ })
51
+ }
52
+
53
+ /** Speech preflights need a confirmed source snapshot without building a visual output. */
54
+ export async function readDocumentSnapshot(client: WorkspaceClient, source: Pick<PreviewSource, 'directory' | 'version' | 'fingerprint'>, signal?: AbortSignal): Promise<IrAnswer> {
55
+ const entry = source.directory ? `${source.directory}/lecture.json` : 'lecture.json', started = Date.now()
56
+ for (;;) {
57
+ signal?.throwIfAborted()
58
+ const ir = await waitForPreviewRequest(client.readIr(source.directory), signal)
59
+ if (ir.sourceFingerprints?.[entry] === source.fingerprint && ir.doc.source?.version === source.version && ir.doc.source.fingerprint === source.fingerprint) return ir
60
+ if (Date.now() - started > 15_000) throw new Error('voice-source-pending')
61
+ await waitForPreviewRequest(new Promise<void>(resolve => setTimeout(resolve, 150)), signal)
62
+ }
63
+ }
64
+
65
+ /** The explicit request owner can abort while the compiler confirms one exact source snapshot. */
66
+ export async function compileDocumentPreview(client: WorkspaceClient, source: PreviewSource, signal?: AbortSignal, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner'], onPreparing?: () => void): Promise<CompiledDocumentPreview> {
67
+ const { directory, fingerprint } = source, entry = directory ? `${directory}/lecture.json` : 'lecture.json'
68
+ let started = Date.now()
69
+ for (;;) {
70
+ signal?.throwIfAborted()
71
+ const ir = await waitForPreviewRequest(client.readIr(directory), signal)
72
+ if (ir.sourceFingerprints?.[entry] === fingerprint && ir.doc.source?.version === source.version && ir.doc.source.fingerprint === fingerprint) {
73
+ const preview = await readPreviewWhenReady(client, directory, 'web', { signal, feedbackOwner, onPreparing })
74
+ // Time spent while the server prepared the preview does not count against the source's confirmation.
75
+ started = Math.max(started, Date.now() - 1000)
76
+ const confirmed = await waitForPreviewRequest(client.readIr(directory), signal)
77
+ if (confirmed.sourceFingerprints?.[entry] !== fingerprint || confirmed.doc.source?.version !== source.version || confirmed.doc.source.fingerprint !== fingerprint) throw new Error('presentation-source-changed')
78
+ if (confirmed.revision === ir.revision) return { address: preview.web, revision: confirmed.revision, preview, ir: confirmed }
79
+ }
80
+ if (Date.now() - started > 15_000) throw new Error('document-preview-source-pending')
81
+ await waitForPreviewRequest(new Promise<void>(resolve => setTimeout(resolve, 150)), signal)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Visible widgets share lightweight references, without retaining old IR payloads or another event stream. `onPreparing`
87
+ * hears that the server is still building what the preview needs; the preview follows once it is ready.
88
+ */
89
+ export function prepareDocumentPreview(client: WorkspaceClient, source: PreviewSource, retry = false, onPreparing?: () => void): Promise<DocumentPreview> {
90
+ let cache = prepared.get(client)
91
+ if (!cache) prepared.set(client, cache = new Map())
92
+ const { directory, version, fingerprint, project } = source
93
+ const key = JSON.stringify([directory, version, fingerprint, project])
94
+ if (retry) cache.delete(key)
95
+ let entry = cache.get(key)
96
+ if (!entry) {
97
+ const created: Prepared = { work: undefined!, preparing: false, listeners: new Set() }
98
+ created.work = compileDocumentPreview(client, source, undefined, 'inline-preview', () => {
99
+ created.preparing = true
100
+ for (const listener of created.listeners) listener()
101
+ }).then(({ address, revision, preview }) => ({ address, revision, preview }))
102
+ entry = created
103
+ cache.set(key, entry)
104
+ created.work.catch(() => { if (cache?.get(key) === created) cache.delete(key) }).finally(() => created.listeners.clear())
105
+ while (cache.size > 8) cache.delete(cache.keys().next().value!)
106
+ }
107
+ if (onPreparing) {
108
+ if (entry.preparing) onPreparing()
109
+ entry.listeners.add(onPreparing)
110
+ }
111
+ return entry.work
112
+ }
@@ -0,0 +1,59 @@
1
+ import type { TeachingDocument } from '@lincyaw/kernel'
2
+
3
+ export interface DocumentReturnPoint {
4
+ sectionId: string
5
+ stepId?: string
6
+ offset?: number
7
+ }
8
+
9
+ /** A browser return address, never document content or an instruction to open another document. */
10
+ export class DocumentReturnPoints {
11
+ readonly #memory = new Map<string, DocumentReturnPoint>()
12
+
13
+ read(identity: string): DocumentReturnPoint | undefined {
14
+ const cached = this.#memory.get(identity)
15
+ if (cached) return cached
16
+ try {
17
+ const value: unknown = JSON.parse(sessionStorage.getItem(`presmium.document-position.v1:${identity}`) ?? 'null')
18
+ if (!value || typeof value !== 'object' || !('sectionId' in value) || typeof value.sectionId !== 'string') return
19
+ const stepId = 'stepId' in value ? value.stepId : undefined, offset = 'offset' in value ? value.offset : undefined
20
+ if (stepId !== undefined && typeof stepId !== 'string' || offset !== undefined && (typeof offset !== 'number' || !Number.isFinite(offset))) return
21
+ const point: DocumentReturnPoint = { sectionId: value.sectionId,
22
+ ...stepId !== undefined ? { stepId } : {}, ...offset !== undefined ? { offset } : {} }
23
+ this.#memory.set(identity, point)
24
+ return point
25
+ } catch { return }
26
+ }
27
+
28
+ write(identity: string, point: DocumentReturnPoint): void {
29
+ const previous = this.#memory.get(identity)
30
+ if (previous?.sectionId === point.sectionId && previous.stepId === point.stepId && previous.offset === point.offset) return
31
+ this.#memory.set(identity, point)
32
+ try { sessionStorage.setItem(`presmium.document-position.v1:${identity}`, JSON.stringify(point)) } catch { /* Editing does not depend on position storage. */ }
33
+ }
34
+
35
+ forget(identity: string): void {
36
+ this.#memory.delete(identity)
37
+ try { sessionStorage.removeItem(`presmium.document-position.v1:${identity}`) } catch { /* Editing remains available. */ }
38
+ }
39
+ }
40
+
41
+ export function documentReturnStep(document: TeachingDocument, point: DocumentReturnPoint): number | undefined {
42
+ const section = document.sections[point.sectionId]
43
+ if (!section || !document.sectionOrder.includes(point.sectionId)) return
44
+ if (point.stepId === undefined) return section.stepOrder.length === 0 ? 0 : undefined
45
+ const index = section.stepOrder.indexOf(point.stepId)
46
+ return index >= 0 && document.steps[point.stepId]?.sectionId === point.sectionId ? index : undefined
47
+ }
48
+
49
+ export function documentViewportOffset(element: HTMLElement, sectionId: string): number | undefined {
50
+ if (!element.clientHeight) return
51
+ const heading = element.querySelector<HTMLElement>(`[data-document-section="${CSS.escape(sectionId)}"]`)
52
+ return heading ? element.getBoundingClientRect().top - heading.getBoundingClientRect().top : undefined
53
+ }
54
+
55
+ export function restoreDocumentViewport(element: HTMLElement, point: DocumentReturnPoint): void {
56
+ if (!element.clientHeight) return
57
+ const heading = element.querySelector<HTMLElement>(`[data-document-section="${CSS.escape(point.sectionId)}"]`)
58
+ if (heading) element.scrollTop += heading.getBoundingClientRect().top - element.getBoundingClientRect().top + (point.offset ?? 0)
59
+ }