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