From 31f6ca01d71f117243982dd076bce40e534f2e69 Mon Sep 17 00:00:00 2001 From: khachuongvn Date: Sun, 19 Jul 2026 23:21:54 +0700 Subject: [PATCH] feat(editor): add the studio panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scene filmstrip, a stage you select on by clicking, an inspector and a track timeline of what happens when — the arrangement every editing tool has converged on, and worth being unoriginal about. Every panel changes a lesson by dispatching an operation, so undo, history and audit work for edits nobody has written yet. The operation layer now acts on a workspace — the document *and* its locale bundles. Words live in bundles because structure and language are orthogonal (R4), which made text editing the one thing undo could not take back. 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 stay written against the document and are lifted by `documentOp`. The package now has two entry points: `/ops` is pure and pulls in no renderer, for a server applying edits headlessly. The default entry adds the panels. - clip model derived from the compiled timeline, packed into rows - palette built from the painter registry, so a kit's element is creatable - typing coalesces into one undo step - new ops: text.set/clear, narration.setScript/restore/remove, meta.setTitle, meta.setTheme, scene.setBeats, element.setFlow Found while testing the panels: the new-scene fallback named a template that does not exist in the built-in set, so adding a scene laid out nothing. It now takes whatever the deployment registered first. Signed-off-by: khachuongvn --- docs/ARCHITECTURE.md | 13 +- eslint.config.mjs | 5 +- packages/editor/package.json | 19 + packages/editor/src/clips.ts | 237 ++++++++++ packages/editor/src/history.ts | 21 +- packages/editor/src/index.ts | 66 ++- packages/editor/src/op.ts | 59 ++- packages/editor/src/ops.ts | 72 +++ packages/editor/src/ops/element.ts | 404 +++++++++------- packages/editor/src/ops/meta.ts | 39 ++ packages/editor/src/ops/scene.ts | 228 +++++---- packages/editor/src/ops/text.ts | 146 ++++++ packages/editor/src/ui/EditableStage.tsx | 72 +++ packages/editor/src/ui/Inspector.tsx | 40 ++ packages/editor/src/ui/SceneStrip.tsx | 241 ++++++++++ packages/editor/src/ui/Studio.tsx | 191 ++++++++ packages/editor/src/ui/TopBar.tsx | 144 ++++++ packages/editor/src/ui/TrackTimeline.tsx | 238 ++++++++++ packages/editor/src/ui/context.tsx | 53 +++ packages/editor/src/ui/fields.tsx | 204 ++++++++ packages/editor/src/ui/ids.ts | 31 ++ .../editor/src/ui/inspector/AddElement.tsx | 100 ++++ .../editor/src/ui/inspector/ElementFields.tsx | 197 ++++++++ .../editor/src/ui/inspector/SceneFields.tsx | 106 +++++ packages/editor/src/ui/render.tsx | 116 +++++ packages/editor/src/ui/store.ts | 208 +++++++++ packages/editor/src/ui/styles.ts | 442 ++++++++++++++++++ packages/editor/src/ui/useScenePlayback.ts | 96 ++++ packages/editor/test/clips.test.ts | 173 +++++++ packages/editor/test/editor.test.ts | 96 ++-- packages/editor/test/setup.ts | 6 + packages/editor/test/store.test.ts | 150 ++++++ packages/editor/test/studio.test.tsx | 187 ++++++++ packages/editor/test/text.test.ts | 126 +++++ packages/editor/tsconfig.json | 6 +- packages/editor/tsup.config.ts | 5 +- packages/editor/vitest.config.ts | 5 + pnpm-lock.yaml | 82 ++++ 38 files changed, 4246 insertions(+), 378 deletions(-) create mode 100644 packages/editor/src/clips.ts create mode 100644 packages/editor/src/ops.ts create mode 100644 packages/editor/src/ops/meta.ts create mode 100644 packages/editor/src/ops/text.ts create mode 100644 packages/editor/src/ui/EditableStage.tsx create mode 100644 packages/editor/src/ui/Inspector.tsx create mode 100644 packages/editor/src/ui/SceneStrip.tsx create mode 100644 packages/editor/src/ui/Studio.tsx create mode 100644 packages/editor/src/ui/TopBar.tsx create mode 100644 packages/editor/src/ui/TrackTimeline.tsx create mode 100644 packages/editor/src/ui/context.tsx create mode 100644 packages/editor/src/ui/fields.tsx create mode 100644 packages/editor/src/ui/ids.ts create mode 100644 packages/editor/src/ui/inspector/AddElement.tsx create mode 100644 packages/editor/src/ui/inspector/ElementFields.tsx create mode 100644 packages/editor/src/ui/inspector/SceneFields.tsx create mode 100644 packages/editor/src/ui/render.tsx create mode 100644 packages/editor/src/ui/store.ts create mode 100644 packages/editor/src/ui/styles.ts create mode 100644 packages/editor/src/ui/useScenePlayback.ts create mode 100644 packages/editor/test/clips.test.ts create mode 100644 packages/editor/test/setup.ts create mode 100644 packages/editor/test/store.test.ts create mode 100644 packages/editor/test/studio.test.tsx create mode 100644 packages/editor/test/text.test.ts create mode 100644 packages/editor/vitest.config.ts 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 ? ( +