@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
package/src/outline.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { IRDocument, MdastNode, TeachingDocument } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
/** A section as the navigation pane lists it: in the slides each section is one slide, numbered from 1. */
|
|
4
|
+
export interface OutlineItem {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
depth: number
|
|
8
|
+
number: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function documentOutline(doc: IRDocument | TeachingDocument | undefined): OutlineItem[] {
|
|
12
|
+
if (doc && 'schemaVersion' in doc) return doc.sectionOrder.map((id, index) => ({ id, title: doc.sections[id]!.title, depth: doc.sections[id]!.level ?? 1, number: index + 1 }))
|
|
13
|
+
return (doc?.sections ?? []).map((section, i) => ({ id: section.id, title: section.title, depth: section.depth, number: i + 1 }))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A stable display snapshot lets navigation ignore changes to the document body. */
|
|
17
|
+
export class DocumentOutline {
|
|
18
|
+
#identity?: string
|
|
19
|
+
#items: readonly OutlineItem[] = []
|
|
20
|
+
|
|
21
|
+
read(document: TeachingDocument | undefined, identity = document?.id): readonly OutlineItem[] {
|
|
22
|
+
const items = documentOutline(document)
|
|
23
|
+
if (identity !== this.#identity || items.length !== this.#items.length || items.some((item, index) => {
|
|
24
|
+
const previous = this.#items[index]!
|
|
25
|
+
return item.id !== previous.id || item.title !== previous.title || item.depth !== previous.depth || item.number !== previous.number
|
|
26
|
+
})) {
|
|
27
|
+
this.#identity = identity
|
|
28
|
+
this.#items = items
|
|
29
|
+
}
|
|
30
|
+
return this.#items
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// As a word processor counts: each CJK character is a word, and so is each run of Latin letters or digits.
|
|
35
|
+
const CJK = /[-ヿ㐀-䶿一-鿿가-豈-]/g
|
|
36
|
+
const WORD = /[A-Za-z0-9À-ɏ]+(?:['’.-][A-Za-z0-9À-ɏ]+)*/g
|
|
37
|
+
|
|
38
|
+
function countText(text: string): number {
|
|
39
|
+
return (text.match(CJK)?.length ?? 0) + (text.replace(CJK, ' ').match(WORD)?.length ?? 0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countNode(node: MdastNode): number {
|
|
43
|
+
const own = (node.type === 'text' || node.type === 'inlineCode') && typeof node.value === 'string' ? countText(node.value) : 0
|
|
44
|
+
return own + (node.children ?? []).reduce((sum, child) => sum + countNode(child), 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function richTextParts(value: unknown, parts: string[] = []): string[] {
|
|
48
|
+
if (!value || typeof value !== 'object') return parts
|
|
49
|
+
if (Array.isArray(value)) { for (const child of value) richTextParts(child, parts); return parts }
|
|
50
|
+
const node = value as { type?: string, text?: string, children?: unknown[], fallback?: unknown }
|
|
51
|
+
if (node.type === 'code') return parts
|
|
52
|
+
if (node.type === 'text' && node.text) parts.push(node.text)
|
|
53
|
+
richTextParts(node.children, parts); richTextParts(node.fallback, parts)
|
|
54
|
+
return parts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Counts authored titles and rich text; code, components and separate narration are excluded. */
|
|
58
|
+
export function countWords(doc: IRDocument | TeachingDocument | undefined): number {
|
|
59
|
+
if (doc && 'schemaVersion' in doc) {
|
|
60
|
+
return doc.sectionOrder.reduce((sum, id) => sum + countText(doc.sections[id]!.title), 0) + Object.values(doc.blocks).reduce((sum, block) => sum + (block.type === 'text' ? richTextParts(block.content).reduce((sum, text) => sum + countText(text), 0) : 0), 0)
|
|
61
|
+
}
|
|
62
|
+
let words = 0
|
|
63
|
+
for (const section of doc?.sections ?? []) {
|
|
64
|
+
words += countText(section.title)
|
|
65
|
+
for (const block of section.blocks) if (block.node.type === 'markdown') words += countNode(block.node.mdast)
|
|
66
|
+
}
|
|
67
|
+
return words
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Display-only counts, bounded to the current document's titles and text blocks. */
|
|
71
|
+
export class DocumentWordCounter {
|
|
72
|
+
#identity?: string
|
|
73
|
+
readonly #counts = new Map<string, { signature: string, words: number }>()
|
|
74
|
+
|
|
75
|
+
count(doc: TeachingDocument | undefined, identity = doc?.id): number {
|
|
76
|
+
if (identity !== this.#identity || !doc) { this.#counts.clear(); this.#identity = identity }
|
|
77
|
+
if (!doc) return 0
|
|
78
|
+
const unused = new Set(this.#counts.keys())
|
|
79
|
+
const read = (key: string, signature: string, count: () => number): number => {
|
|
80
|
+
unused.delete(key)
|
|
81
|
+
let found = this.#counts.get(key)
|
|
82
|
+
if (found?.signature !== signature) { found = { signature, words: count() }; this.#counts.set(key, found) }
|
|
83
|
+
return found.words
|
|
84
|
+
}
|
|
85
|
+
let words = 0
|
|
86
|
+
for (const id of doc.sectionOrder) {
|
|
87
|
+
const title = doc.sections[id]!.title
|
|
88
|
+
words += read(`title:${id}`, title, () => countText(title))
|
|
89
|
+
}
|
|
90
|
+
for (const block of Object.values(doc.blocks)) if (block.type === 'text') {
|
|
91
|
+
const texts = richTextParts(block.content)
|
|
92
|
+
words += read(`body:${block.id}`, JSON.stringify(texts), () => texts.reduce((sum, text) => sum + countText(text), 0))
|
|
93
|
+
}
|
|
94
|
+
for (const key of unused) this.#counts.delete(key)
|
|
95
|
+
return words
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CSSProperties, RefObject } from 'react'
|
|
3
|
+
import type { MascotAction } from '@lincyaw/design/mascot'
|
|
4
|
+
import { PmMascot } from '../components.tsx'
|
|
5
|
+
import { useMascotEnabled } from '../mascot-preference.ts'
|
|
6
|
+
import { useWorkbenchState } from '../context.tsx'
|
|
7
|
+
import { pageId, richNodeIds } from '../document-editor/projection.ts'
|
|
8
|
+
|
|
9
|
+
/** How long the kitten walks from one changed place to the next, pats it, and stays when it is done (ms). */
|
|
10
|
+
const WALK_MS = 700, PAT_MS = 900, DONE_MS = 2200
|
|
11
|
+
/** At most this many changed places are visited. */
|
|
12
|
+
const STOPS = 6
|
|
13
|
+
|
|
14
|
+
type Point = { x: number, y: number }
|
|
15
|
+
type Phase = 'walk' | 'pat' | 'done' | 'gone'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The kitten visiting a list of points in its host (each the lower left of a changed place): it walks to each, pats it,
|
|
19
|
+
* hops when it is done and leaves. A new `id` starts it again from the first point; `where(i)` is asked on every
|
|
20
|
+
* render, so the kitten follows the places as the host lays out again. Nothing shows when the kitten is turned off.
|
|
21
|
+
*/
|
|
22
|
+
function KittenTrail({ id, count, where }: { id: number | undefined, count: number, where(index: number): Point | undefined }) {
|
|
23
|
+
const enabled = useMascotEnabled()
|
|
24
|
+
const [step, setStep] = useState<{ id: number, stop: number, phase: Phase }>()
|
|
25
|
+
const facing = useRef<'left' | 'right'>('right'), last = useRef<Point>()
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (id !== undefined && count > 0) setStep(current => current?.id === id ? current : { id, stop: 0, phase: 'walk' })
|
|
28
|
+
}, [id, count])
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!step || step.phase === 'gone') return
|
|
31
|
+
const stops = Math.min(STOPS, count)
|
|
32
|
+
const next: typeof step = step.phase === 'walk' ? { ...step, phase: 'pat' }
|
|
33
|
+
: step.phase === 'pat' ? (step.stop + 1 < stops ? { ...step, stop: step.stop + 1, phase: 'walk' } : { ...step, phase: 'done' })
|
|
34
|
+
: { ...step, phase: 'gone' }
|
|
35
|
+
const timer = setTimeout(() => setStep(next), step.phase === 'walk' ? WALK_MS : step.phase === 'pat' ? PAT_MS : DONE_MS)
|
|
36
|
+
return () => clearTimeout(timer)
|
|
37
|
+
}, [step, count])
|
|
38
|
+
if (!enabled || !step || step.phase === 'gone' || step.id !== id) return null
|
|
39
|
+
const point = where(Math.min(step.stop, count - 1))
|
|
40
|
+
if (!point) return null
|
|
41
|
+
if (last.current && Math.abs(point.x - last.current.x) > 2 && step.phase === 'walk') facing.current = point.x < last.current.x ? 'left' : 'right'
|
|
42
|
+
if (step.phase === 'walk') last.current = point
|
|
43
|
+
const action: MascotAction = step.phase === 'walk' ? 'walk' : step.phase === 'pat' ? 'write' : 'done'
|
|
44
|
+
return (
|
|
45
|
+
<div className="pmw-change-trail" data-phase={step.phase} style={{ '--pmw-trail-x': `${point.x}px`, '--pmw-trail-y': `${point.y}px` } as CSSProperties}>
|
|
46
|
+
<div className="pmw-change-trail__facing" data-facing={facing.current}><PmMascot size="small" action={action} /></div>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** In the preview: the blocks the preview reports blinking, in the active frame's viewport. */
|
|
52
|
+
export function PreviewChangeTrail({ stage, frames }: { stage: RefObject<HTMLDivElement | null>, frames: RefObject<HTMLDivElement | null> }) {
|
|
53
|
+
const trail = useWorkbenchState(state => state.changeTrail)
|
|
54
|
+
return <KittenTrail id={trail?.id} count={trail?.rects.length ?? 0} where={index => {
|
|
55
|
+
const rect = trail?.rects[index]
|
|
56
|
+
const frame = frames.current?.querySelector<HTMLIFrameElement>('iframe[data-active="true"]:not([hidden])')
|
|
57
|
+
const host = stage.current
|
|
58
|
+
if (!rect || !frame || !host) return undefined
|
|
59
|
+
const offset = frame.getBoundingClientRect(), origin = host.getBoundingClientRect()
|
|
60
|
+
return { x: offset.left - origin.left + rect.x, y: offset.top - origin.top + rect.y + rect.height }
|
|
61
|
+
}} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** In the editor: each changed block (a text block by its first paragraph), else its page's title, on the sheet. */
|
|
65
|
+
export function EditorChangeTrail({ sheet }: { sheet: RefObject<HTMLDivElement | null> }) {
|
|
66
|
+
const places = useWorkbenchState(state => state.changedPlaces)
|
|
67
|
+
const content = useWorkbenchState(state => state.authoring?.content)
|
|
68
|
+
return <KittenTrail id={places?.id} count={places?.targets.length ?? 0} where={index => {
|
|
69
|
+
const target = places?.targets[index], host = sheet.current
|
|
70
|
+
if (!target || !host) return undefined
|
|
71
|
+
const block = target.block ? content?.blocks[target.block] : undefined
|
|
72
|
+
const ids = [...target.block ? [target.block] : [], ...block?.type === 'text' ? richNodeIds(block.content) : [], pageId(target.section)]
|
|
73
|
+
const element = ids.map(id => host.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)).find(Boolean)
|
|
74
|
+
if (!element) return undefined
|
|
75
|
+
const box = element.getBoundingClientRect(), origin = host.getBoundingClientRect()
|
|
76
|
+
// the kitten stands on the place's first line, at its left edge
|
|
77
|
+
return { x: box.left - origin.left, y: box.top - origin.top + Math.min(box.height, 28) }
|
|
78
|
+
}} />
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect, useId } from 'react'
|
|
2
|
+
import { PmEmpty, PmIconButton } from '../components.tsx'
|
|
3
|
+
import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
|
|
4
|
+
import { DocumentStepNarration } from '../document-editor/DocumentStepNarration.tsx'
|
|
5
|
+
|
|
6
|
+
/** The same step-bound fields are available even when a page has no runtime block. */
|
|
7
|
+
export function DocumentNarrationPanel({ onClose }: { onClose(): void }) {
|
|
8
|
+
const workbench = useWorkbench(), t = useMessages(), panelId = useId()
|
|
9
|
+
const source = useWorkbenchState(state => state.authoring)
|
|
10
|
+
const position = useWorkbenchState(state => state.position)
|
|
11
|
+
const document = source?.content
|
|
12
|
+
const sectionId = position && document?.sections[position.section] ? position.section : document?.sectionOrder[0]
|
|
13
|
+
const section = sectionId ? document?.sections[sectionId] : undefined
|
|
14
|
+
const stepId = section?.stepOrder[position && position.section === sectionId ? position.step : 0] ?? section?.stepOrder[0]
|
|
15
|
+
const pause = () => {
|
|
16
|
+
if (sectionId && stepId) workbench.pauseDocumentStep(sectionId, stepId)
|
|
17
|
+
workbench.stopAuditions()
|
|
18
|
+
}
|
|
19
|
+
useEffect(() => { pause() }, [workbench, source?.identity])
|
|
20
|
+
return <section className="pmw-pane" aria-labelledby={panelId}>
|
|
21
|
+
<header className="pmw-pane__header"><h2 id={panelId} className="pmw-pane__title">{t.documentNarration}</h2><PmIconButton small icon="close" label={t.close} onClick={onClose} /></header>
|
|
22
|
+
{section && sectionId && stepId ? <>
|
|
23
|
+
<p className="pmw-pane__footer">{section.title}</p>
|
|
24
|
+
<DocumentStepNarration sectionId={sectionId} stepId={stepId} expanded showLabel={false} panelId={`${panelId}-fields`} onPause={pause} />
|
|
25
|
+
</> : <PmEmpty icon="captions">{t.noSections}</PmEmpty>}
|
|
26
|
+
</section>
|
|
27
|
+
}
|