@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.
- package/README.md +40 -8
- package/package.json +27 -22
- 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 +547 -90
- 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 +33 -43
- 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 +751 -203
- 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 +456 -44
- 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 +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- 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 +757 -361
- 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
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- 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
|
+
}
|