@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

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