diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 367cbb3..1d6906b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -449,8 +449,17 @@ which is also what makes the localhost demo work with nothing installed. `editor` is scene-centric — you edit one scene at a time, like slides, with a derived clip timeline underneath. -- **Every mutation is a pure `EditOp` with `apply(state, op) → {state, inverse}`.** Nothing - mutates in place. The inverse is the backbone of undo/redo, version history and audit. +- **Every mutation is a pure `EditOp` with `apply(workspace, op) → {workspace, inverse}`.** + Nothing mutates in place. The inverse is the backbone of undo/redo, version history and audit. +- The workspace is **the document *and* its locale bundles**. Editing text is the commonest + edit there is, and words live in bundles (R4) — so both move under one history. With two + histories, undo after "rename the heading, then reorder the scenes" walks back through them + in an order that belongs to neither. Operations that only touch structure are still written + against the document alone and lifted by `documentOp`, so they cannot alter a bundle by + accident. +- The package has two entry points: `@bingoo.ai/explainer-editor/ops` is pure and pulls in no + renderer, for a server applying edits or a script running a migration. The default entry + adds the panels. - The clip model is **derived from the cue/beat document** — the timeline never invents timing, so dragging a clip is translated back into `EditOp`s on beats and cues. - The author never sees cue strings, verbs or canvas fractions. They see clips, effects and diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a2d2b7..f84765b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,7 +16,10 @@ const PURE_PACKAGES = [ 'packages/quality/**', 'packages/providers/**', 'packages/generator/**', - 'packages/editor/**', + 'packages/editor/src/op.ts', + 'packages/editor/src/ops.ts', + 'packages/editor/src/ops/**', + 'packages/editor/src/history.ts', ] const FORBIDDEN_IN_PURE = [ diff --git a/packages/editor/package.json b/packages/editor/package.json index 4e8f431..4008137 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -23,6 +23,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./ops": { + "types": "./dist/ops.d.ts", + "import": "./dist/ops.js", + "require": "./dist/ops.cjs" } }, "scripts": { @@ -34,10 +39,24 @@ "dependencies": { "@bingoo.ai/explainer-core": "workspace:*", "@bingoo.ai/explainer-kernel": "workspace:*", + "@bingoo.ai/explainer-react": "workspace:*", "zod": "^4.4.3" }, "publishConfig": { "access": "public", "provenance": true + }, + "peerDependencies": { + "react": "^18.3.0 || ^19.0.0", + "react-dom": "^18.3.0 || ^19.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^25.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@testing-library/jest-dom": "^6.6.3" } } diff --git a/packages/editor/src/clips.ts b/packages/editor/src/clips.ts new file mode 100644 index 0000000..b7cab57 --- /dev/null +++ b/packages/editor/src/clips.ts @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import type { Element, Program, Scene, SceneSlot, VisualAction } from '@bingoo.ai/explainer-core' +import { stripCues } from '@bingoo.ai/explainer-core' + +/** + * The clip model: a `Program` seen the way a video editor sees it. + * + * A document says *what happens* — "reveal the diagram when the narrator reaches this word". + * A timeline has to show *when things exist*, as bars you can look along. This turns one + * into the other: an element becomes a clip that starts at its entrance and ends at its + * exit, and clips that overlap get stacked onto separate rows. + * + * Every time here is derived from the compiled timeline. Nothing is invented and nothing is + * stored, so a clip cannot drift from what playback will actually do — which is the failure + * mode of every editor that keeps its own copy of the timings. + * + * This module is pure and holds no React, so a server can build the same view for an API + * response or a diff without pulling a renderer in with it. + */ + +export interface ClipEffect { + /** Seconds from the start of the program. */ + readonly at: number + readonly verb: string + readonly beatId: string +} + +export interface Clip { + readonly key: string + readonly sceneId: string + readonly elementId: string + readonly label: string + readonly type: string + readonly start: number + readonly end: number + readonly sceneStart: number + readonly sceneEnd: number + readonly effects: readonly ClipEffect[] +} + +/** A row of clips that do not overlap, so they can share a lane. */ +export interface Track { + readonly row: number + readonly clips: readonly Clip[] +} + +export interface NarrationClip { + readonly sceneId: string + readonly ref: string + readonly start: number + readonly end: number + readonly text: string + readonly voiced: boolean +} + +export interface GateClip { + readonly sceneId: string + readonly start: number + readonly end: number + readonly kind: string +} + +export interface SceneBound { + readonly id: string + readonly start: number + readonly end: number + readonly kind: string +} + +export interface Project { + readonly duration: number + readonly scenes: readonly SceneBound[] + readonly tracks: readonly Track[] + readonly narration: readonly NarrationClip[] + readonly gates: readonly GateClip[] +} + +/** + * Verbs that bring an element on screen. + * + * A set rather than a list of `if`s because a kit may register its own reveal-like verb, and + * a timeline that did not know about it would draw the clip as starting at the scene's + * beginning — visible, wrong, and hard to explain (R1). + */ +export const ENTRANCE_VERBS: ReadonlySet = new Set(['reveal', 'drawOn', 'revealItem', 'revealAll']) +export const EXIT_VERBS: ReadonlySet = new Set(['exit']) + +export function buildProject(program: Program): Project { + const clips: Clip[] = [] + const narration: NarrationClip[] = [] + const gates: GateClip[] = [] + + for (const slot of program.slots) { + if (slot.scene.gate) { + gates.push({ + sceneId: slot.scene.id, + start: slot.start + (slot.gateOpensAt ?? 0), + end: slot.end, + kind: slot.scene.gate.kind, + }) + } + + for (const segment of slot.narrations) { + narration.push({ + sceneId: slot.scene.id, + ref: segment.ref, + start: slot.start + segment.at, + end: slot.start + segment.at + segment.duration, + text: stripCues(segment.narration.script).replace(/\s+/g, ' ').trim(), + voiced: segment.narration.words.length > 0, + }) + } + + for (const element of slot.scene.elements) { + clips.push(clipFor(slot, element, program)) + } + } + + return { + duration: program.duration, + scenes: program.slots.map((slot) => ({ + id: slot.scene.id, + start: slot.start, + end: slot.end, + kind: slot.scene.kind, + })), + tracks: pack(clips), + narration, + gates, + } +} + +/** The shortest a clip may be drawn. Below this it is a sliver nobody can grab. */ +const MIN_CLIP_SECONDS = 0.3 + +function clipFor(slot: SceneSlot, element: Element, program: Program): Clip { + const beatAt = new Map(slot.timeline.beatTimes.map((beat) => [beat.id, beat.at] as const)) + const effects: ClipEffect[] = [] + + for (const beat of slot.scene.beats) { + const at = beatAt.get(beat.id) + if (at === undefined) continue + + for (const action of beat.actions) { + if (!targets(action, element.id)) continue + effects.push({ at: slot.start + at, verb: action.verb, beatId: beat.id }) + } + } + + const entrances = effects.filter((effect) => ENTRANCE_VERBS.has(effect.verb)) + const exits = effects.filter((effect) => EXIT_VERBS.has(effect.verb)) + + // An element with no entrance is on screen from the start — which is what `startVisible` + // means, and also what an unauthored element does. Both read the same on a timeline. + const start = entrances.length > 0 ? Math.min(...entrances.map((e) => e.at)) : slot.start + const end = exits.length > 0 ? Math.max(...exits.map((e) => e.at)) : slot.end + + return { + key: `${slot.scene.id}:${element.id}`, + sceneId: slot.scene.id, + elementId: element.id, + label: clipLabel(element, program), + type: element.type, + start, + end: Math.max(end, start + MIN_CLIP_SECONDS), + sceneStart: slot.start, + sceneEnd: slot.end, + effects: effects.sort((a, b) => a.at - b.at), + } +} + +/** Whether an action acts on this element. `connect` names two, and both ends of an arrow + * are things the arrow happens *to*. */ +function targets(action: VisualAction, elementId: string): boolean { + if ('target' in action) return action.target === elementId + if (action.verb === 'connect') return action.from === elementId || action.to === elementId + return false +} + +/** + * What to call a clip. + * + * The element's own words where it has any, because that is how an author recognises it — + * a strip of clips labelled `text`, `text`, `text` is a strip you have to click through. + */ +function clipLabel(element: Element, program: Program): string { + const key = (element.content as { key?: string }).key + const phrase = key === undefined ? undefined : program.bundle.strings[key] + if (phrase !== undefined && phrase !== '') return truncate(stripCues(phrase)) + return element.type +} + +const LABEL_LIMIT = 24 + +function truncate(text: string): string { + const clean = text.replace(/\s+/g, ' ').trim() + return clean.length > LABEL_LIMIT ? `${clean.slice(0, LABEL_LIMIT - 1)}…` : clean +} + +/** + * Greedy row packing, so clips that overlap in time end up on separate rows. + * + * Sorted by start, then each clip goes on the first row whose last clip has already + * finished. This is the standard interval-partitioning result: it uses the fewest rows + * possible, which matters because every extra row is timeline height taken away from the + * preview above it. + */ +function pack(clips: readonly Clip[]): Track[] { + const sorted = [...clips].sort((a, b) => a.start - b.start || a.key.localeCompare(b.key)) + const rows: Clip[][] = [] + + for (const clip of sorted) { + const row = rows.find((candidate) => candidate[candidate.length - 1]!.end <= clip.start + 0.001) + if (row) row.push(clip) + else rows.push([clip]) + } + + return rows.map((clips, row) => ({ row, clips })) +} + +/** The clip a given element belongs to, if it is on the timeline at all. */ +export function clipOf(project: Project, sceneId: string, elementId: string): Clip | undefined { + for (const track of project.tracks) { + const found = track.clips.find((clip) => clip.sceneId === sceneId && clip.elementId === elementId) + if (found) return found + } + return undefined +} + +/** The scene covering a program time, for turning a click on the timeline into a selection. */ +export function sceneAt(project: Project, t: number): SceneBound | undefined { + return project.scenes.find((scene) => t >= scene.start && t < scene.end) ?? project.scenes.at(-1) +} + +export type { Scene } diff --git a/packages/editor/src/history.ts b/packages/editor/src/history.ts index 9abbe31..76436e5 100644 --- a/packages/editor/src/history.ts +++ b/packages/editor/src/history.ts @@ -1,8 +1,7 @@ // SPDX-FileCopyrightText: 2026 Bingoo.ai // SPDX-License-Identifier: Apache-2.0 -import type { ExplainerDocument } from '@bingoo.ai/explainer-core' -import type { EditOp, OpRegistry } from './op.js' +import type { EditOp, OpRegistry, Workspace } from './op.js' /** * Undo and redo, built entirely from the inverses the operations already produced. @@ -18,15 +17,15 @@ import type { EditOp, OpRegistry } from './op.js' */ export interface HistoryState { - readonly document: ExplainerDocument + readonly workspace: Workspace /** Inverses of what has been done, most recent last. */ readonly past: readonly EditOp[] /** Operations undone and available to redo, most recent last. */ readonly future: readonly EditOp[] } -export function initialHistory(document: ExplainerDocument): HistoryState { - return { document, past: [], future: [] } +export function initialHistory(workspace: Workspace): HistoryState { + return { workspace, past: [], future: [] } } export interface HistoryLimits { @@ -47,12 +46,12 @@ export function commit( op: EditOp, limits: HistoryLimits = {}, ): HistoryState { - const result = registry.apply(state.document, op) + const result = registry.apply(state.workspace, op) const depth = limits.depth ?? 200 const past = [...state.past, result.inverse] return { - document: result.document, + workspace: result.workspace, past: past.length > depth ? past.slice(past.length - depth) : past, future: [], } @@ -62,9 +61,9 @@ export function undo(registry: OpRegistry, state: HistoryState): HistoryState { const inverse = state.past[state.past.length - 1] if (!inverse) return state - const result = registry.apply(state.document, inverse) + const result = registry.apply(state.workspace, inverse) return { - document: result.document, + workspace: result.workspace, past: state.past.slice(0, -1), // The inverse of the inverse is the original operation, so redo needs no separate // record — and cannot disagree with what undo actually did. @@ -76,9 +75,9 @@ export function redo(registry: OpRegistry, state: HistoryState): HistoryState { const op = state.future[state.future.length - 1] if (!op) return state - const result = registry.apply(state.document, op) + const result = registry.apply(state.workspace, op) return { - document: result.document, + workspace: result.workspace, past: [...state.past, result.inverse], future: state.future.slice(0, -1), } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 0d33039..bc9c446 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -2,47 +2,39 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Editing operations. + * The editor: the operation layer, and the panels built on it. * - * Every change is a pure operation returning a new document and its inverse. Undo, redo, - * version history and an audit trail all fall out of that pairing, and none of them can - * drift from what actually happened — because the inverse is produced by the same code that - * made the change, at the moment it still held the old value. + * Every panel here changes a lesson by dispatching an operation, never by mutating a + * document. That is what makes undo, history and an audit trail work for edits nobody has + * written yet — a kit that registers its own operation gets all three for free. + * + * A host that only needs the operations — a server applying an edit, a script running a + * migration — should import `@bingoo.ai/explainer-editor/ops` instead. That entry point is + * pure and pulls in no renderer. */ -export { - applyAll, - createOpRegistry, - registerBatch, - type EditOp, - type OpHandler, - type OpRegistry, - type OpResult, -} from './op.js' +export * from './ops.js' -export { registerSceneOps } from './ops/scene.js' -export { registerElementOps } from './ops/element.js' +export { ExplainerStudio, type ExplainerStudioProps } from './ui/Studio.js' +export { TopBar, DEFAULT_ASPECTS, type SaveState, type TopBarProps } from './ui/TopBar.js' +export { SceneStrip } from './ui/SceneStrip.js' +export { TrackTimeline, CLIP_COLORS, type TrackTimelineProps } from './ui/TrackTimeline.js' +export { Inspector } from './ui/Inspector.js' +export { EditableStage, type EditableStageProps } from './ui/EditableStage.js' +export { EditorProvider, useActiveScene, useEditor, useStore } from './ui/context.js' +export { RenderProvider, useRender, type RenderContextValue, type StudioRegistries } from './ui/render.js' export { - canRedo, - canUndo, - commit, - initialHistory, - redo, - undo, - type HistoryLimits, - type HistoryState, -} from './history.js' - -import { createOpRegistry, registerBatch, type OpRegistry } from './op.js' -import { registerElementOps } from './ops/element.js' -import { registerSceneOps } from './ops/scene.js' + createEditorStore, + DEFAULT_ASPECT, + type CreateStoreOptions, + type EditorSnapshot, + type EditorStore, + type Selection, + type UIState, +} from './ui/store.js' +export { useScenePlayback, type ScenePlayback } from './ui/useScenePlayback.js' +export { ensureEditorStyles, editorStyles, EDITOR_PREFIX } from './ui/styles.js' +export { generateId, stringKeyFor } from './ui/ids.js' -/** A registry with the built-in operations. An instance, never shared state (R16). */ -export function createEditor(): OpRegistry { - const registry = createOpRegistry() - registerBatch(registry) - registerSceneOps(registry) - registerElementOps(registry) - return registry -} +export { Field, NumberField, SelectField, Segmented, TextField, type Choice } from './ui/fields.js' diff --git a/packages/editor/src/op.ts b/packages/editor/src/op.ts index 2051ec7..307b4a4 100644 --- a/packages/editor/src/op.ts +++ b/packages/editor/src/op.ts @@ -1,9 +1,22 @@ // SPDX-FileCopyrightText: 2026 Bingoo.ai // SPDX-License-Identifier: Apache-2.0 -import type { ExplainerDocument } from '@bingoo.ai/explainer-core' +import type { ExplainerDocument, LocaleBundle } from '@bingoo.ai/explainer-core' import { KernelError } from '@bingoo.ai/explainer-kernel' +/** + * What an edit can change. + * + * A lesson is a document *and* its locale bundles: the structure says there is a caption + * here, the bundle says what it reads. Editing text is the commonest edit there is, so both + * have to move under one history — with two, undo after "rename the heading, then reorder + * the scenes" walks back through them in an order that belongs to neither. + */ +export interface Workspace { + readonly document: ExplainerDocument + readonly bundles: Readonly> +} + /** * Every edit is a pure operation that returns a new document **and its inverse**. * @@ -22,8 +35,8 @@ import { KernelError } from '@bingoo.ai/explainer-kernel' */ export interface OpResult { - readonly document: ExplainerDocument - /** Applying this to `document` restores the input exactly. */ + readonly workspace: Workspace + /** Applying this to `workspace` restores the input exactly. */ readonly inverse: EditOp } @@ -39,7 +52,7 @@ export interface EditOp { readonly [key: string]: unknown } -export type OpHandler = (document: ExplainerDocument, op: EditOp) => OpResult +export type OpHandler = (workspace: Workspace, op: EditOp) => OpResult /** * Reads a required string field from an op. @@ -73,10 +86,26 @@ function describe(value: unknown): string { return typeof value } +/** + * Lifts an operation that only changes structure. + * + * Most do: reordering scenes has nothing to say about locale bundles. Writing them against + * the document keeps them honest about what they touch, and means a bundle cannot be + * altered by an operation that never meant to. + */ +export function documentOp( + handler: (document: ExplainerDocument, op: EditOp) => { document: ExplainerDocument; inverse: EditOp }, +): OpHandler { + return (workspace, op) => { + const { document, inverse } = handler(workspace.document, op) + return { workspace: { ...workspace, document }, inverse } + } +} + export interface OpRegistry { register(kind: string, handler: OpHandler): void has(kind: string): boolean - apply(document: ExplainerDocument, op: EditOp): OpResult + apply(workspace: Workspace, op: EditOp): OpResult kinds(): readonly string[] } @@ -105,7 +134,7 @@ export function createOpRegistry(): OpRegistry { return handlers.has(kind) }, - apply(document, op) { + apply(workspace, op) { const handler = handlers.get(op.kind) if (!handler) { throw new KernelError('REGISTRY_UNKNOWN_ID', 'No editing operation is registered under this kind.', { @@ -113,7 +142,7 @@ export function createOpRegistry(): OpRegistry { hint: `Registered operations: ${[...handlers.keys()].join(', ') || 'none'}.`, }) } - return handler(document, op) + return handler(workspace, op) }, kinds() { @@ -130,27 +159,23 @@ export function createOpRegistry(): OpRegistry { * and leaves the document subtly wrong — the ops still apply, they just apply against * states that no longer exist. */ -export function applyAll( - registry: OpRegistry, - document: ExplainerDocument, - ops: readonly EditOp[], -): OpResult { - let current = document +export function applyAll(registry: OpRegistry, workspace: Workspace, ops: readonly EditOp[]): OpResult { + let current = workspace const inverses: EditOp[] = [] for (const op of ops) { const result = registry.apply(current, op) - current = result.document + current = result.workspace inverses.unshift(result.inverse) } - return { document: current, inverse: { kind: 'batch', ops: inverses } } + return { workspace: current, inverse: { kind: 'batch', ops: inverses } } } /** Registers `batch`, so a compound edit is itself an op and undoes in one step. */ export function registerBatch(registry: OpRegistry): void { - registry.register('batch', (document, op) => { + registry.register('batch', (workspace, op) => { const ops = Array.isArray(op['ops']) ? (op['ops'] as EditOp[]) : [] - return applyAll(registry, document, ops) + return applyAll(registry, workspace, ops) }) } diff --git a/packages/editor/src/ops.ts b/packages/editor/src/ops.ts new file mode 100644 index 0000000..5080893 --- /dev/null +++ b/packages/editor/src/ops.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +/** + * Editing operations. + * + * Every change is a pure operation returning a new workspace — the document and its locale + * bundles together — and its inverse. Undo, redo, + * version history and an audit trail all fall out of that pairing, and none of them can + * drift from what actually happened — because the inverse is produced by the same code that + * made the change, at the moment it still held the old value. + */ + +export { + applyAll, + createOpRegistry, + documentOp, + registerBatch, + type EditOp, + type OpHandler, + type OpRegistry, + type OpResult, + type Workspace, +} from './op.js' + +export { registerSceneOps } from './ops/scene.js' +export { registerElementOps } from './ops/element.js' +export { registerTextOps } from './ops/text.js' +export { registerMetaOps } from './ops/meta.js' + +export { + canRedo, + canUndo, + commit, + initialHistory, + redo, + undo, + type HistoryLimits, + type HistoryState, +} from './history.js' + +import { createOpRegistry, registerBatch, type OpRegistry } from './op.js' +import { registerElementOps } from './ops/element.js' +import { registerSceneOps } from './ops/scene.js' +import { registerMetaOps } from './ops/meta.js' +import { registerTextOps } from './ops/text.js' + +/** A registry with the built-in operations. An instance, never shared state (R16). */ +export function createEditor(): OpRegistry { + const registry = createOpRegistry() + registerBatch(registry) + registerSceneOps(registry) + registerElementOps(registry) + registerTextOps(registry) + registerMetaOps(registry) + return registry +} + +export { + buildProject, + clipOf, + sceneAt, + ENTRANCE_VERBS, + EXIT_VERBS, + type Clip, + type ClipEffect, + type GateClip, + type NarrationClip, + type Project, + type SceneBound, + type Track, +} from './clips.js' diff --git a/packages/editor/src/ops/element.ts b/packages/editor/src/ops/element.ts index 76ea826..7e7c1ba 100644 --- a/packages/editor/src/ops/element.ts +++ b/packages/editor/src/ops/element.ts @@ -3,7 +3,7 @@ import type { Element, Scene } from '@bingoo.ai/explainer-core' import { KernelError } from '@bingoo.ai/explainer-kernel' -import { requireString, type OpRegistry } from '../op.js' +import { documentOp, requireString, type OpRegistry } from '../op.js' import { indexOf, replaceAt } from './scene.js' /** @@ -16,186 +16,234 @@ import { indexOf, replaceAt } from './scene.js' */ export function registerElementOps(registry: OpRegistry): void { - registry.register('element.add', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = op['element'] as Element - - const region = element.region - const existing = scene.layout.regions[region] ?? [] - const at = - typeof op['at'] === 'number' ? Math.max(0, Math.min(existing.length, op['at'])) : existing.length - const members = [...existing] - members.splice(at, 0, element.id) - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: [...scene.elements, element], - layout: { ...scene.layout, regions: { ...scene.layout.regions, [region]: members } }, - }), - }, - inverse: { kind: 'element.remove', sceneId, id: element.id }, - } - }) - - registry.register('element.remove', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = elementIn(scene, requireString(op, 'id')) - - const regions = Object.fromEntries( - Object.entries(scene.layout.regions).map(([name, ids]) => [ - name, - ids.filter((id) => id !== element.id), - ]), - ) - const at = (scene.layout.regions[element.region] ?? []).indexOf(element.id) - - // Beats that acted on it go too, and come back with it. Leaving them behind produces a - // document the lint gate rejects, from an action the author thought was safe. - const orphaned = scene.beats.map((beat) => ({ - ...beat, - actions: beat.actions.filter((action) => - action.verb === 'sfx' - ? true - : action.verb === 'connect' - ? action.from !== element.id && action.to !== element.id - : action.target !== element.id, - ), - })) - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: scene.elements.filter((e) => e.id !== element.id), - beats: orphaned, - layout: { ...scene.layout, regions }, - }), - }, - inverse: { - kind: 'element.restore', - sceneId, - element, - at, - beats: scene.beats, - }, - } - }) + registry.register( + 'element.add', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = op['element'] as Element + + const region = element.region + const existing = scene.layout.regions[region] ?? [] + const at = + typeof op['at'] === 'number' ? Math.max(0, Math.min(existing.length, op['at'])) : existing.length + const members = [...existing] + members.splice(at, 0, element.id) + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: [...scene.elements, element], + layout: { ...scene.layout, regions: { ...scene.layout.regions, [region]: members } }, + }), + }, + inverse: { kind: 'element.remove', sceneId, id: element.id }, + } + }), + ) + + registry.register( + 'element.remove', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = elementIn(scene, requireString(op, 'id')) + + const regions = Object.fromEntries( + Object.entries(scene.layout.regions).map(([name, ids]) => [ + name, + ids.filter((id) => id !== element.id), + ]), + ) + const at = (scene.layout.regions[element.region] ?? []).indexOf(element.id) + + // Beats that acted on it go too, and come back with it. Leaving them behind produces a + // document the lint gate rejects, from an action the author thought was safe. + const orphaned = scene.beats.map((beat) => ({ + ...beat, + actions: beat.actions.filter((action) => + action.verb === 'sfx' + ? true + : action.verb === 'connect' + ? action.from !== element.id && action.to !== element.id + : action.target !== element.id, + ), + })) + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: scene.elements.filter((e) => e.id !== element.id), + beats: orphaned, + layout: { ...scene.layout, regions }, + }), + }, + inverse: { + kind: 'element.restore', + sceneId, + element, + at, + beats: scene.beats, + }, + } + }), + ) // The inverse of a removal, which has to put the beats back as well — `element.add` // alone would restore the element and quietly drop everything that referred to it. - registry.register('element.restore', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = op['element'] as Element - const beats = op['beats'] as Scene['beats'] - - const existing = scene.layout.regions[element.region] ?? [] - const at = - typeof op['at'] === 'number' ? Math.max(0, Math.min(existing.length, op['at'])) : existing.length - const members = [...existing] - members.splice(at, 0, element.id) - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: [...scene.elements, element], - beats, - layout: { ...scene.layout, regions: { ...scene.layout.regions, [element.region]: members } }, - }), - }, - inverse: { kind: 'element.remove', sceneId, id: element.id }, - } - }) - - registry.register('element.setContent', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = elementIn(scene, requireString(op, 'id')) - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: scene.elements.map((e) => (e.id === element.id ? { ...e, content: op['content'] } : e)), - }), - }, - inverse: { kind: 'element.setContent', sceneId, id: element.id, content: element.content }, - } - }) - - registry.register('element.setStyle', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = elementIn(scene, requireString(op, 'id')) - const style = op['style'] as Element['style'] - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: scene.elements.map((e) => (e.id === element.id ? { ...e, style } : e)), - }), - }, - inverse: { kind: 'element.setStyle', sceneId, id: element.id, style: element.style }, - } - }) - - registry.register('element.move', (document, op) => { - const sceneId = requireString(op, 'sceneId') - const index = indexOf(document, sceneId) - const scene = document.scenes[index]! - const element = elementIn(scene, requireString(op, 'id')) - const region = requireString(op, 'region') - - // Where it *actually* sits, taken from the layout rather than from the element's own - // `region` field. - // - // The two can disagree: `element.region` names a region and `layout.regions` lists the - // members, so a document can say one thing in each place. The layout is what decides - // where the element is drawn, so it is what a move has to reverse. Trusting the field - // produces an inverse that returns the element somewhere it never was. - // - // The redundancy itself is a format smell worth revisiting — one of the two should go. - // That is a breaking change to explainer/v1, so it needs an ADR rather than a quiet fix. - const placed = Object.entries(scene.layout.regions).find(([, ids]) => ids.includes(element.id)) - const from = placed?.[0] ?? element.region - const at = placed?.[1].indexOf(element.id) ?? 0 - - const regions: Record = Object.fromEntries( - Object.entries(scene.layout.regions).map(([name, ids]) => [ - name, - ids.filter((id) => id !== element.id), - ]), - ) - regions[region] = [...(regions[region] ?? []), element.id] - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - elements: scene.elements.map((e) => (e.id === element.id ? { ...e, region } : e)), - layout: { ...scene.layout, regions }, - }), - }, - inverse: { kind: 'element.move', sceneId, id: element.id, region: from, at }, - } - }) + registry.register( + 'element.restore', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = op['element'] as Element + const beats = op['beats'] as Scene['beats'] + + const existing = scene.layout.regions[element.region] ?? [] + const at = + typeof op['at'] === 'number' ? Math.max(0, Math.min(existing.length, op['at'])) : existing.length + const members = [...existing] + members.splice(at, 0, element.id) + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: [...scene.elements, element], + beats, + layout: { ...scene.layout, regions: { ...scene.layout.regions, [element.region]: members } }, + }), + }, + inverse: { kind: 'element.remove', sceneId, id: element.id }, + } + }), + ) + + registry.register( + 'element.setContent', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = elementIn(scene, requireString(op, 'id')) + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: scene.elements.map((e) => (e.id === element.id ? { ...e, content: op['content'] } : e)), + }), + }, + inverse: { kind: 'element.setContent', sceneId, id: element.id, content: element.content }, + } + }), + ) + + registry.register( + 'element.setStyle', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = elementIn(scene, requireString(op, 'id')) + const style = op['style'] as Element['style'] + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: scene.elements.map((e) => (e.id === element.id ? { ...e, style } : e)), + }), + }, + inverse: { kind: 'element.setStyle', sceneId, id: element.id, style: element.style }, + } + }), + ) + + /** + * How an element sits within its region: what order, how much room it takes, how it aligns. + * + * This is the whole of positioning (R6). There is no x or y to set — an author says "this + * comes second and fills the space", and the layout decides where that lands at each + * aspect. Coordinates would look more direct and would break the moment the lesson was + * opened on a phone. + */ + registry.register( + 'element.setFlow', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = elementIn(scene, requireString(op, 'id')) + const flow = op['flow'] as Element['flow'] + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: scene.elements.map((e) => (e.id === element.id ? { ...e, flow } : e)), + }), + }, + inverse: { kind: 'element.setFlow', sceneId, id: element.id, flow: element.flow }, + } + }), + ) + + registry.register( + 'element.move', + documentOp((document, op) => { + const sceneId = requireString(op, 'sceneId') + const index = indexOf(document, sceneId) + const scene = document.scenes[index]! + const element = elementIn(scene, requireString(op, 'id')) + const region = requireString(op, 'region') + + // Where it *actually* sits, taken from the layout rather than from the element's own + // `region` field. + // + // The two can disagree: `element.region` names a region and `layout.regions` lists the + // members, so a document can say one thing in each place. The layout is what decides + // where the element is drawn, so it is what a move has to reverse. Trusting the field + // produces an inverse that returns the element somewhere it never was. + // + // The redundancy itself is a format smell worth revisiting — one of the two should go. + // That is a breaking change to explainer/v1, so it needs an ADR rather than a quiet fix. + const placed = Object.entries(scene.layout.regions).find(([, ids]) => ids.includes(element.id)) + const from = placed?.[0] ?? element.region + const at = placed?.[1].indexOf(element.id) ?? 0 + + const regions: Record = Object.fromEntries( + Object.entries(scene.layout.regions).map(([name, ids]) => [ + name, + ids.filter((id) => id !== element.id), + ]), + ) + regions[region] = [...(regions[region] ?? []), element.id] + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + elements: scene.elements.map((e) => (e.id === element.id ? { ...e, region } : e)), + layout: { ...scene.layout, regions }, + }), + }, + inverse: { kind: 'element.move', sceneId, id: element.id, region: from, at }, + } + }), + ) } function elementIn(scene: Scene, id: string): Element { diff --git a/packages/editor/src/ops/meta.ts b/packages/editor/src/ops/meta.ts new file mode 100644 index 0000000..569b9d6 --- /dev/null +++ b/packages/editor/src/ops/meta.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { documentOp, requireString, type OpRegistry } from '../op.js' + +/** + * Document-level operations: what the lesson is called, what it is tagged with, which theme + * it wears. + * + * Small, and separate from scenes, because these are the fields a library view reads. A + * lesson list showing stale titles is the visible symptom of an editor that only wrote them + * on save. + */ + +export function registerMetaOps(registry: OpRegistry): void { + registry.register( + 'meta.setTitle', + documentOp((document, op) => { + const title = requireString(op, 'title') + + return { + document: { ...document, meta: { ...document.meta, title } }, + inverse: { kind: 'meta.setTitle', title: document.meta.title }, + } + }), + ) + + registry.register( + 'meta.setTheme', + documentOp((document, op) => { + const theme = requireString(op, 'theme') + + return { + document: { ...document, theme }, + inverse: { kind: 'meta.setTheme', theme: document.theme }, + } + }), + ) +} diff --git a/packages/editor/src/ops/scene.ts b/packages/editor/src/ops/scene.ts index fb4d471..49ab75c 100644 --- a/packages/editor/src/ops/scene.ts +++ b/packages/editor/src/ops/scene.ts @@ -3,7 +3,14 @@ import type { ExplainerDocument, Scene } from '@bingoo.ai/explainer-core' import { KernelError } from '@bingoo.ai/explainer-kernel' -import { optionalString, requireString, type EditOp, type OpRegistry, type OpResult } from '../op.js' +import { + documentOp, + optionalString, + requireString, + type EditOp, + type OpRegistry, + type OpResult, +} from '../op.js' /** * Scene-level operations. @@ -14,95 +21,136 @@ import { optionalString, requireString, type EditOp, type OpRegistry, type OpRes */ export function registerSceneOps(registry: OpRegistry): void { - registry.register('scene.add', (document, op) => { - const scene = op['scene'] as Scene - const at = clampIndex(op['at'], document.scenes.length) - - const scenes = [...document.scenes] - scenes.splice(at, 0, scene) - - return { - document: { ...document, scenes }, - inverse: { kind: 'scene.remove', id: scene.id }, - } - }) - - registry.register('scene.remove', (document, op) => { - const id = requireString(op, 'id') - const index = indexOf(document, id) - const scene = document.scenes[index]! - - return { - document: { ...document, scenes: document.scenes.filter((_, i) => i !== index) }, - // Carries the scene itself, not a reference to it. An inverse that pointed at the - // removed scene would be an inverse that could not restore it. - inverse: { kind: 'scene.add', scene, at: index }, - } - }) - - registry.register('scene.move', (document, op) => { - const id = requireString(op, 'id') - const from = indexOf(document, id) - const to = clampIndex(op['to'], document.scenes.length - 1) - - const scenes = [...document.scenes] - const [scene] = scenes.splice(from, 1) - scenes.splice(to, 0, scene!) - - return { - document: { ...document, scenes }, - inverse: { kind: 'scene.move', id, to: from }, - } - }) - - registry.register('scene.setPhase', (document, op) => { - const id = requireString(op, 'id') - const index = indexOf(document, id) - const scene = document.scenes[index]! - const phase = optionalString(op, 'phase') - - const next = { ...scene } - if (phase === undefined) delete (next as { phase?: string }).phase - else next.phase = phase - - return { - document: { ...document, scenes: replaceAt(document.scenes, index, next) }, - inverse: { kind: 'scene.setPhase', id, ...(scene.phase === undefined ? {} : { phase: scene.phase }) }, - } - }) - - registry.register('scene.setTemplate', (document, op) => { - const id = requireString(op, 'id') - const index = indexOf(document, id) - const scene = document.scenes[index]! - const template = requireString(op, 'template') - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { - ...scene, - layout: { ...scene.layout, template }, - }), - }, - inverse: { kind: 'scene.setTemplate', id, template: scene.layout.template }, - } - }) - - registry.register('scene.setRegions', (document, op) => { - const id = requireString(op, 'id') - const index = indexOf(document, id) - const scene = document.scenes[index]! - const regions = op['regions'] as Scene['layout']['regions'] - - return { - document: { - ...document, - scenes: replaceAt(document.scenes, index, { ...scene, layout: { ...scene.layout, regions } }), - }, - inverse: { kind: 'scene.setRegions', id, regions: scene.layout.regions }, - } - }) + registry.register( + 'scene.add', + documentOp((document, op) => { + const scene = op['scene'] as Scene + const at = clampIndex(op['at'], document.scenes.length) + + const scenes = [...document.scenes] + scenes.splice(at, 0, scene) + + return { + document: { ...document, scenes }, + inverse: { kind: 'scene.remove', id: scene.id }, + } + }), + ) + + registry.register( + 'scene.remove', + documentOp((document, op) => { + const id = requireString(op, 'id') + const index = indexOf(document, id) + const scene = document.scenes[index]! + + return { + document: { ...document, scenes: document.scenes.filter((_, i) => i !== index) }, + // Carries the scene itself, not a reference to it. An inverse that pointed at the + // removed scene would be an inverse that could not restore it. + inverse: { kind: 'scene.add', scene, at: index }, + } + }), + ) + + registry.register( + 'scene.move', + documentOp((document, op) => { + const id = requireString(op, 'id') + const from = indexOf(document, id) + const to = clampIndex(op['to'], document.scenes.length - 1) + + const scenes = [...document.scenes] + const [scene] = scenes.splice(from, 1) + scenes.splice(to, 0, scene!) + + return { + document: { ...document, scenes }, + inverse: { kind: 'scene.move', id, to: from }, + } + }), + ) + + registry.register( + 'scene.setPhase', + documentOp((document, op) => { + const id = requireString(op, 'id') + const index = indexOf(document, id) + const scene = document.scenes[index]! + const phase = optionalString(op, 'phase') + + const next = { ...scene } + if (phase === undefined) delete (next as { phase?: string }).phase + else next.phase = phase + + return { + document: { ...document, scenes: replaceAt(document.scenes, index, next) }, + inverse: { kind: 'scene.setPhase', id, ...(scene.phase === undefined ? {} : { phase: scene.phase }) }, + } + }), + ) + + registry.register( + 'scene.setTemplate', + documentOp((document, op) => { + const id = requireString(op, 'id') + const index = indexOf(document, id) + const scene = document.scenes[index]! + const template = requireString(op, 'template') + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { + ...scene, + layout: { ...scene.layout, template }, + }), + }, + inverse: { kind: 'scene.setTemplate', id, template: scene.layout.template }, + } + }), + ) + + registry.register( + 'scene.setRegions', + documentOp((document, op) => { + const id = requireString(op, 'id') + const index = indexOf(document, id) + const scene = document.scenes[index]! + const regions = op['regions'] as Scene['layout']['regions'] + + return { + document: { + ...document, + scenes: replaceAt(document.scenes, index, { ...scene, layout: { ...scene.layout, regions } }), + }, + inverse: { kind: 'scene.setRegions', id, regions: scene.layout.regions }, + } + }), + ) + + /** + * The scene's beats — what happens, and at which cue. + * + * Set wholesale rather than one beat at a time. Beats are ordered and refer to each other's + * cues, so an editor that added one in isolation would need a second operation to fix the + * order, and a crash between the two would leave a scene whose visuals fire in the wrong + * sequence. One operation, one valid state. + */ + registry.register( + 'scene.setBeats', + documentOp((document, op) => { + const id = requireString(op, 'id') + const index = indexOf(document, id) + const scene = document.scenes[index]! + const beats = op['beats'] as Scene['beats'] + + return { + document: { ...document, scenes: replaceAt(document.scenes, index, { ...scene, beats }) }, + inverse: { kind: 'scene.setBeats', id, beats: scene.beats }, + } + }), + ) } export function indexOf(document: ExplainerDocument, id: string): number { diff --git a/packages/editor/src/ops/text.ts b/packages/editor/src/ops/text.ts new file mode 100644 index 0000000..a1764f7 --- /dev/null +++ b/packages/editor/src/ops/text.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import type { LocaleBundle, Narration } from '@bingoo.ai/explainer-core' +import { KernelError } from '@bingoo.ai/explainer-kernel' +import { requireString, type EditOp, type OpRegistry, type Workspace } from '../op.js' + +/** + * Operations on words: the phrases elements display, and the lines the narrator speaks. + * + * These live in locale bundles rather than in the document, because structure and language + * are orthogonal (R4) — one lesson, many languages, the same scenes. That separation is what + * makes translation possible, and it is also why these operations exist: without them, the + * commonest edit in the editor would be the one thing undo could not take back. + * + * Every operation names its locale. There is no "current language" here — a store has one, + * an operation must not, or an edit replayed from a history file lands in whichever language + * happened to be open. + */ + +export function registerTextOps(registry: OpRegistry): void { + registry.register('text.set', (workspace, op) => { + const locale = requireString(op, 'locale') + const key = requireString(op, 'key') + const bundle = bundleFor(workspace, locale) + const previous = bundle.strings[key] + const text = typeof op['text'] === 'string' ? op['text'] : '' + + return { + workspace: withBundle(workspace, locale, { ...bundle, strings: { ...bundle.strings, [key]: text } }), + // A key that did not exist is restored by removing it, not by writing an empty string: + // an empty string is a phrase the author chose, and the quality gate treats the two + // differently. + inverse: + previous === undefined + ? { kind: 'text.clear', locale, key } + : { kind: 'text.set', locale, key, text: previous }, + } + }) + + registry.register('text.clear', (workspace, op) => { + const locale = requireString(op, 'locale') + const key = requireString(op, 'key') + const bundle = bundleFor(workspace, locale) + const previous = bundle.strings[key] + + const strings = { ...bundle.strings } + delete strings[key] + + return { + workspace: withBundle(workspace, locale, { ...bundle, strings }), + inverse: + previous === undefined + ? { kind: 'text.clear', locale, key } + : { kind: 'text.set', locale, key, text: previous }, + } + }) + + registry.register('narration.setScript', (workspace, op) => { + const locale = requireString(op, 'locale') + const ref = requireString(op, 'ref') + const bundle = bundleFor(workspace, locale) + const previous = bundle.narration[ref] + const script = typeof op['script'] === 'string' ? op['script'] : '' + + // Rewriting the script invalidates the recording and the word timings that came from it. + // Keeping them would leave visuals cued to words that are no longer spoken — the lesson + // would play, out of step, with nothing to say it had gone wrong. + const next: Narration = { + ...(previous ?? EMPTY_NARRATION), + script, + marks: [], + words: [], + } + delete (next as { audioUrl?: string }).audioUrl + delete (next as { durationSeconds?: number }).durationSeconds + + return { + workspace: withBundle(workspace, locale, { + ...bundle, + narration: { ...bundle.narration, [ref]: next }, + }), + inverse: + previous === undefined + ? { kind: 'narration.remove', locale, ref } + : { kind: 'narration.restore', locale, ref, narration: previous }, + } + }) + + /** Restores a whole narration, timings included. Only ever produced as an inverse. */ + registry.register('narration.restore', (workspace, op) => { + const locale = requireString(op, 'locale') + const ref = requireString(op, 'ref') + const bundle = bundleFor(workspace, locale) + const previous = bundle.narration[ref] + const narration = op['narration'] as Narration + + return { + workspace: withBundle(workspace, locale, { + ...bundle, + narration: { ...bundle.narration, [ref]: narration }, + }), + inverse: + previous === undefined + ? { kind: 'narration.remove', locale, ref } + : { kind: 'narration.restore', locale, ref, narration: previous }, + } + }) + + registry.register('narration.remove', (workspace, op) => { + const locale = requireString(op, 'locale') + const ref = requireString(op, 'ref') + const bundle = bundleFor(workspace, locale) + const previous = bundle.narration[ref] + + const narration = { ...bundle.narration } + delete narration[ref] + + return { + workspace: withBundle(workspace, locale, { ...bundle, narration }), + inverse: + previous === undefined + ? { kind: 'narration.remove', locale, ref } + : { kind: 'narration.restore', locale, ref, narration: previous }, + } + }) +} + +const EMPTY_NARRATION: Narration = { script: '', marks: [], words: [] } + +function bundleFor(workspace: Workspace, locale: string): LocaleBundle { + const bundle = workspace.bundles[locale] + if (!bundle) { + throw new KernelError('REGISTRY_UNKNOWN_ID', 'This locale has no bundle to edit.', { + at: locale, + hint: `Loaded locales: ${Object.keys(workspace.bundles).join(', ') || 'none'}.`, + }) + } + return bundle +} + +function withBundle(workspace: Workspace, locale: string, bundle: LocaleBundle): Workspace { + return { ...workspace, bundles: { ...workspace.bundles, [locale]: bundle } } +} + +export type { EditOp } diff --git a/packages/editor/src/ui/EditableStage.tsx b/packages/editor/src/ui/EditableStage.tsx new file mode 100644 index 0000000..69a6eb2 --- /dev/null +++ b/packages/editor/src/ui/EditableStage.tsx @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { frame } from '@bingoo.ai/explainer-core' +import { Stage } from '@bingoo.ai/explainer-react' +import { useMemo, type JSX } from 'react' +import { useEditor, useStore } from './context.js' +import { useRender } from './render.js' + +/** + * The stage, with the selection layer over it. + * + * Selecting is done by clicking the thing itself. The alternative — a tree of element ids in + * a side panel — is how an editor ends up requiring you to know what `el-7f3a` looks like + * before you can change it. + * + * The hit targets come from the same `RenderModel` that drew the frame, so a box is exactly + * where the element is at this instant. Anything derived separately drifts, and a box that + * is close but not right is worse than none: you click a caption and select the diagram + * behind it. + */ + +export interface EditableStageProps { + /** Playhead, in seconds from the start of the active scene. */ + readonly t: number +} + +export function EditableStage({ t }: EditableStageProps): JSX.Element { + const store = useStore() + const { ui } = useEditor() + const { program, profiles, painters, theme } = useRender() + + const slot = program.slots.find((candidate) => candidate.scene.id === ui.activeSceneId) ?? program.slots[0] + const at = (slot?.start ?? 0) + t + + const model = useMemo(() => frame(program, at), [program, at]) + + const aspectRatio = + program.canvas.height === 0 ? '16 / 9' : `${program.canvas.width} / ${program.canvas.height}` + + return ( +
+ + + {/* One button per element on screen. Rendered after the stage so it takes the clicks, + and transparent so it takes nothing else. */} + {model.items.map((item) => { + const box = model.rects[item.id] + if (!box) return null + + return ( +
+ ) +} diff --git a/packages/editor/src/ui/Inspector.tsx b/packages/editor/src/ui/Inspector.tsx new file mode 100644 index 0000000..dd83906 --- /dev/null +++ b/packages/editor/src/ui/Inspector.tsx @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import type { JSX } from 'react' +import { useActiveScene, useEditor } from './context.js' +import { ElementFields } from './inspector/ElementFields.js' +import { SceneFields } from './inspector/SceneFields.js' + +/** + * The inspector: what the selection is, and how to change it. + * + * Selecting nothing shows the scene, because that is what you are looking at. An inspector + * that goes blank when nothing is selected wastes a third of the window most of the time and + * teaches people to click something before they can do anything. + */ + +export function Inspector(): JSX.Element { + const { ui } = useEditor() + const { scene } = useActiveScene() + + const element = + ui.selection?.elementId === undefined + ? undefined + : scene?.elements.find((candidate) => candidate.id === ui.selection?.elementId) + + return ( + + ) +} diff --git a/packages/editor/src/ui/SceneStrip.tsx b/packages/editor/src/ui/SceneStrip.tsx new file mode 100644 index 0000000..001a59c --- /dev/null +++ b/packages/editor/src/ui/SceneStrip.tsx @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { buildProgram, frame, type Scene } from '@bingoo.ai/explainer-core' +import { Stage } from '@bingoo.ai/explainer-react' +import { memo, useMemo, useRef, useState, type JSX } from 'react' +import { useEditor, useStore } from './context.js' +import { generateId } from './ids.js' +import { useRender } from './render.js' + +/** + * The scene filmstrip. + * + * A lesson is built the way slides are: see them all, click one to work on it, drag to + * reorder. Every thumbnail is a real render of the scene at its final moment — where + * everything that will appear has appeared — rather than a stored image, so a thumbnail + * cannot be stale (ARCHITECTURE §1). + */ + +export function SceneStrip(): JSX.Element { + const store = useStore() + const { document, ui } = useEditor() + const { templates } = useRender() + const dragging = useRef(null) + const [over, setOver] = useState(null) + + const activeIndex = document.scenes.findIndex((scene) => scene.id === ui.activeSceneId) + + const drop = (to: number): void => { + const from = dragging.current + dragging.current = null + setOver(null) + if (from === null || from === to) return + + const scene = document.scenes[from] + if (scene) store.dispatch({ kind: 'scene.move', id: scene.id, to }) + } + + const addScene = (): void => { + const id = generateId('scene') + // A blank scene, not a templated one: the author says what it is. It is valid the moment + // it exists, so the document never passes through a state the gate would reject. + const scene: Scene = { + id, + kind: 'exposition', + // The scene being worked on, or whatever the deployment registered first — never a + // template named here. A hard-coded default renders nothing the moment a deployment + // ships its own set (R1), and the failure looks like a broken editor. + layout: { + template: document.scenes[activeIndex]?.layout.template ?? templates.ids()[0] ?? '', + regions: {}, + overrides: {}, + }, + elements: [], + beats: [], + narrations: [], + } + + store.dispatch({ + kind: 'scene.add', + scene, + at: activeIndex < 0 ? document.scenes.length : activeIndex + 1, + }) + store.select({ sceneId: id }) + } + + const duplicate = (index: number): void => { + const source = document.scenes[index] + if (!source) return + + // Fresh ids throughout: elements are referenced by beats and by the layout, so a copy + // that kept them would have two scenes whose beats fire on each other's elements. + const clone = withFreshIds(source) + store.dispatch({ kind: 'scene.add', scene: clone, at: index + 1 }) + store.select({ sceneId: clone.id }) + } + + return ( +
+ {document.scenes.map((scene, index) => ( +
{ + dragging.current = index + event.dataTransfer.effectAllowed = 'move' + }} + onDragOver={(event) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + if (over !== index) setOver(index) + }} + onDragLeave={() => { + setOver((current) => (current === index ? null : current)) + }} + onDrop={(event) => { + event.preventDefault() + drop(index) + }} + onDragEnd={() => { + dragging.current = null + setOver(null) + }} + > + + +
+ + + + +
+ +
+ {index + 1} + {scene.phase ?? scene.layout.template} + {scene.gate ? ask : null} +
+
+ ))} + + +
+ ) +} + +/** + * One scene, rendered alone. + * + * Built as a one-scene program so the thumbnail shows scene-local time — a scene late in a + * lesson is at `t=0` of its own program, not at minute four of the whole one. + * + * Memoised on the scene: a strip of twenty scenes re-rendering on every playhead tick is + * the difference between an editor that feels immediate and one that stutters while typing. + */ +const SceneThumb = memo(function SceneThumb({ scene }: { readonly scene: Scene }): JSX.Element { + const { profiles, painters, theme, templates, emphasis, bundle } = useRender() + const { document } = useEditor() + + const model = useMemo(() => { + const program = buildProgram({ ...document, scenes: [scene] }, bundle, { + profiles, + templates, + emphasis, + aspect: '16:9', + }) + // A hair before the end: everything has arrived, nothing has been cut off by the + // boundary. At exactly `duration` a scene with an exit has already played it. + return frame(program, Math.max(0, program.duration - 0.05)) + }, [document, scene, bundle, profiles, templates, emphasis]) + + return ( + + ) +}) + +function withFreshIds(scene: Scene): Scene { + const id = generateId('scene') + const remap = new Map(scene.elements.map((element) => [element.id, generateId('el')] as const)) + const to = (old: string): string => remap.get(old) ?? old + + return { + ...scene, + id, + elements: scene.elements.map((element) => ({ ...element, id: to(element.id) })), + layout: { + ...scene.layout, + regions: Object.fromEntries( + Object.entries(scene.layout.regions).map(([region, ids]) => [region, ids.map(to)]), + ), + }, + beats: scene.beats.map((beat) => ({ + ...beat, + id: generateId('beat'), + actions: beat.actions.map((action) => + 'target' in action + ? { ...action, target: to(action.target) } + : action.verb === 'connect' + ? { ...action, from: to(action.from), to: to(action.to) } + : action, + ), + })), + } +} diff --git a/packages/editor/src/ui/Studio.tsx b/packages/editor/src/ui/Studio.tsx new file mode 100644 index 0000000..e041e15 --- /dev/null +++ b/packages/editor/src/ui/Studio.tsx @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import type { ExplainerDocument, LocaleBundle } from '@bingoo.ai/explainer-core' +import { useEffect, useMemo, type JSX } from 'react' +import { createEditor } from '../ops.js' +import type { OpRegistry } from '../op.js' +import { EditableStage } from './EditableStage.js' +import { Inspector } from './Inspector.js' +import { SceneStrip } from './SceneStrip.js' +import { TopBar, type SaveState } from './TopBar.js' +import { TrackTimeline } from './TrackTimeline.js' +import { EditorProvider, useEditor } from './context.js' +import { RenderProvider, type StudioRegistries } from './render.js' +import { createEditorStore } from './store.js' +import { ensureEditorStyles } from './styles.js' +import { useScenePlayback } from './useScenePlayback.js' + +/** + * The studio: a filmstrip of scenes, the stage, an inspector, and a timeline of what happens + * when. + * + * The arrangement is the one every editing tool has converged on, and it is worth being + * unoriginal about: a teacher who has used slides or a video editor should not have to learn + * where anything is. + * + * Persistence belongs to the host. `onChange` fires on every edit with the whole workspace; + * where that goes — localStorage, a database, a file — is not this component's business + * (R8). It runs with no network at all (R9). + */ + +export interface ExplainerStudioProps { + readonly document: ExplainerDocument + readonly bundles: Readonly> + /** Fires on every edit. Debounce on the host side before writing anywhere. */ + readonly onChange?: (document: ExplainerDocument, bundles: Readonly>) => void + readonly onSave?: () => void + readonly saveState?: SaveState + /** Registries to extend the editor: painters, templates, themes (R1). */ + readonly registries?: StudioRegistries + /** Operations to extend it with. Defaults to the built-in set. */ + readonly registry?: OpRegistry + readonly locale?: string + readonly aspect?: string + /** Host actions in the top bar — preview, export, publish. */ + readonly actions?: JSX.Element +} + +export function ExplainerStudio({ + document, + bundles, + onChange, + onSave, + saveState, + registries = {}, + registry, + locale, + aspect, + actions, +}: ExplainerStudioProps): JSX.Element { + ensureEditorStyles() + + const store = useMemo( + () => + createEditorStore({ + registry: registry ?? createEditor(), + document, + bundles, + ...(locale === undefined ? {} : { locale }), + ...(aspect === undefined ? {} : { aspect }), + }), + // Keyed on the operation registry alone. The document is the store's *initial* state, + // not a prop it tracks: rebuilding on every edit would throw the history away on the + // first keystroke. A host swapping lessons should remount with a `key`. + [registry], + ) + + useEffect(() => { + if (!onChange) return + + return store.subscribe(() => { + const snapshot = store.getSnapshot() + onChange(snapshot.document, snapshot.bundles) + }) + }, [store, onChange]) + + return ( + + + + + + ) +} + +function StudioShell({ + saveState, + onSave, + actions, +}: { + readonly saveState?: SaveState + readonly onSave?: () => void + readonly actions?: JSX.Element +}): JSX.Element { + const playback = useScenePlayback() + + return ( +
+ + +
+ + +
+
+ +
+ +
+ + + + {timecode(playback.t)} / {timecode(playback.duration)} + +
+
+ + +
+ + + +
+ ) +} + +/** + * The left rail: the lesson's scenes as a list, alongside the filmstrip below. + * + * A list as well as a strip because they answer different questions — the strip shows what + * each scene *looks* like, the list shows how the lesson is *structured*, and a teacher + * checking that they explain before they ask is reading the second one. + */ +function SceneList(): JSX.Element { + const { document, ui } = useEditor() + + return ( + + ) +} + +function timecode(seconds: number): string { + const whole = Math.max(0, Math.floor(seconds)) + const minutes = Math.floor(whole / 60) + const rest = whole % 60 + const hundredths = Math.floor((seconds % 1) * 100) + + return `${String(minutes)}:${String(rest).padStart(2, '0')}.${String(hundredths).padStart(2, '0')}` +} diff --git a/packages/editor/src/ui/TopBar.tsx b/packages/editor/src/ui/TopBar.tsx new file mode 100644 index 0000000..7393ca8 --- /dev/null +++ b/packages/editor/src/ui/TopBar.tsx @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import type { JSX, ReactNode } from 'react' +import { useEditor, useStore } from './context.js' +import { useRender } from './render.js' + +/** + * The top bar: what this lesson is, and the actions that apply to all of it. + * + * Undo and redo sit here rather than in a menu because they are pressed more than anything + * else in an editor, and because their being visible is what makes the rest of the tool safe + * to explore. + * + * Save is the host's business. This bar shows the state the host reports and calls back when + * asked; it does not know where a lesson goes (R8). + */ + +export interface SaveState { + readonly label: string + readonly tone: 'idle' | 'saving' | 'saved' | 'error' +} + +export interface TopBarProps { + readonly saveState?: SaveState + readonly onSave?: () => void + /** Aspects offered. A deployment that only ever ships portrait should say so. */ + readonly aspects?: readonly string[] + /** Host actions — preview, export, publish. Rendered at the end of the bar. */ + readonly actions?: ReactNode +} + +export const DEFAULT_ASPECTS = ['16:9', '9:16'] as const + +export function TopBar({ saveState, onSave, aspects = DEFAULT_ASPECTS, actions }: TopBarProps): JSX.Element { + const store = useStore() + const { document, ui, canUndo, canRedo } = useEditor() + const { program } = useRender() + + const issueCount = program.issues.reduce((total, entry) => total + entry.issues.length, 0) + + return ( +
+ { + store.dispatch({ kind: 'meta.setTitle', title: event.target.value }, 'meta.title') + }} + onBlur={() => { + store.endCoalesce() + }} + /> + + {onSave ? ( + + ) : null} + + {saveState ? ( + + {saveState.label} + + ) : null} + + + + + + +
+ {aspects.map((aspect) => ( + + ))} +
+ + + + {/* Layout problems, counted. A number that is usually zero is a thing people notice + when it stops being zero — which is the entire point of showing it. */} + + {issueCount === 0 ? '✓ Layout' : `${String(issueCount)} layout`} + + + {actions} +
+ ) +} diff --git a/packages/editor/src/ui/TrackTimeline.tsx b/packages/editor/src/ui/TrackTimeline.tsx new file mode 100644 index 0000000..8dea3d4 --- /dev/null +++ b/packages/editor/src/ui/TrackTimeline.tsx @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useRef, type JSX } from 'react' +import type { Clip, Project } from '../clips.js' +import { useEditor, useStore } from './context.js' +import { useRender } from './render.js' + +/** + * The track timeline: what exists, when, as bars you can look along. + * + * Scene-centric, like the filmstrip above it — the timeline shows the scene being edited, in + * that scene's own time. A whole-lesson timeline sounds more useful and is not: a + * twelve-minute lesson compresses a four-second reveal into three pixels, and the thing you + * are actually working on becomes untouchable. + * + * Clicking a clip selects its element. Clicking the ruler moves the playhead. Both are the + * same gesture people already know from every editor they have used, which is the only + * reason to arrange it this way. + */ + +export interface TrackTimelineProps { + /** Playhead position, in seconds from the start of the active scene. */ + readonly t: number + readonly onSeek: (t: number) => void +} + +/** Colour per element type. A host adding a type adds a colour by setting `--xse-clip-`. */ +export const CLIP_COLORS: Readonly> = { + text: '#3b82f6', + shape: '#22c55e', + stick: '#a855f7', + diagram: '#f59e0b', + stroke: '#ec4899', + image: '#14b8a6', + list: '#0ea5e9', + table: '#8b5cf6', + chart: '#f97316', + stat: '#06b6d4', + quote: '#64748b', + connector: '#94a3b8', +} + +const FALLBACK_COLOR = '#6b7280' + +export function TrackTimeline({ t, onSeek }: TrackTimelineProps): JSX.Element { + const store = useStore() + const { ui } = useEditor() + const { project } = useRender() + const surface = useRef(null) + + const scene = project.scenes.find((candidate) => candidate.id === ui.activeSceneId) ?? project.scenes[0] + const start = scene?.start ?? 0 + const duration = Math.max((scene?.end ?? 0) - start, 0.001) + + const local = useMemo(() => localise(project, ui.activeSceneId, start), [project, ui.activeSceneId, start]) + + const percent = (seconds: number): string => `${((seconds / duration) * 100).toFixed(3)}%` + + const seekFrom = (clientX: number): void => { + const box = surface.current?.getBoundingClientRect() + if (!box || box.width === 0) return + onSeek(Math.max(0, Math.min(duration, ((clientX - box.left) / box.width) * duration))) + } + + return ( +
+
{ + seekFrom(event.clientX) + }} + role="slider" + tabIndex={0} + aria-label="Playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={t} + onKeyDown={(event) => { + if (event.key === 'ArrowRight') onSeek(Math.min(duration, t + NUDGE_SECONDS)) + if (event.key === 'ArrowLeft') onSeek(Math.max(0, t - NUDGE_SECONDS)) + }} + > + {ticks(duration).map((tick) => ( + + {tick}s + + ))} +
+ +
+ {local.gates.map((gate) => ( +
+
+ Question · {gate.kind} +
+
+ ))} + + {local.narration.map((line, index) => ( +
+ +
+ ))} + + {local.tracks.map((track) => ( +
+ {track.clips.map((clip) => ( + { + store.select({ sceneId: clip.sceneId, elementId: clip.elementId }) + onSeek(clip.start) + }} + /> + ))} +
+ ))} + + {local.tracks.length === 0 && local.narration.length === 0 ? ( +

Nothing on this scene yet. Add an element to see it here.

+ ) : null} + +
+
+
+ ) +} + +/** How far an arrow key moves the playhead — a beat, not a frame. */ +const NUDGE_SECONDS = 0.5 + +function ClipBar({ + clip, + duration, + selected, + onSelect, +}: { + readonly clip: Clip + readonly duration: number + readonly selected: boolean + readonly onSelect: () => void +}): JSX.Element { + const width = ((clip.end - clip.start) / duration) * 100 + + return ( + + ) +} + +/** + * The project, rebased onto the active scene. + * + * `buildProject` works in program time because that is what the document means. The timeline + * draws one scene, so everything is shifted back by where that scene starts — done here, + * once, rather than by every component subtracting an offset and one of them forgetting. + */ +function localise(project: Project, sceneId: string | undefined, start: number): Project { + const mine = (item: T): boolean => item.sceneId === sceneId + const shift = (item: T): T => ({ + ...item, + start: item.start - start, + end: item.end - start, + }) + + return { + ...project, + tracks: project.tracks + .map((track) => ({ + ...track, + clips: track.clips.filter(mine).map((clip) => ({ + ...shift(clip), + effects: clip.effects.map((effect) => ({ ...effect, at: effect.at - start })), + })), + })) + .filter((track) => track.clips.length > 0), + narration: project.narration.filter(mine).map(shift), + gates: project.gates.filter(mine).map(shift), + } +} + +/** + * Where to put the second markers. + * + * Chosen so a scene shows roughly six to twelve of them at any length — dense enough to read + * a position off, sparse enough that the labels do not collide. A fixed interval works for + * one scene length and fails for every other. + */ +const TICK_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60] +const TARGET_TICKS = 8 + +function ticks(duration: number): number[] { + const step = TICK_STEPS.find((candidate) => duration / candidate <= TARGET_TICKS) ?? TICK_STEPS.at(-1) ?? 60 + const out: number[] = [] + for (let at = step; at < duration; at += step) out.push(Math.round(at * 10) / 10) + return out +} diff --git a/packages/editor/src/ui/context.tsx b/packages/editor/src/ui/context.tsx new file mode 100644 index 0000000..d817ff2 --- /dev/null +++ b/packages/editor/src/ui/context.tsx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { KernelError } from '@bingoo.ai/explainer-kernel' +import { createContext, useContext, useSyncExternalStore, type JSX, type ReactNode } from 'react' +import type { EditorSnapshot, EditorStore } from './store.js' + +/** + * Context is how the panels reach the store, so the layout can be rearranged without + * threading a document through every intermediate component. The store itself is passed in + * by the host — never created here as module state, so two editors on one page do not share + * a history (R16). + */ + +const StoreContext = createContext(null) + +export function EditorProvider({ + store, + children, +}: { + readonly store: EditorStore + readonly children: ReactNode +}): JSX.Element { + return {children} +} + +export function useStore(): EditorStore { + const store = useContext(StoreContext) + if (!store) { + throw new KernelError('CONFIG_INVALID', 'This component must be rendered inside an EditorProvider.', { + at: 'useStore', + hint: 'Wrap it in , or render which does that for you.', + }) + } + return store +} + +/** The current state, re-rendering the caller when it changes. */ +export function useEditor(): EditorSnapshot { + const store = useStore() + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) +} + +/** The scene being edited, and where it sits in the document. */ +export function useActiveScene(): { + readonly index: number + readonly scene: EditorSnapshot['document']['scenes'][number] | undefined +} { + const { document, ui } = useEditor() + const index = document.scenes.findIndex((scene) => scene.id === ui.activeSceneId) + const resolved = index === -1 ? 0 : index + return { index: resolved, scene: document.scenes[resolved] } +} diff --git a/packages/editor/src/ui/fields.tsx b/packages/editor/src/ui/fields.tsx new file mode 100644 index 0000000..c64e346 --- /dev/null +++ b/packages/editor/src/ui/fields.tsx @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 Bingoo.ai +// SPDX-License-Identifier: Apache-2.0 + +import { useId, type JSX, type ReactNode } from 'react' + +/** + * The inspector's building blocks. + * + * Small on purpose. An inspector grows a field every time the schema does, and the way that + * ends up as a two-thousand-line file is one component holding every field inline. Here a + * field is a label bound to a control, and a panel is a list of them — so adding one is + * adding a line, not extending a switch. + * + * Every control is labelled and focusable. An editor is used with a keyboard by people who + * are looking at the stage rather than at the panel. + */ + +export function Field({ + label, + hint, + children, +}: { + readonly label: string + readonly hint?: string + readonly children: (id: string) => ReactNode +}): JSX.Element { + const id = useId() + + return ( + <> + + {hint === undefined ? null :

{hint}

} + + ) +} + +export function TextField({ + label, + value, + onChange, + onCommit, + placeholder, + multiline = false, + hint, +}: { + readonly label: string + readonly value: string + readonly onChange: (value: string) => void + /** Called when editing stops, so the store can end a coalescing run. */ + readonly onCommit?: () => void + readonly placeholder?: string + readonly multiline?: boolean + readonly hint?: string +}): JSX.Element { + return ( + + {(id) => + multiline ? ( +