@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.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. 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
+ }