@lincyaw/workbench 0.1.0 → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** A microphone take remains an in-memory candidate until the author saves its original bytes. */
|
|
2
|
+
export interface RecordingCandidate { file: File, recordedAt: string, elapsedMs: number, assetId?: string }
|
|
3
|
+
export interface AudioRecordingState {
|
|
4
|
+
phase: 'idle' | 'requesting' | 'recording' | 'stopping' | 'ready' | 'failed'
|
|
5
|
+
candidate?: RecordingCandidate
|
|
6
|
+
elapsedMs: number
|
|
7
|
+
error?: string
|
|
8
|
+
}
|
|
9
|
+
const MAX_BYTES = 16 << 20
|
|
10
|
+
const MAX_DURATION_MS = 10 * 60_000
|
|
11
|
+
const MIME_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4']
|
|
12
|
+
|
|
13
|
+
export function recordingUnavailable(): string | undefined {
|
|
14
|
+
if (!globalThis.isSecureContext || !navigator.mediaDevices?.getUserMedia) return 'recording-context'
|
|
15
|
+
if (typeof MediaRecorder === 'undefined' || !MIME_TYPES.some(type => MediaRecorder.isTypeSupported(type))) return 'recording-unsupported'
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Take {
|
|
20
|
+
recorder: MediaRecorder
|
|
21
|
+
stream: MediaStream
|
|
22
|
+
chunks: Blob[]
|
|
23
|
+
bytes: number
|
|
24
|
+
started: number
|
|
25
|
+
recordedAt: string
|
|
26
|
+
discard: boolean
|
|
27
|
+
error?: string
|
|
28
|
+
interval?: ReturnType<typeof setInterval>
|
|
29
|
+
release: () => void
|
|
30
|
+
ended: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AudioRecording {
|
|
34
|
+
#state: AudioRecordingState = { phase: 'idle', elapsedMs: 0 }
|
|
35
|
+
readonly #listeners = new Set<() => void>()
|
|
36
|
+
#generation = 0
|
|
37
|
+
#disposed = false
|
|
38
|
+
#take?: Take
|
|
39
|
+
#release?: () => void
|
|
40
|
+
#warning = false
|
|
41
|
+
readonly #beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
|
42
|
+
|
|
43
|
+
snapshot = (): AudioRecordingState => this.#state
|
|
44
|
+
subscribe = (listener: () => void): (() => void) => { if (this.#disposed) return () => {}; this.#listeners.add(listener); return () => { this.#listeners.delete(listener) } }
|
|
45
|
+
#set(state: AudioRecordingState): void {
|
|
46
|
+
if (this.#disposed) return
|
|
47
|
+
this.#state = state
|
|
48
|
+
const warning = ['requesting', 'recording', 'stopping'].includes(state.phase) || Boolean(state.candidate && !state.candidate.assetId)
|
|
49
|
+
if (warning !== this.#warning) {
|
|
50
|
+
this.#warning = warning
|
|
51
|
+
if (warning) window.addEventListener('beforeunload', this.#beforeUnload)
|
|
52
|
+
else window.removeEventListener('beforeunload', this.#beforeUnload)
|
|
53
|
+
}
|
|
54
|
+
for (const listener of this.#listeners) listener()
|
|
55
|
+
}
|
|
56
|
+
#settle(error?: string): void {
|
|
57
|
+
this.#set({ phase: error ? 'failed' : this.#state.candidate ? 'ready' : 'idle', candidate: this.#state.candidate, elapsedMs: this.#state.candidate?.elapsedMs ?? 0, ...error ? { error } : {} })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async start(prepare: () => Promise<() => void>): Promise<void> {
|
|
61
|
+
if (this.#disposed || this.#take || this.#state.phase === 'requesting') return
|
|
62
|
+
const unavailable = recordingUnavailable()
|
|
63
|
+
if (unavailable) { this.#settle(unavailable); return }
|
|
64
|
+
const generation = ++this.#generation
|
|
65
|
+
this.#set({ ...this.#state, phase: 'requesting', error: undefined })
|
|
66
|
+
let stream: MediaStream | undefined
|
|
67
|
+
let release: (() => void) | undefined
|
|
68
|
+
try {
|
|
69
|
+
release = await prepare()
|
|
70
|
+
if (generation !== this.#generation) { release(); return }
|
|
71
|
+
this.#release = release
|
|
72
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: { ideal: 1 } }, video: false })
|
|
73
|
+
if (generation !== this.#generation) { stream.getTracks().forEach(track => track.stop()); return }
|
|
74
|
+
const mimeType = MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type))!
|
|
75
|
+
const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 128_000 })
|
|
76
|
+
const take: Take = {
|
|
77
|
+
recorder, stream, chunks: [], bytes: 0, started: performance.now(), recordedAt: new Date().toISOString(), discard: false,
|
|
78
|
+
release: () => { release?.(); if (this.#release === release) this.#release = undefined },
|
|
79
|
+
ended: () => { if (this.#take === take) this.stop('recording-device-ended') },
|
|
80
|
+
}
|
|
81
|
+
this.#take = take
|
|
82
|
+
recorder.ondataavailable = event => {
|
|
83
|
+
if (this.#take !== take || !event.data.size) return
|
|
84
|
+
take.chunks.push(event.data); take.bytes += event.data.size
|
|
85
|
+
if (take.bytes > MAX_BYTES) this.stop('recording-size')
|
|
86
|
+
}
|
|
87
|
+
recorder.onerror = () => { if (this.#take === take) this.stop('recording-failed') }
|
|
88
|
+
recorder.onstop = () => this.#finish(take)
|
|
89
|
+
for (const track of stream.getTracks()) track.addEventListener('ended', take.ended)
|
|
90
|
+
recorder.start(1000)
|
|
91
|
+
this.#set({ ...this.#state, phase: 'recording', elapsedMs: 0, error: undefined })
|
|
92
|
+
take.interval = setInterval(() => {
|
|
93
|
+
if (this.#take !== take || recorder.state !== 'recording') return
|
|
94
|
+
const elapsedMs = performance.now() - take.started
|
|
95
|
+
this.#set({ ...this.#state, elapsedMs })
|
|
96
|
+
if (elapsedMs >= MAX_DURATION_MS) this.stop('recording-duration')
|
|
97
|
+
}, 1000)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
stream?.getTracks().forEach(track => track.stop())
|
|
100
|
+
release?.(); if (this.#release === release) this.#release = undefined
|
|
101
|
+
if (generation !== this.#generation) return
|
|
102
|
+
this.#take = undefined
|
|
103
|
+
const name = error instanceof DOMException ? error.name : ''
|
|
104
|
+
this.#settle(name === 'NotAllowedError' || name === 'SecurityError' ? 'recording-permission' : name === 'NotFoundError' ? 'recording-device' : name === 'NotReadableError' ? 'recording-device-busy' : name === 'NotSupportedError' ? 'recording-device-unsupported' : 'recording-failed')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop(error?: string): void {
|
|
109
|
+
const take = this.#take
|
|
110
|
+
if (!take) {
|
|
111
|
+
if (this.#state.phase === 'requesting') { this.#generation++; this.#release?.(); this.#release = undefined; this.#settle(error) }
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (error) take.error ??= error
|
|
115
|
+
clearInterval(take.interval)
|
|
116
|
+
if (take.recorder.state !== 'inactive') {
|
|
117
|
+
this.#set({ ...this.#state, phase: 'stopping' })
|
|
118
|
+
try { take.recorder.stop() }
|
|
119
|
+
catch { take.error ??= 'recording-failed'; this.#finish(take) }
|
|
120
|
+
}
|
|
121
|
+
take.stream.getTracks().forEach(track => track.stop())
|
|
122
|
+
take.release()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#finish(take: Take): void {
|
|
126
|
+
if (this.#take !== take) return
|
|
127
|
+
this.#take = undefined
|
|
128
|
+
clearInterval(take.interval)
|
|
129
|
+
for (const track of take.stream.getTracks()) { track.removeEventListener('ended', take.ended); track.stop() }
|
|
130
|
+
take.recorder.ondataavailable = null; take.recorder.onstop = null; take.recorder.onerror = null
|
|
131
|
+
take.release()
|
|
132
|
+
if (take.discard) { this.#settle(); return }
|
|
133
|
+
if (take.error && this.#state.candidate) { this.#settle(take.error); return }
|
|
134
|
+
if (!take.bytes || take.bytes > MAX_BYTES) { this.#settle(take.bytes ? 'recording-size' : 'recording-empty'); return }
|
|
135
|
+
const mime = take.recorder.mimeType || take.chunks[0]!.type
|
|
136
|
+
const extension = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'm4a' : 'webm'
|
|
137
|
+
const file = new File(take.chunks, `recording-${take.recordedAt.replace(/[:.]/g, '-')}.${extension}`, { type: mime })
|
|
138
|
+
this.#set({ phase: 'ready', candidate: { file, recordedAt: take.recordedAt, elapsedMs: performance.now() - take.started }, elapsedMs: performance.now() - take.started, ...take.error ? { error: take.error } : {} })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cancelTake(): void {
|
|
142
|
+
if (this.#take) this.#take.discard = true
|
|
143
|
+
this.stop()
|
|
144
|
+
}
|
|
145
|
+
imported(file: File, assetId: string): void {
|
|
146
|
+
if (this.#state.candidate?.file !== file) return
|
|
147
|
+
this.#set({ ...this.#state, candidate: { ...this.#state.candidate, assetId }, error: undefined })
|
|
148
|
+
}
|
|
149
|
+
forgetImport(assetId: string): void {
|
|
150
|
+
if (this.#state.candidate?.assetId !== assetId) return
|
|
151
|
+
this.#set({ ...this.#state, candidate: { ...this.#state.candidate, assetId: undefined } })
|
|
152
|
+
}
|
|
153
|
+
discard(): void {
|
|
154
|
+
this.cancelTake()
|
|
155
|
+
this.#set({ phase: 'idle', elapsedMs: 0 })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
dispose(): void {
|
|
159
|
+
if (this.#disposed) return
|
|
160
|
+
this.#disposed = true
|
|
161
|
+
this.#generation++
|
|
162
|
+
const take = this.#take
|
|
163
|
+
if (take) { take.discard = true; this.stop(); this.#finish(take) }
|
|
164
|
+
this.#release?.(); this.#release = undefined
|
|
165
|
+
if (this.#warning) window.removeEventListener('beforeunload', this.#beforeUnload)
|
|
166
|
+
this.#warning = false
|
|
167
|
+
this.#state = { phase: 'idle', elapsedMs: 0 }
|
|
168
|
+
this.#listeners.clear()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const recordings = new WeakMap<object, Map<string, AudioRecording>>()
|
|
173
|
+
/** Includes preparation and recorder finalization, when a playback lease alone cannot describe the take. */
|
|
174
|
+
export function documentRecordingActive(owner: object): boolean {
|
|
175
|
+
return [...recordings.get(owner)?.values() ?? []].some(recording => ['requesting', 'recording', 'stopping'].includes(recording.snapshot().phase))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Volatile UI material, isolated by the real workspace/document identity; never a persisted source model. */
|
|
179
|
+
export function documentRecording(owner: object, identity: string): AudioRecording {
|
|
180
|
+
let documents = recordings.get(owner)
|
|
181
|
+
if (!documents) { documents = new Map(); recordings.set(owner, documents) }
|
|
182
|
+
let recording = documents.get(identity)
|
|
183
|
+
if (!recording) { recording = new AudioRecording(); documents.set(identity, recording) }
|
|
184
|
+
return recording
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function disposeDocumentRecordings(owner: object): void {
|
|
188
|
+
const documents = recordings.get(owner)
|
|
189
|
+
recordings.delete(owner)
|
|
190
|
+
for (const recording of documents?.values() ?? []) recording.dispose()
|
|
191
|
+
documents?.clear()
|
|
192
|
+
}
|
package/src/audio-wav.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A recording or an audio file as 16-bit mono WAV, with its length. */
|
|
2
|
+
export interface WavAudio { blob: Blob, seconds: number }
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decodes any audio the browser plays (a microphone take in WebM, Ogg or MP4; an MP3, M4A or WAV file) and writes
|
|
6
|
+
* it as 16-bit mono WAV at `rate`: the one format every voice-cloning provider takes. Rendering through an offline
|
|
7
|
+
* context mixes the channels down and resamples, with no realtime audio and no permission.
|
|
8
|
+
*/
|
|
9
|
+
export async function toWav(source: Blob, rate = 24000): Promise<WavAudio> {
|
|
10
|
+
const decoded = await new OfflineAudioContext(1, 1, rate).decodeAudioData(await source.arrayBuffer())
|
|
11
|
+
const offline = new OfflineAudioContext(1, Math.max(1, Math.ceil(decoded.duration * rate)), rate)
|
|
12
|
+
const node = offline.createBufferSource()
|
|
13
|
+
node.buffer = decoded
|
|
14
|
+
node.connect(offline.destination)
|
|
15
|
+
node.start()
|
|
16
|
+
const samples = (await offline.startRendering()).getChannelData(0)
|
|
17
|
+
const wav = new DataView(new ArrayBuffer(44 + samples.length * 2))
|
|
18
|
+
const text = (at: number, value: string) => { for (let i = 0; i < value.length; i++) wav.setUint8(at + i, value.charCodeAt(i)) }
|
|
19
|
+
text(0, 'RIFF'); wav.setUint32(4, 36 + samples.length * 2, true); text(8, 'WAVE')
|
|
20
|
+
text(12, 'fmt '); wav.setUint32(16, 16, true); wav.setUint16(20, 1, true); wav.setUint16(22, 1, true)
|
|
21
|
+
wav.setUint32(24, rate, true); wav.setUint32(28, rate * 2, true); wav.setUint16(32, 2, true); wav.setUint16(34, 16, true)
|
|
22
|
+
text(36, 'data'); wav.setUint32(40, samples.length * 2, true)
|
|
23
|
+
for (let i = 0; i < samples.length; i++) wav.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, samples[i]!)) * 32767), true)
|
|
24
|
+
return { blob: new Blob([wav.buffer], { type: 'audio/wav' }), seconds: decoded.duration }
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Read-only peaks from decoded media, never a replacement for the lecture's timing plan. */
|
|
2
|
+
export interface AudioWaveform {
|
|
3
|
+
readonly durationMs: number
|
|
4
|
+
readonly peaks: readonly number[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MAX_BYTES = 16 * 1024 * 1024
|
|
8
|
+
const MAX_DURATION_MS = 10 * 60 * 1000
|
|
9
|
+
const MAX_PENDING = 128
|
|
10
|
+
const MAX_CACHED = 24
|
|
11
|
+
const CONCURRENCY = 2
|
|
12
|
+
|
|
13
|
+
interface Job {
|
|
14
|
+
key: string
|
|
15
|
+
durationMs: number
|
|
16
|
+
bins: number
|
|
17
|
+
load(signal: AbortSignal): Promise<Blob>
|
|
18
|
+
signal?: AbortSignal
|
|
19
|
+
unlisten(): void
|
|
20
|
+
resolve(value: AudioWaveform): void
|
|
21
|
+
reject(error: unknown): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function aborted(): DOMException { return new DOMException('Waveform reading cancelled', 'AbortError') }
|
|
25
|
+
|
|
26
|
+
async function decode(blob: Blob, durationMs: number, bins: number, signal: AbortSignal): Promise<AudioWaveform> {
|
|
27
|
+
signal.throwIfAborted()
|
|
28
|
+
if (!blob.size || blob.size > MAX_BYTES) throw new Error('waveform-size-limit')
|
|
29
|
+
if (typeof OfflineAudioContext !== 'function') throw new Error('waveform-decoder-unavailable')
|
|
30
|
+
const bytes = await blob.arrayBuffer()
|
|
31
|
+
signal.throwIfAborted()
|
|
32
|
+
// Offline decoding never starts an AudioContext destination or requests permission to play.
|
|
33
|
+
const context = new OfflineAudioContext(1, 1, 8000)
|
|
34
|
+
let audio: AudioBuffer
|
|
35
|
+
try { audio = await context.decodeAudioData(bytes) }
|
|
36
|
+
catch { signal.throwIfAborted(); throw new Error('waveform-invalid-audio') }
|
|
37
|
+
signal.throwIfAborted()
|
|
38
|
+
const decodedMs = audio.duration * 1000
|
|
39
|
+
if (!Number.isFinite(decodedMs) || decodedMs <= 0 || decodedMs > MAX_DURATION_MS) throw new Error('waveform-duration-limit')
|
|
40
|
+
// A materially different file must not draw a plausible waveform against the old plan's duration.
|
|
41
|
+
if (Math.abs(decodedMs - durationMs) > Math.max(100, durationMs * 0.01)) throw new Error('waveform-duration-changed')
|
|
42
|
+
const peaks = Array.from({ length: bins }, () => 0)
|
|
43
|
+
for (let channel = 0; channel < audio.numberOfChannels; channel++) {
|
|
44
|
+
const samples = audio.getChannelData(channel)
|
|
45
|
+
for (let index = 0; index < samples.length; index++) {
|
|
46
|
+
const bin = Math.min(bins - 1, Math.floor(index * bins / samples.length))
|
|
47
|
+
const sample = samples[index]!
|
|
48
|
+
if (!Number.isFinite(sample)) throw new Error('waveform-invalid-audio')
|
|
49
|
+
peaks[bin] = Math.max(peaks[bin]!, Math.min(1, Math.abs(sample)))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
signal.throwIfAborted()
|
|
53
|
+
return Object.freeze({ durationMs: Math.round(decodedMs), peaks: Object.freeze(peaks) })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A visible production workspace owns this queue; closing it cancels reads and releases cached peaks. */
|
|
57
|
+
export class AudioWaveforms {
|
|
58
|
+
readonly #queue: Job[] = []
|
|
59
|
+
readonly #active = new Set<AbortController>()
|
|
60
|
+
readonly #cache = new Map<string, AudioWaveform>()
|
|
61
|
+
#closed = false
|
|
62
|
+
|
|
63
|
+
/** key includes the confirmed plan and clip identity; media is fetched only after a queue slot is available. */
|
|
64
|
+
read(key: string, load: Job['load'], durationMs: number, signal?: AbortSignal, bins = 128): Promise<AudioWaveform> {
|
|
65
|
+
if (this.#closed || signal?.aborted) return Promise.reject(signal?.reason ?? aborted())
|
|
66
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0 || durationMs > MAX_DURATION_MS) return Promise.reject(new Error('waveform-duration-limit'))
|
|
67
|
+
if (!Number.isInteger(bins) || bins < 16 || bins > 2048) return Promise.reject(new Error('waveform-bin-limit'))
|
|
68
|
+
const identity = JSON.stringify([key, durationMs, bins]), cached = this.#cache.get(identity)
|
|
69
|
+
if (cached) {
|
|
70
|
+
this.#cache.delete(identity); this.#cache.set(identity, cached)
|
|
71
|
+
return Promise.resolve(cached)
|
|
72
|
+
}
|
|
73
|
+
if (this.#queue.length >= MAX_PENDING) return Promise.reject(new Error('waveform-queue-limit'))
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const cancel = () => {
|
|
76
|
+
const index = this.#queue.indexOf(job)
|
|
77
|
+
if (index < 0) return
|
|
78
|
+
this.#queue.splice(index, 1); job.unlisten(); reject(signal?.reason ?? aborted())
|
|
79
|
+
}
|
|
80
|
+
const job: Job = { key: identity, load, durationMs, bins, signal, resolve, reject, unlisten: () => signal?.removeEventListener('abort', cancel) }
|
|
81
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
82
|
+
this.#queue.push(job); this.#pump()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dispose(): void {
|
|
87
|
+
this.#closed = true
|
|
88
|
+
for (const job of this.#queue.splice(0)) { job.unlisten(); job.reject(aborted()) }
|
|
89
|
+
for (const controller of this.#active) controller.abort(aborted())
|
|
90
|
+
this.#cache.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#pump(): void {
|
|
94
|
+
while (!this.#closed && this.#active.size < CONCURRENCY && this.#queue.length) {
|
|
95
|
+
const job = this.#queue.shift()!
|
|
96
|
+
job.unlisten()
|
|
97
|
+
const controller = new AbortController(), signal = job.signal ? AbortSignal.any([controller.signal, job.signal]) : controller.signal
|
|
98
|
+
this.#active.add(controller)
|
|
99
|
+
void (async () => {
|
|
100
|
+
signal.throwIfAborted()
|
|
101
|
+
const value = await decode(await job.load(signal), job.durationMs, job.bins, signal)
|
|
102
|
+
signal.throwIfAborted()
|
|
103
|
+
if (this.#closed) throw aborted()
|
|
104
|
+
this.#cache.delete(job.key); this.#cache.set(job.key, value)
|
|
105
|
+
if (this.#cache.size > MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value!)
|
|
106
|
+
return value
|
|
107
|
+
})().then(job.resolve, job.reject).finally(() => { this.#active.delete(controller); this.#pump() })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/audition.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Workbench } from './workbench.ts'
|
|
2
|
+
import { speechFailureCode } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export interface AuditionState { status: 'idle' | 'loading' | 'playing' | 'failed', error?: string }
|
|
5
|
+
export interface AuditionSource { audio: Blob | string }
|
|
6
|
+
|
|
7
|
+
/** An explicit audio audition owns its request and media; formal playback owns the lecture clock. */
|
|
8
|
+
export class Audition {
|
|
9
|
+
readonly #workbench: Workbench
|
|
10
|
+
readonly #listeners = new Set<() => void>()
|
|
11
|
+
readonly #unregister: () => void
|
|
12
|
+
#state: AuditionState = { status: 'idle' }
|
|
13
|
+
#version = 0
|
|
14
|
+
#request?: AbortController
|
|
15
|
+
#audio?: HTMLAudioElement
|
|
16
|
+
#objectUrl?: string
|
|
17
|
+
#disposed = false
|
|
18
|
+
|
|
19
|
+
constructor(workbench: Workbench) {
|
|
20
|
+
this.#workbench = workbench
|
|
21
|
+
this.#unregister = workbench.registerAudition(() => this.stop())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
snapshot = (): AuditionState => this.#state
|
|
25
|
+
subscribe = (listener: () => void): (() => void) => { this.#listeners.add(listener); return () => { this.#listeners.delete(listener) } }
|
|
26
|
+
#set(state: AuditionState): void { this.#state = state; for (const listener of this.#listeners) listener() }
|
|
27
|
+
|
|
28
|
+
#release(): void {
|
|
29
|
+
this.#request?.abort()
|
|
30
|
+
this.#request = undefined
|
|
31
|
+
const audio = this.#audio
|
|
32
|
+
this.#audio = undefined
|
|
33
|
+
if (audio) { audio.onended = null; audio.onerror = null; audio.pause(); audio.removeAttribute('src'); audio.load() }
|
|
34
|
+
if (this.#objectUrl) URL.revokeObjectURL(this.#objectUrl)
|
|
35
|
+
this.#objectUrl = undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
stop(): void {
|
|
39
|
+
this.#version++
|
|
40
|
+
this.#release()
|
|
41
|
+
if (!this.#disposed) this.#set({ status: 'idle' })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async play(produce: (signal: AbortSignal) => Promise<AuditionSource>): Promise<void> {
|
|
45
|
+
this.#workbench.stopAuditions()
|
|
46
|
+
if (this.#disposed) return
|
|
47
|
+
const version = this.#version
|
|
48
|
+
const request = new AbortController()
|
|
49
|
+
this.#request = request
|
|
50
|
+
const current = () => !this.#disposed && this.#version === version && !request.signal.aborted
|
|
51
|
+
this.#set({ status: 'loading' })
|
|
52
|
+
try {
|
|
53
|
+
await this.#workbench.pausePlayback(true)
|
|
54
|
+
if (!current()) return
|
|
55
|
+
const source = await produce(request.signal)
|
|
56
|
+
if (!current()) return
|
|
57
|
+
const audio = new Audio()
|
|
58
|
+
this.#audio = audio
|
|
59
|
+
audio.preload = 'auto'
|
|
60
|
+
audio.src = typeof source.audio === 'string' ? source.audio : this.#objectUrl = URL.createObjectURL(source.audio)
|
|
61
|
+
audio.onended = () => { if (current()) this.stop() }
|
|
62
|
+
audio.onerror = () => {
|
|
63
|
+
if (!current()) return
|
|
64
|
+
this.#release()
|
|
65
|
+
this.#set({ status: 'failed', error: 'audition-audio' })
|
|
66
|
+
}
|
|
67
|
+
await audio.play()
|
|
68
|
+
if (!current()) { audio.pause(); return }
|
|
69
|
+
this.#set({ status: 'playing' })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!current()) return
|
|
72
|
+
this.#release()
|
|
73
|
+
this.#set({ status: 'failed', error: speechFailureCode(error) ?? (error instanceof Error && error.message.startsWith('preview-playback-') ? error.message : 'audition-request') })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
if (this.#disposed) return
|
|
79
|
+
this.stop()
|
|
80
|
+
this.#disposed = true
|
|
81
|
+
this.#unregister()
|
|
82
|
+
this.#listeners.clear()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -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
|
+
})
|