@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +66 -2
- package/package.json +68 -3
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +631 -0
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +36 -0
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +781 -0
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +462 -0
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +1025 -0
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, ClientCommentDraftMetadata } from '@lincyaw/kernel'
|
|
2
|
+
import { isNewCommentDraft } from './comment-drafts.ts'
|
|
3
|
+
import { authoringDraftFragment, draftContentHash as hash } from '../authoring-draft-fragment.ts'
|
|
4
|
+
import type { CommentDraftCandidate, CommentDrafts } from './comment-drafts.ts'
|
|
5
|
+
|
|
6
|
+
export const commentDraftReference = (value: CommentDraftCandidate) => ({ id: value.id, revision: value.revision, token: value.token })
|
|
7
|
+
|
|
8
|
+
/** Only these fields may leave the client during inspection. Target signatures and baselines contain prose. */
|
|
9
|
+
export function commentDraftMetadata(value: CommentDraftCandidate, drafts: CommentDrafts): ClientCommentDraftMetadata {
|
|
10
|
+
const target = value.target
|
|
11
|
+
return { draft: commentDraftReference(value), kind: isNewCommentDraft(target) ? 'create' : target.commentId ? 'edit' : 'reply', storage: value.storage,
|
|
12
|
+
updatedAt: value.updatedAt, baseVersion: value.version, composing: value.composing, pendingSubmission: Boolean(value.pending), ownersKnown: drafts.ownersKnown, active: drafts.activeRecord(value), discardable: !value.composing && !value.pending && !drafts.targetActive(value.target),
|
|
13
|
+
...isNewCommentDraft(target) ? {} : { threadId: target.threadId, ...target.commentId ? { commentId: target.commentId } : {} } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function inspectCommentDrafts(drafts: CommentDrafts, action: Extract<AuthoringClientAction, { type: 'comment-drafts.inspect' | 'comment-drafts.read' }>, signal: AbortSignal, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
17
|
+
const read = await drafts.readForAgent(signal, action.type === 'comment-drafts.read' ? action.draft : undefined)
|
|
18
|
+
const current = () => { signal.throwIfAborted(); if (drafts.revision !== read.revision) throw new Error('client-comment-drafts-changed') }
|
|
19
|
+
if (action.type === 'comment-drafts.inspect') {
|
|
20
|
+
const values = await Promise.all(read.candidates.map(async value => { const restore = await drafts.restoreChoice(value, signal); return { ...commentDraftMetadata(value, drafts), ...restore ? { restore } : {} } })), token = await hash(JSON.stringify({ values, coverage: read.coverage, skippedInvalid: read.skippedInvalid }))
|
|
21
|
+
current()
|
|
22
|
+
if (action.cursor && action.cursor.token !== token) throw new Error('client-comment-drafts-changed')
|
|
23
|
+
const start = action.cursor ? values.findIndex(value => value.draft.id === action.cursor!.after) + 1 : 0
|
|
24
|
+
if (action.cursor && start === 0) throw new Error('client-comment-drafts-changed')
|
|
25
|
+
const result: Extract<AuthoringClientActionResult, { type: 'comment-drafts.inspect' }> = { type: action.type, family: 'comment', coverage: read.coverage, skippedInvalid: read.skippedInvalid, token, drafts: [] }
|
|
26
|
+
for (const value of values.slice(start, start + (action.limit ?? 10))) {
|
|
27
|
+
const next = { ...result, drafts: [...result.drafts, value], ...start + result.drafts.length + 1 < values.length ? { next: { after: value.draft.id, token } } : {} }
|
|
28
|
+
if (!fits(next)) break
|
|
29
|
+
result.drafts.push(value)
|
|
30
|
+
}
|
|
31
|
+
if (start < values.length && !result.drafts.length) throw new Error('client-comment-metadata-too-large')
|
|
32
|
+
if (start + result.drafts.length < values.length) result.next = { after: result.drafts.at(-1)!.draft.id, token }
|
|
33
|
+
if (!fits(result)) throw new Error('client-comment-metadata-too-large')
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
const value = read.candidates[0]!
|
|
37
|
+
return authoringDraftFragment({ text: JSON.stringify(value.content), base: { type: action.type, family: 'comment', draft: commentDraftReference(value), storage: value.storage, format: 'presmium-rich-text-json-v1' }, offset: action.offset, maxChars: action.maxChars }, current, fits)
|
|
38
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { documentCommentTargets, isRecord, validateDocument, validateDocumentCommentAnchor } from '@lincyaw/kernel'
|
|
2
|
+
import type { DocumentComment, DocumentThread, RichTextNode, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { DocumentDraftStore } from '../document-draft-store.ts'
|
|
4
|
+
import type { DraftStoreResult } from '../document-draft-store.ts'
|
|
5
|
+
|
|
6
|
+
export interface CommentDraftTarget { threadId: string, commentId?: string, threadCreatedAt: string, commentCreatedAt?: string, baseline?: string }
|
|
7
|
+
export interface NewCommentDraftTarget { kind: 'create', intentId: string, documentId: string, anchor?: DocumentThread['anchor'], signature?: string }
|
|
8
|
+
export type CommentComposerTarget = CommentDraftTarget | NewCommentDraftTarget
|
|
9
|
+
export const isNewCommentDraft = (target: CommentComposerTarget): target is NewCommentDraftTarget => 'kind' in target && target.kind === 'create'
|
|
10
|
+
export function newCommentDraftSignature(document: TeachingDocument, anchor: DocumentThread['anchor']): string | undefined {
|
|
11
|
+
if (!anchor) return
|
|
12
|
+
try {
|
|
13
|
+
validateDocumentCommentAnchor(document, anchor)
|
|
14
|
+
const targets = documentCommentTargets(document), from = targets.get(anchor.from.blockId)!, to = targets.get(anchor.to.blockId)!
|
|
15
|
+
return JSON.stringify([...targets].filter(([, value]) => value.order >= from.order && value.order <= to.order).map(([id, value]) => ({ id, text: value.text })))
|
|
16
|
+
} catch { return }
|
|
17
|
+
}
|
|
18
|
+
export interface CommentDraftReference { id: string, revision: number, token: string }
|
|
19
|
+
export interface CommentDraftRecord {
|
|
20
|
+
format: 1
|
|
21
|
+
scope: string
|
|
22
|
+
id: string
|
|
23
|
+
owner: string
|
|
24
|
+
revision: number
|
|
25
|
+
updatedAt: number
|
|
26
|
+
target: CommentComposerTarget
|
|
27
|
+
version: string
|
|
28
|
+
content: RichTextNode[]
|
|
29
|
+
composing: boolean
|
|
30
|
+
pending?: { commentId: string, value: string, threadId?: string, threadCreatedAt?: string }
|
|
31
|
+
}
|
|
32
|
+
export interface CommentDraftCandidate extends CommentDraftRecord { token: string, storage: 'durable' | 'session' }
|
|
33
|
+
export interface CommentDraftState { candidates: readonly CommentDraftCandidate[], pending: boolean, failed: boolean, reading: boolean }
|
|
34
|
+
export interface CommentDraftOperation { signal?: AbortSignal, guard?(): void }
|
|
35
|
+
export interface CommentDraftComposer {
|
|
36
|
+
id: string
|
|
37
|
+
target: CommentComposerTarget
|
|
38
|
+
snapshot(): { record?: CommentDraftRecord, composing: boolean, available: boolean }
|
|
39
|
+
targetState(): 'current' | 'changed' | 'unavailable'
|
|
40
|
+
restore?(candidate: CommentDraftCandidate, operation: CommentDraftOperation): Promise<CommentDraftRecord | undefined>
|
|
41
|
+
}
|
|
42
|
+
interface Writing { record: CommentDraftRecord, raw: string, store: DocumentDraftStore, generation: number, pending: boolean, failed: boolean, done: Promise<DraftStoreResult>, clearing?: boolean }
|
|
43
|
+
const PREFIX = 'presmium.comment-draft.v1:'
|
|
44
|
+
const SETTLED = 'presmium.comment-draft-settled.v1:'
|
|
45
|
+
const MAX_CHARS = 1 << 20
|
|
46
|
+
const prefix = (scope: string) => `${PREFIX}${JSON.stringify(scope)}:`
|
|
47
|
+
const markerPrefix = (scope: string) => `${SETTLED}[${JSON.stringify(scope)},`
|
|
48
|
+
const keyOf = (scope: string, id: string) => `${prefix(scope)}${id}`
|
|
49
|
+
const markerOf = (scope: string, value: CommentDraftReference) => `${SETTLED}${JSON.stringify([scope, value.id, value.revision, value.token])}`
|
|
50
|
+
export const commentDraftBaseline = (comment: DocumentComment) => JSON.stringify({ userId: comment.userId, content: comment.content })
|
|
51
|
+
const committedValue = (comment: DocumentComment) => JSON.stringify({ id: comment.id, userId: comment.userId, createdAt: comment.createdAt, updatedAt: comment.updatedAt, content: comment.content })
|
|
52
|
+
export const sameCommentDraftTarget = (a: CommentComposerTarget, b: CommentComposerTarget) => isNewCommentDraft(a) || isNewCommentDraft(b)
|
|
53
|
+
? isNewCommentDraft(a) && isNewCommentDraft(b) && a.documentId === b.documentId && a.signature === b.signature && JSON.stringify(a.anchor) === JSON.stringify(b.anchor)
|
|
54
|
+
: a.threadId === b.threadId && a.commentId === b.commentId && a.threadCreatedAt === b.threadCreatedAt && a.commentCreatedAt === b.commentCreatedAt
|
|
55
|
+
async function tokenOf(raw: string): Promise<string> {
|
|
56
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw))
|
|
57
|
+
return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join('')
|
|
58
|
+
}
|
|
59
|
+
const point = (value: unknown): boolean => isRecord(value) && typeof value.blockId === 'string' && value.blockId.length > 0 && value.blockId.length <= 205 && Number.isSafeInteger(value.offset) && Number(value.offset) >= 0
|
|
60
|
+
function targetShape(value: unknown): boolean {
|
|
61
|
+
if (!isRecord(value)) return false
|
|
62
|
+
if (value.kind === 'create') return typeof value.intentId === 'string' && value.intentId.length > 0 && value.intentId.length <= 200
|
|
63
|
+
&& typeof value.documentId === 'string' && value.documentId.length > 0 && value.documentId.length <= 200
|
|
64
|
+
&& (value.anchor === undefined && value.signature === undefined || isRecord(value.anchor) && point(value.anchor.from) && point(value.anchor.to) && typeof value.signature === 'string')
|
|
65
|
+
return typeof value.threadId === 'string' && typeof value.threadCreatedAt === 'string'
|
|
66
|
+
&& (value.commentId === undefined || typeof value.commentId === 'string' && typeof value.commentCreatedAt === 'string' && typeof value.baseline === 'string')
|
|
67
|
+
}
|
|
68
|
+
function valid(value: unknown, scope: string): value is CommentDraftRecord {
|
|
69
|
+
return isRecord(value) && value.format === 1 && value.scope === scope && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200
|
|
70
|
+
&& typeof value.owner === 'string' && value.owner.length > 0 && value.owner.length <= 200 && Number.isSafeInteger(value.revision) && Number(value.revision) > 0
|
|
71
|
+
&& Number.isSafeInteger(value.updatedAt) && Number(value.updatedAt) >= 0 && typeof value.version === 'string' && value.version.length > 0 && value.version.length <= 200
|
|
72
|
+
&& targetShape(value.target) && Array.isArray(value.content) && value.content.length > 0 && value.content.every(node => isRecord(node) && typeof node.id === 'string' && typeof node.type === 'string')
|
|
73
|
+
&& typeof value.composing === 'boolean' && (value.pending === undefined || isRecord(value.pending) && typeof value.pending.commentId === 'string' && typeof value.pending.value === 'string'
|
|
74
|
+
&& (value.pending.threadId === undefined || typeof value.pending.threadId === 'string' && typeof value.pending.threadCreatedAt === 'string'))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Native composer backups use the document backup owner's strict transactions, never the source writer. */
|
|
78
|
+
export class CommentDrafts {
|
|
79
|
+
readonly #scope: string
|
|
80
|
+
readonly #owner = crypto.randomUUID()
|
|
81
|
+
#reader?: DocumentDraftStore
|
|
82
|
+
readonly #listeners = new Set<() => void>()
|
|
83
|
+
readonly #writing = new Map<string, Writing>()
|
|
84
|
+
readonly #composers = new Map<string, CommentDraftComposer>()
|
|
85
|
+
#state: CommentDraftState = { candidates: [], pending: false, failed: false, reading: true }
|
|
86
|
+
#closed = false
|
|
87
|
+
#started = false
|
|
88
|
+
#read = 0
|
|
89
|
+
#document?: TeachingDocument
|
|
90
|
+
#version?: string
|
|
91
|
+
#acknowledged = false
|
|
92
|
+
#revision = 0
|
|
93
|
+
#settling = 0
|
|
94
|
+
#unregisteredComposers = 0
|
|
95
|
+
readonly #changed?: () => void
|
|
96
|
+
#readFailure = false
|
|
97
|
+
#admissionFailure = false
|
|
98
|
+
constructor(identity: string, memberId: string, changed?: () => void) {
|
|
99
|
+
this.#scope = JSON.stringify([identity, memberId])
|
|
100
|
+
this.#changed = changed
|
|
101
|
+
}
|
|
102
|
+
registerComposer(composer: CommentDraftComposer): () => void {
|
|
103
|
+
if (this.#closed) return () => {}
|
|
104
|
+
if (this.#composers.size >= 64 || this.#composers.has(composer.id)) {
|
|
105
|
+
this.#unregisteredComposers++; this.#touch()
|
|
106
|
+
let released = false
|
|
107
|
+
return () => { if (!released) { released = true; if (!this.#closed) { this.#unregisteredComposers--; this.#touch() } } }
|
|
108
|
+
}
|
|
109
|
+
this.#composers.set(composer.id, composer); this.#touch()
|
|
110
|
+
return () => { if (this.#composers.get(composer.id) === composer) { this.#composers.delete(composer.id); this.#touch() } }
|
|
111
|
+
}
|
|
112
|
+
composer(id: string): CommentDraftComposer | undefined { return this.#closed ? undefined : this.#composers.get(id) }
|
|
113
|
+
get ownersKnown(): boolean { return !this.#closed && this.#unregisteredComposers === 0 }
|
|
114
|
+
hasRestoreComposer(): boolean { return this.ownersKnown && [...this.#composers.values()].some(value => value.restore && value.snapshot().available) }
|
|
115
|
+
targetActive(target: CommentComposerTarget): boolean { return !this.ownersKnown || [...this.#composers.values()].some(value => sameCommentDraftTarget(value.target, target) && value.snapshot().available) }
|
|
116
|
+
activeRecord(record: CommentDraftRecord): boolean { return [...this.#composers.values()].some(value => value.snapshot().record?.id === record.id && value.snapshot().record?.revision === record.revision && value.snapshot().available) }
|
|
117
|
+
owns(record: CommentDraftRecord): boolean { return !this.#closed && this.#writing.get(record.id)?.record === record }
|
|
118
|
+
async restoreChoice(candidate: CommentDraftCandidate, signal: AbortSignal): Promise<{ composerId: string, expectedCurrent: CommentDraftReference | null } | undefined> {
|
|
119
|
+
if (!this.ownersKnown || isNewCommentDraft(candidate.target) || candidate.storage !== 'durable' || candidate.composing || candidate.pending) return
|
|
120
|
+
const matching = [...this.#composers.values()].filter(value => value.restore && sameCommentDraftTarget(value.target, candidate.target) && value.snapshot().available && !value.snapshot().composing && value.targetState() !== 'unavailable')
|
|
121
|
+
if (matching.length !== 1) return
|
|
122
|
+
const composer = matching[0]!, current = composer.snapshot().record
|
|
123
|
+
if (!current) return { composerId: composer.id, expectedCurrent: null }
|
|
124
|
+
const writing = this.#writing.get(current.id)
|
|
125
|
+
if (current.id === candidate.id || current.composing || current.pending || !writing || writing.record !== current || writing.pending || writing.failed || !writing.store.owns(keyOf(this.#scope, current.id), writing.raw)) return
|
|
126
|
+
const token = await tokenOf(writing.raw); signal.throwIfAborted()
|
|
127
|
+
if (this.#composers.get(composer.id) !== composer || composer.snapshot().record !== current || !composer.snapshot().available || composer.snapshot().composing) throw new Error('client-comment-drafts-changed')
|
|
128
|
+
return { composerId: composer.id, expectedCurrent: { id: current.id, revision: current.revision, token } }
|
|
129
|
+
}
|
|
130
|
+
get revision(): number { return this.#revision }
|
|
131
|
+
get readable(): boolean { return !this.#closed && !this.#settling && this.#started && Boolean(this.#reader && this.#document && this.#version) }
|
|
132
|
+
#touch(): void { if (!this.#closed) { this.#revision++; this.#changed?.() } }
|
|
133
|
+
start(): void {
|
|
134
|
+
if (this.#closed || this.#started) return
|
|
135
|
+
this.#started = true
|
|
136
|
+
this.#reader = new DocumentDraftStore(this.#scope, () => { this.#touch(); void this.refresh() }, { family: 'comment', owner: this.#owner })
|
|
137
|
+
this.#touch()
|
|
138
|
+
if (typeof window !== 'undefined') window.addEventListener('beforeunload', this.#leave)
|
|
139
|
+
void this.refresh()
|
|
140
|
+
}
|
|
141
|
+
readonly #leave = (event: BeforeUnloadEvent) => { if (this.#state.pending || this.#state.failed || [...this.#writing.values()].some(value => value.record.composing)) event.preventDefault() }
|
|
142
|
+
snapshot = (): CommentDraftState => this.#state
|
|
143
|
+
subscribe = (listener: () => void): (() => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener) }
|
|
144
|
+
|
|
145
|
+
/** Explicit agent reads borrow the original reader; they never settle, restore or discard a backup. */
|
|
146
|
+
async readForAgent(signal: AbortSignal, reference?: CommentDraftReference): Promise<{ revision: number, candidates: CommentDraftCandidate[], coverage: 'stored-and-session' | 'session-only', skippedInvalid: number }> {
|
|
147
|
+
signal.throwIfAborted()
|
|
148
|
+
if (!this.readable || !this.#reader) throw new Error('client-comment-drafts-unavailable')
|
|
149
|
+
const revision = this.#revision, writing = new Map(this.#writing), key = reference ? keyOf(this.#scope, reference.id) : undefined
|
|
150
|
+
const known = reference && writing.get(reference.id)
|
|
151
|
+
if (known && known.record.revision !== reference!.revision) throw new Error('client-comment-draft-changed')
|
|
152
|
+
const current = () => { signal.throwIfAborted(); if (this.#closed || revision !== this.#revision) throw new Error('client-comment-drafts-changed') }
|
|
153
|
+
let stored: Map<string, unknown>, coverage: 'stored-and-session' | 'session-only' = 'stored-and-session'
|
|
154
|
+
try { stored = await this.#reader.read(key ? [key, markerOf(this.#scope, reference!)] : [prefix(this.#scope), markerPrefix(this.#scope)], signal) }
|
|
155
|
+
catch (error) { current(); if (reference ? !known : writing.size === 0) throw new Error('client-comment-drafts-unavailable'); stored = new Map(); coverage = 'session-only' }
|
|
156
|
+
current()
|
|
157
|
+
const entries = new Map<string, { raw: unknown, storage: 'durable' | 'session' }>()
|
|
158
|
+
for (const [name, raw] of stored) if (name.startsWith(prefix(this.#scope)) && (!key || name === key)) entries.set(name, { raw, storage: 'durable' })
|
|
159
|
+
// A newer in-memory version always shadows its older IndexedDB value, including failed admission.
|
|
160
|
+
for (const [id, value] of writing) if (!reference || id === reference.id) entries.set(keyOf(this.#scope, id), { raw: value.raw, storage: stored.get(keyOf(this.#scope, id)) === value.raw ? 'durable' : 'session' })
|
|
161
|
+
const candidates: CommentDraftCandidate[] = []
|
|
162
|
+
let skippedInvalid = 0
|
|
163
|
+
for (const [name, entry] of entries) {
|
|
164
|
+
try {
|
|
165
|
+
if (typeof entry.raw !== 'string' || entry.storage === 'durable' && entry.raw.length > MAX_CHARS) throw new Error('comment-draft-data')
|
|
166
|
+
const value: unknown = JSON.parse(entry.raw)
|
|
167
|
+
if (!valid(value, this.#scope) || name !== keyOf(this.#scope, value.id) || !this.#validContent(value)) throw new Error('comment-draft-data')
|
|
168
|
+
const token = await tokenOf(entry.raw)
|
|
169
|
+
current()
|
|
170
|
+
const marked = stored.get(markerOf(this.#scope, { id: value.id, revision: value.revision, token })) === token
|
|
171
|
+
if (marked && !writing.has(value.id) || this.#isCommitted(value)) continue
|
|
172
|
+
if (reference && (value.revision !== reference.revision || token !== reference.token)) throw new Error('client-comment-draft-changed')
|
|
173
|
+
candidates.push({ ...value, token, storage: marked ? 'session' : entry.storage })
|
|
174
|
+
} catch (error) { current(); if (reference) throw new Error('client-comment-draft-changed'); skippedInvalid++ }
|
|
175
|
+
}
|
|
176
|
+
current()
|
|
177
|
+
if (reference && candidates.length !== 1) throw new Error('client-comment-draft-changed')
|
|
178
|
+
return { revision, candidates: candidates.sort((a, b) => a.id.localeCompare(b.id)), coverage, skippedInvalid }
|
|
179
|
+
}
|
|
180
|
+
#emit(patch: Partial<CommentDraftState>): void {
|
|
181
|
+
if (this.#closed) return
|
|
182
|
+
this.#state = { ...this.#state, ...patch }
|
|
183
|
+
for (const listener of this.#listeners) listener()
|
|
184
|
+
}
|
|
185
|
+
#pending(): void { this.#emit({ pending: [...this.#writing.values()].some(value => value.pending), failed: this.#readFailure || this.#admissionFailure || [...this.#writing.values()].some(value => value.failed) }) }
|
|
186
|
+
#validContent(value: CommentDraftRecord): boolean {
|
|
187
|
+
if (!this.#document) return false
|
|
188
|
+
const target = value.target, id = isNewCommentDraft(target) ? `validation:${value.id}` : target.threadId, time = isNewCommentDraft(target) ? new Date(value.updatedAt).toISOString() : target.threadCreatedAt
|
|
189
|
+
const document = { ...this.#document, comments: { [id]: { id, type: 'thread' as const, createdAt: time, updatedAt: time, resolved: false, comments: [{ id: value.id, type: 'comment' as const, userId: value.owner, createdAt: time, updatedAt: time, content: value.content, reactions: [] }] } } }
|
|
190
|
+
const result = validateDocument(document)
|
|
191
|
+
return Boolean(result.document && !result.issues.some(issue => issue.severity === 'error'))
|
|
192
|
+
}
|
|
193
|
+
async confirm(record: CommentDraftRecord): Promise<boolean> {
|
|
194
|
+
const value = this.#writing.get(record.id)
|
|
195
|
+
if (!value || value.record.revision !== record.revision || this.#closed) return false
|
|
196
|
+
const result = await value.done
|
|
197
|
+
return !this.#closed && result === 'committed' && this.#writing.get(record.id) === value && value.store.owns(keyOf(this.#scope, record.id), value.raw)
|
|
198
|
+
}
|
|
199
|
+
async refresh(): Promise<void> {
|
|
200
|
+
if (!this.#reader || this.#closed) return
|
|
201
|
+
const read = ++this.#read
|
|
202
|
+
try {
|
|
203
|
+
const values = await this.#reader.read([prefix(this.#scope), markerPrefix(this.#scope)]), candidates: CommentDraftCandidate[] = []
|
|
204
|
+
const entries: [string, unknown, 'durable' | 'session'][] = [...values].map(([key, raw]) => [key, raw, 'durable'])
|
|
205
|
+
for (const value of this.#writing.values()) if (values.get(keyOf(this.#scope, value.record.id)) !== value.raw) entries.push([keyOf(this.#scope, value.record.id), value.raw, 'session'])
|
|
206
|
+
let failed = false
|
|
207
|
+
for (const [key, raw, storage] of entries) if (key.startsWith(prefix(this.#scope))) {
|
|
208
|
+
try {
|
|
209
|
+
if (typeof raw !== 'string' || storage === 'durable' && raw.length > MAX_CHARS) throw new Error('comment-draft-size')
|
|
210
|
+
const value: unknown = JSON.parse(raw)
|
|
211
|
+
if (!valid(value, this.#scope) || key !== keyOf(this.#scope, value.id) || !this.#validContent(value)) throw new Error('comment-draft-data')
|
|
212
|
+
const token = await tokenOf(raw)
|
|
213
|
+
if (this.#closed || read !== this.#read) return
|
|
214
|
+
if (values.get(markerOf(this.#scope, { id: value.id, revision: value.revision, token })) === token) continue
|
|
215
|
+
if (this.#isCommitted(value)) { if (await this.#reader!.mark([[markerOf(this.#scope, { id: value.id, revision: value.revision, token }), token]])) this.#touch(); continue }
|
|
216
|
+
candidates.push({ ...value, token, storage })
|
|
217
|
+
} catch { failed = true }
|
|
218
|
+
}
|
|
219
|
+
if (!this.#closed && read === this.#read) { this.#readFailure = failed; this.#emit({ candidates: candidates.sort((a, b) => b.updatedAt - a.updatedAt), reading: false }); this.#pending() }
|
|
220
|
+
} catch {
|
|
221
|
+
const candidates = await Promise.all([...this.#writing.values()].map(async value => ({ ...value.record, token: await tokenOf(value.raw), storage: 'session' as const })))
|
|
222
|
+
if (!this.#closed && read === this.#read) { this.#readFailure = true; this.#emit({ candidates, reading: false }); this.#pending() }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
#isCommitted(record: CommentDraftRecord): boolean {
|
|
226
|
+
if (!this.#acknowledged || !record.pending || !this.#document || !this.#version || record.version === this.#version) return false
|
|
227
|
+
const target = record.target, created = isNewCommentDraft(target)
|
|
228
|
+
const thread = this.#document.comments?.[created ? record.pending.threadId ?? '' : target.threadId]
|
|
229
|
+
const comment = thread?.comments.find(value => value.id === record.pending!.commentId)
|
|
230
|
+
return Boolean(thread && !thread.deletedAt && thread.createdAt === (created ? record.pending.threadCreatedAt : target.threadCreatedAt)
|
|
231
|
+
&& (!created || JSON.stringify(thread.anchor) === JSON.stringify(target.anchor)) && comment && !comment.deletedAt && committedValue(comment) === record.pending.value)
|
|
232
|
+
}
|
|
233
|
+
synchronize(document: TeachingDocument, version: string | undefined, acknowledged = false): void {
|
|
234
|
+
this.#document = document; this.#version = version; this.#acknowledged = acknowledged
|
|
235
|
+
for (const value of this.#writing.values()) if (this.#isCommitted(value.record)) void this.clearOwn(value.record)
|
|
236
|
+
if (this.#started) void this.refresh()
|
|
237
|
+
}
|
|
238
|
+
write(target: CommentComposerTarget, version: string, content: RichTextNode[], composing: boolean, previous?: CommentDraftRecord): CommentDraftRecord | undefined {
|
|
239
|
+
if (this.#closed) return
|
|
240
|
+
const known = previous && this.#writing.get(previous.id)
|
|
241
|
+
if (previous && (known && known.record.revision !== previous.revision || previous.owner !== this.#owner)) return
|
|
242
|
+
const branched = known?.store.conflicted(keyOf(this.#scope, known.record.id))
|
|
243
|
+
const record: CommentDraftRecord = { format: 1, scope: this.#scope, id: !branched && known ? known.record.id : crypto.randomUUID(), owner: this.#owner,
|
|
244
|
+
revision: !branched && known ? known.record.revision + 1 : 1, updatedAt: Date.now(), target: structuredClone(target), version, content: structuredClone(content), composing }
|
|
245
|
+
return this.#put(record, !branched ? known : undefined)
|
|
246
|
+
}
|
|
247
|
+
#put(record: CommentDraftRecord, previous?: Writing): CommentDraftRecord | undefined {
|
|
248
|
+
const raw = JSON.stringify(record)
|
|
249
|
+
const store = previous?.store ?? new DocumentDraftStore(this.#scope, undefined, { family: 'comment', owner: this.#owner }), commit = raw.length <= MAX_CHARS ? store.put(keyOf(this.#scope, record.id), raw) : undefined
|
|
250
|
+
if (!commit) {
|
|
251
|
+
// Keep the latest native bytes in this Workbench even when durable admission is refused.
|
|
252
|
+
this.#writing.set(record.id, { record, raw, store, generation: 0, pending: false, failed: true, done: Promise.resolve('failed') })
|
|
253
|
+
this.#touch()
|
|
254
|
+
this.#pending(); void this.refresh()
|
|
255
|
+
return record
|
|
256
|
+
}
|
|
257
|
+
const writing: Writing = { record, raw, store, generation: commit.generation, pending: true, failed: previous?.failed ?? false, done: commit.done }
|
|
258
|
+
this.#writing.set(record.id, writing); this.#touch(); this.#pending()
|
|
259
|
+
void commit.done.then(result => {
|
|
260
|
+
if (this.#closed || this.#writing.get(record.id) !== writing) return
|
|
261
|
+
writing.pending = false
|
|
262
|
+
writing.failed = result === 'failed'; this.#admissionFailure = false; this.#pending()
|
|
263
|
+
this.#touch()
|
|
264
|
+
void this.refresh()
|
|
265
|
+
})
|
|
266
|
+
return record
|
|
267
|
+
}
|
|
268
|
+
submitted(record: CommentDraftRecord | undefined, comment: DocumentComment | undefined, thread?: DocumentThread): void {
|
|
269
|
+
if (!record || !comment || this.#closed) return
|
|
270
|
+
const value = this.#writing.get(record.id)
|
|
271
|
+
if (!value || value.record.revision !== record.revision) return
|
|
272
|
+
this.#put({ ...record, revision: record.revision + 1, pending: { commentId: comment.id, value: committedValue(comment), ...thread ? { threadId: thread.id, threadCreatedAt: thread.createdAt } : {} } }, value)
|
|
273
|
+
}
|
|
274
|
+
async clearOwn(record: CommentDraftRecord): Promise<boolean> {
|
|
275
|
+
const value = this.#writing.get(record.id)
|
|
276
|
+
if (!value || value.clearing || value.record.revision !== record.revision || record.owner !== this.#owner || this.#closed) return false
|
|
277
|
+
value.clearing = true; this.#settling++; this.#touch()
|
|
278
|
+
try {
|
|
279
|
+
const commit = value.store.remove(keyOf(this.#scope, record.id), [])
|
|
280
|
+
if (!commit) return false
|
|
281
|
+
value.pending = true; this.#pending()
|
|
282
|
+
const result = await commit.done
|
|
283
|
+
if (this.#closed || this.#writing.get(record.id) !== value) return false
|
|
284
|
+
value.pending = false
|
|
285
|
+
if (result === 'committed' && value.record.revision === record.revision) { this.#writing.delete(record.id); this.#touch(); value.store.close() }
|
|
286
|
+
else if (result === 'failed') this.#admissionFailure = true
|
|
287
|
+
this.#pending(); void this.refresh()
|
|
288
|
+
return result === 'committed'
|
|
289
|
+
} finally { value.clearing = false; this.#settling--; this.#touch() }
|
|
290
|
+
}
|
|
291
|
+
async discard(reference: CommentDraftReference, operation?: CommentDraftOperation): Promise<boolean> {
|
|
292
|
+
operation?.signal?.throwIfAborted(); operation?.guard?.()
|
|
293
|
+
if (this.#closed || !this.#reader) return false
|
|
294
|
+
this.#settling++; this.#touch()
|
|
295
|
+
try {
|
|
296
|
+
if (this.#closed || !this.#reader) return false
|
|
297
|
+
const values = await this.#reader.read([prefix(this.#scope)], operation?.signal), writing = this.#writing.get(reference.id)
|
|
298
|
+
const raw = writing && writing.record.revision === reference.revision && await tokenOf(writing.raw) === reference.token ? writing.raw : values.get(keyOf(this.#scope, reference.id))
|
|
299
|
+
if (this.#closed || typeof raw !== 'string' || await tokenOf(raw) !== reference.token) return false
|
|
300
|
+
const value: unknown = JSON.parse(raw)
|
|
301
|
+
if (!valid(value, this.#scope) || value.revision !== reference.revision || this.#closed) return false
|
|
302
|
+
operation?.signal?.throwIfAborted(); operation?.guard?.()
|
|
303
|
+
const committed = await this.#reader!.mark([[markerOf(this.#scope, reference), reference.token]])
|
|
304
|
+
operation?.signal?.throwIfAborted(); operation?.guard?.()
|
|
305
|
+
if (!this.#closed) {
|
|
306
|
+
if (committed) this.#touch()
|
|
307
|
+
if (committed && writing && this.#writing.get(reference.id) === writing && writing.record.revision === reference.revision && await tokenOf(writing.raw) === reference.token) { this.#writing.delete(reference.id); writing.store.close() }
|
|
308
|
+
if (!committed) this.#admissionFailure = true
|
|
309
|
+
this.#pending(); await this.refresh()
|
|
310
|
+
}
|
|
311
|
+
return committed
|
|
312
|
+
} catch (error) { if (operation) throw error; this.#readFailure = true; this.#pending(); return false }
|
|
313
|
+
finally { this.#settling--; this.#touch() }
|
|
314
|
+
}
|
|
315
|
+
async recover(reference: CommentDraftReference, target: CommentComposerTarget, version: string, operation?: CommentDraftOperation): Promise<CommentDraftRecord | undefined> {
|
|
316
|
+
operation?.signal?.throwIfAborted(); operation?.guard?.()
|
|
317
|
+
if (this.#closed || !this.#reader) return
|
|
318
|
+
this.#settling++; this.#touch()
|
|
319
|
+
try {
|
|
320
|
+
const values = await this.#reader.read([prefix(this.#scope)], operation?.signal), writing = this.#writing.get(reference.id)
|
|
321
|
+
const raw = writing && writing.record.revision === reference.revision && await tokenOf(writing.raw) === reference.token ? writing.raw : values.get(keyOf(this.#scope, reference.id))
|
|
322
|
+
if (this.#closed || typeof raw !== 'string' || await tokenOf(raw) !== reference.token || values.get(markerOf(this.#scope, reference)) === reference.token) return
|
|
323
|
+
const value: unknown = JSON.parse(raw)
|
|
324
|
+
if (!valid(value, this.#scope) || value.revision !== reference.revision || !sameCommentDraftTarget(value.target, target) || !this.#validContent(value) || this.#closed) return
|
|
325
|
+
operation?.signal?.throwIfAborted(); operation?.guard?.()
|
|
326
|
+
const record: CommentDraftRecord = { ...value, target: isNewCommentDraft(target) ? structuredClone(target) : value.target, id: crypto.randomUUID(), owner: this.#owner, revision: 1, updatedAt: Date.now(), version, composing: false, pending: undefined }
|
|
327
|
+
const store = new DocumentDraftStore(this.#scope, undefined, { family: 'comment', owner: this.#owner }), next = JSON.stringify(record)
|
|
328
|
+
if (next.length > MAX_CHARS || !await store.create(keyOf(this.#scope, record.id), next) || this.#closed) { store.close(); if (!this.#closed) { this.#admissionFailure = true; this.#pending() }; return }
|
|
329
|
+
try { operation?.signal?.throwIfAborted(); operation?.guard?.() } catch (error) { store.close(); throw error }
|
|
330
|
+
this.#writing.set(record.id, { record, raw: next, store, generation: 0, pending: false, failed: false, done: Promise.resolve('committed') })
|
|
331
|
+
this.#touch(); void this.refresh()
|
|
332
|
+
return record
|
|
333
|
+
} catch (error) { if (operation) throw error; this.#readFailure = true; this.#pending(); return }
|
|
334
|
+
finally { this.#settling--; this.#touch() }
|
|
335
|
+
}
|
|
336
|
+
close(): void {
|
|
337
|
+
if (this.#closed) return
|
|
338
|
+
this.#closed = true; ++this.#read
|
|
339
|
+
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', this.#leave)
|
|
340
|
+
this.#reader?.close()
|
|
341
|
+
for (const value of this.#writing.values()) value.store.close()
|
|
342
|
+
this.#listeners.clear(); this.#composers.clear()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Workbench lifetime keeps unpublished session material when a document surface unmounts. */
|
|
347
|
+
export class CommentDraftBank {
|
|
348
|
+
readonly #documents = new Map<string, CommentDrafts>()
|
|
349
|
+
readonly #listeners = new Set<() => void>()
|
|
350
|
+
#closed = false
|
|
351
|
+
get(identity: string, memberId: string): CommentDrafts | undefined {
|
|
352
|
+
if (this.#closed) return
|
|
353
|
+
const key = JSON.stringify([identity, memberId])
|
|
354
|
+
let value = this.#documents.get(key)
|
|
355
|
+
if (!value) { value = new CommentDrafts(identity, memberId, () => { for (const listener of this.#listeners) listener() }); this.#documents.set(key, value) }
|
|
356
|
+
return value
|
|
357
|
+
}
|
|
358
|
+
peek(identity: string, memberId: string): CommentDrafts | undefined { return this.#closed ? undefined : this.#documents.get(JSON.stringify([identity, memberId])) }
|
|
359
|
+
subscribe(listener: () => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener) }
|
|
360
|
+
close(): void { this.#closed = true; this.#listeners.clear(); for (const value of this.#documents.values()) value.close(); this.#documents.clear() }
|
|
361
|
+
}
|