@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,136 @@
|
|
|
1
|
+
import { authoringClientResultFits } from '@lincyaw/kernel'
|
|
2
|
+
import type { AuthoringClientActionResult, AuthoringClientEvent, AuthoringClientSnapshot, CanonicalSource } from '@lincyaw/kernel'
|
|
3
|
+
import type { Workbench } from './workbench.ts'
|
|
4
|
+
import type { AuthoringClientChannel } from './workspace-client.ts'
|
|
5
|
+
|
|
6
|
+
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
7
|
+
// Clock samples are observations, not user input revisions. Never push at animation-frame frequency.
|
|
8
|
+
const signature = (value: AuthoringClientSnapshot) => JSON.stringify({ ...value, revision: 0, playback: value.playback && { ...value.playback,
|
|
9
|
+
position: value.playback.position && { ...value.playback.position, timeMs: 0, stepTimeMs: 0 } } })
|
|
10
|
+
type Attachment = { directory: string, identity: string, epoch: string, channel: AuthoringClientChannel, connectionId?: string }
|
|
11
|
+
|
|
12
|
+
/** One logical reader on Studio's shared authenticated socket; no extra browser SSE or polling timer. */
|
|
13
|
+
export function connectAuthoringClient(workbench: Workbench): () => void {
|
|
14
|
+
const open = workbench.client.openAuthoringClient
|
|
15
|
+
if (!open) return () => {}
|
|
16
|
+
const clientId = crypto.randomUUID()
|
|
17
|
+
let closed = false, revision = 0, sequence = 0
|
|
18
|
+
let attached: Attachment | undefined
|
|
19
|
+
let lastSignature: string | undefined, lastContent: object | undefined
|
|
20
|
+
let lastDraftRevision: string | undefined
|
|
21
|
+
let active: { requestId: string, controller: AbortController } | undefined
|
|
22
|
+
let reports: Promise<void> = Promise.resolve()
|
|
23
|
+
let pendingState: { owner: Attachment, connectionId: string, value: AuthoringClientSnapshot } | undefined
|
|
24
|
+
let stateSending = false
|
|
25
|
+
const seen = new Set<string>()
|
|
26
|
+
const snapshot = (): AuthoringClientSnapshot | undefined => {
|
|
27
|
+
const value = workbench.authoringClientSnapshot(revision)
|
|
28
|
+
if (!value) return
|
|
29
|
+
const next = signature(value), content = workbench.state.authoring?.content, drafts = workbench.authoringDraftRevision()
|
|
30
|
+
if (next !== lastSignature || content !== lastContent || drafts !== lastDraftRevision) { revision++; lastSignature = next; lastContent = content; lastDraftRevision = drafts }
|
|
31
|
+
return { ...value, revision }
|
|
32
|
+
}
|
|
33
|
+
const detach = () => {
|
|
34
|
+
active?.controller.abort(); active = undefined
|
|
35
|
+
attached?.channel.close(); attached = undefined
|
|
36
|
+
pendingState = undefined
|
|
37
|
+
lastSignature = undefined; lastContent = undefined; lastDraftRevision = undefined; seen.clear()
|
|
38
|
+
}
|
|
39
|
+
const send = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot, acknowledgement?: { requestId: string, state: 'executing' | 'done' | 'refused' | 'failed', error?: { code: string }, result?: AuthoringClientActionResult }, preflight?: () => void) => {
|
|
40
|
+
const report = { sequence: ++sequence, snapshot: value, ...acknowledgement }
|
|
41
|
+
const next = reports.then(() => {
|
|
42
|
+
if (closed || attached !== owner || owner.connectionId !== connectionId) throw new Error('client-connection-changed')
|
|
43
|
+
preflight?.()
|
|
44
|
+
return acknowledgement ? owner.channel.acknowledge(connectionId, report as import('@lincyaw/kernel').AuthoringClientAcknowledgement) : owner.channel.publish(connectionId, report)
|
|
45
|
+
})
|
|
46
|
+
reports = next.catch(() => {})
|
|
47
|
+
return next
|
|
48
|
+
}
|
|
49
|
+
const publishState = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot) => {
|
|
50
|
+
pendingState = { owner, connectionId, value }
|
|
51
|
+
if (stateSending) return
|
|
52
|
+
stateSending = true
|
|
53
|
+
void (async () => {
|
|
54
|
+
try {
|
|
55
|
+
while (pendingState) {
|
|
56
|
+
const pending = pendingState; pendingState = undefined
|
|
57
|
+
if (attached !== pending.owner || attached.connectionId !== pending.connectionId) continue
|
|
58
|
+
// Coalesce rapid input while a publication is in flight; never retain an unbounded keystroke queue.
|
|
59
|
+
const latest = snapshot() ?? pending.value
|
|
60
|
+
await send(pending.owner, pending.connectionId, latest).catch(() => {})
|
|
61
|
+
}
|
|
62
|
+
} finally { stateSending = false }
|
|
63
|
+
})()
|
|
64
|
+
}
|
|
65
|
+
const received = async (owner: Attachment, event: AuthoringClientEvent) => {
|
|
66
|
+
if (closed || attached !== owner) return
|
|
67
|
+
if (event.type === 'connected') {
|
|
68
|
+
active?.controller.abort(); active = undefined
|
|
69
|
+
owner.connectionId = event.connectionId
|
|
70
|
+
const value = snapshot()
|
|
71
|
+
if (value) publishState(owner, event.connectionId, value)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (event.type === 'cancel') { if (event.connectionId === owner.connectionId && event.requestId === active?.requestId) active.controller.abort(); return }
|
|
75
|
+
const request = event.request, connectionId = owner.connectionId
|
|
76
|
+
if (!connectionId || request.clientId !== clientId || request.epoch !== owner.epoch || request.expectedClientState.connectionId !== connectionId || seen.has(request.requestId)) return
|
|
77
|
+
seen.add(request.requestId)
|
|
78
|
+
if (seen.size > 64) seen.delete(seen.values().next().value!)
|
|
79
|
+
const value = snapshot()
|
|
80
|
+
if (!value) return
|
|
81
|
+
const controller = new AbortController(), task = { requestId: request.requestId, controller }
|
|
82
|
+
const owned = () => {
|
|
83
|
+
controller.signal.throwIfAborted()
|
|
84
|
+
if (closed || attached !== owner || owner.connectionId !== connectionId || active !== task || Date.now() >= Date.parse(event.expiresAt)) throw new Error('client-request-expired')
|
|
85
|
+
}
|
|
86
|
+
if (active || value.revision !== request.expectedClientState.revision || !sameSource(value.source, request.source) || !value.actions.includes(request.action.type) || Date.now() >= Date.parse(event.expiresAt)) {
|
|
87
|
+
await send(owner, connectionId, value, { requestId: request.requestId, state: 'refused', error: { code: 'client-state-changed' } }).catch(() => {})
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
active = task
|
|
91
|
+
const expiresAt = Date.parse(event.expiresAt)
|
|
92
|
+
const timeout = setTimeout(() => controller.abort(new Error('client-request-expired')), Math.max(0, expiresAt - Date.now()))
|
|
93
|
+
try {
|
|
94
|
+
// Only a confirmed executing acknowledgement grants this epoch permission to invoke a control.
|
|
95
|
+
await send(owner, connectionId, value, { requestId: request.requestId, state: 'executing' })
|
|
96
|
+
owned()
|
|
97
|
+
const latest = snapshot()
|
|
98
|
+
if (!latest || latest.revision !== request.expectedClientState.revision || !sameSource(latest.source, request.source)) throw new Error('client-state-changed')
|
|
99
|
+
const fits = (result: AuthoringClientActionResult) => authoringClientResultFits({ request, connection: { clientId, epoch: owner.epoch, connectionId }, document: owner.directory, snapshot: snapshot() ?? value }, result)
|
|
100
|
+
const actionResult = await workbench.executeAuthoringClientAction(request.action, request.source, controller.signal, expiresAt, fits)
|
|
101
|
+
owned()
|
|
102
|
+
const result = snapshot()
|
|
103
|
+
if (!result) throw new Error('client-document-changed')
|
|
104
|
+
await send(owner, connectionId, result, { requestId: request.requestId, state: 'done', ...actionResult ? { result: actionResult } : {} }, actionResult ? () => {
|
|
105
|
+
owned()
|
|
106
|
+
const latest = snapshot()
|
|
107
|
+
if (!latest || latest.revision !== result.revision || !['component-drafts.restore', 'component-drafts.discard', 'comment-drafts.restore', 'comment-drafts.discard'].includes(actionResult.type) && latest.revision !== request.expectedClientState.revision) throw new Error(actionResult.family === 'component' ? 'client-component-drafts-changed' : 'client-comment-drafts-changed')
|
|
108
|
+
if (!fits(actionResult)) throw new Error(actionResult.family === 'component' ? 'client-component-result-too-large' : 'client-comment-result-too-large')
|
|
109
|
+
} : undefined)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const result = snapshot()
|
|
112
|
+
const code = error instanceof Error && /^(client|production)-[a-z-]{1,100}$/.test(error.message) ? error.message : 'client-action-not-verified'
|
|
113
|
+
if (!controller.signal.aborted && attached === owner && owner.connectionId === connectionId && result) await send(owner, connectionId, result, { requestId: request.requestId, state: 'failed', error: { code } }).catch(() => {})
|
|
114
|
+
} finally { clearTimeout(timeout); if (active === task) active = undefined }
|
|
115
|
+
}
|
|
116
|
+
const changed = () => {
|
|
117
|
+
const state = workbench.state, source = state.authoring
|
|
118
|
+
if (closed || state.document === undefined || !source?.identity || source.loading) { detach(); return }
|
|
119
|
+
const before = revision, value = snapshot()
|
|
120
|
+
if (!value) { detach(); return }
|
|
121
|
+
if (attached && (attached.directory !== state.document || attached.identity !== source.identity)) detach()
|
|
122
|
+
if (!attached) {
|
|
123
|
+
const epoch = crypto.randomUUID()
|
|
124
|
+
// Callbacks run after the channel has been installed; the socket implementation is asynchronous.
|
|
125
|
+
const owner = { directory: state.document, identity: source.identity, epoch } as Attachment
|
|
126
|
+
owner.channel = open(state.document, { clientId, epoch, snapshot: value }, event => { void received(owner, event) }, () => {
|
|
127
|
+
if (attached === owner) { owner.connectionId = undefined; active?.controller.abort(); active = undefined }
|
|
128
|
+
})
|
|
129
|
+
attached = owner
|
|
130
|
+
} else if (before !== revision && attached.connectionId) publishState(attached, attached.connectionId, value)
|
|
131
|
+
}
|
|
132
|
+
const stop = workbench.subscribe(changed)
|
|
133
|
+
const stopDrafts = workbench.subscribeAuthoringDraftChanges(changed)
|
|
134
|
+
changed()
|
|
135
|
+
return () => { closed = true; stop(); stopDrafts(); detach() }
|
|
136
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AuthoringClientActionResult } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
type Fragment = Extract<AuthoringClientActionResult, { jsonText: string }>
|
|
4
|
+
type Fields = 'offset' | 'endOffset' | 'totalUtf16' | 'totalUtf8' | 'sha256' | 'jsonText'
|
|
5
|
+
type FragmentBase = Fragment extends infer Value ? Value extends Fragment ? Omit<Value, Fields> : never : never
|
|
6
|
+
export const draftContentHash = async (text: string): Promise<string> => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map(value => value.toString(16).padStart(2, '0')).join('')
|
|
7
|
+
const boundary = (text: string, offset: number) => !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
|
|
8
|
+
|
|
9
|
+
/** Budget the complete client receipt and WS frame, preserving exact JSON text and Unicode progress. */
|
|
10
|
+
export async function authoringDraftFragment(options: { text: string, base: FragmentBase, offset: number, maxChars?: number }, current: () => void, fits: (value: AuthoringClientActionResult) => boolean): Promise<Fragment> {
|
|
11
|
+
const { text, base, offset } = options, maxChars = options.maxChars ?? 2048
|
|
12
|
+
const sha256 = await draftContentHash(text)
|
|
13
|
+
current()
|
|
14
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !boundary(text, offset)) throw new Error(`client-${base.family}-offset-invalid`)
|
|
15
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 2 || maxChars > 2048) throw new Error(`client-${base.family}-fragment-limit`)
|
|
16
|
+
const lengths = { offset, totalUtf16: text.length, totalUtf8: new TextEncoder().encode(text).byteLength, sha256 }
|
|
17
|
+
let low = offset, high = Math.min(text.length, offset + maxChars), best: Fragment | undefined
|
|
18
|
+
while (low <= high) {
|
|
19
|
+
const middle = Math.floor((low + high) / 2)
|
|
20
|
+
const end = boundary(text, middle) ? middle : middle - 1
|
|
21
|
+
const result: Fragment = { ...base, ...lengths, endOffset: end, jsonText: text.slice(offset, end) }
|
|
22
|
+
if (fits(result)) { if (end > offset || end === text.length) best = result; low = middle + 1 }
|
|
23
|
+
else high = middle - 1
|
|
24
|
+
}
|
|
25
|
+
if (!best) throw new Error(`client-${base.family}-fragment-too-large`)
|
|
26
|
+
current()
|
|
27
|
+
return best
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ICONS } from '@lincyaw/design'
|
|
2
|
+
import type { IconName } from '@lincyaw/design'
|
|
3
|
+
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
4
|
+
import { createElement, forwardRef } from 'react'
|
|
5
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
6
|
+
|
|
7
|
+
const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
|
|
8
|
+
|
|
9
|
+
export function PmIcon({ name, size: _size, strokeWidth = 1.7 }: { name: IconName, size?: number | string, strokeWidth?: number }) {
|
|
10
|
+
return (
|
|
11
|
+
<svg className="pm-ui-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
12
|
+
{ICONS[name].map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
|
|
13
|
+
</svg>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
|
|
18
|
+
|
|
19
|
+
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & { type?: 'button' | 'submit' | 'reset' }
|
|
20
|
+
export type ControlDensity = 'compact' | 'regular' | 'touch'
|
|
21
|
+
export type ControlProps = { density?: ControlDensity, loading?: boolean }
|
|
22
|
+
|
|
23
|
+
export const PmButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { variant?: ButtonVariant, small?: boolean, icon?: IconName }>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, type = 'button', className, children, disabled, ...rest }, ref) {
|
|
24
|
+
return (
|
|
25
|
+
<button {...rest} ref={ref} type={type} data-density={density} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className)}>
|
|
26
|
+
<span className={cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text')}>{(icon || loading !== undefined) && <span aria-hidden="true" className={cls('pm-ui-button__icon', loading && 'pm-ui-loading')}>{loading ? <PmIcon name="loading" /> : icon && <PmIcon name={icon} />}</span>}{children}</span>
|
|
27
|
+
</button>
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Radix opens on keyboard focus immediately; pointer hover uses the I11 delay. */
|
|
32
|
+
export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
|
|
33
|
+
return (
|
|
34
|
+
<Tooltip.Provider delayDuration={300} skipDelayDuration={0}>
|
|
35
|
+
<Tooltip.Root><Tooltip.Trigger asChild>{children}</Tooltip.Trigger><Tooltip.Portal><div className="pm-ui-tooltip-layer"><Tooltip.Content className="pm-ui-tooltip pm-ui" side="bottom" sideOffset={6} collisionPadding={8}>
|
|
36
|
+
{label}{shortcut && <kbd className="pm-ui-shortcut">{shortcut}</kbd>}
|
|
37
|
+
</Tooltip.Content></div></Tooltip.Portal></Tooltip.Root>
|
|
38
|
+
</Tooltip.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { icon: IconName, label: string, hint?: string, shortcut?: string, small?: boolean, tooltip?: boolean }>(function PmIconButton({ icon, label, hint, shortcut, small, tooltip = true, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
|
|
43
|
+
const button = <button {...rest} ref={ref} type="button" data-density={density} aria-label={label} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-icon-button', className)}>
|
|
44
|
+
<span className={cls('pm-ui-button__content', loading && 'pm-ui-loading')}><PmIcon name={loading ? 'loading' : icon} /></span>
|
|
45
|
+
</button>
|
|
46
|
+
return tooltip ? <PmTooltip label={hint ?? label} shortcut={shortcut}>{button}</PmTooltip> : button
|
|
47
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CaptionTimingSource, TeachingDocument } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface CaptionTimingRow { id: string, from: number, to: number, start: string, end: string }
|
|
4
|
+
export interface CaptionTimingDraft { version: string, text: string, basis: CaptionTimingSource, rows: CaptionTimingRow[] }
|
|
5
|
+
type Scope = { identity: string, memberId: string }
|
|
6
|
+
interface Entry { values: Map<string, CaptionTimingDraft>, failed: boolean, unreadable: boolean }
|
|
7
|
+
const MAX_CHARS = 1 << 20
|
|
8
|
+
const MAX_TARGETS = 512
|
|
9
|
+
const valid = (value: unknown): value is CaptionTimingDraft => {
|
|
10
|
+
if (!value || typeof value !== 'object') return false
|
|
11
|
+
const d = value as CaptionTimingDraft, b = d.basis
|
|
12
|
+
if (typeof d.version !== 'string' || !d.version || d.version.length > 256 || typeof d.text !== 'string' || d.text.length > MAX_CHARS
|
|
13
|
+
|| !b || typeof b !== 'object' || typeof b.available !== 'boolean' || typeof b.sourceFingerprint !== 'string'
|
|
14
|
+
|| b.sourceFingerprint.length > 256 || b.playbackFingerprint !== undefined && (typeof b.playbackFingerprint !== 'string' || b.playbackFingerprint.length > 256)
|
|
15
|
+
|| b.durationMs !== undefined && (!Number.isSafeInteger(b.durationMs) || b.durationMs < 0)
|
|
16
|
+
|| b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > 256)
|
|
17
|
+
|| !Array.isArray(b.narrationIds) || b.narrationIds.length > MAX_TARGETS || b.narrationIds.some(id => typeof id !== 'string' || id.length > 256)
|
|
18
|
+
|| !Array.isArray(d.rows) || d.rows.length > MAX_TARGETS) return false
|
|
19
|
+
return d.rows.every(r => r && typeof r.id === 'string' && r.id.length <= 256 && Number.isSafeInteger(r.from) && r.from >= 0
|
|
20
|
+
&& Number.isSafeInteger(r.to) && r.to >= r.from && r.to <= d.text.length && typeof r.start === 'string' && r.start.length <= 32 && typeof r.end === 'string' && r.end.length <= 32)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Unapplied author inputs are per-tab recovery data, never the canonical production timing. */
|
|
24
|
+
export class CaptionTimingDrafts {
|
|
25
|
+
readonly #entries = new Map<string, Entry>()
|
|
26
|
+
readonly #leave = (event: BeforeUnloadEvent) => { if ([...this.#entries.values()].some(entry => entry.values.size || entry.failed)) event.preventDefault() }
|
|
27
|
+
constructor() { if (typeof window !== 'undefined') window.addEventListener('beforeunload', this.#leave) }
|
|
28
|
+
#key(scope: Scope): string { return `presmium.caption-timing.v1:${JSON.stringify([scope.identity, scope.memberId])}` }
|
|
29
|
+
#entry(scope: Scope): Entry {
|
|
30
|
+
const key = this.#key(scope), existing = this.#entries.get(key)
|
|
31
|
+
if (existing) return existing
|
|
32
|
+
const result: Entry = { values: new Map(), failed: false, unreadable: false }; this.#entries.set(key, result)
|
|
33
|
+
try {
|
|
34
|
+
const raw = sessionStorage.getItem(key)
|
|
35
|
+
if (raw === null) return result
|
|
36
|
+
if (raw.length > MAX_CHARS) throw new Error('caption-draft-limit')
|
|
37
|
+
const data: unknown = JSON.parse(raw)
|
|
38
|
+
if (!Array.isArray(data) || data.length > MAX_TARGETS) throw new Error('caption-draft-data')
|
|
39
|
+
for (const pair of data) {
|
|
40
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || !pair[0] || pair[0].length > 256 || !valid(pair[1]) || result.values.has(pair[0])) throw new Error('caption-draft-data')
|
|
41
|
+
result.values.set(pair[0], pair[1])
|
|
42
|
+
}
|
|
43
|
+
} catch { result.values.clear(); result.failed = true; result.unreadable = true }
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
get(scope: Scope, stepId: string): CaptionTimingDraft | undefined { return this.#entry(scope).values.get(stepId) }
|
|
47
|
+
steps(scope: Scope, document: TeachingDocument): string[] { return [...this.#entry(scope).values.keys()].filter(id => Object.hasOwn(document.steps, id)) }
|
|
48
|
+
failed(scope: Scope): boolean { return this.#entry(scope).failed }
|
|
49
|
+
set(scope: Scope, stepId: string, value: CaptionTimingDraft | undefined): void {
|
|
50
|
+
const entry = this.#entry(scope)
|
|
51
|
+
if (value) entry.values.set(stepId, value); else entry.values.delete(stepId)
|
|
52
|
+
if (entry.unreadable) { entry.failed = true; return }
|
|
53
|
+
try {
|
|
54
|
+
const key = this.#key(scope), raw = JSON.stringify([...entry.values])
|
|
55
|
+
if (raw.length > MAX_CHARS || entry.values.size > MAX_TARGETS || [...entry.values.values()].some(draft => !valid(draft))) throw new Error('caption-draft-limit')
|
|
56
|
+
if (entry.values.size) sessionStorage.setItem(key, raw); else sessionStorage.removeItem(key)
|
|
57
|
+
entry.failed = false
|
|
58
|
+
} catch { entry.failed = true }
|
|
59
|
+
}
|
|
60
|
+
close(): void { if (typeof window !== 'undefined') window.removeEventListener('beforeunload', this.#leave) }
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const CAPTION_TIMING_MESSAGES = {
|
|
2
|
+
'zh-CN': {
|
|
3
|
+
title: '手动字幕时间', open: '设置字幕时间', cancel: '取消', done: '完成', apply: '应用', automatic: '使用自动时间', split: '按换行分段',
|
|
4
|
+
start: '开始(秒)', end: '结束(秒)', step: '本步骤 · 时间从步骤起点计算', invalid: '时间须递增且不超出本步骤。',
|
|
5
|
+
loading: '正在加载字幕时间…', prepare: '请先准备当前步骤。', unavailable: '当前步骤的字幕时间不可用。', changed: '讲稿或音频已变化。', recheck: '再次检查', saved: '字幕时间已保存到当前步骤。',
|
|
6
|
+
failed: '无法确认字幕时间已保存。输入已保留,请重试。', storageFailed: '无法备份字幕时间,请保持此页面打开。', discard: '放弃修改', limit: '本次最多设置 32 段。', segment: (n: number) => `字幕 ${n}`,
|
|
7
|
+
},
|
|
8
|
+
en: {
|
|
9
|
+
title: 'Manual Caption Timing', open: 'Set Caption Times', cancel: 'Cancel', done: 'Done', apply: 'Apply', automatic: 'Use Automatic Times', split: 'Split at Line Breaks',
|
|
10
|
+
start: 'Start (seconds)', end: 'End (seconds)', step: 'Current step · Times start at the step boundary', invalid: 'Times must increase and fit inside this step.',
|
|
11
|
+
loading: 'Loading caption times…', prepare: 'Prepare the current step first.', unavailable: 'Caption timing is unavailable for this step.', changed: 'The script or audio has changed.', recheck: 'Check Again', saved: 'Caption times saved to this step.',
|
|
12
|
+
failed: 'Couldn’t confirm that caption times were saved. Your input is kept; try again.', storageFailed: 'Couldn’t back up caption times. Keep this page open.', discard: 'Discard Changes', limit: 'Set no more than 32 segments at once.', segment: (n: number) => `Caption ${n}`,
|
|
13
|
+
},
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.pmw-caption-times {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: var(--pm-space-3);
|
|
4
|
+
min-width: 0;
|
|
5
|
+
}
|
|
6
|
+
.pmw-caption-times__text {
|
|
7
|
+
margin: 0;
|
|
8
|
+
white-space: pre-wrap;
|
|
9
|
+
overflow-wrap: anywhere;
|
|
10
|
+
}
|
|
11
|
+
.pmw-caption-times__range {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
14
|
+
gap: var(--pm-space-2);
|
|
15
|
+
}
|
|
16
|
+
.pmw-caption-times__actions {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: var(--pm-space-2);
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { applyDocumentCaptionTiming, captionSourceFingerprint } from '@lincyaw/kernel'
|
|
2
|
+
import type { DocumentAnswer, DocumentCaptionTimingAction, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
5
|
+
/** The source actor adopts only this caption slot; later native body input remains owned by the editor. */
|
|
6
|
+
export function reconcileCaptionTiming(action: DocumentCaptionTimingAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
|
|
7
|
+
try {
|
|
8
|
+
if (before.id !== answer.content.id || before.id !== current.id || !same(applyDocumentCaptionTiming(before, action).content, answer.content)) return
|
|
9
|
+
if (!current.steps[action.stepId]) return current
|
|
10
|
+
if (!same(before.production.captionTimings?.[action.stepId], current.production.captionTimings?.[action.stepId])
|
|
11
|
+
|| captionSourceFingerprint(before, action.stepId) !== captionSourceFingerprint(current, action.stepId)) return
|
|
12
|
+
return applyDocumentCaptionTiming(current, action).content
|
|
13
|
+
} catch { return }
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftReference } from '@lincyaw/kernel'
|
|
2
|
+
import { draftContentHash } from './authoring-draft-fragment.ts'
|
|
3
|
+
import { componentDraftMetadata, componentDraftReference } from './component-draft-inspection.ts'
|
|
4
|
+
import type { ComponentPropertyDraft, ComponentPropertyDrafts } from './component-property-drafts.ts'
|
|
5
|
+
|
|
6
|
+
export const sameComponentDraftReference = (a: ClientComponentDraftReference | null, b: ClientComponentDraftReference | null): boolean => a === null || b === null ? a === b
|
|
7
|
+
: a.id === b.id && a.revision === b.revision && a.token === b.token && a.blockId === b.blockId && a.stepId === b.stepId
|
|
8
|
+
|
|
9
|
+
/** Explicit form actions reuse the human bank; they never apply parameters to lecture.json. */
|
|
10
|
+
export async function mutateComponentDrafts(bank: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.restore' | 'component-drafts.discard' }>, signal: AbortSignal, owned: (draft?: ComponentPropertyDraft) => void, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
11
|
+
const read = await bank.readForAgent(context, signal, action.draft)
|
|
12
|
+
owned()
|
|
13
|
+
const candidate = read.candidates[0]!, target = candidate.record.target, previous = bank.get(target)
|
|
14
|
+
owned(candidate.record)
|
|
15
|
+
const unchanged = () => { signal.throwIfAborted(); owned(candidate.record); if (bank.get(target) !== previous) throw new Error('client-component-drafts-changed') }
|
|
16
|
+
if (action.type === 'component-drafts.discard') {
|
|
17
|
+
if (candidate.current && (!previous || previous.id !== candidate.record.id || previous.revision !== candidate.record.revision)) throw new Error('client-component-drafts-changed')
|
|
18
|
+
if (candidate.current !== action.current) throw new Error('client-component-drafts-changed')
|
|
19
|
+
if (candidate.current && (candidate.storage !== 'durable' || candidate.status !== 'available' || candidate.record.composing || bank.status(target) !== 'available')) throw new Error('client-component-draft-needs-human')
|
|
20
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft, current: action.current, discarded: true }
|
|
21
|
+
if (!fits(result)) throw new Error('client-component-result-too-large')
|
|
22
|
+
unchanged()
|
|
23
|
+
const discarded = candidate.current ? await bank.clear(previous!, { preserveOrigins: true }) : await bank.discard(candidate, signal, unchanged)
|
|
24
|
+
signal.throwIfAborted(); owned()
|
|
25
|
+
if (!discarded) throw new Error('client-component-drafts-changed')
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
if (candidate.current || candidate.record.composing) throw new Error('client-component-draft-needs-human')
|
|
29
|
+
if (previous) {
|
|
30
|
+
if (!action.expectedCurrent || action.expectedCurrent.blockId !== target.blockId || action.expectedCurrent.stepId !== target.stepId) throw new Error('client-component-drafts-changed')
|
|
31
|
+
const current = await bank.readForAgent(context, signal, action.expectedCurrent)
|
|
32
|
+
unchanged()
|
|
33
|
+
const value = current.candidates[0]!
|
|
34
|
+
if (!value.current || !sameComponentDraftReference(componentDraftReference(value), action.expectedCurrent)) throw new Error('client-component-drafts-changed')
|
|
35
|
+
if (value.storage !== 'durable' || value.status !== 'available' || value.record.composing || bank.status(target) !== 'available') throw new Error('client-component-draft-needs-human')
|
|
36
|
+
} else if (action.expectedCurrent !== null) throw new Error('client-component-drafts-changed')
|
|
37
|
+
unchanged()
|
|
38
|
+
if (!bank.canKeep(target)) throw new Error('client-component-drafts-limit')
|
|
39
|
+
const projected = { ...candidate.record, id: '0'.repeat(36), revision: 1, composing: false, updatedAt: new Date().toISOString() }
|
|
40
|
+
if (!fits({ type: action.type, family: 'component', draft: action.draft,
|
|
41
|
+
restored: componentDraftMetadata({ ...candidate, record: projected, token: '0'.repeat(64), current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent })) throw new Error('client-component-result-too-large')
|
|
42
|
+
const restored = await bank.recover(candidate, previous, signal, unchanged)
|
|
43
|
+
signal.throwIfAborted(); owned()
|
|
44
|
+
if (!restored) throw new Error('client-component-drafts-changed')
|
|
45
|
+
const record = bank.get(target)
|
|
46
|
+
if (!record || record.id === candidate.record.id || record.id === previous?.id || record.composing || bank.status(target) !== 'available') throw new Error('client-component-drafts-changed')
|
|
47
|
+
const token = await draftContentHash(JSON.stringify(record))
|
|
48
|
+
signal.throwIfAborted(); owned()
|
|
49
|
+
if (bank.get(target) !== record) throw new Error('client-component-drafts-changed')
|
|
50
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'component', draft: action.draft,
|
|
51
|
+
restored: componentDraftMetadata({ record, token, key: candidate.key, current: true, storage: 'durable', status: 'available' }, context.source), preservedCurrent: action.expectedCurrent }
|
|
52
|
+
if (!fits(result)) throw new Error('client-component-result-too-large')
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, CanonicalSource, ClientComponentDraftMetadata } from '@lincyaw/kernel'
|
|
2
|
+
import { authoringDraftFragment, draftContentHash } from './authoring-draft-fragment.ts'
|
|
3
|
+
import type { ComponentPropertyAgentCandidate, ComponentPropertyDrafts } from './component-property-drafts.ts'
|
|
4
|
+
|
|
5
|
+
export const componentDraftReference = ({ record, token }: ComponentPropertyAgentCandidate) => ({ id: record.id, revision: record.revision, token, blockId: record.target.blockId, ...record.target.stepId !== undefined ? { stepId: record.target.stepId } : {} })
|
|
6
|
+
/** Metadata is an explicit whitelist: neither baseline/value nor trusted scope identities are spread. */
|
|
7
|
+
export function componentDraftMetadata(value: ComponentPropertyAgentCandidate, source: CanonicalSource): ClientComponentDraftMetadata {
|
|
8
|
+
const { record } = value
|
|
9
|
+
return { draft: componentDraftReference(value), current: value.current, definitionId: record.definitionId, storage: value.storage, status: value.status, updatedAt: Date.parse(record.updatedAt), baseVersion: record.source.version,
|
|
10
|
+
sourceCurrent: record.source.documentId === source.documentId && record.source.version === source.version && record.source.fingerprint === source.fingerprint, composing: record.composing, reset: record.reset }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function inspectComponentDrafts(drafts: ComponentPropertyDrafts, context: { identity: string, memberId: string, source: CanonicalSource }, action: Extract<AuthoringClientAction, { type: 'component-drafts.inspect' | 'component-drafts.read' }>, signal: AbortSignal, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
14
|
+
const read = await drafts.readForAgent(context, signal, action.type === 'component-drafts.read' ? action.draft : undefined)
|
|
15
|
+
const current = () => { signal.throwIfAborted(); if (!drafts.readable(context.identity, context.memberId) || drafts.revision(context.identity, context.memberId) !== read.revision) throw new Error('client-component-drafts-changed') }
|
|
16
|
+
if (action.type === 'component-drafts.inspect') {
|
|
17
|
+
const values = read.candidates.map(value => componentDraftMetadata(value, context.source)), token = await draftContentHash(JSON.stringify({ values, coverage: read.coverage, skippedInvalid: read.skippedInvalid }))
|
|
18
|
+
current()
|
|
19
|
+
if (action.cursor && action.cursor.token !== token) throw new Error('client-component-drafts-changed')
|
|
20
|
+
const start = action.cursor ? values.findIndex(value => value.draft.token === action.cursor!.after) + 1 : 0
|
|
21
|
+
if (action.cursor && start === 0) throw new Error('client-component-drafts-changed')
|
|
22
|
+
const result: Extract<AuthoringClientActionResult, { type: 'component-drafts.inspect' }> = { type: action.type, family: 'component', coverage: read.coverage, skippedInvalid: read.skippedInvalid, token, drafts: [] }
|
|
23
|
+
for (const value of values.slice(start, start + (action.limit ?? 10))) {
|
|
24
|
+
const next = { ...result, drafts: [...result.drafts, value], ...start + result.drafts.length + 1 < values.length ? { next: { after: value.draft.token, token } } : {} }
|
|
25
|
+
if (!fits(next)) break
|
|
26
|
+
result.drafts.push(value)
|
|
27
|
+
}
|
|
28
|
+
if (start < values.length && !result.drafts.length) throw new Error('client-component-metadata-too-large')
|
|
29
|
+
if (start + result.drafts.length < values.length) result.next = { after: result.drafts.at(-1)!.draft.token, token }
|
|
30
|
+
if (!fits(result)) throw new Error('client-component-metadata-too-large')
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
const value = read.candidates[0]!
|
|
34
|
+
return authoringDraftFragment({ text: JSON.stringify(value.record.value), base: { type: action.type, family: 'component', draft: componentDraftReference(value), storage: value.storage, format: 'presmium-component-parameters-json-v1' }, offset: action.offset, maxChars: action.maxChars }, current, fits)
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.component-properties__content,
|
|
2
|
+
.component-properties__backups,
|
|
3
|
+
.component-properties__references {
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: var(--pm-space-3);
|
|
6
|
+
min-width: 0;
|
|
7
|
+
}
|
|
8
|
+
.component-properties__actions {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-wrap: wrap;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: var(--pm-space-2);
|
|
13
|
+
}
|
|
14
|
+
.component-properties__reference {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: var(--pm-space-2);
|
|
19
|
+
min-width: 0;
|
|
20
|
+
}
|
|
21
|
+
.component-properties__reference > span {
|
|
22
|
+
min-width: 0;
|
|
23
|
+
overflow-wrap: anywhere;
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { applyDocumentComponentAction, createDocumentParameterChanges, effectiveComponentParameters } from '@lincyaw/kernel'
|
|
2
|
+
import type { CanonicalSource, DocumentAnswer, DocumentComponentAction, JsonObject, TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import type { ComponentPropertyDraft } from './component-property-drafts.ts'
|
|
4
|
+
|
|
5
|
+
export const sameComponentSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
6
|
+
export const componentSource = (answer: Pick<DocumentAnswer, 'content' | 'version' | 'fingerprint'>): CanonicalSource => ({ documentId: answer.content.id, version: answer.version, fingerprint: answer.fingerprint })
|
|
7
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
8
|
+
|
|
9
|
+
export function componentPropertyAction(draft: ComponentPropertyDraft): DocumentComponentAction | undefined {
|
|
10
|
+
const { blockId, stepId } = draft.target
|
|
11
|
+
if (stepId && draft.reset) return { type: 'component.step-parameters.reset', blockId, stepId }
|
|
12
|
+
const changes = createDocumentParameterChanges(draft.baseline, draft.value)
|
|
13
|
+
if (!changes.length) return
|
|
14
|
+
return stepId ? { type: 'component.step-parameters.patch', blockId, stepId, changes } : { type: 'component.parameters.patch', blockId, changes }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A checked rebase borrows the same reducer as the service, preserving unrelated current data. */
|
|
18
|
+
export function rebaseComponentPropertyDraft(document: TeachingDocument, draft: ComponentPropertyDraft): JsonObject {
|
|
19
|
+
const block = document.blocks[draft.target.blockId]
|
|
20
|
+
if (block?.type !== 'component' || block.definitionId !== draft.definitionId) throw new Error('component-definition-changed')
|
|
21
|
+
const action = componentPropertyAction(draft)
|
|
22
|
+
const content = action ? applyDocumentComponentAction(document, action).content : document
|
|
23
|
+
return effectiveComponentParameters(content, block.id, draft.target.stepId).parameters
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An external acknowledgement owns its parameter paths, never a later body tree or sibling form. */
|
|
27
|
+
export function reconcileComponentAction(action: DocumentComponentAction, before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument): TeachingDocument | undefined {
|
|
28
|
+
try {
|
|
29
|
+
if (answer.content.id !== before.id || current.id !== before.id) return
|
|
30
|
+
const expected = applyDocumentComponentAction(before, action).content
|
|
31
|
+
if (!same(expected, answer.content)) return
|
|
32
|
+
const original = before.blocks[action.blockId], actual = current.blocks[action.blockId]
|
|
33
|
+
if (original?.type !== 'component') return
|
|
34
|
+
// A later native deletion stays deleted. Its history is updated separately from the verified canonical reply.
|
|
35
|
+
if (!actual) return current
|
|
36
|
+
if (actual.type !== 'component' || actual.definitionId !== original.definitionId || !same(actual.parameters, original.parameters)
|
|
37
|
+
|| !same(before.definitions[original.definitionId], current.definitions[actual.definitionId])) return
|
|
38
|
+
if ('stepId' in action) {
|
|
39
|
+
const oldStep = before.steps[action.stepId], newStep = current.steps[action.stepId]
|
|
40
|
+
if (!oldStep || !newStep || oldStep.sectionId !== newStep.sectionId || oldStep.inherits !== newStep.inherits
|
|
41
|
+
|| !same(effectiveComponentParameters(before, action.blockId, action.stepId), effectiveComponentParameters(current, action.blockId, action.stepId))) return
|
|
42
|
+
}
|
|
43
|
+
return applyDocumentComponentAction(current, action).content
|
|
44
|
+
} catch { return }
|
|
45
|
+
}
|