diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a0cd6ee912..f24782a23f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
+ branches: [main, feat/maic-editor-v0]
concurrency:
group: ci-${{ github.ref }}
diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx
index 14a6f82e4b..6eabca6213 100644
--- a/app/classroom/[id]/page.tsx
+++ b/app/classroom/[id]/page.tsx
@@ -12,6 +12,8 @@ import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import { createLogger } from '@/lib/logger';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
+import { migrateScene } from '@/lib/edit/slide-schema';
+import type { Scene } from '@/lib/types/stage';
const log = createLogger('Classroom');
@@ -46,9 +48,18 @@ export default function ClassroomDetailPage() {
if (json.success && json.classroom) {
const { stage, scenes } = json.classroom;
useStageStore.getState().setStage(stage);
+ // Normalize legacy slide content (missing schemaVersion) on the
+ // way in, same as the store's setScenes/loadFromStorage paths —
+ // server snapshots predate the schema field.
+ const migrated = (scenes as Scene[]).map(migrateScene);
useStageStore.setState({
- scenes,
- currentSceneId: scenes[0]?.id ?? null,
+ scenes: migrated,
+ currentSceneId: migrated[0]?.id ?? null,
+ // Match `loadFromStorage` semantics: mode is transient UI
+ // state, not persisted with the stage. Reset on every
+ // classroom load so SPA navigation doesn't carry Pro
+ // mode across.
+ mode: 'playback',
});
log.info('Loaded from server-side storage:', classroomId);
diff --git a/app/editor-fonts.ts b/app/editor-fonts.ts
new file mode 100644
index 0000000000..27d9db3e4d
--- /dev/null
+++ b/app/editor-fonts.ts
@@ -0,0 +1,39 @@
+/**
+ * Loads the web fonts offered in the slide editor's text-format picker.
+ *
+ * `@fontsource` ships the font files via npm (no binaries committed to the
+ * repo) and `unicode-range`-subsets the CJK faces, so a CJK font downloads
+ * lazily — only the glyph-range chunks a slide actually uses. Imported once
+ * from the root layout.
+ *
+ * The picker list lives in `configs/font.ts`; each entry's `value` must match
+ * the `@font-face` family name of a package imported here. Inter is loaded
+ * separately via `next/font` in `app/layout.tsx`.
+ */
+
+// Latin
+import '@fontsource/roboto/400.css';
+import '@fontsource/roboto/700.css';
+import '@fontsource/open-sans/400.css';
+import '@fontsource/open-sans/700.css';
+import '@fontsource/montserrat/400.css';
+import '@fontsource/montserrat/700.css';
+import '@fontsource/source-sans-3/400.css';
+import '@fontsource/source-sans-3/700.css';
+import '@fontsource/merriweather/400.css';
+import '@fontsource/merriweather/700.css';
+import '@fontsource/literata/400.css';
+import '@fontsource/literata/700.css';
+import '@fontsource/source-serif-4/400.css';
+import '@fontsource/source-serif-4/700.css';
+import '@fontsource/jetbrains-mono/400.css';
+import '@fontsource/jetbrains-mono/700.css';
+
+// Chinese — @fontsource unicode-range-subsets these, so each loads lazily.
+import '@fontsource/noto-sans-sc/400.css';
+import '@fontsource/noto-sans-sc/700.css';
+import '@fontsource/noto-serif-sc/400.css';
+import '@fontsource/noto-serif-sc/700.css';
+import '@fontsource/lxgw-wenkai/500.css';
+import '@fontsource/lxgw-wenkai/700.css';
+import '@fontsource/zcool-kuaile/400.css';
diff --git a/app/globals.css b/app/globals.css
index d21d190f54..b9499f3ad4 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -132,12 +132,86 @@
body {
@apply bg-background text-foreground;
}
+ /* Radix Select / Popover wrap with `react-remove-scroll`, which adds a
+ compensation `padding-right` to
when they open. Our
+ already reserves the scrollbar gutter (above), so that compensation
+ creates a visible layout shift on every dropdown open. Scope the
+ override to editor mode — the SlideCanvas sets `data-maic-editor` on
+ the body while mounted — so Radix's compensation still works on the
+ rest of the app (modals, sheets, etc. on non-editor pages). */
+ body[data-maic-editor='true'] {
+ padding-right: 0 !important;
+ }
}
/* ProseMirror Editor Styles */
.prosemirror-editor {
cursor: text;
}
+/* The slide editor draws a text element's frame via the renderer's Operate
+ layer. The focused contenteditable must not also paint a UA focus ring on
+ top of it (the base `* { @apply outline-ring/50 }` rule gives every focused
+ element an outline). `.prosemirror-editor` is an editor-only class —
+ playback's BaseTextElement never carries it, so playback is unaffected. */
+.prosemirror-editor :focus,
+.prosemirror-editor :focus-visible {
+ outline: none;
+}
+
+/* Tailwind's preflight resets `list-style: none` and `padding: 0` on
+ ``/``. The text element's `bulletList` / `orderedList` commands
+ genuinely wrap content in `` / ``, but without those
+ resets undone no marker would be visible. `!important` defeats any
+ layered preflight specificity we'd otherwise have to chase.
+
+ `.editable-element-text` is the PLAYBACK text wrapper, rendered for every
+ classroom/playback user — not just in the editor. So its rules MUST be
+ scoped to editor mode (`body[data-maic-editor='true']`, set by
+ EditChromeRoot while Pro mode is mounted) to keep flag-off playback
+ rendering byte-unchanged: the editor surface isn't GA yet, so playback
+ display of editor-authored lists is deferred. Scoping keeps list markers
+ visible while editing (the user needs to see their bullets) without
+ altering how the same wrapper renders during normal playback for all
+ users. `.prosemirror-editor` is an editor-only class already (playback's
+ BaseTextElement never carries it), so it stays unscoped. */
+body[data-maic-editor='true'] .editable-element-text ul,
+.prosemirror-editor ul {
+ list-style: disc outside !important;
+ padding-inline-start: 1.5rem !important;
+}
+body[data-maic-editor='true'] .editable-element-text ol,
+.prosemirror-editor ol {
+ list-style: decimal outside !important;
+ padding-inline-start: 1.5rem !important;
+}
+body[data-maic-editor='true'] .editable-element-text li,
+.prosemirror-editor li {
+ display: list-item !important;
+}
+
+/* Compact react-colorful for the slide editor's color popover. Defaults are
+ a 200×200 square with a thick separator — we squeeze it tight + round it
+ so the popover feels intentional, not stock. */
+.color-picker .react-colorful {
+ width: 100%;
+ height: auto;
+}
+.color-picker .react-colorful__saturation {
+ height: 128px;
+ border-radius: 6px;
+ border-bottom: none;
+}
+.color-picker .react-colorful__hue {
+ height: 10px;
+ margin-top: 10px;
+ border-radius: 999px;
+}
+.color-picker .react-colorful__pointer {
+ width: 14px;
+ height: 14px;
+ border-width: 2px;
+}
+
.prosemirror-editor.format-painter {
cursor:
url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+')
diff --git a/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx
new file mode 100644
index 0000000000..33009d466b
--- /dev/null
+++ b/components/edit/EditChromeRoot.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { useEffect } from 'react';
+import { EditShell } from '@/components/edit/EditShell';
+import { SlideNavRail } from '@/components/edit/SlideNavRail';
+import { HeaderControls } from '@/components/stage/header-controls';
+import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
+import { preloadEditor } from '@/lib/edit/preload-editor';
+import type { Scene } from '@/lib/types/stage';
+
+interface EditChromeRootProps {
+ readonly scene: Scene;
+ readonly isEditable: boolean;
+ readonly onToggleEditMode?: () => void;
+}
+
+/**
+ * Edit-mode root — wraps the Pro mode chrome assembly so `stage.tsx`
+ * has a single component to mount in the edit branch instead of a
+ * 13-line inline JSX with three children.
+ *
+ * Owned here: `EditShell` (Frame + CommandBar + canvas + overlays),
+ * `SlideNavRail` (leftRail slot), and the `HeaderControls` trailing
+ * (settings pill + Pro Switch) that rides in CommandBar's right slot.
+ *
+ * NOT owned here:
+ * - `MultiTabEditConflictPrompt` — must mount even in playback mode so
+ * the lock-conflict dialog can be shown when entering edit mode is
+ * refused (mode is still 'playback' at that point).
+ * - `useEditModeLock` — the lock is acquired by the Pro toggle in
+ * stage.tsx BEFORE the live session is torn down, so it can't live
+ * in a component that only mounts after the switch.
+ *
+ * `scene` is required (non-null). The parent gates mounting on
+ * `mode === 'edit' && currentScene` to satisfy this contract.
+ */
+export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChromeRootProps) {
+ // Mark the body while edit mode is mounted, so the editor-scoped CSS
+ // rule in globals.css that pins `body.padding-right` to 0 only fires
+ // in Pro mode — not on non-editor pages where Radix's
+ // react-remove-scroll compensation is still wanted. Lifted from
+ // SlideCanvas (which was mounted only for slide scenes) so the
+ // attribute now covers read-only scene types in Pro mode too.
+ useEffect(() => {
+ document.body.dataset.maicEditor = 'true';
+ return () => {
+ delete document.body.dataset.maicEditor;
+ };
+ }, []);
+
+ // Safety net: the editor chunk (fonts + slide surface registration) is
+ // normally preloaded by the Pro Switch handler in stage.tsx BEFORE mode
+ // flips, so by the time we mount the surface is already registered and
+ // EditShell resolves it immediately (no NOOP flash). This call is a
+ // promise-cached no-op in that path; it only does real work if edit mode
+ // is ever entered without going through the handler. Render is NOT gated
+ // on it — the preload-before-flip contract keeps the chrome smooth.
+ useEffect(() => {
+ void preloadEditor();
+ }, []);
+
+ return (
+ }
+ commandTrailing={
+
+ }
+ />
+ );
+}
diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx
new file mode 100644
index 0000000000..5b2a4c7721
--- /dev/null
+++ b/components/edit/EditShell/CommandBar.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { ArrowLeft, Redo2, Undo2 } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { cn } from '@/lib/utils';
+import type { EditorCommand, SurfaceHistory } from '@/lib/edit/scene-editor-surface';
+
+interface CommandBarProps {
+ readonly title: string;
+ readonly history?: SurfaceHistory;
+ readonly commands?: readonly EditorCommand[];
+ /**
+ * Right-edge slot owned by Stage. In Pro mode it carries the
+ * HeaderControls (settings pill + Pro Switch + Download) since Stage
+ * Header is unmounted to keep top chrome to a single bar.
+ */
+ readonly trailing?: ReactNode;
+}
+
+/**
+ * Top bar of the Pro mode chrome. Undo/redo + title on the left, insert
+ * primitives in the center, surface commands on the right. History /
+ * insertItems / commands are all optional so the bar renders cleanly when
+ * no surface is registered for the current scene type.
+ *
+ * Exiting Pro mode is handled by the global Pro Switch in the playback
+ * Header (which stays mounted above this bar) — Pro mode is a toggle,
+ * not a one-way state, so we deliberately do *not* place a "Done" pill
+ * here that would compete with the Switch's affordance.
+ */
+export function CommandBar({ title, history, commands, trailing }: CommandBarProps) {
+ const { t } = useI18n();
+ const router = useRouter();
+
+ return (
+
+ );
+}
+
+function IconButton({
+ title,
+ children,
+ ...props
+}: React.ComponentProps & { readonly title: string }) {
+ return (
+
+
+
+ {children}
+
+
+ {title}
+
+ );
+}
diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx
new file mode 100644
index 0000000000..9bc4d45b1c
--- /dev/null
+++ b/components/edit/EditShell/EditShell.tsx
@@ -0,0 +1,276 @@
+'use client';
+
+import { motion, useReducedMotion } from 'motion/react';
+import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
+import type { SceneEditorSurface, SurfaceState } from '@/lib/edit/scene-editor-surface';
+import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
+import { NOOP_SURFACE } from '@/lib/edit/noop-surface';
+import type { Scene } from '@/lib/types/stage';
+import { CHROME_DURATION, CHROME_EASE, CHROME_STAGGER } from '@/lib/edit/transitions';
+import { StageGrid } from '@/components/edit/StageGrid';
+import { CommandBar } from './CommandBar';
+import { FloatingInsertToolbar } from './FloatingInsertToolbar';
+import { FloatingToolbar } from './FloatingToolbar';
+import { HintRail } from './HintRail';
+
+interface EditShellProps {
+ readonly scene: Scene;
+ /**
+ * Optional left-side navigator slot. In v0 this is the SlideNavRail
+ * passed from Stage when mode === 'edit'. Surface code never imports
+ * the rail (the prop is the only handoff seam), keeping chrome and
+ * surface separable.
+ */
+ readonly leftRail?: ReactNode;
+ /**
+ * Right-edge slot of the CommandBar — Stage uses this to hand in the
+ * global controls (settings pill + Pro Switch) when the Stage Header
+ * is hidden, so the entire top chrome reduces to a single bar.
+ */
+ readonly commandTrailing?: ReactNode;
+}
+
+const CHROME_TRANSITION = { duration: CHROME_DURATION, ease: CHROME_EASE } as const;
+const COMMANDBAR_DELAY = CHROME_STAGGER;
+const LEFT_RAIL_DELAY = CHROME_STAGGER * 2;
+
+/**
+ * Pro mode (edit) chrome — mounts inside the canvas slot of Stage, replacing
+ * CanvasArea. The playback Header above stays mounted because it owns the
+ * global Pro toggle Switch: exiting Pro mode is done by flipping that Switch
+ * off, not by a dedicated button here.
+ *
+ * ┌──────────────────────────────────────────────┐ (Stage Header above)
+ * ├──────────────────────────────────────────────┤
+ * │ CommandBar (undo/redo · title · insert · │
+ * │ surface commands) │
+ * ├──────────┬───────────────────────────────────┤
+ * │ leftRail │ Canvas / unsupported-scene │
+ * │ (opt) │ FloatingToolbar (when selected) │
+ * │ │ HintRail (AI, reserved) │
+ * └──────────┴───────────────────────────────────┘
+ *
+ * Mount choreography: CommandBar drops in from top, leftRail slides in
+ * from left after a stagger, content opacity-fades in. All three share
+ * the single `CHROME_*` source in `lib/edit/transitions.ts` so timing
+ * stays consistent with the outer Stage-level cross-fade.
+ *
+ * Architecture: this shell resolves `scene.type` to a registered surface
+ * (or falls back to NOOP_SURFACE for unregistered types) and **never
+ * branches into a different component type**. The same ` ` mounts
+ * across every scene-type change — only the `surface.CanvasComponent`
+ * inside the canvas slot swaps. That guarantees CommandBar and `leftRail`
+ * never remount during scene navigation, removing the chrome flicker that
+ * the previous two-branch design caused (PR3a rearch).
+ */
+export function EditShell({ scene, leftRail, commandTrailing }: EditShellProps) {
+ const surface = sceneEditorRegistry.resolve(scene.type) ?? NOOP_SURFACE;
+ // Surface state is published from a child runner (keyed by sceneType so it
+ // remounts when the surface identity changes — that's the boundary at which
+ // rules-of-hooks naturally allows a different hook signature). The chrome
+ // around it stays mounted and consumes state via these props.
+ const [state, setState] = useState(null);
+ const CanvasComponent = surface.CanvasComponent;
+
+ return (
+ <>
+ {/* `key={scene.type}` is the remount boundary. We can't use
+ `surface.sceneType` here because NOOP_SURFACE deliberately reuses
+ 'slide' as a placeholder (the SceneType union is closed and NOOP
+ isn't a real type). The scene's own `type` is the actual signal
+ that the hook signature inside `useSurfaceState` is about to
+ change — so we remount the runner exactly when it does, keeping
+ rules-of-hooks happy across the slide ↔ read-only surface swap
+ while the rest of the chrome stays mounted. */}
+
+
+
+ {state?.insertItems && state.insertItems.length > 0 && (
+
+ )}
+ {state?.hasSelection && }
+
+
+ >
+ );
+}
+
+/**
+ * Hidden runner that owns the surface state hook. `key={surface.sceneType}`
+ * ensures it remounts when the surface itself changes (slide → noop) — the
+ * only point at which the hook call signature can vary. Within a single mount
+ * the hook signature is fixed (the surface object is constant), so React's
+ * rules-of-hooks are respected.
+ *
+ * Renders no DOM; state flows up to the chrome via `onChange`. A custom
+ * shallow comparison gates the publish — surface hooks (e.g. slideSurface's
+ * `useSlideSurfaceState`) return a fresh object literal every render, so naive
+ * reference equality would loop infinitely (every publish causes the parent to
+ * re-render, which re-runs this hook, which yields a new ref, which publishes
+ * again, etc.). We only publish when one of the fields the chrome actually
+ * reads has materially changed.
+ */
+function SurfaceStateRunner({
+ surface,
+ onChange,
+}: {
+ readonly surface: SceneEditorSurface;
+ readonly onChange: (state: SurfaceState) => void;
+}) {
+ const state = surface.useSurfaceState();
+ const lastRef = useRef(null);
+ useLayoutEffect(() => {
+ if (surfaceStateEqual(state, lastRef.current)) return;
+ lastRef.current = state;
+ onChange(state);
+ });
+ return null;
+}
+
+/**
+ * Field-by-field equality for the subset of SurfaceState that the chrome
+ * reads. Surface hooks (e.g. `useSlideSurfaceState`) return a fresh object
+ * literal every render, so naive reference equality would publish on
+ * every render and trip an infinite render loop via the runner →
+ * setState → re-render cycle. We compare semantic content instead.
+ *
+ * **When you extend `SurfaceState`** (new field on `EditorCommand`,
+ * `InsertPaletteItem`, `FloatingAction`, `EditorHint`, or a new top-level
+ * field), update this function in lock-step. A field that's read by the
+ * chrome but missing from the comparison silently goes stale.
+ *
+ * - `content` is reference-equal as long as the in-memory slide buffer
+ * hasn't been committed — the canonical "real change" signal.
+ * - History flags compared individually (functions like undo/redo are
+ * stable references via `useSlideEditSession.getState()`).
+ * - InsertPaletteItem / EditorCommand / FloatingAction arrays: length +
+ * per-item `id` + per-item flags that drive visual state.
+ * - EditorHint compared by length + per-item severity/message.
+ *
+ * **Callback identity (`onInvoke`, `popoverContent`) is intentionally
+ * NOT compared.** Per-render closure rebinding is normal and would
+ * trip equality every render. Today this is safe because slide is the
+ * only registered surface and its `floatingActions` is `[]` — the only
+ * `onInvoke` set the chrome reads is on `InsertPaletteItem`, which is
+ * a stable module-level closure from `buildInsertItems`. A future
+ * surface that returns non-empty `floatingActions` with closures
+ * capturing per-render state must fold its own change signal (a
+ * content ref / version counter) into the comparison, otherwise the
+ * stale callback fires at click time.
+ */
+function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean {
+ if (!b) return false;
+ if (a.content !== b.content) return false;
+ if (a.hasSelection !== b.hasSelection) return false;
+ if ((a.history?.canUndo ?? null) !== (b.history?.canUndo ?? null)) return false;
+ if ((a.history?.canRedo ?? null) !== (b.history?.canRedo ?? null)) return false;
+ if (a.insertItems.length !== b.insertItems.length) return false;
+ for (let i = 0; i < a.insertItems.length; i++) {
+ if (a.insertItems[i].id !== b.insertItems[i].id) return false;
+ if (a.insertItems[i].active !== b.insertItems[i].active) return false;
+ if (a.insertItems[i].disabled !== b.insertItems[i].disabled) return false;
+ // Label/tooltip are user-facing and locale-dependent: without them the
+ // insert toolbar text stays stale after a language switch.
+ if (a.insertItems[i].label !== b.insertItems[i].label) return false;
+ if (a.insertItems[i].tooltip !== b.insertItems[i].tooltip) return false;
+ }
+ if (a.commands.length !== b.commands.length) return false;
+ for (let i = 0; i < a.commands.length; i++) {
+ if (a.commands[i].id !== b.commands[i].id) return false;
+ if (a.commands[i].disabled !== b.commands[i].disabled) return false;
+ if (a.commands[i].label !== b.commands[i].label) return false;
+ if (a.commands[i].tooltip !== b.commands[i].tooltip) return false;
+ }
+ if (a.floatingActions.length !== b.floatingActions.length) return false;
+ for (let i = 0; i < a.floatingActions.length; i++) {
+ if (a.floatingActions[i].id !== b.floatingActions[i].id) return false;
+ if (a.floatingActions[i].disabled !== b.floatingActions[i].disabled) return false;
+ if (a.floatingActions[i].label !== b.floatingActions[i].label) return false;
+ }
+ const aHints = a.hints ?? [];
+ const bHints = b.hints ?? [];
+ if (aHints.length !== bHints.length) return false;
+ for (let i = 0; i < aHints.length; i++) {
+ if (aHints[i].id !== bHints[i].id) return false;
+ if (aHints[i].severity !== bHints[i].severity) return false;
+ if (aHints[i].message !== bHints[i].message) return false;
+ }
+ return true;
+}
+
+interface FrameProps {
+ readonly title: string;
+ readonly leftRail?: ReactNode;
+ readonly history?: React.ComponentProps['history'];
+ readonly commands?: React.ComponentProps['commands'];
+ readonly trailing?: ReactNode;
+ readonly children: ReactNode;
+}
+
+function Frame({ title, leftRail, history, commands, trailing, children }: FrameProps) {
+ const prefersReducedMotion = useReducedMotion();
+
+ // Chrome layers fade in (opacity only) — deliberately NO transform (x/y)
+ // slide. Two reasons: (1) the CommandBar's trailing slot and the rail host
+ // the Pro Switch / settings pill, which animate across the mode swap via
+ // `layoutId`; a transform on an ancestor distorts motion's layout
+ // measurement and makes the shared element drift. (2) the rail
+ // (`backdrop-blur-xl`) and the pills (`backdrop-blur-md`) would force a
+ // per-frame backdrop-filter recompute while transforming, which drops
+ // frames. A pure opacity fade composites cleanly and keeps the layout
+ // static so layoutId morphs land precisely.
+ const cmdInitial = { opacity: 0 };
+ const cmdAnimate = { opacity: 1 };
+ const railInitial = { opacity: 0 };
+ const railAnimate = { opacity: 1 };
+
+ const stepTransition = prefersReducedMotion
+ ? { duration: 0.12, ease: CHROME_EASE }
+ : CHROME_TRANSITION;
+
+ return (
+
+
+
+ }
+ leftSlot={
+ leftRail ? (
+
+ {leftRail}
+
+ ) : null
+ }
+ centerSlot={
+ // Padded studio frame around the actual scene renderer. Lifted
+ // up from SlideCanvas so the slide and the non-slide read-only
+ // renderers share the exact same canvas bounding rect (no
+ // layout jump when switching scene type). Children render
+ // inside an inner ring/shadow card that the playback
+ // CanvasArea visually mirrors.
+
+ }
+ />
+ );
+}
diff --git a/components/edit/EditShell/FloatingInsertToolbar.tsx b/components/edit/EditShell/FloatingInsertToolbar.tsx
new file mode 100644
index 0000000000..8244d935db
--- /dev/null
+++ b/components/edit/EditShell/FloatingInsertToolbar.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { AnimatePresence, motion } from 'motion/react';
+import { ChevronDown, ChevronUp } from 'lucide-react';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { useSettingsStore } from '@/lib/store/settings';
+import { CHROME_EASE } from '@/lib/edit/transitions';
+import type { InsertPaletteItem } from '@/lib/edit/scene-editor-surface';
+import { cn } from '@/lib/utils';
+import { InsertButton } from './InsertButton';
+
+interface Props {
+ readonly items: readonly InsertPaletteItem[];
+}
+
+/**
+ * Persistent floating insert toolbar — sits centered above the slide
+ * canvas, ~12px from the top of the studio frame. Replaces the inline
+ * insert slot in CommandBar so the global stage controls (back, undo
+ * /redo, title, settings, Pro, Download) aren't visually mixed with
+ * content-insertion affordances ("text box / image / shape ..." live
+ * with the content, not with stage controls).
+ *
+ * Collapses to a small chevron handle at the same anchor position;
+ * the collapsed flag persists in `settings.editInsertToolbarCollapsed`.
+ */
+export function FloatingInsertToolbar({ items }: Props) {
+ const { t } = useI18n();
+ const collapsed = useSettingsStore((s) => s.editInsertToolbarCollapsed);
+ const setCollapsed = useSettingsStore((s) => s.setEditInsertToolbarCollapsed);
+
+ if (items.length === 0) return null;
+
+ return (
+
+
+ {collapsed ? (
+ setCollapsed(false)}
+ aria-label={t('edit.insert.expandToolbar')}
+ title={t('edit.insert.expandToolbar')}
+ initial={{ opacity: 0, y: -6 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -6 }}
+ transition={{ duration: 0.18, ease: CHROME_EASE }}
+ className={cn(
+ 'pointer-events-auto inline-flex h-7 w-9 items-center justify-center rounded-b-lg rounded-t-none',
+ 'bg-white/90 dark:bg-zinc-900/90 backdrop-blur-md',
+ 'ring-1 ring-zinc-200/80 dark:ring-zinc-700/80 border-t-0',
+ 'shadow-sm text-zinc-500 dark:text-zinc-400',
+ 'hover:text-violet-600 dark:hover:text-violet-300',
+ 'transition-colors',
+ )}
+ >
+
+
+ ) : (
+
+ {items.map((item) => (
+
+ ))}
+
+ setCollapsed(true)}
+ aria-label={t('edit.insert.collapseToolbar')}
+ title={t('edit.insert.collapseToolbar')}
+ className={cn(
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
+ 'text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200',
+ 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
+ 'transition-colors',
+ )}
+ >
+
+
+
+ )}
+
+
+ );
+}
diff --git a/components/edit/EditShell/FloatingToolbar.tsx b/components/edit/EditShell/FloatingToolbar.tsx
new file mode 100644
index 0000000000..f23057a316
--- /dev/null
+++ b/components/edit/EditShell/FloatingToolbar.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { Fragment } from 'react';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import type { FloatingAction } from '@/lib/edit/scene-editor-surface';
+
+interface FloatingToolbarProps {
+ readonly actions: readonly FloatingAction[];
+}
+
+/**
+ * Contextual mini-bar shown above the canvas when something is selected.
+ * Each action is either a button (onInvoke) or a popover trigger
+ * (popoverContent) — used by the slide surface for color pickers, font
+ * select, etc. so properties live here instead of in a fixed right panel.
+ */
+export function FloatingToolbar({ actions }: FloatingToolbarProps) {
+ if (actions.length === 0) return null;
+
+ const grouped = groupByGroup(actions);
+
+ return (
+
+
+ {grouped.map((group, groupIndex) => (
+
+ {groupIndex > 0 &&
}
+ {group.map((action) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+function ActionButton({ action }: { readonly action: FloatingAction }) {
+ const isDanger = action.id === 'delete';
+ const button = (
+
+ {action.icon}
+ {!action.icon && {action.label} }
+
+ );
+
+ const triggerWithTooltip = (
+
+ {button}
+ {action.tooltip ?? action.label}
+
+ );
+
+ if (!action.popoverContent) return triggerWithTooltip;
+
+ // Chain both triggers' asChild Slots directly onto the real .
+ // Wrapping PopoverTrigger around (a provider, not a DOM node)
+ // dropped the popover trigger handler, so the popover never opened —
+ // this is the first popoverContent consumer to exercise that path.
+ return (
+
+
+
+ {button}
+
+ {action.tooltip ?? action.label}
+
+ {/* w-auto: let the action's own content size the popover (the text
+ property bar is a wide single row); max-w-[92vw] keeps it on-screen
+ and Radix handles edge collision. Avoids the fixed-w-72 clip.
+
+ onOpenAutoFocus prevented: opening the bar must NOT pull focus off
+ the canvas selection (commands apply to the live selection).
+ onFocusOutside prevented: format commands refocus the editor
+ (execCommand → editorView.focus()); without this the bar would
+ dismiss after every single click. Escape and pointer-down truly
+ outside still close it (defaults untouched), so it behaves like a
+ contextual bar that stays up across consecutive formatting steps. */}
+ e.preventDefault()}
+ onFocusOutside={(e) => e.preventDefault()}
+ >
+ {action.popoverContent()}
+
+
+ );
+}
+
+function groupByGroup(items: readonly FloatingAction[]): FloatingAction[][] {
+ const groups: FloatingAction[][] = [];
+ let current: FloatingAction[] = [];
+ let currentKey: string | undefined;
+ for (const item of items) {
+ const key = item.group;
+ if (key !== currentKey && current.length > 0) {
+ groups.push(current);
+ current = [];
+ }
+ currentKey = key;
+ current.push(item);
+ }
+ if (current.length > 0) groups.push(current);
+ return groups;
+}
diff --git a/components/edit/EditShell/HintRail.tsx b/components/edit/EditShell/HintRail.tsx
new file mode 100644
index 0000000000..5481f0786e
--- /dev/null
+++ b/components/edit/EditShell/HintRail.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { Info, Lightbulb, AlertTriangle } from 'lucide-react';
+import type { EditorHint } from '@/lib/edit/scene-editor-surface';
+
+interface HintRailProps {
+ readonly hints?: readonly EditorHint[];
+}
+
+/**
+ * Reserved AI inline-coach surface. Renders nothing in Phase 1 (surfaces
+ * return [] for hints). Layout slot is wired so future phases can populate
+ * it without restructuring the shell.
+ */
+export function HintRail({ hints }: HintRailProps) {
+ if (!hints || hints.length === 0) return null;
+
+ return (
+
+
+ {hints.map((hint) => (
+
+ ))}
+
+
+ );
+}
+
+const ICONS = {
+ info: Info,
+ suggestion: Lightbulb,
+ warning: AlertTriangle,
+} as const;
+
+const SEVERITY_STYLES = {
+ info: 'border-zinc-200 bg-white text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200',
+ suggestion:
+ 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100',
+ warning:
+ 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-100',
+} as const;
+
+function HintCard({ hint }: { readonly hint: EditorHint }) {
+ const Icon = ICONS[hint.severity];
+ return (
+
+
+
{hint.message}
+ {hint.action && (
+
+ {hint.action.label}
+
+ )}
+
+ );
+}
diff --git a/components/edit/EditShell/InsertButton.tsx b/components/edit/EditShell/InsertButton.tsx
new file mode 100644
index 0000000000..4a3f37bab8
--- /dev/null
+++ b/components/edit/EditShell/InsertButton.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import type { InsertPaletteItem } from '@/lib/edit/scene-editor-surface';
+
+/**
+ * Single insert-palette button. Reused by both the (legacy) CommandBar
+ * insert slot and the FloatingInsertToolbar that lives above the
+ * canvas now.
+ *
+ * When the item declares `popoverContent`, the button doubles as a
+ * popover trigger — and PopoverTrigger's `asChild` Slot is chained
+ * directly onto the real `` so wrapping a ``
+ * (provider, not DOM) doesn't drop the popover trigger handler.
+ */
+export function InsertButton({ item }: { readonly item: InsertPaletteItem }) {
+ const button = (
+
+
+ {item.icon}
+
+ {item.label}
+
+ );
+
+ const triggerWithTooltip = (
+
+ {button}
+ {item.tooltip && {item.tooltip} }
+
+ );
+
+ if (!item.popoverContent) return triggerWithTooltip;
+
+ return (
+
+
+
+ {button}
+
+ {item.tooltip && {item.tooltip} }
+
+
+ {item.popoverContent()}
+
+
+ );
+}
diff --git a/components/edit/EditShell/index.ts b/components/edit/EditShell/index.ts
new file mode 100644
index 0000000000..ef5b704bf0
--- /dev/null
+++ b/components/edit/EditShell/index.ts
@@ -0,0 +1 @@
+export { EditShell } from './EditShell';
diff --git a/components/edit/MultiTabEditConflictPrompt.tsx b/components/edit/MultiTabEditConflictPrompt.tsx
new file mode 100644
index 0000000000..ac65d7e515
--- /dev/null
+++ b/components/edit/MultiTabEditConflictPrompt.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { Users } from 'lucide-react';
+import { VisuallyHidden } from 'radix-ui';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { useI18n } from '@/lib/hooks/use-i18n';
+
+interface MultiTabEditConflictPromptProps {
+ readonly open: boolean;
+ readonly onDismiss: () => void;
+ readonly onOpenChange?: (open: boolean) => void;
+}
+
+/**
+ * Standalone refusal dialog the slide-surface PR will mount when
+ * `tryAcquireEditLock` returns false on Pro-toggle entry. Pure
+ * presenter — wiring (calling `tryAcquireEditLock` / `refreshEditLock`
+ * / `releaseEditLock` against the current course id, generating a
+ * tabId, etc.) lives in the slide surface's edit-entry effect.
+ *
+ * Single dismissive action only — the user has no remediation here
+ * besides closing the other tab; this dialog just makes the refusal
+ * visible instead of silently dropping the click.
+ */
+export function MultiTabEditConflictPrompt({
+ open,
+ onDismiss,
+ onOpenChange,
+}: MultiTabEditConflictPromptProps) {
+ const { t } = useI18n();
+
+ return (
+
+
+
+ {t('edit.multiTab.conflict.title')}
+
+
+
+
+
+
+
+ {t('edit.multiTab.conflict.title')}
+
+
+ {t('edit.multiTab.conflict.body')}
+
+
+
+
+ {t('edit.multiTab.conflict.actionDismiss')}
+
+
+
+
+ );
+}
diff --git a/components/edit/PlaybackChromeRoot.tsx b/components/edit/PlaybackChromeRoot.tsx
new file mode 100644
index 0000000000..8e23f978b5
--- /dev/null
+++ b/components/edit/PlaybackChromeRoot.tsx
@@ -0,0 +1,1340 @@
+'use client';
+
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useStageStore } from '@/lib/store';
+import { PENDING_SCENE_ID } from '@/lib/store/stage';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { useSettingsStore } from '@/lib/store/settings';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { SceneSidebar } from '@/components/stage/scene-sidebar';
+import { Header } from '@/components/header';
+import { CanvasArea } from '@/components/canvas/canvas-area';
+import { Roundtable } from '@/components/roundtable';
+import { PlaybackEngine, computePlaybackView } from '@/lib/playback';
+import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback';
+import { ActionEngine } from '@/lib/action/engine';
+import { createAudioPlayer } from '@/lib/utils/audio-player';
+import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts';
+import { useWidgetIframeStore } from '@/lib/store/widget-iframe';
+import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator';
+import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action';
+import { cn } from '@/lib/utils';
+// Playback state persistence removed — refresh always starts from the beginning
+import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area';
+import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store';
+import type { AgentConfig } from '@/lib/orchestration/registry/types';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogTitle,
+ AlertDialogFooter,
+ AlertDialogAction,
+ AlertDialogCancel,
+} from '@/components/ui/alert-dialog';
+import { AlertTriangle } from 'lucide-react';
+import { VisuallyHidden } from 'radix-ui';
+
+/**
+ * Imperative handle exposed via `ref` so the parent (`Stage`) can tear
+ * down playback state synchronously before flipping mode to `'edit'`.
+ * Unmount cleanup would run anyway, but the toggle needs to `await`
+ * `endActiveSession()` (which aborts SSE) before we trust the engine /
+ * chat to be quiescent — fire-and-forget on unmount loses that guarantee.
+ */
+export interface PlaybackChromeRootHandle {
+ /** Ends any active SSE session, stops the engine, cleans up TTS audio. */
+ teardown: () => Promise;
+}
+
+interface PlaybackChromeRootProps {
+ readonly onRetryOutline?: (outlineId: string) => Promise;
+ /** Whether the Pro Switch in Header should be enabled. */
+ readonly canEnterProMode?: boolean;
+ /** Pro Switch click handler — parent coordinates editLock + teardown. */
+ readonly onEnterProMode?: () => void;
+}
+
+/**
+ * PlaybackChromeRoot — owns the entire playback/autonomous chrome and
+ * its state. Mounted whenever `mode !== 'edit'`. The Pro Switch in
+ * `Header` calls `onEnterProMode`; the parent `Stage` is responsible
+ * for calling `ref.teardown()` before unmounting this root so SSE and
+ * the engine wind down cleanly.
+ */
+export const PlaybackChromeRoot = forwardRef(
+ function PlaybackChromeRoot({ onRetryOutline, canEnterProMode, onEnterProMode }, ref) {
+ const { t } = useI18n();
+ const {
+ mode,
+ getCurrentScene,
+ scenes,
+ currentSceneId,
+ setCurrentSceneId,
+ generatingOutlines,
+ outlines,
+ } = useStageStore();
+ const failedOutlines = useStageStore.use.failedOutlines();
+
+ const currentScene = getCurrentScene();
+
+ // Layout state from settings store (persisted via localStorage)
+ const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
+ const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
+ const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
+ const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth);
+ const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed);
+ const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed);
+ const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
+ const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
+
+ // PlaybackEngine state
+ const [engineMode, setEngineMode] = useState('idle');
+ const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle
+ const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture)
+ const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA)
+ const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1)
+ const [discussionTrigger, setDiscussionTrigger] = useState(null);
+
+ // Speaking agent tracking (Issue 2)
+ const [speakingAgentId, setSpeakingAgentId] = useState(null);
+
+ // Thinking state (Issue 5)
+ const [thinkingState, setThinkingState] = useState<{
+ stage: string;
+ agentId?: string;
+ } | null>(null);
+
+ // Cue user state (Issue 7)
+ const [isCueUser, setIsCueUser] = useState(false);
+
+ // End flash state (Issue 3)
+ const [showEndFlash, setShowEndFlash] = useState(false);
+ const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>(
+ 'discussion',
+ );
+
+ // Streaming state for stop button (Issue 1)
+ const [chatIsStreaming, setChatIsStreaming] = useState(false);
+ const [chatSessionType, setChatSessionType] = useState(null);
+
+ // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input
+ const [isTopicPending, setIsTopicPending] = useState(false);
+
+ // Active bubble ID for playback highlight in chat area (Issue 8)
+ const [activeBubbleId, setActiveBubbleId] = useState(null);
+
+ // Scene switch confirmation dialog state
+ const [pendingSceneId, setPendingSceneId] = useState(null);
+ const [isPresenting, setIsPresenting] = useState(false);
+ const [controlsVisible, setControlsVisible] = useState(true);
+ const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false);
+
+ // Whiteboard state (from canvas store so AI tools can open it)
+ const whiteboardOpen = useCanvasStore.use.whiteboardOpen();
+ const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen();
+
+ // Selected agents from settings store (Zustand)
+ const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
+ const ttsMuted = useSettingsStore((s) => s.ttsMuted);
+ const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
+
+ // Generate participants from selected agents
+ const participants = useMemo(
+ () => agentsToParticipants(selectedAgentIds, t),
+ [selectedAgentIds, t],
+ );
+
+ // Resolved AgentConfig array for hooks that need full agent objects
+ // Subscribe to the agents record so voiceConfig changes trigger re-resolution
+ const agentsRecord = useAgentRegistry((s) => s.agents);
+ const selectedAgents = useMemo(
+ () =>
+ selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null),
+ [agentsRecord, selectedAgentIds],
+ );
+
+ // Discussion TTS: audio indicator state
+ const [audioIndicatorState, setAudioIndicatorState] = useState('idle');
+ const [audioAgentId, setAudioAgentId] = useState(null);
+
+ const discussionTTS = useDiscussionTTS({
+ enabled: ttsEnabled && !ttsMuted,
+ agents: selectedAgents,
+ onAudioStateChange: (agentId, state) => {
+ setAudioAgentId(agentId);
+ setAudioIndicatorState(state);
+ },
+ });
+
+ // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback)
+ const pickStudentAgent = useCallback((): string => {
+ const registry = useAgentRegistry.getState();
+ const agents = selectedAgentIds
+ .map((id) => registry.getAgent(id))
+ .filter((a): a is AgentConfig => a != null);
+ const students = agents.filter((a) => a.role === 'student');
+ if (students.length > 0) {
+ return students[Math.floor(Math.random() * students.length)].id;
+ }
+ const nonTeachers = agents.filter((a) => a.role !== 'teacher');
+ if (nonTeachers.length > 0) {
+ return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id;
+ }
+ return agents[0]?.id || 'default-1';
+ }, [selectedAgentIds]);
+
+ const engineRef = useRef(null);
+ const audioPlayerRef = useRef(createAudioPlayer());
+ const chatAreaRef = useRef(null);
+ const lectureSessionIdRef = useRef(null);
+ const lectureActionCounterRef = useRef(0);
+ const discussionAbortRef = useRef(null);
+ const presentationIdleTimerRef = useRef | null>(null);
+ const stageRef = useRef(null);
+ // Guard to prevent double flash when manual stop triggers onDiscussionEnd
+ const manualStopRef = useRef(false);
+ // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks
+ const sceneEpochRef = useRef(0);
+ // When true, the next engine init will auto-start playback (for auto-play scene advance)
+ const autoStartRef = useRef(false);
+ // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE)
+ const [isDiscussionPaused, setIsDiscussionPaused] = useState(false);
+
+ /**
+ * Resume a soft-paused topic: re-call /chat with existing session messages.
+ * The director picks the next agent to continue.
+ */
+ const doResumeTopic = useCallback(async () => {
+ // Clear old bubble immediately — no lingering on interrupted text
+ setIsTopicPending(false);
+ setLiveSpeech(null);
+ setSpeakingAgentId(null);
+ setThinkingState({ stage: 'director' });
+ setChatIsStreaming(true);
+ // Transition engine back to live — onInputActivate paused it when soft-pausing,
+ // so we must explicitly resume to keep engine mode in sync with the chat loop.
+ engineRef.current?.resume();
+ // Fire new chat round — SSE events will drive thinking → agent_start → speech
+ await chatAreaRef.current?.resumeActiveSession();
+ }, []);
+
+ /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */
+ const resetLiveState = useCallback(() => {
+ setLiveSpeech(null);
+ setSpeakingAgentId(null);
+ setSpeechProgress(null);
+ setThinkingState(null);
+ setIsCueUser(false);
+ setIsTopicPending(false);
+ setChatIsStreaming(false);
+ setChatSessionType(null);
+ setIsDiscussionPaused(false);
+ }, []);
+
+ /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */
+ const resetSceneState = useCallback(() => {
+ resetLiveState();
+ setPlaybackCompleted(false);
+ setLectureSpeech(null);
+ setSpeechProgress(null);
+ setShowEndFlash(false);
+ setActiveBubbleId(null);
+ setDiscussionTrigger(null);
+ }, [resetLiveState]);
+
+ /** Request failure should exit live discussion UI without hard-closing the session. */
+ const handleLiveSessionError = useCallback(() => {
+ engineRef.current?.handleDiscussionError();
+ resetLiveState();
+ setActiveBubbleId(null);
+ }, [resetLiveState]);
+
+ /**
+ * Unified session cleanup — called by both roundtable stop button and chat area end button.
+ * Handles: engine transition, flash, roundtable state clearing.
+ */
+ const doSessionCleanup = useCallback(() => {
+ const activeType = chatSessionType;
+
+ // Engine cleanup — guard to avoid double flash from onDiscussionEnd
+ manualStopRef.current = true;
+ engineRef.current?.handleEndDiscussion();
+ manualStopRef.current = false;
+
+ // Show end flash with correct session type
+ if (activeType === 'qa' || activeType === 'discussion') {
+ setEndFlashSessionType(activeType);
+ setShowEndFlash(true);
+ setTimeout(() => setShowEndFlash(false), 1800);
+ }
+
+ // Stop any in-flight discussion TTS audio
+ discussionTTS.cleanup();
+
+ resetLiveState();
+ }, [chatSessionType, resetLiveState, discussionTTS]);
+
+ // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar)
+ const handleStopDiscussion = useCallback(async () => {
+ await chatAreaRef.current?.endActiveSession();
+ doSessionCleanup();
+ }, [doSessionCleanup]);
+
+ // Imperative teardown so the parent can `await` SSE / engine / TTS
+ // shutdown before flipping mode to 'edit'. Mirrors what the old in-
+ // component `handleToggleEditMode` did, but exposed through ref so
+ // the toggle lives one layer up.
+ useImperativeHandle(
+ ref,
+ () => ({
+ teardown: async () => {
+ await chatAreaRef.current?.endActiveSession();
+ if (discussionAbortRef.current) {
+ discussionAbortRef.current.abort();
+ discussionAbortRef.current = null;
+ }
+ engineRef.current?.stop();
+ discussionTTS.cleanup();
+ resetSceneState();
+ },
+ }),
+ [discussionTTS, resetSceneState],
+ );
+
+ const clearPresentationIdleTimer = useCallback(() => {
+ if (presentationIdleTimerRef.current) {
+ clearTimeout(presentationIdleTimerRef.current);
+ presentationIdleTimerRef.current = null;
+ }
+ }, []);
+
+ const resetPresentationIdleTimer = useCallback(() => {
+ setControlsVisible(true);
+ clearPresentationIdleTimer();
+ if (isPresenting && !isPresentationInteractionActive) {
+ presentationIdleTimerRef.current = setTimeout(() => {
+ setControlsVisible(false);
+ }, 3000);
+ }
+ }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]);
+
+ const togglePresentation = useCallback(async () => {
+ const stageElement = stageRef.current;
+ if (!stageElement) return;
+
+ try {
+ if (document.fullscreenElement === stageElement) {
+ // Unlock Escape key before exiting fullscreen
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (navigator as any).keyboard?.unlock?.();
+ await document.exitFullscreen();
+ return;
+ }
+
+ setControlsVisible(true);
+ await stageElement.requestFullscreen();
+ // Lock Escape key so it doesn't auto-exit fullscreen (#255)
+ // Escape is handled manually in our keydown handler instead
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {});
+ setSidebarCollapsed(true);
+ setChatAreaCollapsed(true);
+ } catch {
+ // Firefox may deny fullscreen from certain keyboard events (e.g. F11)
+ console.warn('[Presentation] Fullscreen request denied — browser policy');
+ }
+ }, [setChatAreaCollapsed, setSidebarCollapsed]);
+
+ useEffect(() => {
+ const onFullscreenChange = () => {
+ const active = document.fullscreenElement === stageRef.current;
+ setIsPresenting(active);
+
+ if (!active) {
+ // Ensure keyboard unlock on any fullscreen exit
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (navigator as any).keyboard?.unlock?.();
+ setControlsVisible(true);
+ clearPresentationIdleTimer();
+ }
+ };
+
+ document.addEventListener('fullscreenchange', onFullscreenChange);
+ return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
+ }, [clearPresentationIdleTimer]);
+
+ useEffect(() => {
+ if (!isPresenting) {
+ setControlsVisible(true);
+ clearPresentationIdleTimer();
+ return;
+ }
+
+ const handleActivity = () => {
+ resetPresentationIdleTimer();
+ };
+
+ window.addEventListener('mousemove', handleActivity);
+ window.addEventListener('mousedown', handleActivity);
+ window.addEventListener('touchstart', handleActivity);
+ if (isPresentationInteractionActive) {
+ setControlsVisible(true);
+ clearPresentationIdleTimer();
+ } else {
+ resetPresentationIdleTimer();
+ }
+
+ return () => {
+ window.removeEventListener('mousemove', handleActivity);
+ window.removeEventListener('mousedown', handleActivity);
+ window.removeEventListener('touchstart', handleActivity);
+ clearPresentationIdleTimer();
+ };
+ }, [
+ clearPresentationIdleTimer,
+ isPresenting,
+ isPresentationInteractionActive,
+ resetPresentationIdleTimer,
+ ]);
+
+ // Initialize playback engine when scene changes
+ useEffect(() => {
+ // Bump epoch so any stale SSE callbacks from the previous scene are discarded
+ sceneEpochRef.current++;
+
+ // End any active QA/discussion session — this synchronously aborts the SSE
+ // stream inside use-chat-sessions (abortControllerRef.abort()), preventing
+ // stale onLiveSpeech callbacks from leaking into the new scene.
+ chatAreaRef.current?.endActiveSession();
+
+ // Also abort the engine-level discussion controller
+ if (discussionAbortRef.current) {
+ discussionAbortRef.current.abort();
+ discussionAbortRef.current = null;
+ }
+
+ // Stop any in-flight discussion TTS audio on scene switch
+ discussionTTS.cleanup();
+
+ // Reset all roundtable/live state so scenes are fully isolated
+ resetSceneState();
+
+ if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) {
+ engineRef.current = null;
+ setEngineMode('idle');
+
+ return;
+ }
+
+ // Stop previous engine
+ if (engineRef.current) {
+ engineRef.current.stop();
+ }
+
+ // Get widget iframe messaging callback for interactive scenes (keyed by sceneId)
+ const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id);
+
+ // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging)
+ const actionEngine = new ActionEngine(
+ useStageStore,
+ audioPlayerRef.current,
+ widgetSendMessage,
+ );
+
+ // Create new PlaybackEngine
+ const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, {
+ onModeChange: (mode) => {
+ setEngineMode(mode);
+ },
+ onSceneChange: (_sceneId) => {
+ // Scene change handled by engine
+ },
+ onSpeechStart: (text) => {
+ setLectureSpeech(text);
+ // Add to lecture session with incrementing index for dedup
+ // Chat area pacing is handled by the StreamBuffer (onTextReveal)
+ if (lectureSessionIdRef.current) {
+ const idx = lectureActionCounterRef.current++;
+ const speechId = `speech-${Date.now()}`;
+ chatAreaRef.current?.addLectureMessage(
+ lectureSessionIdRef.current,
+ { id: speechId, type: 'speech', text } as Action,
+ idx,
+ );
+ // Track active bubble for highlight (Issue 8)
+ const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!);
+ if (msgId) setActiveBubbleId(msgId);
+ }
+ },
+ onSpeechEnd: () => {
+ // Don't clear lectureSpeech — let it persist until the next
+ // onSpeechStart replaces it or the scene transitions.
+ // Clearing here causes fallback to idleText (first sentence).
+ setActiveBubbleId(null);
+ },
+ onEffectFire: (effect: Effect) => {
+ // Add to lecture session with incrementing index
+ if (
+ lectureSessionIdRef.current &&
+ (effect.kind === 'spotlight' || effect.kind === 'laser')
+ ) {
+ const idx = lectureActionCounterRef.current++;
+ chatAreaRef.current?.addLectureMessage(
+ lectureSessionIdRef.current,
+ {
+ id: `${effect.kind}-${Date.now()}`,
+ type: effect.kind,
+ elementId: effect.targetId,
+ } as Action,
+ idx,
+ );
+ }
+ },
+ onProactiveShow: (trigger) => {
+ if (!trigger.agentId) {
+ // Mutate in-place so engine.currentTrigger also gets the agentId
+ // (confirmDiscussion reads agentId from the same object reference)
+ trigger.agentId = pickStudentAgent();
+ }
+ setDiscussionTrigger(trigger);
+ },
+ onProactiveHide: () => {
+ setDiscussionTrigger(null);
+ },
+ onDiscussionConfirmed: (topic, prompt, agentId) => {
+ // Start SSE discussion via ChatArea
+ handleDiscussionSSE(topic, prompt, agentId);
+ },
+ onDiscussionEnd: () => {
+ // Abort any active SSE
+ if (discussionAbortRef.current) {
+ discussionAbortRef.current.abort();
+ discussionAbortRef.current = null;
+ }
+ setDiscussionTrigger(null);
+ // Stop any in-flight discussion TTS audio
+ discussionTTS.cleanup();
+ // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup)
+ resetLiveState();
+ // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup)
+ if (!manualStopRef.current) {
+ setEndFlashSessionType('discussion');
+ setShowEndFlash(true);
+ setTimeout(() => setShowEndFlash(false), 1800);
+ }
+ // If all actions are exhausted (discussion was the last action), mark
+ // playback as completed so the bubble shows reset instead of play.
+ if (engineRef.current?.isExhausted()) {
+ setPlaybackCompleted(true);
+ }
+ },
+ onUserInterrupt: (text) => {
+ // User interrupted → start a discussion via chat
+ chatAreaRef.current?.sendMessage(text);
+ },
+ isAgentSelected: (agentId) => {
+ const ids = useSettingsStore.getState().selectedAgentIds;
+ return ids.includes(agentId);
+ },
+ getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1,
+ onComplete: () => {
+ // lectureSpeech intentionally NOT cleared — last sentence stays visible
+ // until scene transition (auto-play) or user restarts. Scene change
+ // effect handles the reset.
+ setPlaybackCompleted(true);
+
+ // End lecture session on playback complete
+ if (lectureSessionIdRef.current) {
+ chatAreaRef.current?.endSession(lectureSessionIdRef.current);
+ lectureSessionIdRef.current = null;
+ }
+ // Auto-play: advance to next scene after a short pause
+ const { autoPlayLecture } = useSettingsStore.getState();
+ if (autoPlayLecture) {
+ setTimeout(() => {
+ const stageState = useStageStore.getState();
+ if (!useSettingsStore.getState().autoPlayLecture) return;
+ const allScenes = stageState.scenes;
+ const curId = stageState.currentSceneId;
+ const idx = allScenes.findIndex((s) => s.id === curId);
+ if (idx >= 0 && idx < allScenes.length - 1) {
+ const currentScene = allScenes[idx];
+ if (
+ currentScene.type === 'quiz' ||
+ currentScene.type === 'interactive' ||
+ currentScene.type === 'pbl'
+ ) {
+ return;
+ }
+ autoStartRef.current = true;
+ stageState.setCurrentSceneId(allScenes[idx + 1].id);
+ } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) {
+ // Last scene exhausted but next is still generating — go to pending page
+ const currentScene = allScenes[idx];
+ if (
+ currentScene.type === 'quiz' ||
+ currentScene.type === 'interactive' ||
+ currentScene.type === 'pbl'
+ ) {
+ return;
+ }
+ autoStartRef.current = true;
+ stageState.setCurrentSceneId(PENDING_SCENE_ID);
+ }
+ }, 1500);
+ }
+ },
+ });
+
+ engineRef.current = engine;
+
+ // Auto-start if triggered by auto-play scene advance
+ if (autoStartRef.current) {
+ autoStartRef.current = false;
+ (async () => {
+ if (currentScene && chatAreaRef.current) {
+ const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
+ lectureSessionIdRef.current = sessionId;
+ lectureActionCounterRef.current = 0;
+ }
+ engine.start();
+ })();
+ } else {
+ // Load saved playback state and restore position (but never auto-play).
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs
+ }, [currentScene]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ const audioPlayer = audioPlayerRef.current;
+ const chatArea = chatAreaRef.current;
+ return () => {
+ if (engineRef.current) {
+ engineRef.current.stop();
+ }
+ audioPlayer.destroy();
+ if (discussionAbortRef.current) {
+ discussionAbortRef.current.abort();
+ }
+ discussionTTS.cleanup();
+ chatArea?.endActiveSession();
+ clearPresentationIdleTimer();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable
+ }, []);
+
+ // Sync mute state from settings store to audioPlayer
+ useEffect(() => {
+ audioPlayerRef.current.setMuted(ttsMuted);
+ }, [ttsMuted]);
+
+ // Sync volume from settings store to audioPlayer
+ const ttsVolume = useSettingsStore((s) => s.ttsVolume);
+ useEffect(() => {
+ if (!ttsMuted) {
+ audioPlayerRef.current.setVolume(ttsVolume);
+ }
+ }, [ttsVolume, ttsMuted]);
+
+ // Sync playback speed to audio player (for live-updating current audio)
+ const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
+ useEffect(() => {
+ audioPlayerRef.current.setPlaybackRate(playbackSpeed);
+ }, [playbackSpeed]);
+
+ /**
+ * Handle discussion SSE — POST /api/chat and push events to engine
+ */
+ const handleDiscussionSSE = useCallback(
+ async (topic: string, prompt?: string, agentId?: string) => {
+ // Start discussion display in ChatArea (lecture speech is preserved independently)
+ chatAreaRef.current?.startDiscussion({
+ topic,
+ prompt,
+ agentId: agentId || 'default-1',
+ });
+ // Auto-switch to chat tab when discussion starts
+ chatAreaRef.current?.switchToTab('chat');
+ // Immediately mark streaming for synchronized stop button
+ setChatIsStreaming(true);
+ setChatSessionType('discussion');
+ // Optimistic thinking: show thinking dots immediately (same as onMessageSend)
+ setThinkingState({ stage: 'director' });
+ },
+ [],
+ );
+
+ // First speech text for idle display (extracted here for playbackView)
+ const firstSpeechText = useMemo(
+ () =>
+ currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null,
+ [currentScene],
+ );
+
+ // Whether the speaking agent is a student (for bubble role derivation)
+ const speakingStudentFlag = useMemo(() => {
+ if (!speakingAgentId) return false;
+ const agent = useAgentRegistry.getState().getAgent(speakingAgentId);
+ return agent?.role !== 'teacher';
+ }, [speakingAgentId]);
+
+ // Centralised derived playback view
+ const playbackView = useMemo(
+ () =>
+ computePlaybackView({
+ engineMode,
+ lectureSpeech,
+ liveSpeech,
+ speakingAgentId,
+ thinkingState,
+ isCueUser,
+ isTopicPending,
+ chatIsStreaming,
+ discussionTrigger,
+ playbackCompleted,
+ idleText: firstSpeechText,
+ speakingStudent: speakingStudentFlag,
+ sessionType: chatSessionType,
+ }),
+ [
+ engineMode,
+ lectureSpeech,
+ liveSpeech,
+ speakingAgentId,
+ thinkingState,
+ isCueUser,
+ isTopicPending,
+ chatIsStreaming,
+ discussionTrigger,
+ playbackCompleted,
+ firstSpeechText,
+ speakingStudentFlag,
+ chatSessionType,
+ ],
+ );
+
+ const isTopicActive = playbackView.isTopicActive;
+
+ /**
+ * Gated scene switch — if a topic is active, show AlertDialog before switching.
+ * Returns true if the switch was immediate, false if gated (dialog shown).
+ */
+ const gatedSceneSwitch = useCallback(
+ (targetSceneId: string): boolean => {
+ if (targetSceneId === currentSceneId) return false;
+ if (isTopicActive) {
+ setPendingSceneId(targetSceneId);
+ return false;
+ }
+ setCurrentSceneId(targetSceneId);
+ return true;
+ },
+ [currentSceneId, isTopicActive, setCurrentSceneId],
+ );
+
+ /** User confirmed scene switch via AlertDialog */
+ const confirmSceneSwitch = useCallback(() => {
+ if (!pendingSceneId) return;
+ chatAreaRef.current?.endActiveSession();
+ doSessionCleanup();
+ setCurrentSceneId(pendingSceneId);
+ setPendingSceneId(null);
+ }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]);
+
+ /** User cancelled scene switch via AlertDialog */
+ const cancelSceneSwitch = useCallback(() => {
+ setPendingSceneId(null);
+ }, []);
+
+ // play/pause toggle
+ const handlePlayPause = useCallback(async () => {
+ const engine = engineRef.current;
+ if (!engine) return;
+
+ const mode = engine.getMode();
+ if (mode === 'playing' || mode === 'live') {
+ engine.pause();
+ // Pause lecture buffer so text stops immediately
+ if (lectureSessionIdRef.current) {
+ chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current);
+ }
+ } else if (mode === 'paused') {
+ engine.resume();
+ // Resume lecture buffer
+ if (lectureSessionIdRef.current) {
+ chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current);
+ }
+ } else {
+ const wasCompleted = playbackCompleted;
+ setPlaybackCompleted(false);
+ // Starting playback - create/reuse lecture session
+ if (currentScene && chatAreaRef.current) {
+ const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
+ lectureSessionIdRef.current = sessionId;
+ }
+ if (wasCompleted) {
+ // Restart from beginning (user clicked restart after completion)
+ lectureActionCounterRef.current = 0;
+ engine.start();
+ } else {
+ // Continue from current position (e.g. after discussion end)
+ engine.continuePlayback();
+ }
+ }
+ }, [playbackCompleted, currentScene]);
+
+ // get scene information
+ const isPendingScene = currentSceneId === PENDING_SCENE_ID;
+ const hasNextPending = generatingOutlines.length > 0;
+ // True when every outline has materialized into a scene and nothing is
+ // currently generating — signals the classroom has finished and the user
+ // can see a completion page. Comparing scenes.length === outlines.length
+ // (rather than just `scenes.length > 0`) means a partial generation with
+ // some failed outlines does not falsely trigger completion.
+ const isCourseComplete =
+ outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0;
+ const canAdvanceToPendingSlot = hasNextPending || isCourseComplete;
+
+ // previous scene (gated)
+ const handlePreviousScene = useCallback(() => {
+ if (isPendingScene) {
+ // From pending page → go to last real scene
+ if (scenes.length > 0) {
+ gatedSceneSwitch(scenes[scenes.length - 1].id);
+ }
+ return;
+ }
+ const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
+ if (currentIndex > 0) {
+ gatedSceneSwitch(scenes[currentIndex - 1].id);
+ }
+ }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]);
+
+ // next scene (gated)
+ const handleNextScene = useCallback(() => {
+ if (isPendingScene) return; // Already on pending, nowhere to go
+ const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
+ if (currentIndex < scenes.length - 1) {
+ gatedSceneSwitch(scenes[currentIndex + 1].id);
+ } else if (canAdvanceToPendingSlot) {
+ // On last real scene → advance to pending slot (generating or completion page)
+ setCurrentSceneId(PENDING_SCENE_ID);
+ }
+ }, [
+ currentSceneId,
+ gatedSceneSwitch,
+ canAdvanceToPendingSlot,
+ isPendingScene,
+ scenes,
+ setCurrentSceneId,
+ ]);
+
+ const currentSceneIndex = isPendingScene
+ ? scenes.length
+ : scenes.findIndex((s) => s.id === currentSceneId);
+ const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0);
+
+ // get action information
+ const totalActions = currentScene?.actions?.length || 0;
+
+ // whiteboard toggle
+ const handleWhiteboardToggle = () => {
+ setWhiteboardOpen(!whiteboardOpen);
+ };
+
+ const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => {
+ if (!(target instanceof HTMLElement)) return false;
+
+ if (target.isContentEditable || target.closest('[contenteditable="true"]')) {
+ return true;
+ }
+
+ return (
+ target.closest(
+ ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '),
+ ) !== null
+ );
+ }, []);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return;
+ // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ if (
+ isPresentationShortcutTarget(event.target) ||
+ isPresentationShortcutTarget(document.activeElement)
+ ) {
+ return;
+ }
+
+ switch (event.key) {
+ case 'ArrowLeft':
+ if (!isPresenting) return;
+ event.preventDefault();
+ handlePreviousScene();
+ resetPresentationIdleTimer();
+ break;
+ case 'ArrowRight':
+ if (!isPresenting) return;
+ event.preventDefault();
+ handleNextScene();
+ resetPresentationIdleTimer();
+ break;
+ case ' ':
+ case 'Spacebar':
+ // During active QA/discussion, Roundtable owns Space for
+ // buffer-level pause/resume — don't also fire engine play/pause.
+ if (chatSessionType === 'qa' || chatSessionType === 'discussion') break;
+ event.preventDefault();
+ handlePlayPause();
+ break;
+ case 'Escape':
+ // With keyboard.lock(), Escape no longer auto-exits fullscreen.
+ // If panels are open, roundtable handles Escape (close panels).
+ // If no panels are open, manually exit fullscreen.
+ if (isPresenting && !isPresentationInteractionActive) {
+ event.preventDefault();
+ togglePresentation();
+ }
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ setTTSVolume(ttsVolume + 0.1);
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ setTTSVolume(ttsVolume - 0.1);
+ break;
+ case 'm':
+ case 'M':
+ event.preventDefault();
+ setTTSMuted(!ttsMuted);
+ break;
+ case 's':
+ case 'S':
+ event.preventDefault();
+ setSidebarCollapsed(!sidebarCollapsed);
+ break;
+ case 'c':
+ case 'C':
+ event.preventDefault();
+ setChatAreaCollapsed(!chatAreaCollapsed);
+ break;
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [
+ chatSessionType,
+ chatAreaCollapsed,
+ handleNextScene,
+ handlePlayPause,
+ handlePreviousScene,
+ isPresenting,
+ isPresentationInteractionActive,
+ isPresentationShortcutTarget,
+ resetPresentationIdleTimer,
+ setChatAreaCollapsed,
+ setSidebarCollapsed,
+ setTTSMuted,
+ setTTSVolume,
+ sidebarCollapsed,
+ togglePresentation,
+ ttsMuted,
+ ttsVolume,
+ ]);
+
+ // Intercept F11 to use our presentation fullscreen instead of browser fullscreen
+ // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit)
+ useEffect(() => {
+ const onF11 = (event: KeyboardEvent) => {
+ if (event.key === 'F11') {
+ event.preventDefault();
+ togglePresentation();
+ }
+ };
+
+ window.addEventListener('keydown', onF11);
+ return () => window.removeEventListener('keydown', onF11);
+ }, [togglePresentation]);
+
+ // Map engine mode to the CanvasArea's expected engine state
+ const canvasEngineState = (() => {
+ switch (engineMode) {
+ case 'playing':
+ case 'live':
+ return 'playing';
+ case 'paused':
+ return 'paused';
+ default:
+ return 'idle';
+ }
+ })();
+
+ // Build discussion request for Roundtable ProactiveCard from trigger
+ const discussionRequest: DiscussionAction | null = discussionTrigger
+ ? {
+ type: 'discussion',
+ id: discussionTrigger.id,
+ topic: discussionTrigger.question,
+ prompt: discussionTrigger.prompt,
+ agentId: discussionTrigger.agentId || 'default-1',
+ }
+ : null;
+
+ // Scene viewer height — header is 80px when visible, roundtable is
+ // 192px in playback mode (autonomous hides it). Mode is guaranteed
+ // non-'edit' here since the parent Stage unmounts this component
+ // when entering Pro mode.
+ const sceneViewerHeight = (() => {
+ const headerHeight = isPresenting ? 0 : 80;
+ const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0;
+ return `calc(100% - ${headerHeight + roundtableHeight}px)`;
+ })();
+
+ return (
+
+
+
+ {/* Main Content Area */}
+
+ {/* Header — playback only. The Pro Switch fires `onEnterProMode`
+ (passed by the parent Stage) which acquires the cross-tab
+ edit lock and then awaits our `teardown()` before flipping
+ mode to 'edit'. */}
+ {!isPresenting && (
+
+ )}
+
+ {/* Canvas Area — playback-only renderer. The parent Stage swaps
+ this whole PlaybackChromeRoot out when entering edit mode, so
+ no inline branching is needed here. */}
+
+ setSidebarCollapsed(!sidebarCollapsed)}
+ onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
+ onPrevSlide={handlePreviousScene}
+ onNextSlide={handleNextScene}
+ onPlayPause={handlePlayPause}
+ onWhiteboardClose={handleWhiteboardToggle}
+ isPresenting={isPresenting}
+ onTogglePresentation={togglePresentation}
+ showStopDiscussion={
+ engineMode === 'live' ||
+ (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion'))
+ }
+ onStopDiscussion={handleStopDiscussion}
+ hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)}
+ isPendingScene={isPendingScene}
+ isCourseComplete={isCourseComplete}
+ isGenerationFailed={
+ isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id)
+ }
+ onRetryGeneration={
+ onRetryOutline && generatingOutlines[0]
+ ? () => onRetryOutline(generatingOutlines[0].id)
+ : undefined
+ }
+ />
+
+
+ {/* Roundtable Area */}
+ {mode === 'playback' && (
+
+ {
+ // Always clear Level-1 pause state — the closure may hold a stale
+ // isDiscussionPaused value (e.g. voice input's onTranscription callback
+ // captures onMessageSend before React re-renders with the updated state).
+ setIsDiscussionPaused(false);
+ // Clear the sticky livePausedRef so the next agent-loop buffer
+ // starts unpaused. (pauseActiveLiveBuffer sets a ref that new
+ // buffers inherit — must be cleared before sendMessage creates one.)
+ chatAreaRef.current?.resumeActiveLiveBuffer();
+ // Flush any buffered / in-flight TTS audio from the previous
+ // agent turn so it doesn't leak into the next round.
+ discussionTTS.cleanup();
+ // Clear soft-paused state — user is continuing the topic
+ if (isTopicPending) {
+ setIsTopicPending(false);
+ setLiveSpeech(null);
+ setSpeakingAgentId(null);
+ }
+ // User interrupts during playback — handleUserInterrupt triggers
+ // onUserInterrupt callback which already calls sendMessage, so skip
+ // the direct sendMessage below to avoid sending twice.
+ // Include 'paused' because onInputActivate pauses the engine before
+ // the user finishes typing — without this the interrupt position
+ // would never be saved and resuming after QA skips to the next sentence.
+ if (
+ engineRef.current &&
+ (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused')
+ ) {
+ engineRef.current.handleUserInterrupt(msg);
+ } else {
+ chatAreaRef.current?.sendMessage(msg);
+ }
+ // Auto-switch to chat tab when user sends a message
+ chatAreaRef.current?.switchToTab('chat');
+ setIsCueUser(false);
+ // Immediately mark streaming for synchronized stop button
+ setChatIsStreaming(true);
+ setChatSessionType(chatSessionType || 'qa');
+ // Optimistic thinking: show thinking dots immediately so there's
+ // no blank gap between userMessage expiry and the SSE thinking event.
+ // The real SSE event will overwrite this with the same or updated value.
+ setThinkingState({ stage: 'director' });
+ }}
+ onDiscussionStart={() => {
+ // User clicks "Join" on ProactiveCard
+ engineRef.current?.confirmDiscussion();
+ }}
+ onDiscussionSkip={() => {
+ // User clicks "Skip" on ProactiveCard
+ engineRef.current?.skipDiscussion();
+ }}
+ onStopDiscussion={handleStopDiscussion}
+ onInputActivate={() => {
+ // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering.
+ // User resumes manually via Space / pause button after closing the input.
+ // No isDiscussionPaused guard — always attempt to pause the buffer.
+ // The return value ensures UI state stays in sync with buffer state.
+ if (chatSessionType === 'qa' || chatSessionType === 'discussion') {
+ const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
+ if (paused) {
+ discussionTTS.pause();
+ setIsDiscussionPaused(true);
+ }
+ }
+ // Also pause playback engine
+ if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) {
+ engineRef.current.pause();
+ }
+ }}
+ onResumeTopic={doResumeTopic}
+ onPlayPause={handlePlayPause}
+ isDiscussionPaused={isDiscussionPaused}
+ onDiscussionPause={() => {
+ const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
+ if (paused) {
+ discussionTTS.pause();
+ setIsDiscussionPaused(true);
+ }
+ }}
+ onDiscussionResume={() => {
+ chatAreaRef.current?.resumeActiveLiveBuffer();
+ discussionTTS.resume();
+ setIsDiscussionPaused(false);
+ }}
+ totalActions={totalActions}
+ currentActionIndex={0}
+ currentSceneIndex={currentSceneIndex}
+ scenesCount={totalScenesCount}
+ whiteboardOpen={whiteboardOpen}
+ sidebarCollapsed={sidebarCollapsed}
+ chatCollapsed={chatAreaCollapsed}
+ onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
+ onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
+ onPrevSlide={handlePreviousScene}
+ onNextSlide={handleNextScene}
+ onWhiteboardClose={handleWhiteboardToggle}
+ isPresenting={isPresenting}
+ controlsVisible={controlsVisible}
+ onTogglePresentation={togglePresentation}
+ onPresentationInteractionChange={setIsPresentationInteractionActive}
+ fullscreenContainerRef={stageRef}
+ />
+
+ )}
+
+
+ {/* Chat Area — playback / autonomous always renders it here; Pro
+ (edit) mode unmounts this whole PlaybackChromeRoot, so the
+ edit branch has no chat. */}
+
+ setActiveBubbleId(id)}
+ currentSceneId={currentSceneId}
+ onLiveSpeech={(text, agentId) => {
+ // Capture epoch at call time — discard if scene has changed since
+ const epoch = sceneEpochRef.current;
+ // Use queueMicrotask to let any pending scene-switch reset settle first
+ queueMicrotask(() => {
+ if (sceneEpochRef.current !== epoch) return; // stale — scene changed
+ setLiveSpeech(text);
+ if (agentId !== undefined) {
+ setSpeakingAgentId(agentId);
+ }
+ if (text !== null || agentId) {
+ setChatIsStreaming(true);
+ setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null);
+ setIsTopicPending(false);
+ } else if (text === null && agentId === null) {
+ setChatIsStreaming(false);
+ // Don't clear chatSessionType here — it's needed by the stop
+ // button when director cues user (cue_user → done → liveSpeech null).
+ // It gets properly cleared in doSessionCleanup and scene change.
+ }
+ });
+ }}
+ onSpeechProgress={(ratio) => {
+ const epoch = sceneEpochRef.current;
+ queueMicrotask(() => {
+ if (sceneEpochRef.current !== epoch) return;
+ setSpeechProgress(ratio);
+ });
+ }}
+ onThinking={(state) => {
+ const epoch = sceneEpochRef.current;
+ queueMicrotask(() => {
+ if (sceneEpochRef.current !== epoch) return;
+ setThinkingState(state);
+ });
+ }}
+ onCueUser={(_fromAgentId, _prompt) => {
+ setIsCueUser(true);
+ }}
+ onLiveSessionError={handleLiveSessionError}
+ onStopSession={doSessionCleanup}
+ onSegmentSealed={discussionTTS.handleSegmentSealed}
+ shouldHoldAfterReveal={discussionTTS.shouldHold}
+ />
+
+
+ {/* Scene switch confirmation dialog */}
+
{
+ if (!open) cancelSceneSwitch();
+ }}
+ >
+
+
+ {t('stage.confirmSwitchTitle')}
+
+ {/* Top accent bar */}
+
+
+
+ {/* Icon */}
+
+ {/* Title */}
+
+ {t('stage.confirmSwitchTitle')}
+
+ {/* Description */}
+
+ {t('stage.confirmSwitchMessage')}
+
+
+
+
+
+ {t('common.cancel')}
+
+
+ {t('common.confirm')}
+
+
+
+
+
+ );
+ },
+);
diff --git a/components/edit/SlideNavRail/InsertionZone.tsx b/components/edit/SlideNavRail/InsertionZone.tsx
new file mode 100644
index 0000000000..1c20472c49
--- /dev/null
+++ b/components/edit/SlideNavRail/InsertionZone.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { Plus } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface InsertionZoneProps {
+ readonly label: string;
+ readonly onInsert: () => void;
+}
+
+/**
+ * Hover-revealed insertion affordance between two thumbs.
+ *
+ * The gap is a slim 8px hit zone that matches playback `SceneSidebar`'s
+ * `space-y-2` density (no layout shift, ever). On hover the `+` badge
+ * pops out to the right side of the gap with a small overshoot, sitting
+ * on its own z-layer with a solid background + soft drop shadow so it
+ * clearly floats above any adjacent violet ring.
+ */
+export function InsertionZone({ label, onInsert }: InsertionZoneProps) {
+ // `z-20` lifts the whole zone above adjacent `Reorder.Item` siblings.
+ // Without this, the next-in-DOM-order ThumbItem (which has a `transform`
+ // via motion's Reorder, creating its own stacking context) paints on
+ // top, and its violet ring clips through the `+` badge regardless of
+ // any z-index applied inside the InsertionZone itself.
+ return (
+
+ );
+}
diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx
new file mode 100644
index 0000000000..39697cfb3e
--- /dev/null
+++ b/components/edit/SlideNavRail/SlideNavRail.tsx
@@ -0,0 +1,515 @@
+'use client';
+
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react';
+import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
+import { toast } from 'sonner';
+import { cn } from '@/lib/utils';
+import { useStageStore } from '@/lib/store';
+import { useSettingsStore } from '@/lib/store/settings';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle';
+import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults';
+import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled';
+import { CHROME_DURATION_MS, CHROME_EASE, CHROME_EASE_CSS } from '@/lib/edit/transitions';
+import type { Scene } from '@/lib/types/stage';
+import { ThumbItem } from './ThumbItem';
+import { InsertionZone } from './InsertionZone';
+
+const RAIL_COLLAPSED_PX = 56;
+const RAIL_MIN_PX = 180;
+const RAIL_MAX_PX = 360;
+
+/**
+ * Pro mode slide-navigation left rail (Studio Editor aesthetic).
+ *
+ * Layout: a vertical thumbnail strip with monospaced index captions
+ * below each tile, inter-thumb "+" insertion zones revealed on hover,
+ * and a collapse toggle at the rail head. All scene types are
+ * first-class — slides render a live `ThumbnailSlide`, non-slide scenes
+ * get a type-icon stub but stay clickable, draggable, and right-clickable
+ * so page-level management is uniform across the deck.
+ *
+ * Visuals: low-chroma zinc surface + single violet brand accent, no
+ * per-row chrome (rejected `EditModeSidebar` pattern). Drag uses an
+ * explicit grip handle on the thumb so the whole tile remains
+ * click-to-switch.
+ */
+export function SlideNavRail() {
+ const { t } = useI18n();
+ const router = useRouter();
+ const scenes = useStageStore.use.scenes();
+ const currentSceneId = useStageStore.use.currentSceneId();
+ const setCurrentSceneId = useStageStore.use.setCurrentSceneId();
+ const setScenes = useStageStore.use.setScenes();
+ const insertSceneAfter = useStageStore.use.insertSceneAfter();
+ const deleteScene = useStageStore.use.deleteScene();
+ const stage = useStageStore.use.stage();
+ const collapsed = useSettingsStore((s) => s.editRailCollapsed);
+ const setCollapsed = useSettingsStore((s) => s.setEditRailCollapsed);
+ const persistedWidth = useSettingsStore((s) => s.editRailWidth);
+ const setPersistedWidth = useSettingsStore((s) => s.setEditRailWidth);
+ const prefersReducedMotion = useReducedMotion();
+
+ // Drag-to-resize.
+ //
+ // We mutate the rail's `style.width` directly on the DOM during pointer
+ // move (bypassing React entirely) and only commit the final width to the
+ // settings store on pointer-up. This is what makes the handle feel glued
+ // to the cursor: there's no React render → reconcile → DOM commit
+ // latency between move events and the visible width change.
+ //
+ // Pointer Events (with `setPointerCapture` on the handle) replace the
+ // older `document` mousemove/mouseup binding. With capture, the handle
+ // receives `pointerup` / `pointercancel` even if the cursor leaves the
+ // window, the OS reclaims focus, or a tab switch interrupts the gesture
+ // — none of which fire `document` mouseup, which previously left the
+ // rail stuck in a "drag is still in progress" state until remount.
+ //
+ // `isDragging` is still React state so we can turn off the CSS
+ // `transition: width` for the duration of the gesture — otherwise the
+ // 280ms tween from the collapse/expand animation would fight every
+ // direct width write.
+ const railRef = useRef(null);
+ const dragStateRef = useRef<{
+ startX: number;
+ startWidth: number;
+ lastWidth: number;
+ pointerId: number;
+ } | null>(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const cleanupDrag = useCallback(() => {
+ dragStateRef.current = null;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ setIsDragging(false);
+ }, []);
+
+ const handleResizeStart = useCallback(
+ (e: React.PointerEvent) => {
+ if (collapsed) return;
+ // Only primary button; ignore right-click / middle-click.
+ if (e.button !== 0) return;
+ e.preventDefault();
+ const target = e.currentTarget;
+ // Pointer capture guarantees this element receives pointermove /
+ // pointerup / pointercancel for the duration of the gesture, even
+ // when the cursor leaves the window.
+ try {
+ target.setPointerCapture(e.pointerId);
+ } catch {
+ // Spec-wise `setPointerCapture` can only throw `InvalidPointerId`,
+ // which shouldn't happen inside the same pointer's `pointerdown`.
+ // This catch is paranoia, NOT a real fallback: if capture
+ // genuinely fails the gesture still tracks for in-window moves
+ // but `pointerup` outside the handle's bbox won't route here and
+ // the rail will stay in `isDragging` until SlideNavRail
+ // unmounts. The pointermove path remains useful so dropping the
+ // throw on the floor is preferable to bailing the gesture.
+ }
+ dragStateRef.current = {
+ startX: e.clientX,
+ startWidth: persistedWidth,
+ lastWidth: persistedWidth,
+ pointerId: e.pointerId,
+ };
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ setIsDragging(true);
+ },
+ [collapsed, persistedWidth],
+ );
+
+ const handleResizeMove = useCallback((e: React.PointerEvent) => {
+ const drag = dragStateRef.current;
+ if (!drag || e.pointerId !== drag.pointerId) return;
+ const delta = e.clientX - drag.startX;
+ const next = Math.min(RAIL_MAX_PX, Math.max(RAIL_MIN_PX, drag.startWidth + delta));
+ drag.lastWidth = next;
+ if (railRef.current) railRef.current.style.width = `${next}px`;
+ }, []);
+
+ const handleResizeEnd = useCallback(
+ (e: React.PointerEvent) => {
+ const drag = dragStateRef.current;
+ if (!drag || e.pointerId !== drag.pointerId) return;
+ try {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch {
+ // Capture may already have been released by a pointercancel.
+ }
+ // Commit final width to persisted settings exactly once per gesture.
+ // React will re-render with `style.width = persistedWidth`, which
+ // matches the DOM value we already wrote — no visual jump.
+ setPersistedWidth(drag.lastWidth);
+ cleanupDrag();
+ },
+ [cleanupDrag, setPersistedWidth],
+ );
+
+ useEffect(
+ () => () => {
+ // Belt and suspenders: clear any document-level overrides on unmount.
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ },
+ [],
+ );
+
+ const slideCount = useMemo(() => scenes.filter((s) => s.type === 'slide').length, [scenes]);
+ // For non-slide scenes (no recreate path), only allow delete if there's
+ // more than one scene overall — otherwise the deck would become empty.
+ const totalScenes = scenes.length;
+
+ const currentScene = useMemo(
+ () => scenes.find((s) => s.id === currentSceneId) ?? null,
+ [scenes, currentSceneId],
+ );
+
+ const onReorderIds = useCallback(
+ (newOrder: string[]) => {
+ const byId = new Map(scenes.map((s) => [s.id, s] as const));
+ const next: Scene[] = newOrder
+ .map((id) => byId.get(id))
+ .filter((s): s is Scene => Boolean(s));
+ if (next.length !== scenes.length) return;
+ const rebalanced = next.map((s, i) => (s.order === i + 1 ? s : { ...s, order: i + 1 }));
+ setScenes(rebalanced);
+ },
+ [scenes, setScenes],
+ );
+
+ const handleActivate = useCallback(
+ (sceneId: string) => {
+ if (sceneId === currentSceneId) return;
+ // Switching to a non-slide scene is fine — useEditModeLock will
+ // auto-exit Pro mode the moment the new scene is uneditable.
+ setCurrentSceneId(sceneId);
+ },
+ [currentSceneId, setCurrentSceneId],
+ );
+
+ /**
+ * Insert a fresh blank slide *before* the given scene. The first
+ * InsertionZone (above the first thumb) calls this with `scenes[0]`
+ * so it ends up at index 0 — `setScenes([blank, ...scenes])` is
+ * used directly there since the `insertSceneAfter` API only supports
+ * insertion after an existing anchor.
+ */
+ const handleInsertBefore = useCallback(
+ (beforeSceneId: string) => {
+ if (!stage) return;
+ const beforeIndex = scenes.findIndex((s) => s.id === beforeSceneId);
+ if (beforeIndex < 0) return;
+ const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), beforeIndex + 1);
+ if (beforeIndex === 0) {
+ // Prepend: setScenes rebalances `order` to match the array index.
+ setScenes([blank, ...scenes]);
+ setCurrentSceneId(blank.id);
+ return;
+ }
+ const anchor = scenes[beforeIndex - 1];
+ insertSceneAfter(anchor.id, blank);
+ setCurrentSceneId(blank.id);
+ },
+ [insertSceneAfter, scenes, setCurrentSceneId, setScenes, stage, t],
+ );
+
+ const handleInsertAt = useCallback(
+ (afterSceneId: string | null) => {
+ if (!stage) return;
+ const anchor = afterSceneId
+ ? scenes.find((s) => s.id === afterSceneId)
+ : (currentScene ?? scenes[scenes.length - 1]);
+ if (!anchor) return;
+ const anchorIndex = scenes.findIndex((s) => s.id === anchor.id);
+ const newOrder = anchorIndex + 2;
+ const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), newOrder);
+ insertSceneAfter(anchor.id, blank);
+ setCurrentSceneId(blank.id);
+ },
+ [currentScene, insertSceneAfter, scenes, setCurrentSceneId, stage, t],
+ );
+
+ const handleDuplicate = useCallback(
+ (sceneId: string) => {
+ const source = scenes.find((s) => s.id === sceneId);
+ if (!source) return;
+ const anchorIndex = scenes.findIndex((s) => s.id === sceneId);
+ const newOrder = anchorIndex + 2;
+ // Slide scenes get a deep clone with reseeded element IDs; non-slide
+ // scenes just get a shallow id + title bump.
+ const copy: Scene =
+ source.type === 'slide'
+ ? duplicateSlideScene(source, t('edit.nav.copySuffix'), newOrder)
+ : {
+ ...source,
+ id: crypto.randomUUID(),
+ title: `${source.title} ${t('edit.nav.copySuffix')}`,
+ order: newOrder,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ insertSceneAfter(sceneId, copy);
+ setCurrentSceneId(copy.id);
+ },
+ [insertSceneAfter, scenes, setCurrentSceneId, t],
+ );
+
+ const handleDelete = useCallback(
+ (sceneId: string) => {
+ const source = scenes.find((s) => s.id === sceneId);
+ if (!source) return;
+ // Hold deck-empty guard at the rail layer; the store doesn't enforce.
+ if (source.type === 'slide' && slideCount <= 1) return;
+ if (totalScenes <= 1) return;
+ const index = scenes.findIndex((s) => s.id === sceneId);
+ useDeletedSceneRecycle.getState().capture(source, index);
+ deleteScene(sceneId);
+ toast(t('edit.nav.deleted'), {
+ description: source.title,
+ duration: 5000,
+ action: {
+ label: t('edit.nav.undo'),
+ onClick: () => {
+ const entry = useDeletedSceneRecycle.getState().consume();
+ if (!entry) return;
+ // Stage-scope guard: if the user has navigated to a
+ // different stage while the toast was up, the recycle
+ // entry belongs to the previous stage and `insertSceneAfter`
+ // would reject it on stage-id mismatch (silently losing the
+ // deleted scene). Drop the undo when stages don't match
+ // rather than blasting the entry into the wrong deck.
+ const currentStage = useStageStore.getState().stage;
+ if (!currentStage || currentStage.id !== entry.stageId) return;
+ const live = useStageStore.getState().scenes;
+ // Prepend path — `insertSceneAfter` requires an anchor, but
+ // restoring index 0 (the previously-first slide) has no
+ // predecessor to anchor on. Clamping `entry.index - 1` to 0
+ // and inserting after `live[0]` would land the entry at
+ // position 1 instead of 0. setScenes-with-rebalance
+ // preserves the original "first slide" semantics.
+ if (entry.index === 0 || live.length === 0) {
+ useStageStore.getState().setScenes([entry.scene, ...live]);
+ useStageStore.getState().setCurrentSceneId(entry.scene.id);
+ return;
+ }
+ const anchorIndex = Math.min(entry.index - 1, live.length - 1);
+ const anchor = live[anchorIndex];
+ useStageStore.getState().insertSceneAfter(anchor.id, entry.scene);
+ useStageStore.getState().setCurrentSceneId(entry.scene.id);
+ },
+ },
+ onDismiss: () => useDeletedSceneRecycle.getState().clear(),
+ onAutoClose: () => useDeletedSceneRecycle.getState().clear(),
+ });
+ },
+ [deleteScene, scenes, slideCount, totalScenes, t],
+ );
+
+ const canDeleteAny = totalScenes > 1;
+ const canDeleteSlide = slideCount > 1;
+
+ // Plain CSS transition mirrors playback `SceneSidebar` exactly: zero
+ // motion.dev overhead, instant width updates while dragging. The earlier
+ // `motion.aside animate={false}` still ran motion's element-tracking
+ // pipeline per frame even with animation off, which produced the
+ // perceptible drag lag the user reported.
+ const widthTransitionCss = isDragging
+ ? 'none'
+ : prefersReducedMotion
+ ? 'none'
+ : `width ${CHROME_DURATION_MS}ms ${CHROME_EASE_CSS}`;
+
+ return (
+
+ {/* Resize handle — right edge, 6px hit zone, only enabled when
+ expanded. Pointer Events with capture: once the gesture starts
+ this element owns the move/up/cancel stream regardless of
+ cursor location, so the rail can't get stuck in a "still
+ dragging" state on alt-tab / window blur / cursor-leaves-
+ window. */}
+ {!collapsed && (
+
+ )}
+ {/* Header band — mirrors playback `SceneSidebar`: OpenMAIC logo
+ on the left (click → home), action cluster on the right.
+ Height (h-10 + mt-3 mb-1 = ~56px) matches playback so the
+ chrome top edge stays at the same screen pixel across the
+ mode swap. */}
+
+ {!collapsed && (
+
router.push('/')}
+ title={t('generation.backToHome')}
+ className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150"
+ >
+
+
+ )}
+
+ {/* Insertion lives in the `InsertionZone` strips between (and
+ before/after) thumbs now — no header `+` button. */}
+
setCollapsed(!collapsed)}
+ aria-label={collapsed ? t('edit.nav.expand') : t('edit.nav.collapse')}
+ title={collapsed ? t('edit.nav.expand') : t('edit.nav.collapse')}
+ className={cn(
+ 'inline-flex h-7 w-7 items-center justify-center rounded-lg',
+ 'bg-gray-100/80 text-gray-500 ring-1 ring-black/[0.04]',
+ 'dark:bg-gray-800/80 dark:text-gray-400 dark:ring-white/[0.06]',
+ 'hover:bg-gray-200/90 hover:text-gray-700',
+ 'dark:hover:bg-gray-700/90 dark:hover:text-gray-200',
+ 'active:scale-90 transition-all duration-200',
+ )}
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Body — list padding (p-2 space-y-2) matches playback's scene
+ list so spacing/density read the same. */}
+
+ {collapsed ? (
+
+ ) : (
+
+
+ s.id)}
+ onReorder={onReorderIds}
+ as="ol"
+ className="m-0 list-none p-0"
+ >
+ {/* Leading zone — hover the top padding to insert
+ before the first thumb. Hits the `+ at top` use
+ case the user called out. */}
+ {SCENE_CREATION_ENABLED && scenes[0] ? (
+ handleInsertBefore(scenes[0].id)}
+ />
+ ) : null}
+ {scenes.map((scene, index) => (
+
+ handleActivate(scene.id)}
+ onDuplicate={() => handleDuplicate(scene.id)}
+ onDelete={() => handleDelete(scene.id)}
+ />
+ {SCENE_CREATION_ENABLED && (
+ handleInsertAt(scene.id)}
+ />
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+interface CollapsedListProps {
+ readonly scenes: readonly Scene[];
+ readonly currentSceneId: string | null;
+ readonly onActivate: (sceneId: string) => void;
+}
+
+function CollapsedList({ scenes, currentSceneId, onActivate }: CollapsedListProps) {
+ return (
+
+ {scenes.map((scene, index) => {
+ const active = scene.id === currentSceneId;
+ const isSlide = scene.type === 'slide';
+ return (
+
+ onActivate(scene.id)}
+ title={scene.title || `${index + 1}`}
+ data-active={active}
+ data-scene-type={scene.type}
+ className={cn(
+ 'group/cl flex h-7 w-full items-center justify-center rounded-md',
+ 'font-mono text-[10px] leading-none tabular-nums tracking-wide transition-colors',
+ active
+ ? 'bg-violet-500 text-white shadow-sm shadow-violet-500/40'
+ : 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-800 dark:hover:text-zinc-200',
+ !isSlide && !active && 'text-zinc-400/80 dark:text-zinc-500/80',
+ )}
+ >
+ {String(index + 1).padStart(2, '0')}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx
new file mode 100644
index 0000000000..89da732cec
--- /dev/null
+++ b/components/edit/SlideNavRail/ThumbItem.tsx
@@ -0,0 +1,287 @@
+'use client';
+
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { Reorder } from 'motion/react';
+import { MoreHorizontal } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { SceneThumbnailContent } from '@/components/stage/scene-thumbnail-content';
+import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled';
+import type { Scene } from '@/lib/types/stage';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { useStageStore } from '@/lib/store/stage';
+import { useI18n } from '@/lib/hooks/use-i18n';
+
+interface ThumbItemProps {
+ readonly scene: Scene;
+ readonly index: number;
+ readonly active: boolean;
+ readonly canDelete: boolean;
+ readonly onActivate: () => void;
+ readonly onDuplicate: () => void;
+ readonly onDelete: () => void;
+}
+
+function ThumbItemComponent({
+ scene,
+ index,
+ active,
+ canDelete,
+ onActivate,
+ onDuplicate,
+ onDelete,
+}: ThumbItemProps) {
+ const { t } = useI18n();
+ const viewportSize = useCanvasStore.use.viewportSize();
+ const viewportRatio = useCanvasStore.use.viewportRatio();
+ const updateScene = useStageStore.use.updateScene();
+ const ref = useRef(null);
+ const visible = useNearViewport(ref);
+
+ // Inline title-edit state. `draft` is only used while renaming; when
+ // idle we derive the visible title from `scene.title` directly so an
+ // external rename (other tab / Duplicate suffix) shows up without a
+ // sync effect. `startRename` seeds `draft` once at session start.
+ const [renaming, setRenaming] = useState(false);
+ const [draft, setDraft] = useState(scene.title);
+ const inputRef = useRef(null);
+
+ const startRename = useCallback(() => {
+ setDraft(scene.title);
+ setRenaming(true);
+ // Focus + select on next tick so the input is mounted.
+ queueMicrotask(() => {
+ const el = inputRef.current;
+ if (el) {
+ el.focus();
+ el.select();
+ }
+ });
+ }, [scene.title]);
+
+ const commitRename = useCallback(() => {
+ const trimmed = draft.trim();
+ if (trimmed && trimmed !== scene.title) {
+ updateScene(scene.id, { title: trimmed });
+ }
+ setRenaming(false);
+ }, [draft, scene.id, scene.title, updateScene]);
+
+ const cancelRename = useCallback(() => {
+ // Reset draft to the canonical title so the next rename session
+ // starts from a clean state.
+ setDraft(scene.title);
+ setRenaming(false);
+ }, [scene.title]);
+
+ return (
+
+ {
+ if (renaming) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onActivate();
+ }
+ }}
+ // Matches playback `SceneSidebar` tile family — index badge +
+ // title header row above an aspect-video thumbnail card, whole
+ // tile flipped to violet-50 + ring when active. Differences from
+ // playback: inline title edit via the more-actions menu, and a
+ // hover-revealed three-dot menu (the only editor affordance
+ // overlaid on the playback shape).
+ className={cn(
+ 'group/thumb relative flex cursor-pointer select-none flex-col gap-1 rounded-lg p-1.5',
+ 'outline-none transition-colors duration-150',
+ active
+ ? 'bg-violet-50 ring-1 ring-violet-200 dark:bg-violet-900/20 dark:ring-violet-700'
+ : 'hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50',
+ )}
+ >
+ {/* Scene header — index badge + title. Title doubles as the
+ inline rename surface when `renaming` is true. */}
+
+
+
+ {index + 1}
+
+ {renaming ? (
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ cancelRename();
+ }
+ }}
+ onBlur={commitRename}
+ onClick={(e) => e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ aria-label={t('edit.nav.rename')}
+ className={cn(
+ 'min-w-0 flex-1 truncate rounded-sm bg-white px-1 py-0 text-xs font-bold outline-none',
+ 'ring-1 ring-violet-400 focus:ring-violet-500',
+ 'text-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-violet-500',
+ )}
+ />
+ ) : (
+ {
+ e.stopPropagation();
+ startRename();
+ }}
+ className={cn(
+ 'truncate text-xs font-bold transition-colors',
+ active
+ ? 'text-violet-700 dark:text-violet-300'
+ : 'text-zinc-600 group-hover/thumb:text-zinc-900 dark:text-zinc-300 dark:group-hover/thumb:text-zinc-100',
+ )}
+ title={scene.title}
+ >
+ {scene.title || `${t('edit.sceneType.' + scene.type)} ${index + 1}`}
+
+ )}
+
+
+ {/* Three-dot overflow menu — hover-revealed, always visible
+ while open. Hidden during inline rename so the input has
+ the full header row. */}
+ {!renaming && (
+
+
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ aria-label={t('edit.nav.moreActions')}
+ data-testid="slide-nav-more"
+ className={cn(
+ 'shrink-0 rounded p-0.5 text-zinc-400 transition-opacity',
+ 'opacity-0 group-hover/thumb:opacity-100 data-[state=open]:opacity-100',
+ 'hover:bg-zinc-200/80 hover:text-zinc-700',
+ 'dark:hover:bg-zinc-700 dark:hover:text-zinc-200',
+ )}
+ >
+
+
+
+ e.stopPropagation()}
+ >
+ {t('edit.nav.rename')}
+ {SCENE_CREATION_ENABLED && (
+
+ {t('edit.nav.duplicate')}
+
+ )}
+
+ {t('edit.nav.delete')}
+
+
+
+ )}
+
+
+ {/* Thumbnail card — same shape as playback SceneSidebar's tile. */}
+
+
+
+ );
+}
+
+/**
+ * One tile in the Pro mode rail. Visual structure deliberately mirrors
+ * playback `SceneSidebar` — index badge + title row above an aspect-
+ * video thumbnail card, whole tile rounded with a violet background +
+ * ring when active — so the two sidebars read as the same component
+ * family across mode toggle. Editor-only additions: hover-revealed
+ * three-dot menu (Rename / Duplicate / Delete) and inline title rename
+ * (also reachable via double-click on the title text).
+ *
+ * All scene types are first-class — slides render a live
+ * `ThumbnailSlide`, non-slide scenes render the same stylised mockups
+ * playback's `SceneSidebar` uses. EditShell renders non-slide scenes
+ * read-only inside Pro mode (no auto-exit on click).
+ */
+export const ThumbItem = memo(ThumbItemComponent);
+
+/**
+ * Cheap "near viewport" IntersectionObserver so off-screen thumbs
+ * skip the live ThumbnailSlide render (which mounts a downscaled
+ * slide-renderer scene). Items within 200px of the viewport remain
+ * eager so scrolling feels instant.
+ */
+function useNearViewport(ref: React.RefObject) {
+ const [visible, setVisible] = useState(true);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || typeof IntersectionObserver === 'undefined') return;
+ const io = new IntersectionObserver(
+ (entries) => {
+ for (const e of entries) setVisible(e.isIntersecting);
+ },
+ { root: null, rootMargin: '200px 0px', threshold: 0 },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [ref]);
+ return visible;
+}
diff --git a/components/edit/SlideNavRail/index.ts b/components/edit/SlideNavRail/index.ts
new file mode 100644
index 0000000000..9d193c84db
--- /dev/null
+++ b/components/edit/SlideNavRail/index.ts
@@ -0,0 +1 @@
+export { SlideNavRail } from './SlideNavRail';
diff --git a/components/edit/StageGrid.tsx b/components/edit/StageGrid.tsx
new file mode 100644
index 0000000000..8a7d14fb59
--- /dev/null
+++ b/components/edit/StageGrid.tsx
@@ -0,0 +1,72 @@
+import type { CSSProperties, ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+interface StageGridProps {
+ /** Top bar — auto-height row spanning the full width (e.g. CommandBar). */
+ readonly topSlot?: ReactNode;
+ /** Left column — auto-width (e.g. SlideNavRail). */
+ readonly leftSlot?: ReactNode;
+ /** Right column — auto-width (future: properties panel, AI hints). */
+ readonly rightSlot?: ReactNode;
+ /** Bottom bar — auto-height row spanning the full width (future: timeline). */
+ readonly bottomSlot?: ReactNode;
+ /** Center cell — fills remaining space, hosts the scene canvas. */
+ readonly centerSlot: ReactNode;
+ readonly className?: string;
+}
+
+/**
+ * Edit-mode chrome layout shell with five named slots
+ * (top / left / right / bottom / center). Optional slots collapse to
+ * zero width / height when not provided, so adding a right or bottom
+ * panel later is a drop-in prop — no restructure of the existing edit
+ * tree. The center slot is mandatory and gets `minWidth: 0` /
+ * `minHeight: 0` so its children can shrink correctly (the usual
+ * "flex / grid item won't shrink below content" trap).
+ *
+ * ┌──────────┬─────────────────────┬─────────────┐
+ * │ │ topSlot │
+ * │ leftSlot ├─────────────────────┬─────────────┤
+ * │ │ centerSlot │ rightSlot │
+ * │ (full ├─────────────────────┴─────────────┤
+ * │ height) │ bottomSlot │
+ * └──────────┴────────────────────────────────────┘
+ *
+ * The left slot spans all three rows so a sidebar always renders at
+ * the absolute left edge of the chrome — matches the playback
+ * `SceneSidebar` shape exactly so mode swaps don't shift the
+ * sidebar/header pixel positions and the user's click targets stay
+ * anchored.
+ *
+ * Inline `gridTemplateAreas` is used instead of Tailwind utility
+ * classes because Tailwind can't statically generate dynamic named
+ * areas; the grid template is a literal shape, not a state-dependent
+ * one, so the inline style is stable.
+ */
+const GRID_STYLE: CSSProperties = {
+ display: 'grid',
+ gridTemplateAreas: `"left top top" "left center right" "left bottom bottom"`,
+ gridTemplateColumns: 'auto minmax(0, 1fr) auto',
+ gridTemplateRows: 'auto minmax(0, 1fr) auto',
+};
+
+export function StageGrid({
+ topSlot,
+ leftSlot,
+ rightSlot,
+ bottomSlot,
+ centerSlot,
+ className,
+}: StageGridProps) {
+ return (
+
+ {topSlot ?
{topSlot}
: null}
+ {leftSlot ?
{leftSlot}
: null}
+
+ {centerSlot}
+
+ {rightSlot ?
{rightSlot}
: null}
+ {bottomSlot ?
{bottomSlot}
: null}
+
+ );
+}
diff --git a/components/edit/surfaces/slide/AnchoredBar.tsx b/components/edit/surfaces/slide/AnchoredBar.tsx
new file mode 100644
index 0000000000..79741c42bd
--- /dev/null
+++ b/components/edit/surfaces/slide/AnchoredBar.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { useTrackedRect } from './use-tracked-rect';
+
+interface AnchoredBarProps {
+ /** The element to anchor to, or "" when the bar should not show. */
+ readonly elementId: string;
+ readonly children: ReactNode;
+}
+
+/**
+ * The generic selection-anchored bar shell — a Radix Popover positioned against
+ * a virtual anchor: an invisible fixed-positioned box at the element's live
+ * screen rect (from useTrackedRect). PopoverContent is portaled, so the canvas's
+ * overflow-hidden never clips it, and Radix flips it below / clamps it
+ * horizontally on its own. AnchoredTextBar and AnchoredDeleteBar supply the
+ * contents.
+ */
+export function AnchoredBar({ elementId, children }: AnchoredBarProps) {
+ const rect = useTrackedRect(elementId);
+ const open = elementId !== '' && rect !== null;
+
+ return (
+ {
+ if (!o) useCanvasStore.getState().setActiveElementIdList([]);
+ }}
+ >
+ {rect && (
+
+
+
+ )}
+ {open && (
+ e.preventDefault()}
+ onPointerDownOutside={(e) => e.preventDefault()}
+ onFocusOutside={(e) => e.preventDefault()}
+ className="w-auto max-w-[92vw] p-1"
+ >
+ {children}
+
+ )}
+
+ );
+}
diff --git a/components/edit/surfaces/slide/AnchoredDeleteBar.tsx b/components/edit/surfaces/slide/AnchoredDeleteBar.tsx
new file mode 100644
index 0000000000..9ad1921ab4
--- /dev/null
+++ b/components/edit/surfaces/slide/AnchoredDeleteBar.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { AnchoredBar } from './AnchoredBar';
+import { DeleteButton } from './DeleteButton';
+
+interface AnchoredDeleteBarProps {
+ /** The selected non-text element, or "" when none is selected. */
+ readonly elementId: string;
+}
+
+/**
+ * The selection-anchored bar for non-text elements — image, shape, line,
+ * table, chart, … They have no format controls, so it carries just the delete
+ * action. Hugs the selected element — see AnchoredBar.
+ */
+export function AnchoredDeleteBar({ elementId }: AnchoredDeleteBarProps) {
+ return (
+
+
+
+ );
+}
diff --git a/components/edit/surfaces/slide/AnchoredTextBar.tsx b/components/edit/surfaces/slide/AnchoredTextBar.tsx
new file mode 100644
index 0000000000..58792cdf98
--- /dev/null
+++ b/components/edit/surfaces/slide/AnchoredTextBar.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { AnchoredBar } from './AnchoredBar';
+import { ConnectedTextFormatBar } from './text-format-bar';
+import { DeleteButton } from './DeleteButton';
+
+interface AnchoredTextBarProps {
+ /** The text element being edited, or "" when no text element is being edited. */
+ readonly editingElementId: string;
+}
+
+/**
+ * The selection-anchored bar for a text element — the format controls plus
+ * delete, hugging the element being edited. See AnchoredBar for the shell.
+ */
+export function AnchoredTextBar({ editingElementId }: AnchoredTextBarProps) {
+ return (
+
+
+
+ );
+}
diff --git a/components/edit/surfaces/slide/ColorPicker.tsx b/components/edit/surfaces/slide/ColorPicker.tsx
new file mode 100644
index 0000000000..6b78454406
--- /dev/null
+++ b/components/edit/surfaces/slide/ColorPicker.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { HexColorPicker } from 'react-colorful';
+import { Pipette } from 'lucide-react';
+
+// Common slide-text colors — single tight row at the foot of the picker so they
+// stay one-click reachable without dominating the popover.
+const COMMON: readonly string[] = [
+ '#000000',
+ '#525252',
+ '#a3a3a3',
+ '#ffffff',
+ '#ef4444',
+ '#f97316',
+ '#eab308',
+ '#22c55e',
+ '#3b82f6',
+ '#8b5cf6',
+];
+
+// EyeDropper API (not in `lib.dom` yet under our TS config). Feature-detected
+// at render so the button hides on browsers without it (Safari / Firefox).
+interface EyeDropperInstance {
+ open(): Promise<{ sRGBHex: string }>;
+}
+interface EyeDropperCtor {
+ new (): EyeDropperInstance;
+}
+
+interface ColorPickerProps {
+ readonly value: string;
+ /** Live color update — fires on every gradient/slider drag tick. */
+ readonly onChange: (color: string) => void;
+ /** Discrete commit (swatch click / eyedropper). Caller closes the popover. */
+ readonly onCommit: (color: string) => void;
+}
+
+/**
+ * Editor text-color picker. Saturation/value pad + hue slider (react-colorful)
+ * for free-form colors, the OS eye-dropper for sampling the screen, and a
+ * tight row of common colors at the bottom. No hex text input — picking is
+ * meant to be tactile.
+ */
+export function ColorPicker({ value, onChange, onCommit }: ColorPickerProps) {
+ // Local mirror so the picker UI stays responsive while dragging without
+ // round-tripping through ProseMirror + store on every tick.
+ const [color, setColor] = useState(value);
+ // Don't snap the picker back mid-drag: a stale `value` arriving from a
+ // ProseMirror dispatch a few ticks behind would otherwise overwrite the
+ // user's current pointer position. Gate the re-sync on the pointer being
+ // up. External commits (swatch / eyedropper) sync immediately because
+ // they fire while no drag is in flight.
+ const isDragging = useRef(false);
+ useEffect(() => {
+ const onUp = () => {
+ isDragging.current = false;
+ };
+ // react-colorful dispatches `mouseup` / `touchend` directly (not
+ // synthetic pointer events), so we listen on every gesture-end channel
+ // to catch any browser / emulator that only emits one family.
+ // `pointercancel` handles the OS yanking the gesture mid-drag.
+ const channels = ['mouseup', 'touchend', 'pointerup', 'pointercancel'] as const;
+ channels.forEach((ev) => window.addEventListener(ev, onUp));
+ return () => channels.forEach((ev) => window.removeEventListener(ev, onUp));
+ }, []);
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ if (!isDragging.current) setColor(value);
+ }, [value]);
+
+ const handleChange = (c: string) => {
+ isDragging.current = true;
+ setColor(c);
+ onChange(c);
+ };
+ const handleCommit = (c: string) => {
+ setColor(c);
+ onCommit(c);
+ };
+
+ const EyeDropper = (globalThis as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;
+ const sampleScreen = async () => {
+ if (!EyeDropper) return;
+ try {
+ const result = await new EyeDropper().open();
+ handleCommit(result.sRGBHex);
+ } catch {
+ // User dismissed the OS picker — nothing to do.
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {color}
+
+
+ {EyeDropper && (
+
e.preventDefault()}
+ onClick={sampleScreen}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
+ >
+
+
+ )}
+
+
+ {COMMON.map((c) => (
+ e.preventDefault()}
+ onClick={() => handleCommit(c)}
+ className="h-[18px] w-[18px] rounded ring-1 ring-inset ring-black/10 transition-transform hover:scale-110 dark:ring-white/20"
+ style={{ backgroundColor: c }}
+ />
+ ))}
+
+
+ );
+}
diff --git a/components/edit/surfaces/slide/DeleteButton.tsx b/components/edit/surfaces/slide/DeleteButton.tsx
new file mode 100644
index 0000000000..a6097ab464
--- /dev/null
+++ b/components/edit/surfaces/slide/DeleteButton.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { Trash2 } from 'lucide-react';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { deleteSlideElement } from './use-slide-surface';
+
+/** Trash button for the anchored bars — deletes the element and clears the selection. */
+export function DeleteButton({ elementId }: { readonly elementId: string }) {
+ const { t } = useI18n();
+ return (
+ e.preventDefault()}
+ onClick={() => deleteSlideElement(elementId)}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-rose-50 hover:text-rose-600 dark:text-zinc-400 dark:hover:bg-rose-950/40 dark:hover:text-rose-400"
+ >
+
+
+ );
+}
diff --git a/components/edit/surfaces/slide/ImagePicker.tsx b/components/edit/surfaces/slide/ImagePicker.tsx
new file mode 100644
index 0000000000..ddf28da002
--- /dev/null
+++ b/components/edit/surfaces/slide/ImagePicker.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { useI18n } from '@/lib/hooks/use-i18n';
+
+interface ImagePickerProps {
+ readonly onPick: (src: string) => void;
+}
+
+function fileToDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+}
+
+export function ImagePicker({ onPick }: ImagePickerProps) {
+ const { t } = useI18n();
+ const inputRef = useRef(null);
+ const [url, setUrl] = useState('');
+
+ async function handleFiles(files: FileList | null) {
+ const file = files?.[0];
+ if (!file || !file.type.startsWith('image/')) return;
+ try {
+ onPick(await fileToDataUrl(file));
+ } catch (err) {
+ console.error('ImagePicker: failed to read image file', err);
+ }
+ }
+
+ return (
+
+
inputRef.current?.click()}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => {
+ e.preventDefault();
+ void handleFiles(e.dataTransfer.files);
+ }}
+ className="rounded-lg border border-dashed border-zinc-300 p-5 text-center text-sm text-zinc-500 hover:border-zinc-400 dark:border-zinc-700 dark:text-zinc-400"
+ >
+ {t('edit.insert.imageDrop')}
+
+
void handleFiles(e.target.files)}
+ />
+
{t('edit.insert.imageOr')}
+
+ setUrl(e.target.value)}
+ placeholder={t('edit.insert.imageUrlPlaceholder')}
+ />
+ onPick(url.trim())}>
+ {t('edit.insert.imageInsert')}
+
+
+
+ );
+}
diff --git a/components/edit/surfaces/slide/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx
new file mode 100644
index 0000000000..6768f0a392
--- /dev/null
+++ b/components/edit/surfaces/slide/SlideCanvas.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { useEffect } from 'react';
+import Canvas from '@/components/slide-renderer/Editor/Canvas';
+import { SceneProvider } from '@/lib/contexts/scene-context';
+import { useCanvasStore } from '@/lib/store/canvas';
+import {
+ useEditingTextElementId,
+ useSelectedNonTextElementId,
+ useSlideCanvasController,
+ useSyncEditingElementId,
+} from './use-slide-surface';
+import { AnchoredTextBar } from './AnchoredTextBar';
+import { AnchoredDeleteBar } from './AnchoredDeleteBar';
+
+/**
+ * The slide surface's canvas. Reuses the unmodified slide renderer
+ * (`components/slide-renderer/Editor/Canvas`) and wraps it in a
+ * surface-controlled scene context so every renderer commit funnels
+ * through the slide-edit-session which auto-saves it back to the
+ * canonical stage store (no staging, no "restore unsaved" prompt).
+ *
+ * It also owns the selection-anchored chrome: it derives the selected element,
+ * mirrors a selected text element into the canvas store's `editingElementId`
+ * (which the renderer reads to draw a clean frame), and renders the anchored
+ * bars — the format bar for text, a delete bar for every other element type.
+ * At most one bar is open at a time (single selection).
+ */
+export function SlideCanvas() {
+ const { controller, gestureProps } = useSlideCanvasController();
+ const editingElementId = useEditingTextElementId();
+ const nonTextElementId = useSelectedNonTextElementId();
+ useSyncEditingElementId(editingElementId);
+
+ // Esc disarms in-flight insert mode. Read via getState so the listener mounts
+ // once; checking inside the handler keeps us inert when nothing is armed.
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ const cs = useCanvasStore.getState();
+ if (cs.creatingElement) cs.setCreatingElement(null);
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, []);
+
+ return (
+ // gestureProps marks pointer-gesture windows so a renderer commit is
+ // classified as a real user edit vs ResizeObserver text normalization
+ // (which fires with no gesture in flight). The padded studio frame
+ // around the canvas now lives in EditShell.Frame so non-slide scenes
+ // (rendered via SceneRenderer in read-only mode) share the exact
+ // same canvas bounding rect — switching scene type no longer
+ // resizes / reflows the frame, which used to cause the slide↔
+ // interactive layout jump.
+
+ );
+}
diff --git a/components/edit/surfaces/slide/SlideSurface.tsx b/components/edit/surfaces/slide/SlideSurface.tsx
new file mode 100644
index 0000000000..3ef50f952d
--- /dev/null
+++ b/components/edit/surfaces/slide/SlideSurface.tsx
@@ -0,0 +1,16 @@
+import type { SceneEditorSurface } from '@/lib/edit/scene-editor-surface';
+import type { SlideContent } from '@/lib/types/stage';
+import { SlideCanvas } from './SlideCanvas';
+import { useSlideSurfaceState, type SlideSelection } from './use-slide-surface';
+
+/**
+ * The slide SceneEditorSurface. EditShell resolves this by scene type and
+ * renders `CanvasComponent` + reads `useSurfaceState()` into the command
+ * bar / floating toolbar. PR1 ships geometry editing only; text / insert /
+ * image / z-order / slide management land in later sub-PRs.
+ */
+export const slideSurface: SceneEditorSurface = {
+ sceneType: 'slide',
+ CanvasComponent: SlideCanvas,
+ useSurfaceState: useSlideSurfaceState,
+};
diff --git a/components/edit/surfaces/slide/editing-state.ts b/components/edit/surfaces/slide/editing-state.ts
new file mode 100644
index 0000000000..3ef28a0a33
--- /dev/null
+++ b/components/edit/surfaces/slide/editing-state.ts
@@ -0,0 +1,31 @@
+import type { PPTElement } from '@/lib/types/slides';
+
+/**
+ * The single selected slide element — `undefined` unless exactly one element is
+ * selected and it resolves in the content. The basis for the surface's
+ * selection-anchored chrome (the text format bar, the image action bar).
+ */
+export function resolveSelectedElement(
+ activeElementIdList: readonly string[],
+ elements: readonly PPTElement[],
+): PPTElement | undefined {
+ if (activeElementIdList.length !== 1) return undefined;
+ return elements.find((el) => el.id === activeElementIdList[0]);
+}
+
+/**
+ * The slide surface's text-editing policy: a single selected text element is,
+ * by definition, the element being edited (there is no separate
+ * "selected-not-editing" state for text). Anything else resolves to "".
+ *
+ * This is the value the surface writes into the canvas store's
+ * `editingElementId`, which the renderer's `TextElementOperate` reads to swap
+ * its dashed select frame for a clean solid editing frame.
+ */
+export function resolveEditingElementId(
+ activeElementIdList: readonly string[],
+ elements: readonly PPTElement[],
+): string {
+ const el = resolveSelectedElement(activeElementIdList, elements);
+ return el?.type === 'text' ? el.id : '';
+}
diff --git a/components/edit/surfaces/slide/index.ts b/components/edit/surfaces/slide/index.ts
new file mode 100644
index 0000000000..b40bcbe379
--- /dev/null
+++ b/components/edit/surfaces/slide/index.ts
@@ -0,0 +1,4 @@
+// Importing this module registers the slide surface as a side effect.
+import './register';
+
+export { slideSurface } from './SlideSurface';
diff --git a/components/edit/surfaces/slide/register.ts b/components/edit/surfaces/slide/register.ts
new file mode 100644
index 0000000000..9243db5af1
--- /dev/null
+++ b/components/edit/surfaces/slide/register.ts
@@ -0,0 +1,7 @@
+import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
+import { slideSurface } from './SlideSurface';
+
+// Side-effect registration. Imported once at app boot (via `stage.tsx`) so
+// EditShell can resolve the slide surface the moment Pro mode is entered;
+// the shell itself never imports surfaces directly.
+sceneEditorRegistry.register(slideSurface);
diff --git a/components/edit/surfaces/slide/slide-edit-session.ts b/components/edit/surfaces/slide/slide-edit-session.ts
new file mode 100644
index 0000000000..e4043f06dd
--- /dev/null
+++ b/components/edit/surfaces/slide/slide-edit-session.ts
@@ -0,0 +1,139 @@
+/**
+ * Module-level slide-edit session — pure in-memory undo/redo for the editor
+ * canvas.
+ *
+ * EditShell invokes a surface's `useSurfaceState()` and renders its
+ * `CanvasComponent` as siblings (state hook on the shell, canvas as a child
+ * of the frame). They must share one `SlideEditHistory`, so it lives in a
+ * store rather than component state — the same idiom the rest of the
+ * renderer uses (useCanvasStore / useStageStore).
+ *
+ * Edits are written through to the canonical stage store by the canvas
+ * controller (`useSlideCanvasController` in `use-slide-surface.ts`); this
+ * store only tracks the undo/redo timeline of an in-progress editing
+ * session. It deliberately does NOT persist to localStorage: the canonical
+ * stage store is the source of truth and already auto-persists via Dexie,
+ * so there is nothing "unsaved" to recover on reload — no "restore unsaved
+ * changes" UX, by design.
+ */
+
+import { create } from 'zustand';
+import { commitSlideEdit } from '@/lib/edit/scene-edit-bridge';
+import { migrateSlideContent } from '@/lib/edit/slide-schema';
+import {
+ applySlideEditOperation,
+ createSlideEditHistory,
+ redoSlideEditOperation,
+ undoSlideEditOperation,
+} from '@/lib/edit/slide-ops';
+import type { SlideEditHistory, SlideEditOperation } from '@/lib/edit/slide-ops';
+import { useStageStore } from '@/lib/store/stage';
+import type { SlideContent } from '@/lib/types/stage';
+
+interface SlideEditSessionState {
+ sceneId: string | null;
+ history: SlideEditHistory | null;
+
+ /** Establish a fresh in-memory baseline for a scene. */
+ seed: (sceneId: string, content: SlideContent) => void;
+ /** Apply one canonical op (numeric inspectors, future affordances). */
+ applyOp: (op: SlideEditOperation) => void;
+ /**
+ * Fold a renderer-committed snapshot in. `isUserEdit` is the causal
+ * discriminator: a real gesture commits synchronously inside a pointer
+ * interaction, whereas the renderer's ResizeObserver normalization (text
+ * auto-height) commits with no pointer gesture in flight. Non-user
+ * commits update `present` only — no new undo step, so `past` is left
+ * untouched (the reflow can chase a user resize and wiping the undo
+ * stack would silently break undo). `future` IS cleared, though: once
+ * `present` is replaced by the normalized content it has diverged from
+ * whatever the redo branch pointed at, so those stale entries are no
+ * longer valid continuations.
+ */
+ commitContent: (next: SlideContent, isUserEdit: boolean) => void;
+ undo: () => void;
+ redo: () => void;
+ /** Tear the session down on exit from edit mode. */
+ end: () => void;
+}
+
+export const useSlideEditSession = create((set, get) => {
+ /**
+ * Write the new canonical content through to the stage store (auto-save).
+ * Single point of write-through so undo, redo, applyOp, user
+ * commitContent, and ResizeObserver normalization all stay in lockstep
+ * with `useStageStore`. Stage updates fire first so renderer subscribers
+ * (SceneProvider reads via `currentSlideContent`) see the new content as
+ * soon as React processes the next batch.
+ */
+ const writeThrough = (next: SlideContent) => {
+ const { sceneId } = get();
+ if (!sceneId) return;
+ useStageStore.getState().updateScene(sceneId, { content: next });
+ };
+
+ const replace = (history: SlideEditHistory) => {
+ const { history: prev } = get();
+ if (history === prev) return;
+ writeThrough(history.present);
+ set({ history });
+ };
+
+ return {
+ sceneId: null,
+ history: null,
+
+ seed: (sceneId, content) => {
+ // Adopt the live scene content as the in-memory baseline. We do NOT
+ // write-through here: if the user makes no edits, the stage store
+ // shouldn't receive a redundant write. Any schema migration the
+ // first user edit triggers will naturally flow back through
+ // commitContent's writeThrough.
+ set({
+ sceneId,
+ history: createSlideEditHistory(migrateSlideContent(content)),
+ });
+ },
+
+ applyOp: (op) => {
+ const { history } = get();
+ if (!history) return;
+ replace(applySlideEditOperation(history, op));
+ },
+
+ commitContent: (next, isUserEdit) => {
+ const { history } = get();
+ if (!history) return;
+ if (!isUserEdit) {
+ // ResizeObserver / auto-height normalization: don't push an undo
+ // step (the reflow can chase a user resize and wiping `past` would
+ // silently break undo), but DO write through — the auto-fit height
+ // IS the new canonical state. Clear `future`, though: `present` now
+ // holds the normalized content, which has diverged from whatever
+ // the redo branch pointed at, so replaying those stale entries
+ // would discard this normalization. Leaving them would let a later
+ // redo silently revert to pre-undo content (canvas/store divergence).
+ writeThrough(next);
+ set({ history: { ...history, present: next, future: [] } });
+ return;
+ }
+ replace(commitSlideEdit(history, next));
+ },
+
+ undo: () => {
+ const { history } = get();
+ if (!history) return;
+ replace(undoSlideEditOperation(history));
+ },
+
+ redo: () => {
+ const { history } = get();
+ if (!history) return;
+ replace(redoSlideEditOperation(history));
+ },
+
+ end: () => {
+ set({ sceneId: null, history: null });
+ },
+ };
+});
diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx
new file mode 100644
index 0000000000..eaad3cbde4
--- /dev/null
+++ b/components/edit/surfaces/slide/text-format-bar.tsx
@@ -0,0 +1,326 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import {
+ Bold,
+ Italic,
+ Underline,
+ AlignLeft,
+ AlignCenter,
+ AlignRight,
+ List,
+ Minus,
+ Plus,
+} from 'lucide-react';
+import { FONTS } from '@/configs/font';
+import type { TextAttrs } from '@/lib/prosemirror/utils';
+import {
+ runActiveTextCommand,
+ type TextCommandPayload,
+} from '@/lib/prosemirror/active-editor-registry';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { ColorPicker } from './ColorPicker';
+
+interface TextFormatBarProps {
+ readonly elementId: string;
+ readonly attrs: TextAttrs;
+}
+
+// Radix Select forbids an empty-string item value, but the canonical "default
+// font" in the FONTS registry IS the empty string. It rides through the Select
+// under this sentinel and is mapped back to '' at the command edge.
+const DEFAULT_FONT = '__default__';
+
+interface ToggleButtonProps {
+ readonly label: string;
+ readonly active: boolean;
+ readonly payload: TextCommandPayload;
+ readonly run: (payload: TextCommandPayload) => void;
+ readonly children: React.ReactNode;
+}
+
+// preventDefault on mousedown keeps ProseMirror focused so the command lands on
+// the live element. The Select and the color deliberately skip it —
+// they own their own focus.
+function BarButton({
+ label,
+ onClick,
+ className,
+ children,
+}: {
+ readonly label: string;
+ readonly onClick: () => void;
+ readonly className?: string;
+ readonly children: React.ReactNode;
+}) {
+ return (
+ e.preventDefault()}
+ onClick={onClick}
+ className={className}
+ >
+ {children}
+
+ );
+}
+
+function ToggleButton({ label, active, payload, run, children }: ToggleButtonProps) {
+ return (
+ run(payload)}
+ className={`flex h-8 w-8 items-center justify-center rounded-md transition-colors ${
+ active
+ ? 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300'
+ : 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100'
+ }`}
+ >
+ {children}
+
+ );
+}
+
+function Divider() {
+ return
;
+}
+
+// Subtle raised −/+ button inside the size stepper pill.
+const STEP_BUTTON =
+ 'flex h-7 w-7 items-center justify-center rounded text-zinc-600 transition-colors ' +
+ 'hover:bg-white hover:text-zinc-900 hover:shadow-sm ' +
+ 'dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100';
+
+export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) {
+ const { t } = useI18n();
+ const run = useCallback(
+ (payload: TextCommandPayload) => runActiveTextCommand(elementId, payload),
+ [elementId],
+ );
+ const fontSize = parseInt(attrs.fontsize, 10) || 16;
+ // Local mirror so the user can type freely; only commits on Enter / blur.
+ // The effect re-syncs from `attrs.fontsize` whenever it changes externally
+ // (+/- buttons, undo, font-attr resync) — `attrs.fontsize` doesn't change
+ // mid-type, so this doesn't clobber the user's partial input.
+ const [sizeInput, setSizeInput] = useState(String(fontSize));
+ useEffect(() => {
+ setSizeInput(String(fontSize));
+ }, [fontSize]);
+ const commitSize = useCallback(() => {
+ const n = parseInt(sizeInput, 10);
+ if (Number.isNaN(n)) {
+ setSizeInput(String(fontSize));
+ return;
+ }
+ const clamped = Math.max(8, Math.min(96, n));
+ if (clamped !== fontSize) run({ command: 'fontsize', value: `${clamped}px` });
+ setSizeInput(String(clamped));
+ }, [sizeInput, fontSize, run]);
+ const [colorOpen, setColorOpen] = useState(false);
+
+ return (
+ // w-max keeps the row at its natural width so the popover (w-auto) sizes to
+ // it — one clean line, nothing squished.
+
+ {/* Font — design-system Select; options come from the FONTS registry
+ (configs/font.ts), scoped to fonts the app actually loads. */}
+
run({ command: 'fontname', value: v === DEFAULT_FONT ? '' : v })}
+ >
+
+ {/* Render the trigger text via children — Radix's `placeholder` only
+ fires for an *empty* `value`, not for an unmatched non-empty one,
+ so legacy fontnames outside the curated FONTS registry (e.g.
+ `Microsoft YaHei`, `PingFang SC`) need to be surfaced here. */}
+ {currentFontLabel(attrs.fontname, t)}
+
+
+ {FONTS.map((f) => (
+
+ {f.labelKey ? t(f.labelKey) : f.label}
+
+ ))}
+
+
+
+ {/* Font size — one cohesive stepper pill */}
+
+
run({ command: 'fontsize', value: stepFontSize(attrs.fontsize, -2) })}
+ className={STEP_BUTTON}
+ >
+
+
+
setSizeInput(e.target.value.replace(/\D/g, ''))}
+ onBlur={commitSize}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') e.currentTarget.blur();
+ else if (e.key === 'Escape') {
+ setSizeInput(String(fontSize));
+ e.currentTarget.blur();
+ }
+ }}
+ className="w-9 bg-transparent text-center text-xs font-semibold tabular-nums text-zinc-800 outline-none focus:bg-white dark:text-zinc-100 dark:focus:bg-zinc-700"
+ />
+
run({ command: 'fontsize', value: stepFontSize(attrs.fontsize, 2) })}
+ className={STEP_BUTTON}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Text color — curated palette + hex input in a popover, replacing the
+ OS color dialog. preventDefault on mousedown so opening the popover
+ doesn't steal focus from ProseMirror. */}
+
+
+ e.preventDefault()}
+ className="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+
+
+
+ e.preventDefault()}
+ onFocusOutside={(e) => e.preventDefault()}
+ >
+ run({ command: 'forecolor', value: c })}
+ onCommit={(c) => {
+ run({ command: 'forecolor', value: c });
+ setColorOpen(false);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Connected variant — subscribes to live richTextAttrs from the canvas store.
+ * Keep separate from TextFormatBar so the pure component stays unit-testable.
+ */
+export function ConnectedTextFormatBar({ elementId }: { readonly elementId: string }) {
+ const attrs = useCanvasStore.use.richTextAttrs();
+ return ;
+}
+
+export function stepFontSize(current: string, delta: number): string {
+ const n = parseInt(current, 10) || 16;
+ return `${Math.max(8, Math.min(96, n + delta))}px`;
+}
+
+/**
+ * Display label for the current font: the registry entry's i18n label /
+ * fallback label when matched, the raw family name when unmatched (legacy
+ * theme fonts like `Microsoft YaHei` aren't in the curated FONTS), the
+ * default-font label when empty.
+ */
+export function currentFontLabel(fontname: string, t: (k: string) => string): string {
+ const matched = FONTS.find((f) => f.value === fontname);
+ if (matched) return matched.labelKey ? t(matched.labelKey) : matched.label;
+ return fontname || t('edit.text.fontDefault');
+}
diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts
new file mode 100644
index 0000000000..51b1780704
--- /dev/null
+++ b/components/edit/surfaces/slide/use-slide-surface.ts
@@ -0,0 +1,303 @@
+'use client';
+
+import { produce } from 'immer';
+import { Image as ImageIcon, Type } from 'lucide-react';
+import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
+import type { SceneDataController } from '@/lib/contexts/scene-context';
+import type { InsertPaletteItem, SurfaceState } from '@/lib/edit/scene-editor-surface';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { createElementId } from '@/lib/edit/element-id';
+import { createDefaultImageElement, createDefaultSlide } from '@/lib/edit/slide-edit-elements';
+import { defaultRichTextAttrs } from '@/lib/prosemirror/utils';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { useStageStore } from '@/lib/store/stage';
+import type { SlideContent } from '@/lib/types/stage';
+import { ImagePicker } from './ImagePicker';
+import { useSlideEditSession } from './slide-edit-session';
+import { resolveEditingElementId, resolveSelectedElement } from './editing-state';
+
+export interface SlideSelection {
+ readonly activeElementIds: readonly string[];
+}
+
+export function buildInsertItems(
+ t: (k: string) => string,
+ // The currently-armed creating type, or undefined when nothing is armed. The
+ // text item toggles `creatingElement` (no auto-insert): the renderer's
+ // ElementCreateSelection then captures the canvas click/drag and the text
+ // branch in useInsertFromCreateSelection adds the element at that rect.
+ creatingType?: string,
+): InsertPaletteItem[] {
+ const armText = () => {
+ const cs = useCanvasStore.getState();
+ cs.setCreatingElement(creatingType === 'text' ? null : { type: 'text' });
+ };
+ return [
+ {
+ id: 'insert-text',
+ label: t('edit.insert.textBox'),
+ tooltip: t('edit.insert.textBox'),
+ icon: React.createElement(Type, { className: 'h-4 w-4' }),
+ active: creatingType === 'text',
+ onInvoke: armText,
+ },
+ {
+ id: 'insert-image',
+ label: t('edit.insert.image'),
+ tooltip: t('edit.insert.image'),
+ icon: React.createElement(ImageIcon, { className: 'h-4 w-4' }),
+ onInvoke: () => {}, // popover-only: CommandBar's InsertButton ignores onInvoke when popoverContent is set
+ popoverContent: () =>
+ React.createElement(ImagePicker, {
+ onPick: insertImageElement,
+ }),
+ },
+ ];
+}
+
+// Default insertion size for an image whose natural dimensions are unknown
+// (e.g. the URL fails to load). Larger sizes get scaled to fit under MAX_W /
+// MAX_H while preserving the natural aspect ratio.
+const IMAGE_MAX_W = 600;
+const IMAGE_MAX_H = 400;
+
+/**
+ * Insert an image element, sized to preserve the source's natural aspect
+ * ratio (scaled down to fit MAX_W × MAX_H, never upscaled). The op is
+ * dispatched on `Image` load; if the source fails to load, we still insert
+ * at the factory's hardcoded default so the user sees something.
+ */
+export function insertImageElement(src: string): void {
+ const id = createElementId('image');
+ // Bind the insert to the scene that was active at click time. Image
+ // sizing is resolved asynchronously (Image.onload), and the user may
+ // switch slides before it resolves — without this guard the element
+ // would be applied to whatever session is current when onload fires,
+ // i.e. inserted into the wrong slide.
+ const targetSceneId = useSlideEditSession.getState().sceneId;
+ const dispatch = (width?: number, height?: number) => {
+ if (useSlideEditSession.getState().sceneId !== targetSceneId) return;
+ const base = createDefaultImageElement(id, src);
+ const element = width && height ? { ...base, width, height } : base;
+ useSlideEditSession.getState().applyOp({ type: 'element.add', element });
+ };
+ if (typeof window === 'undefined') {
+ dispatch();
+ return;
+ }
+ const img = new window.Image();
+ img.onload = () => {
+ const ratio = img.naturalWidth / img.naturalHeight;
+ let width = img.naturalWidth;
+ let height = img.naturalHeight;
+ if (width > IMAGE_MAX_W) {
+ width = IMAGE_MAX_W;
+ height = width / ratio;
+ }
+ if (height > IMAGE_MAX_H) {
+ height = IMAGE_MAX_H;
+ width = height * ratio;
+ }
+ dispatch(Math.round(width), Math.round(height));
+ };
+ img.onerror = () => dispatch();
+ img.src = src;
+}
+
+/** Delete a slide element and clear the canvas selection. */
+export function deleteSlideElement(elementId: string): void {
+ useSlideEditSession.getState().applyOp({ type: 'element.delete', elementId });
+ useCanvasStore.getState().setActiveElementIdList([]);
+}
+
+const EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') };
+
+function currentSlideContent(sceneId: string): SlideContent | null {
+ const scene = useStageStore.getState().scenes.find((s) => s.id === sceneId);
+ return scene && scene.type === 'slide' ? (scene.content as SlideContent) : null;
+}
+
+/**
+ * Resolves the slide content the surface should read from: the in-memory
+ * edit-session present, else the canonical stage scene, else an empty slide.
+ */
+export function useResolvedSlideContent(): SlideContent {
+ const history = useSlideEditSession((s) => s.history);
+ const sessionSceneId = useSlideEditSession((s) => s.sceneId);
+ return (
+ history?.present ?? (sessionSceneId ? currentSlideContent(sessionSceneId) : null) ?? EMPTY_SLIDE
+ );
+}
+
+/**
+ * The slide surface's `useSurfaceState`. Pure read over the shared
+ * session store + the renderer's selection store.
+ */
+export function useSlideSurfaceState(): SurfaceState {
+ const { t } = useI18n();
+ const history = useSlideEditSession((s) => s.history);
+ const activeElementIds = useCanvasStore.use.activeElementIdList();
+ const creatingElement = useCanvasStore.use.creatingElement();
+ const content = useResolvedSlideContent();
+
+ return {
+ content,
+ selection: { activeElementIds },
+ hasSelection: activeElementIds.length > 0,
+ history: {
+ canUndo: !!history && history.past.length > 0,
+ canRedo: !!history && history.future.length > 0,
+ undo: () => useSlideEditSession.getState().undo(),
+ redo: () => useSlideEditSession.getState().redo(),
+ },
+ insertItems: buildInsertItems(t, creatingElement?.type),
+ // Every element type carries its own actions on a selection-anchored bar
+ // (AnchoredTextBar / AnchoredDeleteBar) — the surface contributes no
+ // top-center FloatingToolbar actions.
+ floatingActions: [],
+ commands: [],
+ hints: [],
+ };
+}
+
+interface SlideCanvasController {
+ readonly controller: SceneDataController;
+ /**
+ * Spread onto the canvas wrapper. Tracks whether a pointer gesture is in
+ * flight so a renderer commit can be classified as a real user edit vs
+ * ResizeObserver normalization (which fires with no pointer gesture).
+ */
+ readonly gestureProps: {
+ readonly onPointerDownCapture: () => void;
+ readonly onPointerUpCapture: () => void;
+ readonly onPointerCancelCapture: () => void;
+ };
+}
+
+/**
+ * Owns the edit-entry lifecycle for the slide canvas: seeds the in-memory
+ * undo history from the live scene and exposes the scene-context
+ * controller. The controller's writes flow through `slide-edit-session`
+ * which auto-saves them to the canonical `useStageStore` (no staging, no
+ * "restore unsaved" UX — the stage store is the source of truth).
+ */
+export function useSlideCanvasController(): SlideCanvasController {
+ const sceneId = useStageStore((s) => {
+ const scene = s.scenes.find((x) => x.id === s.currentSceneId) ?? null;
+ return scene && scene.type === 'slide' ? scene.id : '';
+ });
+ // Re-render (and thus re-feed SceneProvider's getSnapshot) on every
+ // history move (apply / commit / undo / redo).
+ useSlideEditSession((s) => s.history);
+
+ // True only while a pointer gesture is in flight. The renderer commits a
+ // geometry edit synchronously inside its mouseup handler (still within
+ // the gesture); its ResizeObserver text-normalization commits later with
+ // no gesture. Cleared on a macrotask after pointerup so the synchronous
+ // commit still observes `true`.
+ const gestureRef = useRef(false);
+ const gestureProps = useMemo(
+ () => ({
+ onPointerDownCapture: () => {
+ gestureRef.current = true;
+ },
+ onPointerUpCapture: () => {
+ setTimeout(() => {
+ gestureRef.current = false;
+ }, 0);
+ },
+ onPointerCancelCapture: () => {
+ setTimeout(() => {
+ gestureRef.current = false;
+ }, 0);
+ },
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ if (!sceneId) return;
+ const content = currentSlideContent(sceneId);
+ if (content && useSlideEditSession.getState().sceneId !== sceneId) {
+ useSlideEditSession.getState().seed(sceneId, content);
+ }
+ }, [sceneId]);
+
+ useEffect(() => () => useSlideEditSession.getState().end(), []);
+
+ const controller = useMemo(
+ () => ({
+ sceneId,
+ sceneType: 'slide',
+ // Read from the canonical stage store; the session writes through to
+ // it on every history move so this is always the up-to-date content.
+ getSnapshot: () => currentSlideContent(sceneId) ?? EMPTY_SLIDE,
+ updateSceneData: (updater) => {
+ const base =
+ useSlideEditSession.getState().history?.present ?? currentSlideContent(sceneId);
+ if (!base) return;
+ const next = produce(base, updater as (draft: SlideContent) => void);
+ useSlideEditSession.getState().commitContent(next, gestureRef.current);
+ },
+ }),
+ [sceneId],
+ );
+
+ return {
+ controller,
+ gestureProps,
+ };
+}
+
+/**
+ * The id of the text element currently being edited — i.e. the sole selected
+ * element, when it is a text element. "" means "not editing text". Drives both
+ * the AnchoredTextBar and the canvas store's `editingElementId`.
+ */
+export function useEditingTextElementId(): string {
+ const activeElementIds = useCanvasStore.use.activeElementIdList();
+ const content = useResolvedSlideContent();
+ return resolveEditingElementId(activeElementIds, content.canvas.elements);
+}
+
+/**
+ * The id of the single selected non-text element (image, shape, line, …), or
+ * "" — drives the selection-anchored delete bar. Text elements get their own
+ * AnchoredTextBar; every other element type shares the delete-only bar.
+ */
+export function useSelectedNonTextElementId(): string {
+ const activeElementIds = useCanvasStore.use.activeElementIdList();
+ const content = useResolvedSlideContent();
+ const el = resolveSelectedElement(activeElementIds, content.canvas.elements);
+ return el && el.type !== 'text' ? el.id : '';
+}
+
+/**
+ * Mirrors the surface's editing-element decision into the canvas store's
+ * `editingElementId` flag, which the renderer's `TextElementOperate` reads.
+ * useLayoutEffect so the renderer suppresses the dashed frame in the same
+ * commit the selection changes — no one-frame flicker. Cleared on unmount.
+ */
+export function useSyncEditingElementId(editingElementId: string): void {
+ const setEditingElementId = useCanvasStore.use.setEditingElementId();
+ const setRichTextAttrs = useCanvasStore.use.setRichtextAttrs();
+ // Track the previous editing id so we only reset attrs on element-to-element
+ // *transitions*. Resetting on the first selection (or initial mount with a
+ // restored selection) would briefly flash neutral defaults — `color #000`,
+ // `fontsize 16px` — before the focusing ProseMirror repopulates the real
+ // values, which is more jarring than skipping the reset there.
+ const prevEditingElementId = useRef('');
+ useLayoutEffect(() => {
+ setEditingElementId(editingElementId);
+ if (prevEditingElementId.current && prevEditingElementId.current !== editingElementId) {
+ // `richTextAttrs` is a single shared store updated by whichever
+ // ProseMirror was last focused. Without this reset on switch, the
+ // format bar visibly carries the previous element's toggle states
+ // (B, I, alignment, …) until the new element's ProseMirror takes
+ // focus and writes its own attrs.
+ setRichTextAttrs(defaultRichTextAttrs);
+ }
+ prevEditingElementId.current = editingElementId;
+ return () => setEditingElementId('');
+ }, [editingElementId, setEditingElementId, setRichTextAttrs]);
+}
diff --git a/components/edit/surfaces/slide/use-tracked-rect.ts b/components/edit/surfaces/slide/use-tracked-rect.ts
new file mode 100644
index 0000000000..3a1d8aef2d
--- /dev/null
+++ b/components/edit/surfaces/slide/use-tracked-rect.ts
@@ -0,0 +1,64 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+export interface TrackedRect {
+ readonly left: number;
+ readonly top: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+function sameRect(a: TrackedRect | null, b: TrackedRect | null): boolean {
+ if (a === null || b === null) return a === b;
+ return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height;
+}
+
+/**
+ * Tracks the on-screen rect of a rendered slide element.
+ *
+ * `#editable-element-{id}` is only a zero-size `absolute` wrapper (it carries
+ * just a z-index); the geometry lives on its inner `.editable-element-{type}`
+ * content root (text, image, shape, …), which has the real left/top/width/
+ * height and inherits the viewport scale. So we resolve the
+ * wrapper by id, then measure that child — measuring the wrapper itself would
+ * collapse to a 0x0 rect at the canvas origin.
+ *
+ * A requestAnimationFrame loop re-measures via getBoundingClientRect — that
+ * one call already resolves canvas scale, viewport offset and page scroll, so
+ * the anchored bar follows the element through every gesture (drag, resize,
+ * zoom) without separate store subscriptions or listeners. The loop starts
+ * after mount, so on first selection the bar appears one frame late — an
+ * imperceptible delay. Returns null while `elementId` is "" or unmounted.
+ */
+export function useTrackedRect(elementId: string): TrackedRect | null {
+ const [rect, setRect] = useState(null);
+
+ useEffect(() => {
+ // No element to track: leave the last rect in place. The consumer gates on
+ // `editingElementId !== ''` anyway, so a stale rect behind a closed popover
+ // is inert — and not calling setState here keeps the effect render-clean.
+ if (!elementId) return;
+ let raf = 0;
+ let current: TrackedRect | null = null;
+ const measure = () => {
+ const wrapper = document.getElementById(`editable-element-${elementId}`);
+ // Every element type renders an `.editable-element-{type}` content root.
+ const node = wrapper?.querySelector('[class*="editable-element-"]') ?? null;
+ let next: TrackedRect | null = null;
+ if (node) {
+ const r = node.getBoundingClientRect();
+ next = { left: r.left, top: r.top, width: r.width, height: r.height };
+ }
+ if (!sameRect(current, next)) {
+ current = next;
+ setRect(next);
+ }
+ raf = requestAnimationFrame(measure);
+ };
+ raf = requestAnimationFrame(measure);
+ return () => cancelAnimationFrame(raf);
+ }, [elementId]);
+
+ return rect;
+}
diff --git a/components/edit/use-edit-mode-lock.ts b/components/edit/use-edit-mode-lock.ts
new file mode 100644
index 0000000000..0cf5445677
--- /dev/null
+++ b/components/edit/use-edit-mode-lock.ts
@@ -0,0 +1,80 @@
+'use client';
+
+import { nanoid } from 'nanoid';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ LOCK_HEARTBEAT_MS,
+ refreshEditLock,
+ releaseEditLock,
+ tryAcquireEditLock,
+} from '@/lib/edit/edit-mode-lock';
+
+export interface EditModeLock {
+ /**
+ * Try to take the cross-tab edit lock. Returns false (and opens the
+ * conflict prompt) when another tab is editing this course — the caller
+ * must NOT enter edit mode on false.
+ */
+ readonly acquire: () => boolean;
+ /** Release the lock if we still hold it (called when leaving edit mode). */
+ readonly release: () => void;
+ readonly conflictOpen: boolean;
+ readonly dismissConflict: () => void;
+}
+
+/**
+ * React lifecycle wrapper around the #571 `edit-mode-lock` primitives:
+ * a stable per-session tabId, a heartbeat while held, and release on
+ * unmount / tab close. When there is no course identity it degrades to
+ * single-tab semantics (never refuses entry).
+ */
+export function useEditModeLock(courseId: string | null | undefined): EditModeLock {
+ // Stable per mount; lazy init avoids reading a ref during render.
+ const [tabId] = useState(() => nanoid());
+ const heldRef = useRef(false);
+ const [conflictOpen, setConflictOpen] = useState(false);
+
+ const acquire = useCallback(() => {
+ if (!courseId) return true;
+ if (!tryAcquireEditLock(courseId, tabId)) {
+ setConflictOpen(true);
+ return false;
+ }
+ heldRef.current = true;
+ return true;
+ }, [courseId, tabId]);
+
+ const release = useCallback(() => {
+ if (!courseId || !heldRef.current) return;
+ heldRef.current = false;
+ releaseEditLock(courseId, tabId);
+ }, [courseId, tabId]);
+
+ useEffect(() => {
+ if (!courseId) return;
+ const id = setInterval(() => {
+ if (heldRef.current) refreshEditLock(courseId, tabId);
+ }, LOCK_HEARTBEAT_MS);
+ return () => clearInterval(id);
+ }, [courseId, tabId]);
+
+ useEffect(() => {
+ const releaseIfHeld = () => {
+ if (courseId && heldRef.current) releaseEditLock(courseId, tabId);
+ };
+ window.addEventListener('beforeunload', releaseIfHeld);
+ return () => {
+ window.removeEventListener('beforeunload', releaseIfHeld);
+ releaseIfHeld();
+ };
+ }, [courseId, tabId]);
+
+ const dismissConflict = useCallback(() => setConflictOpen(false), []);
+
+ // Stable identity (callbacks are useCallback'd) so consumers can depend
+ // on the returned object without re-running effects every render.
+ return useMemo(
+ () => ({ acquire, release, conflictOpen, dismissConflict }),
+ [acquire, release, conflictOpen, dismissConflict],
+ );
+}
diff --git a/components/header.tsx b/components/header.tsx
index 00ae7f6052..c718668f00 100644
--- a/components/header.tsx
+++ b/components/header.tsx
@@ -1,77 +1,21 @@
'use client';
-import {
- Settings,
- Sun,
- Moon,
- Monitor,
- ArrowLeft,
- Loader2,
- Download,
- FileDown,
- Package,
- Archive,
-} from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
-import { useTheme } from '@/lib/hooks/use-theme';
-import { LanguageSwitcher } from './language-switcher';
-import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
-import { SettingsDialog } from './settings';
-import { cn } from '@/lib/utils';
-import { useStageStore } from '@/lib/store/stage';
-import { useMediaGenerationStore } from '@/lib/store/media-generation';
-import { useExportPPTX } from '@/lib/export/use-export-pptx';
-import { useExportClassroom } from '@/lib/export/use-export-classroom';
+import type { StageMode } from '@/lib/types/stage';
+import { HeaderControls } from './stage/header-controls';
interface HeaderProps {
readonly currentSceneTitle: string;
+ readonly mode?: StageMode;
+ readonly canEdit?: boolean;
+ readonly onToggleEditMode?: () => void;
}
-export function Header({ currentSceneTitle }: HeaderProps) {
+export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: HeaderProps) {
const { t } = useI18n();
- const { theme, setTheme } = useTheme();
const router = useRouter();
- const [settingsOpen, setSettingsOpen] = useState(false);
- const [themeOpen, setThemeOpen] = useState(false);
-
- // Export
- const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
- const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom();
- const [exportMenuOpen, setExportMenuOpen] = useState(false);
- const exportRef = useRef(null);
- const scenes = useStageStore((s) => s.scenes);
- const generatingOutlines = useStageStore((s) => s.generatingOutlines);
- const failedOutlines = useStageStore((s) => s.failedOutlines);
- const mediaTasks = useMediaGenerationStore((s) => s.tasks);
-
- const canExport =
- scenes.length > 0 &&
- generatingOutlines.length === 0 &&
- failedOutlines.length === 0 &&
- Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed');
-
- const themeRef = useRef(null);
-
- // Close dropdown when clicking outside
- const handleClickOutside = useCallback(
- (e: MouseEvent) => {
- if (themeOpen && themeRef.current && !themeRef.current.contains(e.target as Node)) {
- setThemeOpen(false);
- }
- if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) {
- setExportMenuOpen(false);
- }
- },
- [themeOpen, exportMenuOpen],
- );
-
- useEffect(() => {
- if (themeOpen || exportMenuOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }
- }, [themeOpen, exportMenuOpen, handleClickOutside]);
return (
<>
@@ -84,173 +28,32 @@ export function Header({ currentSceneTitle }: HeaderProps) {
>
-
-
- {t('stage.currentScene')}
-
-
- {currentSceneTitle || t('common.loading')}
-
-
-
-
-
- {/* Language Selector */}
-
setThemeOpen(false)} />
-
-
-
- {/* Theme Selector */}
-
-
{
- setThemeOpen(!themeOpen);
- }}
- className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
- >
- {theme === 'light' && }
- {theme === 'dark' && }
- {theme === 'system' && }
-
- {themeOpen && (
-
- {
- setTheme('light');
- setThemeOpen(false);
- }}
- className={cn(
- 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
- theme === 'light' &&
- 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
- )}
- >
-
- {t('settings.themeOptions.light')}
-
- {
- setTheme('dark');
- setThemeOpen(false);
- }}
- className={cn(
- 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
- theme === 'dark' &&
- 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
- )}
- >
-
- {t('settings.themeOptions.dark')}
-
- {
- setTheme('system');
- setThemeOpen(false);
- }}
- className={cn(
- 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
- theme === 'system' &&
- 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
- )}
- >
-
- {t('settings.themeOptions.system')}
-
-
- )}
-
-
-
-
- {/* Settings Button */}
-
- setSettingsOpen(true)}
- className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
- >
-
-
-
-
-
- {/* Export Dropdown */}
-
-
{
- if (canExport && !isExporting && !isExportingZip) setExportMenuOpen(!exportMenuOpen);
- }}
- disabled={!canExport || isExporting || isExportingZip}
- title={
- canExport
- ? isExporting || isExportingZip
- ? t('export.exporting')
- : t('export.pptx')
- : t('share.notReady')
- }
- className={cn(
- 'shrink-0 p-2 rounded-full transition-all',
- canExport && !isExporting && !isExportingZip
- ? 'text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm'
- : 'text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50',
- )}
- >
- {isExporting || isExportingZip ? (
-
- ) : (
-
- )}
-
- {exportMenuOpen && (
-
-
{
- setExportMenuOpen(false);
- exportPPTX();
- }}
- className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
- >
-
- {t('export.pptx')}
-
-
{
- setExportMenuOpen(false);
- exportResourcePack();
- }}
- className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
+ {/* Title block — hidden when `mode === 'edit'`. Header lives
+ inside `PlaybackChromeRoot`, which is unmounted by `Stage`
+ once mode flips to 'edit', so in steady state this branch
+ is always taken. The guard exists for the ~280ms
+ AnimatePresence exit window where the playback chrome
+ is still rendering its exit animation while `mode` has
+ already flipped — without the guard, this title would
+ briefly stack on top of the incoming EditChromeRoot's
+ CommandBar title during the cross-fade. */}
+ {mode !== 'edit' && (
+
+
+ {t('stage.currentScene')}
+
+
-
-
-
{t('export.resourcePack')}
-
- {t('export.resourcePackDesc')}
-
-
-
- {
- setExportMenuOpen(false);
- exportClassroomZip();
- }}
- disabled={isExportingZip}
- className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
- >
-
-
-
{t('export.classroomZip')}
-
- {t('export.classroomZipDesc')}
-
-
-
+ {currentSceneTitle || t('common.loading')}
+
)}
+
+
-
>
);
}
diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx
index 684397ad04..4b561560df 100644
--- a/components/language-switcher.tsx
+++ b/components/language-switcher.tsx
@@ -1,64 +1,55 @@
'use client';
-import { useState, useRef, useEffect } from 'react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { supportedLocales } from '@/lib/i18n';
import { cn } from '@/lib/utils';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
interface LanguageSwitcherProps {
- /** Called when the dropdown opens, so parent can close sibling dropdowns */
+ /** Called when the dropdown opens, so parent can close sibling dropdowns. */
onOpen?: () => void;
}
+/**
+ * Locale picker pill. Backed by Radix DropdownMenu so its content is
+ * portaled to `document.body` — important inside Pro mode's CommandBar
+ * (which lives under an `overflow-hidden` canvas slot that would
+ * otherwise clip the dropdown).
+ */
export function LanguageSwitcher({ onOpen }: LanguageSwitcherProps) {
const { locale, setLocale } = useI18n();
- const [open, setOpen] = useState(false);
- const ref = useRef
(null);
-
- // Close on click outside
- useEffect(() => {
- if (!open) return;
- const handler = (e: MouseEvent) => {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- setOpen(false);
- }
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, [open]);
return (
-
-
{
- const next = !open;
- setOpen(next);
- if (next) onOpen?.();
- }}
- className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
- >
- {supportedLocales.find((l) => l.code === locale)?.shortLabel ?? locale}
-
- {open && (
-
- {supportedLocales.map((l) => (
- {
- setLocale(l.code);
- setOpen(false);
- }}
- className={cn(
- 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
- locale === l.code &&
- 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
- )}
- >
- {l.label}
-
- ))}
-
- )}
-
+ {
+ if (open) onOpen?.();
+ }}
+ >
+
+
+ {supportedLocales.find((l) => l.code === locale)?.shortLabel ?? locale}
+
+
+
+ {supportedLocales.map((l) => (
+ setLocale(l.code)}
+ className={cn(
+ 'cursor-pointer',
+ locale === l.code &&
+ 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
+ )}
+ >
+ {l.label}
+
+ ))}
+
+
);
}
diff --git a/components/scene-renderers/pbl-renderer.tsx b/components/scene-renderers/pbl-renderer.tsx
index 22212a9a2c..068adebc76 100644
--- a/components/scene-renderers/pbl-renderer.tsx
+++ b/components/scene-renderers/pbl-renderer.tsx
@@ -1,7 +1,7 @@
'use client';
import { useCallback } from 'react';
-import type { PBLContent } from '@/lib/types/stage';
+import type { PBLContent, StageMode } from '@/lib/types/stage';
import type { PBLProjectConfig } from '@/lib/pbl/types';
import { useStageStore } from '@/lib/store/stage';
import { PBLRoleSelection } from './pbl/role-selection';
@@ -10,7 +10,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
interface PBLRendererProps {
readonly content: PBLContent;
- readonly mode: 'autonomous' | 'playback';
+ readonly mode: StageMode;
readonly sceneId: string;
}
diff --git a/components/slide-renderer/Editor/Canvas/ElementCreateSelection.tsx b/components/slide-renderer/Editor/Canvas/ElementCreateSelection.tsx
index 95357ba067..405d8f1b2d 100644
--- a/components/slide-renderer/Editor/Canvas/ElementCreateSelection.tsx
+++ b/components/slide-renderer/Editor/Canvas/ElementCreateSelection.tsx
@@ -99,6 +99,14 @@ export function ElementCreateSelection({ onCreated }: ElementCreateSelectionProp
start: [startPageX, startPageY],
end: [endPageX, endPageY],
});
+ } else if (creatingElement?.type === 'text') {
+ // Click or sub-threshold wobble for a text box — hand the raw start/end
+ // through; the consumer applies a text-natural default size (a 200×200
+ // square pad would never suit a text box).
+ onCreated({
+ start: [startPageX, startPageY],
+ end: [endPageX, endPageY],
+ });
} else {
const defaultSize = 200;
const minX = Math.min(endPageX, startPageX);
diff --git a/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx b/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx
index 26dac0be44..2ec379ed18 100644
--- a/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx
+++ b/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx
@@ -25,6 +25,8 @@ export function TextElementOperate({
scaleElement,
}: TextElementOperateProps) {
const canvasScale = useCanvasStore.use.canvasScale();
+ const editingElementId = useCanvasStore.use.editingElementId();
+ const isEditing = editingElementId === elementInfo.id;
const scaleWidth = useMemo(
() => elementInfo.width * canvasScale,
@@ -44,14 +46,30 @@ export function TextElementOperate({
return (
- {borderLines.map((line) => (
-
- ))}
+ ) : (
+ borderLines.map((line) => (
+
+ ))
+ )}
{handlerVisible && (
<>
{resizeHandlers.map((point) => (
diff --git a/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts
index fb309ca67e..c10c2df352 100644
--- a/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts
+++ b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts
@@ -1,11 +1,23 @@
import { useCallback, type RefObject } from 'react';
import { useCanvasStore } from '@/lib/store';
+import { createElementId } from '@/lib/edit/element-id';
+import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
import type { CreateElementSelectionData } from '@/lib/types/edit';
+import type { PPTTextElement } from '@/lib/types/slides';
+
+// Click-fallback default size when the user clicks instead of drags (or wobbles
+// under this in either dimension): a sensibly-sized text box at the start point.
+const TEXT_CLICK_MIN = 24;
+const TEXT_DEFAULT_W = 300;
+const TEXT_DEFAULT_H = 60;
+// Empty centered paragraph — caret-ready, no placeholder text to delete.
+const EMPTY_TEXT_CONTENT = '
';
export function useInsertFromCreateSelection(viewportRef: RefObject
) {
const canvasScale = useCanvasStore.use.canvasScale();
const creatingElement = useCanvasStore.use.creatingElement();
const setCreatingElement = useCanvasStore.use.setCreatingElement();
+ const { addElement } = useCanvasOperations();
// Calculate selection position and size from the start and end points of mouse drag selection
const formatCreateSelection = useCallback(
@@ -74,7 +86,35 @@ export function useInsertFromCreateSelection(viewportRef: RefObject size / viewportSize, [size, viewportSize]);
+ const autoSize = size === undefined;
+ const containerRef = useRef(null);
+ const [observedWidth, setObservedWidth] = useState(0);
+
+ useLayoutEffect(() => {
+ if (!autoSize) return;
+ const el = containerRef.current;
+ if (!el) return;
+ const measure = () => {
+ const w = el.clientWidth;
+ // Avoid React state thrash when the box settles on an identical width.
+ setObservedWidth((prev) => (prev === w ? prev : w));
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [autoSize]);
+
+ const effectiveWidth = autoSize ? observedWidth : (size ?? 0);
+ const scale = effectiveWidth > 0 ? effectiveWidth / viewportSize : 0;
// Get background style
const { backgroundStyle } = useSlideBackgroundStyle(slide.background);
+ // In auto mode the outer container is CSS-sized (full parent) so any
+ // animated outer width from the parent is the single source of truth;
+ // we just observe it. In explicit mode we paint a fixed pixel box.
+ const containerClass = autoSize
+ ? 'thumbnail-slide relative bg-white overflow-hidden select-none pointer-events-none w-full h-full'
+ : 'thumbnail-slide bg-white overflow-hidden select-none pointer-events-none';
+ const containerStyle: React.CSSProperties | undefined = autoSize
+ ? undefined
+ : { width: `${size}px`, height: `${(size ?? 0) * viewportRatio}px` };
+
if (!visible) {
return (
-
+
加载中 ...
@@ -52,13 +91,7 @@ export function ThumbnailSlide({
}
return (
-
+
{
- if (!isPlaceholder) return undefined;
- const t = s.tasks[elementInfo.src];
- // Only use task if it belongs to the current stage
- if (t && t.stageId !== stageId) return undefined;
- return t;
- });
+ // Shared with the editor canvas's interactive ImageElement so both variants
+ // resolve gen_img_* placeholders identically (Pro mode previously rendered
+ // the raw placeholder string and showed a broken-image icon).
+ const { resolvedSrc, isPlaceholder, task } = useResolvedImageSrc(elementInfo);
const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled);
- // Resolve actual src: use objectUrl from store if available, otherwise original src
- const resolvedSrc = task?.status === 'done' && task.objectUrl ? task.objectUrl : elementInfo.src;
const showDisabled = isPlaceholder && !task && !imageGenerationEnabled;
const showSkeleton =
isPlaceholder &&
diff --git a/components/slide-renderer/components/element/ImageElement/index.tsx b/components/slide-renderer/components/element/ImageElement/index.tsx
index 85bdb95fd5..e0f0cc9830 100644
--- a/components/slide-renderer/components/element/ImageElement/index.tsx
+++ b/components/slide-renderer/components/element/ImageElement/index.tsx
@@ -11,6 +11,7 @@ import { useClipImage } from './useClipImage';
import { useFilter } from './useFilter';
import { ImageOutline } from './ImageOutline';
import { ImageClipHandler } from './ImageClipHandler';
+import { useResolvedImageSrc } from './useResolvedImageSrc';
export interface ImageElementProps {
elementInfo: PPTImageElement;
@@ -31,6 +32,12 @@ export function ImageElement({ elementInfo, selectElement }: ImageElementProps)
const { clipShape, imgPosition } = useClipImage(elementInfo);
const { filter } = useFilter(elementInfo.filters);
+ // Resolve gen_img_* placeholders against the media generation store so the
+ // editor canvas displays the generated image (the read-only BaseImageElement
+ // has always done this; the interactive variant previously rendered the raw
+ // placeholder string, surfacing a broken-image icon in Pro mode).
+ const { resolvedSrc } = useResolvedImageSrc(elementInfo);
+
const isCliping = clipingImageElementId === elementInfo.id;
const handleSelectElement = (e: React.MouseEvent | React.TouchEvent) => {
@@ -102,7 +109,7 @@ export function ImageElement({ elementInfo, selectElement }: ImageElementProps)
>
{isCliping ? (
`: the generated `objectUrl` when the
+ * placeholder's task is done; otherwise the original `elementInfo.src`.
+ * For non-placeholder src this is byte-equal to `elementInfo.src`.
+ */
+ readonly resolvedSrc: string;
+ readonly isPlaceholder: boolean;
+ readonly task: MediaTask | undefined;
+}
+
+/**
+ * Pure resolver — no hooks. Given an image element plus the already-resolved
+ * stageId and possibly-keyed task, computes the final resolution shape.
+ * Splitting this out of the hook keeps the logic unit-testable in a plain
+ * node environment (no RTL/jsdom needed).
+ *
+ * Behavior is strictly additive: for non-placeholder src (every legacy /
+ * direct-URL / data-URL image), `resolvedSrc === elementInfo.src`. For a
+ * placeholder, the task is honored only if it belongs to the current stage
+ * (cross-course contamination guard) and its objectUrl is set.
+ */
+export function resolveImageSrc(
+ elementInfo: PPTImageElement,
+ stageId: string | undefined,
+ task: MediaTask | undefined,
+): ResolvedImageSrc {
+ const isPlaceholder = !!stageId && isMediaPlaceholder(elementInfo.src);
+ const effectiveTask = isPlaceholder && task && task.stageId === stageId ? task : undefined;
+ const resolvedSrc =
+ effectiveTask?.status === 'done' && effectiveTask.objectUrl
+ ? effectiveTask.objectUrl
+ : elementInfo.src;
+ return { resolvedSrc, isPlaceholder, task: effectiveTask };
+}
+
+/**
+ * Resolve a slide image element's src against the media generation store so
+ * `gen_img_*` placeholders display the generated objectUrl once the task is
+ * ready. Shared by:
+ *
+ * - `BaseImageElement` — the read-only playback variant (consumes the full
+ * return shape for skeleton / error / disabled UX);
+ * - `ImageElement` (this folder's `index.tsx`) — the interactive editor
+ * canvas variant, which historically rendered `elementInfo.src` raw and
+ * therefore showed a broken-image icon when entering Pro mode on any
+ * slide whose image element was a generation placeholder.
+ *
+ * Only subscribe to the media store when inside a classroom (stageId provided
+ * via context). Homepage thumbnails have no stageId context → skip the store
+ * to prevent cross-course contamination.
+ */
+export function useResolvedImageSrc(elementInfo: PPTImageElement): ResolvedImageSrc {
+ const stageId = useMediaStageId();
+ // Tight selector: only the task keyed by this src (and only for placeholder
+ // src), so unrelated task updates don't re-render the renderer.
+ const task = useMediaGenerationStore((s) => {
+ if (!stageId || !isMediaPlaceholder(elementInfo.src)) return undefined;
+ return s.tasks[elementInfo.src];
+ });
+ return resolveImageSrc(elementInfo, stageId, task);
+}
diff --git a/components/slide-renderer/components/element/ProsemirrorEditor.tsx b/components/slide-renderer/components/element/ProsemirrorEditor.tsx
index 5bc01ec5be..d100d13e3f 100644
--- a/components/slide-renderer/components/element/ProsemirrorEditor.tsx
+++ b/components/slide-renderer/components/element/ProsemirrorEditor.tsx
@@ -25,9 +25,13 @@ import { indentCommand, textIndentCommand } from '@/lib/prosemirror/commands/set
import { toggleList } from '@/lib/prosemirror/commands/toggleList';
import { setListStyle } from '@/lib/prosemirror/commands/setListStyle';
import { replaceText } from '@/lib/prosemirror/commands/replaceText';
+import {
+ registerActiveTextEditor,
+ type TextCommandPayload,
+} from '@/lib/prosemirror/active-editor-registry';
+import { shouldPushAttrs } from '@/lib/prosemirror/selection-sync';
import type { TextFormatPainterKeys } from '@/lib/types/edit';
import { KEYS } from '@/configs/hotkey';
-import { toast } from 'sonner';
export interface ProsemirrorEditorProps {
elementId: string;
@@ -68,8 +72,12 @@ export const ProsemirrorEditor = forwardRef {
const editorViewRef = useRef(null);
const editorView = useRef(null);
+ // Mutable refs so the single-init dispatchTransaction always reads fresh values
+ const editableRef = useRef(editable);
+ const pushTextAttrsRef = useRef<((view: EditorView) => void) | null>(null);
const handleElementId = useCanvasStore.use.handleElementId();
+ const editingElementId = useCanvasStore.use.editingElementId();
const textFormatPainter = useCanvasStore.use.textFormatPainter();
const richTextAttrs = useCanvasStore.use.richTextAttrs();
const activeElementIdList = useCanvasStore.use.activeElementIdList();
@@ -134,6 +142,17 @@ export const ProsemirrorEditor = forwardRef {
+ setRichtextAttrs(getTextAttrs(view, { color: defaultColor, fontname: defaultFontName }));
+ },
+ [defaultColor, defaultFontName, setRichtextAttrs],
+ );
+ // Keep mutable refs current on every render so the once-init dispatchTransaction reads fresh values
+ editableRef.current = editable;
+ pushTextAttrsRef.current = pushTextAttrs;
+
// Handle keydown
const handleKeydown = useCallback(
(view: EditorView, e: KeyboardEvent) => {
@@ -165,10 +184,6 @@ export const ProsemirrorEditor = forwardRef {
+ if (!editorView.current) return;
+ switch (payload.command) {
+ case 'bold':
+ case 'em':
+ case 'underline':
+ // bulletList: undefined value → execCommand defaults to the standard bullet (item.value || '')
+ case 'bulletList':
+ case 'fontname':
+ case 'fontsize':
+ execCommand({
+ target: elementId,
+ action: { command: payload.command, value: payload.value },
+ });
+ break;
+ case 'forecolor':
+ execCommand({
+ target: elementId,
+ action: { command: 'color', value: payload.value },
+ });
+ break;
+ case 'align-left':
+ case 'align-center':
+ case 'align-right':
+ execCommand({
+ target: elementId,
+ action: {
+ command: 'align',
+ value: payload.command.replace('align-', ''),
+ },
+ });
+ break;
+ default: {
+ const _exhaustive: never = payload.command;
+ void _exhaustive;
+ }
+ }
+ },
+ [execCommand, elementId],
+ );
+
+ // Register the runner only while this element is editable. Playback uses
+ // editable=false → this effect early-returns → nothing registers, so the
+ // renderer is byte-unchanged on the playback/uncontrolled path (PR1-shaped).
+ useEffect(() => {
+ if (!editable) return;
+ const off = registerActiveTextEditor(elementId, runCommand);
+ return off;
+ }, [editable, elementId, runCommand]);
+
// Handle mouseup for format painter
const handleMouseup = useCallback(() => {
if (!textFormatPainter || !editorView.current) return;
@@ -395,6 +466,16 @@ export const ProsemirrorEditor = forwardRef editable,
+ dispatchTransaction(this: EditorView, tr) {
+ // Apply the transaction (replicates ProseMirror's default dispatch)
+ const newState = this.state.apply(tr);
+ this.updateState(newState);
+ // Additive: push toolbar attrs on selection/doc/marks change — editable only.
+ // Playback path (editable=false) is never reached → byte-unchanged.
+ if (editableRef.current && shouldPushAttrs(tr)) {
+ pushTextAttrsRef.current?.(this);
+ }
+ },
});
if (autoFocus) {
@@ -435,6 +516,22 @@ export const ProsemirrorEditor = forwardRef {
+ if (!editable) return;
+ if (editingElementId !== elementId) return;
+ const view = editorView.current;
+ if (!view || view.hasFocus()) return;
+ view.focus();
+ }, [editingElementId, elementId, editable]);
+
// Expose focus method
useImperativeHandle(ref, () => ({
focus: () => {
diff --git a/components/stage.tsx b/components/stage.tsx
index a887aa0522..5433cd828a 100644
--- a/components/stage.tsx
+++ b/components/stage.tsx
@@ -1,1271 +1,162 @@
'use client';
-import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
+import { AnimatePresence, motion } from 'motion/react';
import { useStageStore } from '@/lib/store';
-import { PENDING_SCENE_ID } from '@/lib/store/stage';
-import { useCanvasStore } from '@/lib/store/canvas';
-import { useSettingsStore } from '@/lib/store/settings';
-import { useI18n } from '@/lib/hooks/use-i18n';
-import { SceneSidebar } from './stage/scene-sidebar';
-import { Header } from './header';
-import { CanvasArea } from '@/components/canvas/canvas-area';
-import { Roundtable } from '@/components/roundtable';
-import { PlaybackEngine, computePlaybackView } from '@/lib/playback';
-import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback';
-import { ActionEngine } from '@/lib/action/engine';
-import { createAudioPlayer } from '@/lib/utils/audio-player';
-import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts';
-import { useWidgetIframeStore } from '@/lib/store/widget-iframe';
-import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator';
-import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action';
-import { cn } from '@/lib/utils';
-// Playback state persistence removed — refresh always starts from the beginning
-import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area';
-import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store';
-import type { AgentConfig } from '@/lib/orchestration/registry/types';
+import { isCurrentSceneEditable } from '@/lib/edit/stage-mode';
+import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
+import { EditChromeRoot } from '@/components/edit/EditChromeRoot';
import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogTitle,
- AlertDialogFooter,
- AlertDialogAction,
- AlertDialogCancel,
-} from '@/components/ui/alert-dialog';
-import { AlertTriangle } from 'lucide-react';
-import { VisuallyHidden } from 'radix-ui';
+ PlaybackChromeRoot,
+ type PlaybackChromeRootHandle,
+} from '@/components/edit/PlaybackChromeRoot';
+import { useEditModeLock } from '@/components/edit/use-edit-mode-lock';
+import { MultiTabEditConflictPrompt } from '@/components/edit/MultiTabEditConflictPrompt';
+import { CHROME_EASE } from '@/lib/edit/transitions';
+import { preloadEditor } from '@/lib/edit/preload-editor';
/**
- * Stage Component
+ * Stage — top-level classroom container. Dispatches between the two
+ * chrome roots based on `useStageStore.mode`:
*
- * The main container for the classroom/course.
- * Combines sidebar (scene navigation) and content area (scene viewer).
- * Supports two modes: autonomous and playback.
+ * mode === 'edit' → EditChromeRoot
+ * mode === 'playback' / 'autonomous' → PlaybackChromeRoot
+ *
+ * The two roots are wholly independent. Stage's only responsibilities
+ * are: mode dispatch, edit-lock coordination (cross-tab), Pro Switch
+ * toggle wiring (calls into PlaybackChromeRoot.teardown via ref before
+ * flipping mode), and rendering the cross-tab conflict prompt (which
+ * needs to be mountable from playback mode too, since the lock-conflict
+ * dialog can surface when Pro Switch is clicked but acquire fails).
*/
export function Stage({
onRetryOutline,
}: {
onRetryOutline?: (outlineId: string) => Promise;
}) {
- const { t } = useI18n();
- const {
- mode,
- getCurrentScene,
- scenes,
+ const { mode, setMode, scenes, currentSceneId, generatingOutlines, stage } = useStageStore();
+ const currentScene = useStageStore((s) => s.getCurrentScene());
+
+ // Predicate for "can the user enter Pro mode for the current scene?".
+ // Single source of truth feeds the Header's Pro Switch state and the
+ // auto-exit effect below; keeping them in lock-step prevents an
+ // edit-mode entry that would immediately auto-exit.
+ const isEditable = isCurrentSceneEditable({
currentSceneId,
- setCurrentSceneId,
- generatingOutlines,
- outlines,
- } = useStageStore();
- const failedOutlines = useStageStore.use.failedOutlines();
-
- const currentScene = getCurrentScene();
-
- // Layout state from settings store (persisted via localStorage)
- const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
- const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
- const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
- const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth);
- const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed);
- const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed);
- const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
- const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
-
- // PlaybackEngine state
- const [engineMode, setEngineMode] = useState('idle');
- const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle
- const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture)
- const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA)
- const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1)
- const [discussionTrigger, setDiscussionTrigger] = useState(null);
-
- // Speaking agent tracking (Issue 2)
- const [speakingAgentId, setSpeakingAgentId] = useState(null);
-
- // Thinking state (Issue 5)
- const [thinkingState, setThinkingState] = useState<{
- stage: string;
- agentId?: string;
- } | null>(null);
-
- // Cue user state (Issue 7)
- const [isCueUser, setIsCueUser] = useState(false);
-
- // End flash state (Issue 3)
- const [showEndFlash, setShowEndFlash] = useState(false);
- const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion');
-
- // Streaming state for stop button (Issue 1)
- const [chatIsStreaming, setChatIsStreaming] = useState(false);
- const [chatSessionType, setChatSessionType] = useState(null);
-
- // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input
- const [isTopicPending, setIsTopicPending] = useState(false);
-
- // Active bubble ID for playback highlight in chat area (Issue 8)
- const [activeBubbleId, setActiveBubbleId] = useState(null);
-
- // Scene switch confirmation dialog state
- const [pendingSceneId, setPendingSceneId] = useState(null);
- const [isPresenting, setIsPresenting] = useState(false);
- const [controlsVisible, setControlsVisible] = useState(true);
- const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false);
-
- // Whiteboard state (from canvas store so AI tools can open it)
- const whiteboardOpen = useCanvasStore.use.whiteboardOpen();
- const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen();
-
- // Selected agents from settings store (Zustand)
- const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
- const ttsMuted = useSettingsStore((s) => s.ttsMuted);
- const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
-
- // Generate participants from selected agents
- const participants = useMemo(
- () => agentsToParticipants(selectedAgentIds, t),
- [selectedAgentIds, t],
- );
-
- // Resolved AgentConfig array for hooks that need full agent objects
- // Subscribe to the agents record so voiceConfig changes trigger re-resolution
- const agentsRecord = useAgentRegistry((s) => s.agents);
- const selectedAgents = useMemo(
- () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null),
- [agentsRecord, selectedAgentIds],
- );
-
- // Discussion TTS: audio indicator state
- const [audioIndicatorState, setAudioIndicatorState] = useState('idle');
- const [audioAgentId, setAudioAgentId] = useState(null);
-
- const discussionTTS = useDiscussionTTS({
- enabled: ttsEnabled && !ttsMuted,
- agents: selectedAgents,
- onAudioStateChange: (agentId, state) => {
- setAudioAgentId(agentId);
- setAudioIndicatorState(state);
- },
+ sceneCount: scenes.length,
+ generatingOutlineCount: generatingOutlines.length,
+ hasCurrentScene: !!currentScene,
});
- // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback)
- const pickStudentAgent = useCallback((): string => {
- const registry = useAgentRegistry.getState();
- const agents = selectedAgentIds
- .map((id) => registry.getAgent(id))
- .filter((a): a is AgentConfig => a != null);
- const students = agents.filter((a) => a.role === 'student');
- if (students.length > 0) {
- return students[Math.floor(Math.random() * students.length)].id;
- }
- const nonTeachers = agents.filter((a) => a.role !== 'teacher');
- if (nonTeachers.length > 0) {
- return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id;
- }
- return agents[0]?.id || 'default-1';
- }, [selectedAgentIds]);
-
- const engineRef = useRef(null);
- const audioPlayerRef = useRef(createAudioPlayer());
- const chatAreaRef = useRef(null);
- const lectureSessionIdRef = useRef(null);
- const lectureActionCounterRef = useRef(0);
- const discussionAbortRef = useRef(null);
- const presentationIdleTimerRef = useRef | null>(null);
- const stageRef = useRef(null);
- // Guard to prevent double flash when manual stop triggers onDiscussionEnd
- const manualStopRef = useRef(false);
- // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks
- const sceneEpochRef = useRef(0);
- // When true, the next engine init will auto-start playback (for auto-play scene advance)
- const autoStartRef = useRef(false);
- // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE)
- const [isDiscussionPaused, setIsDiscussionPaused] = useState(false);
-
- /**
- * Resume a soft-paused topic: re-call /chat with existing session messages.
- * The director picks the next agent to continue.
- */
- const doResumeTopic = useCallback(async () => {
- // Clear old bubble immediately — no lingering on interrupted text
- setIsTopicPending(false);
- setLiveSpeech(null);
- setSpeakingAgentId(null);
- setThinkingState({ stage: 'director' });
- setChatIsStreaming(true);
- // Transition engine back to live — onInputActivate paused it when soft-pausing,
- // so we must explicitly resume to keep engine mode in sync with the chat loop.
- engineRef.current?.resume();
- // Fire new chat round — SSE events will drive thinking → agent_start → speech
- await chatAreaRef.current?.resumeActiveSession();
- }, []);
-
- /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */
- const resetLiveState = useCallback(() => {
- setLiveSpeech(null);
- setSpeakingAgentId(null);
- setSpeechProgress(null);
- setThinkingState(null);
- setIsCueUser(false);
- setIsTopicPending(false);
- setChatIsStreaming(false);
- setChatSessionType(null);
- setIsDiscussionPaused(false);
- }, []);
-
- /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */
- const resetSceneState = useCallback(() => {
- resetLiveState();
- setPlaybackCompleted(false);
- setLectureSpeech(null);
- setSpeechProgress(null);
- setShowEndFlash(false);
- setActiveBubbleId(null);
- setDiscussionTrigger(null);
- }, [resetLiveState]);
-
- /** Request failure should exit live discussion UI without hard-closing the session. */
- const handleLiveSessionError = useCallback(() => {
- engineRef.current?.handleDiscussionError();
- resetLiveState();
- setActiveBubbleId(null);
- }, [resetLiveState]);
-
- /**
- * Unified session cleanup — called by both roundtable stop button and chat area end button.
- * Handles: engine transition, flash, roundtable state clearing.
- */
- const doSessionCleanup = useCallback(() => {
- const activeType = chatSessionType;
-
- // Engine cleanup — guard to avoid double flash from onDiscussionEnd
- manualStopRef.current = true;
- engineRef.current?.handleEndDiscussion();
- manualStopRef.current = false;
-
- // Show end flash with correct session type
- if (activeType === 'qa' || activeType === 'discussion') {
- setEndFlashSessionType(activeType);
- setShowEndFlash(true);
- setTimeout(() => setShowEndFlash(false), 1800);
- }
-
- // Stop any in-flight discussion TTS audio
- discussionTTS.cleanup();
+ // Cross-tab edit lock (#571). Lives at this layer because entry must
+ // be refused BEFORE the live session is torn down; PlaybackChromeRoot
+ // can't own this since it can't refuse its own unmount path.
+ const editLock = useEditModeLock(stage?.id);
- resetLiveState();
- }, [chatSessionType, resetLiveState, discussionTTS]);
+ const playbackRef = useRef(null);
- // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar)
- const handleStopDiscussion = useCallback(async () => {
- await chatAreaRef.current?.endActiveSession();
- doSessionCleanup();
- }, [doSessionCleanup]);
-
- const clearPresentationIdleTimer = useCallback(() => {
- if (presentationIdleTimerRef.current) {
- clearTimeout(presentationIdleTimerRef.current);
- presentationIdleTimerRef.current = null;
- }
- }, []);
-
- const resetPresentationIdleTimer = useCallback(() => {
- setControlsVisible(true);
- clearPresentationIdleTimer();
- if (isPresenting && !isPresentationInteractionActive) {
- presentationIdleTimerRef.current = setTimeout(() => {
- setControlsVisible(false);
- }, 3000);
+ // Pro Switch handler. Edit→playback is a plain flip (PlaybackChromeRoot
+ // will mount fresh; its engine effect re-inits). Playback→edit must
+ // (1) refuse on lock conflict, (2) await SSE / engine / TTS teardown
+ // so PlaybackChromeRoot is quiescent before it unmounts.
+ const handleToggleEditMode = useCallback(async () => {
+ if (mode === 'edit') {
+ setMode('playback');
+ return;
}
- }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]);
-
- const togglePresentation = useCallback(async () => {
- const stageElement = stageRef.current;
- if (!stageElement) return;
-
+ if (!editLock.acquire()) return;
+ // Load the editor chunk (fonts + slide surface) BEFORE flipping mode,
+ // so the edit chrome animates in with its content already present and
+ // the slide surface registered — no mid-animation pop-in / NOOP flash.
+ // Runs concurrently with teardown; the import is promise-cached so it's
+ // a no-op on subsequent toggles.
+ const editorLoad = preloadEditor();
try {
- if (document.fullscreenElement === stageElement) {
- // Unlock Escape key before exiting fullscreen
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (navigator as any).keyboard?.unlock?.();
- await document.exitFullscreen();
- return;
- }
-
- setControlsVisible(true);
- await stageElement.requestFullscreen();
- // Lock Escape key so it doesn't auto-exit fullscreen (#255)
- // Escape is handled manually in our keydown handler instead
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {});
- setSidebarCollapsed(true);
- setChatAreaCollapsed(true);
- } catch {
- // Firefox may deny fullscreen from certain keyboard events (e.g. F11)
- console.warn('[Presentation] Fullscreen request denied — browser policy');
- }
- }, [setChatAreaCollapsed, setSidebarCollapsed]);
-
- useEffect(() => {
- const onFullscreenChange = () => {
- const active = document.fullscreenElement === stageRef.current;
- setIsPresenting(active);
-
- if (!active) {
- // Ensure keyboard unlock on any fullscreen exit
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (navigator as any).keyboard?.unlock?.();
- setControlsVisible(true);
- clearPresentationIdleTimer();
- }
- };
-
- document.addEventListener('fullscreenchange', onFullscreenChange);
- return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
- }, [clearPresentationIdleTimer]);
-
- useEffect(() => {
- if (!isPresenting) {
- setControlsVisible(true);
- clearPresentationIdleTimer();
+ await Promise.all([playbackRef.current?.teardown(), editorLoad]);
+ } catch (err) {
+ // Teardown failed after the cross-tab lock was acquired but before we
+ // flipped into edit mode. Release the lock we just took: otherwise it
+ // stays HELD while mode stays 'playback', and the release effect (keyed
+ // on `mode`) never re-fires, stranding the lock until tab close and
+ // blocking this and every other tab from Pro mode. Stay in playback so
+ // the failure surfaces rather than half-entering edit mode.
+ editLock.release();
+ console.error('[Stage] Pro mode entry failed during teardown', err);
return;
}
+ setMode('edit');
+ }, [editLock, mode, setMode]);
- const handleActivity = () => {
- resetPresentationIdleTimer();
- };
-
- window.addEventListener('mousemove', handleActivity);
- window.addEventListener('mousedown', handleActivity);
- window.addEventListener('touchstart', handleActivity);
- if (isPresentationInteractionActive) {
- setControlsVisible(true);
- clearPresentationIdleTimer();
- } else {
- resetPresentationIdleTimer();
- }
-
- return () => {
- window.removeEventListener('mousemove', handleActivity);
- window.removeEventListener('mousedown', handleActivity);
- window.removeEventListener('touchstart', handleActivity);
- clearPresentationIdleTimer();
- };
- }, [
- clearPresentationIdleTimer,
- isPresenting,
- isPresentationInteractionActive,
- resetPresentationIdleTimer,
- ]);
-
- // Initialize playback engine when scene changes
+ // Auto-exit edit mode when the current scene becomes uneditable
+ // (pending generation, no scenes, currently generating).
useEffect(() => {
- // Bump epoch so any stale SSE callbacks from the previous scene are discarded
- sceneEpochRef.current++;
-
- // End any active QA/discussion session — this synchronously aborts the SSE
- // stream inside use-chat-sessions (abortControllerRef.abort()), preventing
- // stale onLiveSpeech callbacks from leaking into the new scene.
- chatAreaRef.current?.endActiveSession();
-
- // Also abort the engine-level discussion controller
- if (discussionAbortRef.current) {
- discussionAbortRef.current.abort();
- discussionAbortRef.current = null;
- }
-
- // Stop any in-flight discussion TTS audio on scene switch
- discussionTTS.cleanup();
-
- // Reset all roundtable/live state so scenes are fully isolated
- resetSceneState();
-
- if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) {
- engineRef.current = null;
- setEngineMode('idle');
-
- return;
- }
-
- // Stop previous engine
- if (engineRef.current) {
- engineRef.current.stop();
+ if (mode === 'edit' && !isEditable) {
+ setMode('playback');
}
+ }, [mode, isEditable, setMode]);
- // Get widget iframe messaging callback for interactive scenes (keyed by sceneId)
- const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id);
-
- // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging)
- const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage);
-
- // Create new PlaybackEngine
- const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, {
- onModeChange: (mode) => {
- setEngineMode(mode);
- },
- onSceneChange: (_sceneId) => {
- // Scene change handled by engine
- },
- onSpeechStart: (text) => {
- setLectureSpeech(text);
- // Add to lecture session with incrementing index for dedup
- // Chat area pacing is handled by the StreamBuffer (onTextReveal)
- if (lectureSessionIdRef.current) {
- const idx = lectureActionCounterRef.current++;
- const speechId = `speech-${Date.now()}`;
- chatAreaRef.current?.addLectureMessage(
- lectureSessionIdRef.current,
- { id: speechId, type: 'speech', text } as Action,
- idx,
- );
- // Track active bubble for highlight (Issue 8)
- const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!);
- if (msgId) setActiveBubbleId(msgId);
- }
- },
- onSpeechEnd: () => {
- // Don't clear lectureSpeech — let it persist until the next
- // onSpeechStart replaces it or the scene transitions.
- // Clearing here causes fallback to idleText (first sentence).
- setActiveBubbleId(null);
- },
- onEffectFire: (effect: Effect) => {
- // Add to lecture session with incrementing index
- if (
- lectureSessionIdRef.current &&
- (effect.kind === 'spotlight' || effect.kind === 'laser')
- ) {
- const idx = lectureActionCounterRef.current++;
- chatAreaRef.current?.addLectureMessage(
- lectureSessionIdRef.current,
- {
- id: `${effect.kind}-${Date.now()}`,
- type: effect.kind,
- elementId: effect.targetId,
- } as Action,
- idx,
- );
- }
- },
- onProactiveShow: (trigger) => {
- if (!trigger.agentId) {
- // Mutate in-place so engine.currentTrigger also gets the agentId
- // (confirmDiscussion reads agentId from the same object reference)
- trigger.agentId = pickStudentAgent();
- }
- setDiscussionTrigger(trigger);
- },
- onProactiveHide: () => {
- setDiscussionTrigger(null);
- },
- onDiscussionConfirmed: (topic, prompt, agentId) => {
- // Start SSE discussion via ChatArea
- handleDiscussionSSE(topic, prompt, agentId);
- },
- onDiscussionEnd: () => {
- // Abort any active SSE
- if (discussionAbortRef.current) {
- discussionAbortRef.current.abort();
- discussionAbortRef.current = null;
- }
- setDiscussionTrigger(null);
- // Stop any in-flight discussion TTS audio
- discussionTTS.cleanup();
- // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup)
- resetLiveState();
- // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup)
- if (!manualStopRef.current) {
- setEndFlashSessionType('discussion');
- setShowEndFlash(true);
- setTimeout(() => setShowEndFlash(false), 1800);
- }
- // If all actions are exhausted (discussion was the last action), mark
- // playback as completed so the bubble shows reset instead of play.
- if (engineRef.current?.isExhausted()) {
- setPlaybackCompleted(true);
- }
- },
- onUserInterrupt: (text) => {
- // User interrupted → start a discussion via chat
- chatAreaRef.current?.sendMessage(text);
- },
- isAgentSelected: (agentId) => {
- const ids = useSettingsStore.getState().selectedAgentIds;
- return ids.includes(agentId);
- },
- getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1,
- onComplete: () => {
- // lectureSpeech intentionally NOT cleared — last sentence stays visible
- // until scene transition (auto-play) or user restarts. Scene change
- // effect handles the reset.
- setPlaybackCompleted(true);
-
- // End lecture session on playback complete
- if (lectureSessionIdRef.current) {
- chatAreaRef.current?.endSession(lectureSessionIdRef.current);
- lectureSessionIdRef.current = null;
- }
- // Auto-play: advance to next scene after a short pause
- const { autoPlayLecture } = useSettingsStore.getState();
- if (autoPlayLecture) {
- setTimeout(() => {
- const stageState = useStageStore.getState();
- if (!useSettingsStore.getState().autoPlayLecture) return;
- const allScenes = stageState.scenes;
- const curId = stageState.currentSceneId;
- const idx = allScenes.findIndex((s) => s.id === curId);
- if (idx >= 0 && idx < allScenes.length - 1) {
- const currentScene = allScenes[idx];
- if (
- currentScene.type === 'quiz' ||
- currentScene.type === 'interactive' ||
- currentScene.type === 'pbl'
- ) {
- return;
- }
- autoStartRef.current = true;
- stageState.setCurrentSceneId(allScenes[idx + 1].id);
- } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) {
- // Last scene exhausted but next is still generating — go to pending page
- const currentScene = allScenes[idx];
- if (
- currentScene.type === 'quiz' ||
- currentScene.type === 'interactive' ||
- currentScene.type === 'pbl'
- ) {
- return;
- }
- autoStartRef.current = true;
- stageState.setCurrentSceneId(PENDING_SCENE_ID);
- }
- }, 1500);
- }
- },
- });
-
- engineRef.current = engine;
-
- // Auto-start if triggered by auto-play scene advance
- if (autoStartRef.current) {
- autoStartRef.current = false;
- (async () => {
- if (currentScene && chatAreaRef.current) {
- const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
- lectureSessionIdRef.current = sessionId;
- lectureActionCounterRef.current = 0;
- }
- engine.start();
- })();
- } else {
- // Load saved playback state and restore position (but never auto-play).
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs
- }, [currentScene]);
-
- // Cleanup on unmount
- useEffect(() => {
- const audioPlayer = audioPlayerRef.current;
- const chatArea = chatAreaRef.current;
- return () => {
- if (engineRef.current) {
- engineRef.current.stop();
- }
- audioPlayer.destroy();
- if (discussionAbortRef.current) {
- discussionAbortRef.current.abort();
- }
- discussionTTS.cleanup();
- chatArea?.endActiveSession();
- clearPresentationIdleTimer();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable
- }, []);
-
- // Sync mute state from settings store to audioPlayer
- useEffect(() => {
- audioPlayerRef.current.setMuted(ttsMuted);
- }, [ttsMuted]);
-
- // Sync volume from settings store to audioPlayer
- const ttsVolume = useSettingsStore((s) => s.ttsVolume);
- useEffect(() => {
- if (!ttsMuted) {
- audioPlayerRef.current.setVolume(ttsVolume);
- }
- }, [ttsVolume, ttsMuted]);
-
- // Sync playback speed to audio player (for live-updating current audio)
- const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
+ // Release the lock whenever we're not in edit mode (covers manual
+ // exit, auto-exit, scene becomes uneditable). The hook also self-
+ // releases on unmount / tab close.
+ const releaseEditLock = editLock.release;
useEffect(() => {
- audioPlayerRef.current.setPlaybackRate(playbackSpeed);
- }, [playbackSpeed]);
-
- /**
- * Handle discussion SSE — POST /api/chat and push events to engine
- */
- const handleDiscussionSSE = useCallback(
- async (topic: string, prompt?: string, agentId?: string) => {
- // Start discussion display in ChatArea (lecture speech is preserved independently)
- chatAreaRef.current?.startDiscussion({
- topic,
- prompt,
- agentId: agentId || 'default-1',
- });
- // Auto-switch to chat tab when discussion starts
- chatAreaRef.current?.switchToTab('chat');
- // Immediately mark streaming for synchronized stop button
- setChatIsStreaming(true);
- setChatSessionType('discussion');
- // Optimistic thinking: show thinking dots immediately (same as onMessageSend)
- setThinkingState({ stage: 'director' });
- },
- [],
- );
-
- // First speech text for idle display (extracted here for playbackView)
- const firstSpeechText = useMemo(
- () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null,
- [currentScene],
- );
-
- // Whether the speaking agent is a student (for bubble role derivation)
- const speakingStudentFlag = useMemo(() => {
- if (!speakingAgentId) return false;
- const agent = useAgentRegistry.getState().getAgent(speakingAgentId);
- return agent?.role !== 'teacher';
- }, [speakingAgentId]);
-
- // Centralised derived playback view
- const playbackView = useMemo(
- () =>
- computePlaybackView({
- engineMode,
- lectureSpeech,
- liveSpeech,
- speakingAgentId,
- thinkingState,
- isCueUser,
- isTopicPending,
- chatIsStreaming,
- discussionTrigger,
- playbackCompleted,
- idleText: firstSpeechText,
- speakingStudent: speakingStudentFlag,
- sessionType: chatSessionType,
- }),
- [
- engineMode,
- lectureSpeech,
- liveSpeech,
- speakingAgentId,
- thinkingState,
- isCueUser,
- isTopicPending,
- chatIsStreaming,
- discussionTrigger,
- playbackCompleted,
- firstSpeechText,
- speakingStudentFlag,
- chatSessionType,
- ],
- );
-
- const isTopicActive = playbackView.isTopicActive;
-
- /**
- * Gated scene switch — if a topic is active, show AlertDialog before switching.
- * Returns true if the switch was immediate, false if gated (dialog shown).
- */
- const gatedSceneSwitch = useCallback(
- (targetSceneId: string): boolean => {
- if (targetSceneId === currentSceneId) return false;
- if (isTopicActive) {
- setPendingSceneId(targetSceneId);
- return false;
- }
- setCurrentSceneId(targetSceneId);
- return true;
- },
- [currentSceneId, isTopicActive, setCurrentSceneId],
- );
-
- /** User confirmed scene switch via AlertDialog */
- const confirmSceneSwitch = useCallback(() => {
- if (!pendingSceneId) return;
- chatAreaRef.current?.endActiveSession();
- doSessionCleanup();
- setCurrentSceneId(pendingSceneId);
- setPendingSceneId(null);
- }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]);
-
- /** User cancelled scene switch via AlertDialog */
- const cancelSceneSwitch = useCallback(() => {
- setPendingSceneId(null);
- }, []);
-
- // play/pause toggle
- const handlePlayPause = useCallback(async () => {
- const engine = engineRef.current;
- if (!engine) return;
-
- const mode = engine.getMode();
- if (mode === 'playing' || mode === 'live') {
- engine.pause();
- // Pause lecture buffer so text stops immediately
- if (lectureSessionIdRef.current) {
- chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current);
- }
- } else if (mode === 'paused') {
- engine.resume();
- // Resume lecture buffer
- if (lectureSessionIdRef.current) {
- chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current);
- }
- } else {
- const wasCompleted = playbackCompleted;
- setPlaybackCompleted(false);
- // Starting playback - create/reuse lecture session
- if (currentScene && chatAreaRef.current) {
- const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
- lectureSessionIdRef.current = sessionId;
- }
- if (wasCompleted) {
- // Restart from beginning (user clicked restart after completion)
- lectureActionCounterRef.current = 0;
- engine.start();
- } else {
- // Continue from current position (e.g. after discussion end)
- engine.continuePlayback();
- }
- }
- }, [playbackCompleted, currentScene]);
-
- // get scene information
- const isPendingScene = currentSceneId === PENDING_SCENE_ID;
- const hasNextPending = generatingOutlines.length > 0;
- // True when every outline has materialized into a scene and nothing is
- // currently generating — signals the classroom has finished and the user
- // can see a completion page. Comparing scenes.length === outlines.length
- // (rather than just `scenes.length > 0`) means a partial generation with
- // some failed outlines does not falsely trigger completion.
- const isCourseComplete =
- outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0;
- const canAdvanceToPendingSlot = hasNextPending || isCourseComplete;
-
- // previous scene (gated)
- const handlePreviousScene = useCallback(() => {
- if (isPendingScene) {
- // From pending page → go to last real scene
- if (scenes.length > 0) {
- gatedSceneSwitch(scenes[scenes.length - 1].id);
- }
- return;
- }
- const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
- if (currentIndex > 0) {
- gatedSceneSwitch(scenes[currentIndex - 1].id);
- }
- }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]);
-
- // next scene (gated)
- const handleNextScene = useCallback(() => {
- if (isPendingScene) return; // Already on pending, nowhere to go
- const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
- if (currentIndex < scenes.length - 1) {
- gatedSceneSwitch(scenes[currentIndex + 1].id);
- } else if (canAdvanceToPendingSlot) {
- // On last real scene → advance to pending slot (generating or completion page)
- setCurrentSceneId(PENDING_SCENE_ID);
- }
- }, [
- currentSceneId,
- gatedSceneSwitch,
- canAdvanceToPendingSlot,
- isPendingScene,
- scenes,
- setCurrentSceneId,
- ]);
-
- const currentSceneIndex = isPendingScene
- ? scenes.length
- : scenes.findIndex((s) => s.id === currentSceneId);
- const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0);
-
- // get action information
- const totalActions = currentScene?.actions?.length || 0;
-
- // whiteboard toggle
- const handleWhiteboardToggle = () => {
- setWhiteboardOpen(!whiteboardOpen);
- };
-
- const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => {
- if (!(target instanceof HTMLElement)) return false;
-
- if (target.isContentEditable || target.closest('[contenteditable="true"]')) {
- return true;
- }
-
- return (
- target.closest(
- ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '),
- ) !== null
- );
- }, []);
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.defaultPrevented) return;
- // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser
- if (event.ctrlKey || event.metaKey || event.altKey) return;
- if (
- isPresentationShortcutTarget(event.target) ||
- isPresentationShortcutTarget(document.activeElement)
- ) {
- return;
- }
-
- switch (event.key) {
- case 'ArrowLeft':
- if (!isPresenting) return;
- event.preventDefault();
- handlePreviousScene();
- resetPresentationIdleTimer();
- break;
- case 'ArrowRight':
- if (!isPresenting) return;
- event.preventDefault();
- handleNextScene();
- resetPresentationIdleTimer();
- break;
- case ' ':
- case 'Spacebar':
- // During active QA/discussion, Roundtable owns Space for
- // buffer-level pause/resume — don't also fire engine play/pause.
- if (chatSessionType === 'qa' || chatSessionType === 'discussion') break;
- event.preventDefault();
- handlePlayPause();
- break;
- case 'Escape':
- // With keyboard.lock(), Escape no longer auto-exits fullscreen.
- // If panels are open, roundtable handles Escape (close panels).
- // If no panels are open, manually exit fullscreen.
- if (isPresenting && !isPresentationInteractionActive) {
- event.preventDefault();
- togglePresentation();
- }
- break;
- case 'ArrowUp':
- event.preventDefault();
- setTTSVolume(ttsVolume + 0.1);
- break;
- case 'ArrowDown':
- event.preventDefault();
- setTTSVolume(ttsVolume - 0.1);
- break;
- case 'm':
- case 'M':
- event.preventDefault();
- setTTSMuted(!ttsMuted);
- break;
- case 's':
- case 'S':
- event.preventDefault();
- setSidebarCollapsed(!sidebarCollapsed);
- break;
- case 'c':
- case 'C':
- event.preventDefault();
- setChatAreaCollapsed(!chatAreaCollapsed);
- break;
- default:
- break;
- }
- };
-
- window.addEventListener('keydown', onKeyDown);
- return () => window.removeEventListener('keydown', onKeyDown);
- }, [
- chatSessionType,
- chatAreaCollapsed,
- handleNextScene,
- handlePlayPause,
- handlePreviousScene,
- isPresenting,
- isPresentationInteractionActive,
- isPresentationShortcutTarget,
- resetPresentationIdleTimer,
- setChatAreaCollapsed,
- setSidebarCollapsed,
- setTTSMuted,
- setTTSVolume,
- sidebarCollapsed,
- togglePresentation,
- ttsMuted,
- ttsVolume,
- ]);
-
- // Intercept F11 to use our presentation fullscreen instead of browser fullscreen
- // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit)
- useEffect(() => {
- const onF11 = (event: KeyboardEvent) => {
- if (event.key === 'F11') {
- event.preventDefault();
- togglePresentation();
- }
- };
-
- window.addEventListener('keydown', onF11);
- return () => window.removeEventListener('keydown', onF11);
- }, [togglePresentation]);
-
- // Map engine mode to the CanvasArea's expected engine state
- const canvasEngineState = (() => {
- switch (engineMode) {
- case 'playing':
- case 'live':
- return 'playing';
- case 'paused':
- return 'paused';
- default:
- return 'idle';
- }
- })();
-
- // Build discussion request for Roundtable ProactiveCard from trigger
- const discussionRequest: DiscussionAction | null = discussionTrigger
- ? {
- type: 'discussion',
- id: discussionTrigger.id,
- topic: discussionTrigger.question,
- prompt: discussionTrigger.prompt,
- agentId: discussionTrigger.agentId || 'default-1',
- }
- : null;
-
- // Calculate scene viewer height (subtract Header's 80px height)
- const sceneViewerHeight = (() => {
- const headerHeight = isPresenting ? 0 : 80; // Header h-20 = 80px
- const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0;
- return `calc(100% - ${headerHeight + roundtableHeight}px)`;
- })();
-
+ if (mode !== 'edit') releaseEditLock();
+ }, [mode, releaseEditLock]);
+
+ const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined;
+
+ // Mode swap choreography — a clean opacity cross-fade. Both roots layer
+ // via `absolute inset-0` so they coexist for the ~280ms window without
+ // one popping out before the other arrives. The outgoing root keeps
+ // rendering its canvas during exit so `canvasStore` (the shared scale
+ // writer) doesn't briefly read zero.
+ //
+ // Deliberately NO transform (translateY) on these layers: the edit
+ // chrome hosts the Pro Switch / settings pill, which morph across the
+ // swap via `layoutId`. A transform on this ancestor distorts motion's
+ // layout measurement (the pill visibly drifts) and the blurred chrome
+ // would repaint its backdrop-filter every frame while translating. A
+ // pure fade keeps layout static so the shared elements land precisely.
return (
-
- {/* Scene Sidebar */}
-
-
- {/* Main Content Area */}
-
- {/* Header */}
- {!isPresenting && (
-
- )}
-
- {/* Canvas Area */}
-
- setSidebarCollapsed(!sidebarCollapsed)}
- onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
- onPrevSlide={handlePreviousScene}
- onNextSlide={handleNextScene}
- onPlayPause={handlePlayPause}
- onWhiteboardClose={handleWhiteboardToggle}
- isPresenting={isPresenting}
- onTogglePresentation={togglePresentation}
- showStopDiscussion={
- engineMode === 'live' ||
- (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion'))
- }
- onStopDiscussion={handleStopDiscussion}
- hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)}
- isPendingScene={isPendingScene}
- isCourseComplete={isCourseComplete}
- isGenerationFailed={
- isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id)
- }
- onRetryGeneration={
- onRetryOutline && generatingOutlines[0]
- ? () => onRetryOutline(generatingOutlines[0].id)
- : undefined
- }
- />
-
-
- {/* Roundtable Area */}
- {mode === 'playback' && (
-
+
+ {mode === 'edit' && currentScene ? (
+
+
+
+ ) : (
+
- {
- // Always clear Level-1 pause state — the closure may hold a stale
- // isDiscussionPaused value (e.g. voice input's onTranscription callback
- // captures onMessageSend before React re-renders with the updated state).
- setIsDiscussionPaused(false);
- // Clear the sticky livePausedRef so the next agent-loop buffer
- // starts unpaused. (pauseActiveLiveBuffer sets a ref that new
- // buffers inherit — must be cleared before sendMessage creates one.)
- chatAreaRef.current?.resumeActiveLiveBuffer();
- // Flush any buffered / in-flight TTS audio from the previous
- // agent turn so it doesn't leak into the next round.
- discussionTTS.cleanup();
- // Clear soft-paused state — user is continuing the topic
- if (isTopicPending) {
- setIsTopicPending(false);
- setLiveSpeech(null);
- setSpeakingAgentId(null);
- }
- // User interrupts during playback — handleUserInterrupt triggers
- // onUserInterrupt callback which already calls sendMessage, so skip
- // the direct sendMessage below to avoid sending twice.
- // Include 'paused' because onInputActivate pauses the engine before
- // the user finishes typing — without this the interrupt position
- // would never be saved and resuming after QA skips to the next sentence.
- if (
- engineRef.current &&
- (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused')
- ) {
- engineRef.current.handleUserInterrupt(msg);
- } else {
- chatAreaRef.current?.sendMessage(msg);
- }
- // Auto-switch to chat tab when user sends a message
- chatAreaRef.current?.switchToTab('chat');
- setIsCueUser(false);
- // Immediately mark streaming for synchronized stop button
- setChatIsStreaming(true);
- setChatSessionType(chatSessionType || 'qa');
- // Optimistic thinking: show thinking dots immediately so there's
- // no blank gap between userMessage expiry and the SSE thinking event.
- // The real SSE event will overwrite this with the same or updated value.
- setThinkingState({ stage: 'director' });
- }}
- onDiscussionStart={() => {
- // User clicks "Join" on ProactiveCard
- engineRef.current?.confirmDiscussion();
- }}
- onDiscussionSkip={() => {
- // User clicks "Skip" on ProactiveCard
- engineRef.current?.skipDiscussion();
- }}
- onStopDiscussion={handleStopDiscussion}
- onInputActivate={() => {
- // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering.
- // User resumes manually via Space / pause button after closing the input.
- // No isDiscussionPaused guard — always attempt to pause the buffer.
- // The return value ensures UI state stays in sync with buffer state.
- if (chatSessionType === 'qa' || chatSessionType === 'discussion') {
- const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
- if (paused) {
- discussionTTS.pause();
- setIsDiscussionPaused(true);
- }
- }
- // Also pause playback engine
- if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) {
- engineRef.current.pause();
- }
- }}
- onResumeTopic={doResumeTopic}
- onPlayPause={handlePlayPause}
- isDiscussionPaused={isDiscussionPaused}
- onDiscussionPause={() => {
- const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
- if (paused) {
- discussionTTS.pause();
- setIsDiscussionPaused(true);
- }
- }}
- onDiscussionResume={() => {
- chatAreaRef.current?.resumeActiveLiveBuffer();
- discussionTTS.resume();
- setIsDiscussionPaused(false);
- }}
- totalActions={totalActions}
- currentActionIndex={0}
- currentSceneIndex={currentSceneIndex}
- scenesCount={totalScenesCount}
- whiteboardOpen={whiteboardOpen}
- sidebarCollapsed={sidebarCollapsed}
- chatCollapsed={chatAreaCollapsed}
- onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
- onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
- onPrevSlide={handlePreviousScene}
- onNextSlide={handleNextScene}
- onWhiteboardClose={handleWhiteboardToggle}
- isPresenting={isPresenting}
- controlsVisible={controlsVisible}
- onTogglePresentation={togglePresentation}
- onPresentationInteractionChange={setIsPresentationInteractionActive}
- fullscreenContainerRef={stageRef}
+
-
+
)}
-
-
- {/* Chat Area */}
-
setActiveBubbleId(id)}
- currentSceneId={currentSceneId}
- onLiveSpeech={(text, agentId) => {
- // Capture epoch at call time — discard if scene has changed since
- const epoch = sceneEpochRef.current;
- // Use queueMicrotask to let any pending scene-switch reset settle first
- queueMicrotask(() => {
- if (sceneEpochRef.current !== epoch) return; // stale — scene changed
- setLiveSpeech(text);
- if (agentId !== undefined) {
- setSpeakingAgentId(agentId);
- }
- if (text !== null || agentId) {
- setChatIsStreaming(true);
- setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null);
- setIsTopicPending(false);
- } else if (text === null && agentId === null) {
- setChatIsStreaming(false);
- // Don't clear chatSessionType here — it's needed by the stop
- // button when director cues user (cue_user → done → liveSpeech null).
- // It gets properly cleared in doSessionCleanup and scene change.
- }
- });
- }}
- onSpeechProgress={(ratio) => {
- const epoch = sceneEpochRef.current;
- queueMicrotask(() => {
- if (sceneEpochRef.current !== epoch) return;
- setSpeechProgress(ratio);
- });
- }}
- onThinking={(state) => {
- const epoch = sceneEpochRef.current;
- queueMicrotask(() => {
- if (sceneEpochRef.current !== epoch) return;
- setThinkingState(state);
- });
- }}
- onCueUser={(_fromAgentId, _prompt) => {
- setIsCueUser(true);
- }}
- onLiveSessionError={handleLiveSessionError}
- onStopSession={doSessionCleanup}
- onSegmentSealed={discussionTTS.handleSegmentSealed}
- shouldHoldAfterReveal={discussionTTS.shouldHold}
+
+
-
- {/* Scene switch confirmation dialog */}
- {
- if (!open) cancelSceneSwitch();
- }}
- >
-
-
- {t('stage.confirmSwitchTitle')}
-
- {/* Top accent bar */}
-
-
-
- {/* Icon */}
-
- {/* Title */}
-
- {t('stage.confirmSwitchTitle')}
-
- {/* Description */}
-
- {t('stage.confirmSwitchMessage')}
-
-
-
-
-
- {t('common.cancel')}
-
-
- {t('common.confirm')}
-
-
-
-
);
}
diff --git a/components/stage/header-controls.tsx b/components/stage/header-controls.tsx
new file mode 100644
index 0000000000..0d7b234ec8
--- /dev/null
+++ b/components/stage/header-controls.tsx
@@ -0,0 +1,309 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ Archive,
+ Download,
+ FileDown,
+ Loader2,
+ Monitor,
+ Moon,
+ Package,
+ Settings,
+ Sun,
+} from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { useTheme } from '@/lib/hooks/use-theme';
+import { useStageStore } from '@/lib/store';
+import { useMediaGenerationStore } from '@/lib/store/media-generation';
+import { useExportPPTX } from '@/lib/export/use-export-pptx';
+import { useExportClassroom } from '@/lib/export/use-export-classroom';
+import { LanguageSwitcher } from '../language-switcher';
+import { SettingsDialog } from '../settings';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+import type { StageMode } from '@/lib/types/stage';
+
+interface HeaderControlsProps {
+ readonly mode?: StageMode;
+ readonly canEdit?: boolean;
+ readonly onToggleEditMode?: () => void;
+ /**
+ * `default` — the chunky h-9 pill used in the playback Stage Header.
+ * `compact` — slightly tighter padding for embedding in CommandBar's
+ * right slot (Pro mode chrome already eats height, so the pill backs
+ * off ring weight / blur to keep the CommandBar quiet).
+ */
+ readonly variant?: 'default' | 'compact';
+}
+
+/**
+ * Stage-level global controls: language picker, theme picker, settings
+ * modal trigger, and the Pro Switch. Extracted out of `Header` so the
+ * Pro mode CommandBar can absorb the same affordances and the playback
+ * Header doesn't need to stay mounted just to host them — Pro mode
+ * therefore lands on a single top-chrome bar instead of stacking the
+ * Stage Header above the EditShell CommandBar.
+ *
+ * Only one instance is ever mounted at a time (Stage renders Header
+ * for playback and EditShell.CommandBar's trailing slot for edit, but
+ * never both), so dropdown / dialog state and refs stay co-located
+ * here without cross-instance leakage.
+ */
+export function HeaderControls({
+ mode,
+ canEdit,
+ onToggleEditMode,
+ variant = 'default',
+}: HeaderControlsProps) {
+ const { t } = useI18n();
+ const { theme, setTheme } = useTheme();
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ // Export plumbing — uses the stage / media task stores to check
+ // readiness, then hands off to the export hooks. Available in both
+ // playback and edit chrome so the icon's screen position is stable
+ // across mode swaps (was previously in `Header` only, missing from
+ // CommandBar's right cluster).
+ const scenes = useStageStore((s) => s.scenes);
+ const generatingOutlines = useStageStore((s) => s.generatingOutlines);
+ const failedOutlines = useStageStore((s) => s.failedOutlines);
+ const mediaTasks = useMediaGenerationStore((s) => s.tasks);
+ const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
+ const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom();
+ const [exportMenuOpen, setExportMenuOpen] = useState(false);
+ const exportRef = useRef(null);
+
+ const canExport =
+ scenes.length > 0 &&
+ generatingOutlines.length === 0 &&
+ failedOutlines.length === 0 &&
+ Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed');
+
+ const handleClickOutside = useCallback(
+ (e: MouseEvent) => {
+ if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) {
+ setExportMenuOpen(false);
+ }
+ },
+ [exportMenuOpen],
+ );
+ useEffect(() => {
+ if (!exportMenuOpen) return;
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [exportMenuOpen, handleClickOutside]);
+
+ const compact = variant === 'compact';
+
+ // Self-contained spacing so the control cluster is identical regardless of
+ // host. The playback Header (`gap-4`) and the edit CommandBar's trailing
+ // slot (`gap-2`) would otherwise impose different inter-control spacing on
+ // these fragment children, making the pill/switch/export cluster visibly
+ // shift width and position across the mode swap. A fixed internal gap keeps
+ // the cluster pixel-stable; both hosts pad to `px-8`, so the right edge
+ // anchors identically too.
+ return (
+
+
+ {/* Language — Radix DropdownMenu so its menu portals to body
+ and never gets clipped by an ancestor's overflow-hidden. */}
+
+
+ {/* Theme — same Portal-backed DropdownMenu pattern. */}
+
+
+
+ {theme === 'light' && }
+ {theme === 'dark' && }
+ {theme === 'system' && }
+
+
+
+ setTheme('light')}
+ className={cn(
+ 'cursor-pointer gap-2',
+ theme === 'light' &&
+ 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
+ )}
+ >
+
+ {t('settings.themeOptions.light')}
+
+ setTheme('dark')}
+ className={cn(
+ 'cursor-pointer gap-2',
+ theme === 'dark' &&
+ 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
+ )}
+ >
+
+ {t('settings.themeOptions.dark')}
+
+ setTheme('system')}
+ className={cn(
+ 'cursor-pointer gap-2',
+ theme === 'system' &&
+ 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
+ )}
+ >
+
+ {t('settings.themeOptions.system')}
+
+
+
+
+ {/* Settings */}
+ setSettingsOpen(true)}
+ className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
+ aria-label={t('settings.title')}
+ >
+
+
+
+
+ {/* Pro Switch — toggle property: on/off both clickable, not a
+ one-way "Done" button. Disabled only when the current scene
+ can't be entered (pending/generating/etc.). Fades in with its
+ host bar on the mode swap (no cross-bar layoutId morph: the
+ playback Header and edit CommandBar have different left-side
+ widths, so morphing made the pill visibly drift). */}
+ {onToggleEditMode && (
+
+
+ {t('edit.proMode')}
+
+
+
+ )}
+
+ {/* Export / Download — lives to the right of the Pro Switch.
+ Not a settings function so it does not belong inside the
+ settings pill; kept as a separate sibling sitting between the
+ Pro Switch and the right edge of the chrome. */}
+
+
{
+ if (canExport && !isExporting && !isExportingZip) {
+ setExportMenuOpen(!exportMenuOpen);
+ }
+ }}
+ disabled={!canExport || isExporting || isExportingZip}
+ title={
+ canExport
+ ? isExporting || isExportingZip
+ ? t('export.exporting')
+ : t('export.pptx')
+ : t('share.notReady')
+ }
+ className={cn(
+ 'shrink-0 p-2 rounded-full transition-all',
+ canExport && !isExporting && !isExportingZip
+ ? 'text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm'
+ : 'text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50',
+ )}
+ aria-label={t('export.pptx')}
+ >
+ {isExporting || isExportingZip ? (
+
+ ) : (
+
+ )}
+
+ {exportMenuOpen && (
+
+
{
+ setExportMenuOpen(false);
+ exportPPTX();
+ }}
+ className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
+ >
+
+ {t('export.pptx')}
+
+
{
+ setExportMenuOpen(false);
+ exportResourcePack();
+ }}
+ className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
+ >
+
+
+
{t('export.resourcePack')}
+
+ {t('export.resourcePackDesc')}
+
+
+
+
{
+ setExportMenuOpen(false);
+ exportClassroomZip();
+ }}
+ disabled={isExportingZip}
+ className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
+ >
+
+
+
{t('export.classroomZip')}
+
+ {t('export.classroomZipDesc')}
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/stage/scene-renderer.tsx b/components/stage/scene-renderer.tsx
index 8b584fe4df..b0d86b307c 100644
--- a/components/stage/scene-renderer.tsx
+++ b/components/stage/scene-renderer.tsx
@@ -12,6 +12,11 @@ interface SceneRendererProps {
readonly mode: StageMode;
}
+/**
+ * Playback scene dispatcher. In Pro (edit) mode, Stage renders EditShell
+ * directly as a top-level takeover — SceneRenderer is only on the playback
+ * path, so it does not branch on `mode === 'edit'`.
+ */
export function SceneRenderer({ scene, mode }: SceneRendererProps) {
const renderer = useMemo(() => {
switch (scene.type) {
diff --git a/components/stage/scene-thumbnail-content.tsx b/components/stage/scene-thumbnail-content.tsx
new file mode 100644
index 0000000000..079b098143
--- /dev/null
+++ b/components/stage/scene-thumbnail-content.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { BookOpen, Globe } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide';
+import { ThumbnailInteractive } from '@/components/slide-renderer/components/ThumbnailInteractive';
+import type { Scene, SlideContent, InteractiveContent } from '@/lib/types/stage';
+
+interface SceneThumbnailContentProps {
+ readonly scene: Scene;
+ /**
+ * Inner thumbnail pixel size (width) for ThumbnailInteractive's iframe
+ * scaling. Optional — when omitted, ThumbnailInteractive falls back to
+ * a fixed default. The slide branch ignores `size` entirely and always
+ * uses auto-measure (ResizeObserver on the parent container), so editor
+ * rail drag never threads a per-frame pixel width through this prop.
+ */
+ readonly size?: number;
+ readonly viewportSize: number;
+ readonly viewportRatio: number;
+ /** Skip the live live-render path (slide + interactive iframe) when
+ * the tile is far off-screen. */
+ readonly visible?: boolean;
+}
+
+const INTERACTIVE_FALLBACK_SIZE = 200;
+
+/**
+ * Shared per-scene-type thumbnail render — slide gets a live
+ * ThumbnailSlide, quiz/interactive/pbl get the same stylized mockups
+ * that ship in playback SceneSidebar. Extracted so editor ThumbItem
+ * and playback SceneSidebar render identical content (the user
+ * specifically asked for parity instead of the previous icon-only stub
+ * in Pro mode).
+ *
+ * Caller is responsible for the outer aspect-video card + ring; this
+ * component only paints the inner content centered to fill.
+ */
+export function SceneThumbnailContent({
+ scene,
+ size,
+ viewportSize,
+ viewportRatio,
+ visible = true,
+}: SceneThumbnailContentProps) {
+ if (scene.type === 'slide') {
+ const slideContent = scene.content as SlideContent;
+ return (
+
+ );
+ }
+
+ if (scene.type === 'quiz') {
+ return (
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (scene.type === 'interactive') {
+ const interactiveContent = scene.content as InteractiveContent;
+ if (interactiveContent.html && visible) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+ if (scene.type === 'pbl') {
+ return (
+
+
+
+ {[0, 1, 2].map((col) => (
+
+
+ {Array.from({ length: col === 0 ? 3 : col === 1 ? 2 : 1 }).map((_, i) => (
+
+ ))}
+
+ ))}
+
+
+ );
+ }
+
+ // Exhaustive guard — Scene's `type` union is fully handled above.
+ // The fallback only fires for forward-compat scenarios where a new
+ // scene type is loaded by an older client.
+ const unknownType = (scene as { type: string }).type;
+ return (
+
+
+
+ {unknownType}
+
+
+ );
+}
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
index f80793912e..a97b7c37a5 100644
--- a/components/ui/popover.tsx
+++ b/components/ui/popover.tsx
@@ -9,6 +9,8 @@ const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
const PopoverContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
@@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef<
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-export { Popover, PopoverTrigger, PopoverContent };
+export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
diff --git a/configs/font.ts b/configs/font.ts
index 7dfc1416ab..387fab4fe2 100644
--- a/configs/font.ts
+++ b/configs/font.ts
@@ -1,31 +1,39 @@
-export const FONTS = [
- { label: '默认字体', value: '' },
- { label: '思源黑体', value: 'SourceHanSans' },
- { label: '思源宋体', value: 'SourceHanSerif' },
- { label: '文鼎PL楷体', value: 'WenDingPLKaiTi' },
- { label: '文鼎PL宋体', value: 'WenDingPLSongTi' },
- { label: '朱雀仿宋', value: 'ZhuqueFangSong' },
- { label: '霞鹜文楷', value: 'LXGWWenKai' },
- { label: '阿里巴巴普惠体', value: 'AlibabaPuHuiTi' },
- { label: 'MiSans', value: 'MiSans' },
- { label: '得意黑', value: 'DeYiHei' },
- { label: '仓耳小丸子', value: 'CangerXiaowanzi' },
- { label: '优设标题黑', value: 'YousheTitleBlack' },
- { label: '峰广明锐体', value: 'FengguangMingrui' },
- { label: '摄图摩登小方体', value: 'ShetuModernSquare' },
- { label: '站酷快乐体', value: 'ZcoolHappy' },
- { label: '字制区喜脉体', value: 'ZizhiQuXiMai' },
- { label: '素材集市康康体', value: 'SucaiJishiKangkang' },
- { label: '素材集市酷方体', value: 'SucaiJishiCoolSquare' },
- { label: '途牛类圆体', value: 'TuniuRounded' },
- { label: '锐字真言体', value: 'RuiziZhenyan' },
- { label: 'Source Serif 4', value: 'SourceSerif4' },
- { label: 'JetBrains Mono', value: 'JetBrainsMono' },
- { label: 'Literata', value: 'Literata' },
+/**
+ * Fonts offered in the slide editor's text-format picker.
+ *
+ * Every entry is a real web font: Inter via `next/font` (`app/layout.tsx`),
+ * the rest via `@fontsource` packages loaded in `app/editor-fonts.ts`.
+ * `@fontsource` `unicode-range`-subsets the CJK faces, so they download lazily
+ * per glyph range — a picked font actually renders.
+ *
+ * Adding a font: install its `@fontsource` package, import the weight CSS in
+ * `app/editor-fonts.ts`, then add an entry here whose `value` matches the
+ * package's `@font-face` family name.
+ */
+export interface FontEntry {
+ /** Display name; rendered as the fallback when `labelKey` is absent. */
+ readonly label: string;
+ /** CSS font-family value; "" means the element's own default (no override). */
+ readonly value: string;
+ /** Optional i18n key — preferred over `label` when present. */
+ readonly labelKey?: string;
+}
+
+export const FONTS: readonly FontEntry[] = [
+ { labelKey: 'edit.text.fontDefault', label: 'Default', value: '' },
+ // Chinese
+ { label: '思源黑体', value: 'Noto Sans SC' },
+ { label: '思源宋体', value: 'Noto Serif SC' },
+ { label: '霞鹜文楷', value: 'LXGW WenKai' },
+ { label: '站酷快乐体', value: 'ZCOOL KuaiLe' },
+ // Latin
{ label: 'Inter', value: 'Inter' },
{ label: 'Roboto', value: 'Roboto' },
- { label: 'Open Sans', value: 'OpenSans' },
+ { label: 'Open Sans', value: 'Open Sans' },
{ label: 'Montserrat', value: 'Montserrat' },
- { label: 'Source Sans Pro', value: 'SourceSansPro' },
+ { label: 'Source Sans 3', value: 'Source Sans 3' },
{ label: 'Merriweather', value: 'Merriweather' },
+ { label: 'Literata', value: 'Literata' },
+ { label: 'Source Serif 4', value: 'Source Serif 4' },
+ { label: 'JetBrains Mono', value: 'JetBrains Mono' },
];
diff --git a/e2e/tests/slide-scene-creation-gate.spec.ts b/e2e/tests/slide-scene-creation-gate.spec.ts
new file mode 100644
index 0000000000..f08c754234
--- /dev/null
+++ b/e2e/tests/slide-scene-creation-gate.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect } from '../fixtures/base';
+import { HomePage } from '../pages/home.page';
+import { GenerationPreviewPage } from '../pages/generation-preview.page';
+import { ClassroomPage } from '../pages/classroom.page';
+import { createSettingsStorage } from '../fixtures/test-data/settings';
+
+const SETTINGS_STORAGE = createSettingsStorage({ sidebarCollapsed: false });
+
+/**
+ * MVP gate: the slide editor must not expose scene-creation (blank insert +
+ * duplicate) because editor-created scenes have no playback actions and get
+ * skipped during playback. Reorder / delete / rename stay. This test fails if
+ * SCENE_CREATION_ENABLED is flipped back on without removing the gate.
+ */
+test.describe('Slide editor — scene-creation gate (MVP)', () => {
+ test.beforeEach(async ({ page, mockApi }) => {
+ await page.addInitScript((settings) => {
+ localStorage.setItem('settings-storage', settings);
+ }, SETTINGS_STORAGE);
+ await mockApi.setupGenerationMocks();
+ });
+
+ test('Pro mode rail hides insert + duplicate, keeps rename/delete', async ({
+ page,
+ }, testInfo) => {
+ // Generate a classroom through the mocked pipeline.
+ const home = new HomePage(page);
+ await home.goto();
+ await home.fillRequirement('讲解光合作用');
+ await home.submit();
+ await page.waitForURL(/\/generation-preview/);
+
+ const preview = new GenerationPreviewPage(page);
+ await preview.waitForRedirectToClassroom();
+ expect(page.url()).toMatch(/\/classroom\//);
+
+ const classroom = new ClassroomPage(page);
+ await classroom.waitForLoaded();
+ await expect(classroom.sidebarScenes.first()).toBeVisible({ timeout: 10_000 });
+
+ // Enter Pro mode via the header Pro Switch.
+ await page.getByRole('switch').click();
+
+ // The slide nav rail replaces the playback sidebar in Pro mode.
+ const rail = page.getByTestId('slide-nav-rail');
+ await expect(rail).toBeVisible({ timeout: 10_000 });
+
+ // Gate 1: no inter-thumb "+" insertion zones.
+ await expect(page.getByTestId('slide-nav-insert')).toHaveCount(0);
+
+ // Gate 2: the per-slide overflow menu has exactly Rename + Delete
+ // (Duplicate removed). Counting menuitems keeps the assertion
+ // locale-independent.
+ await page.getByTestId('slide-nav-more').first().click();
+ await expect(page.getByRole('menuitem')).toHaveCount(2);
+
+ // Visual evidence of the gated rail, attached to the Playwright report.
+ await testInfo.attach('pro-rail-gated', {
+ body: await rail.screenshot(),
+ contentType: 'image/png',
+ });
+ });
+});
diff --git a/lib/config/feature-flags.ts b/lib/config/feature-flags.ts
new file mode 100644
index 0000000000..f08fd5a540
--- /dev/null
+++ b/lib/config/feature-flags.ts
@@ -0,0 +1,22 @@
+/**
+ * Build-time feature flags. Values come from `NEXT_PUBLIC_*` env vars,
+ * which Next.js inlines at build time so they are safe to read from
+ * client components.
+ *
+ * Truthy values: `'true'` or `'1'`. Anything else (including unset) is
+ * treated as disabled.
+ */
+
+function readBoolean(envValue: string | undefined): boolean {
+ return envValue === 'true' || envValue === '1';
+}
+
+/**
+ * MAIC Editor (Pro mode) gate. Default OFF — gates only the Pro toggle
+ * affordance in `Header`. The `StageMode` type union is unaffected so
+ * existing code paths typecheck identically with the flag in either
+ * state.
+ */
+export function isMaicEditorEnabled(): boolean {
+ return readBoolean(process.env.NEXT_PUBLIC_MAIC_EDITOR_ENABLED);
+}
diff --git a/lib/contexts/scene-context.tsx b/lib/contexts/scene-context.tsx
index 704654fcb6..24b175a61c 100644
--- a/lib/contexts/scene-context.tsx
+++ b/lib/contexts/scene-context.tsx
@@ -25,17 +25,40 @@ interface SceneContextValue {
const SceneContext = createContext(null);
+/**
+ * Controlled data source for `SceneProvider`. Edit surfaces own a private
+ * SlideEditHistory (undo/redo, op stream, autosave) and must NOT write
+ * edits straight back into the live stage store; they pass a controller so
+ * the unmodified slide renderer reads/writes the surface's staged content
+ * instead. Omitting `controller` keeps the original stage-store-backed
+ * behavior unchanged for the playback path.
+ */
+export interface SceneDataController {
+ sceneId: string;
+ sceneType: Scene['type'];
+ getSnapshot: () => T;
+ updateSceneData: (updater: (draft: T) => void) => void;
+}
+
/**
* Generic Scene Provider
- * Provides current scene data and update methods to child components
- * Automatically syncs changes back to stageStore
+ * Provides current scene data and update methods to child components.
+ * Uncontrolled (default): syncs changes back to stageStore.
+ * Controlled (`controller` prop): reads/writes a caller-owned data source
+ * (used by edit surfaces that stage edits in their own history).
*
* Usage:
*
* // Uses useSceneData()
*
*/
-export function SceneProvider({ children }: { children: React.ReactNode }) {
+export function SceneProvider({
+ children,
+ controller,
+}: {
+ children: React.ReactNode;
+ controller?: SceneDataController;
+}) {
// Subscribe to current scene
const currentScene = useStageStore((state) => {
if (!state.currentSceneId) return null;
@@ -44,9 +67,9 @@ export function SceneProvider({ children }: { children: React.ReactNode }) {
const updateScene = useStageStore((state) => state.updateScene);
- const sceneId = currentScene?.id || '';
- const sceneType = currentScene?.type || 'slide';
- const sceneData = currentScene?.content || null;
+ const sceneId = controller ? controller.sceneId : currentScene?.id || '';
+ const sceneType = controller ? controller.sceneType : currentScene?.type || 'slide';
+ const sceneData = controller ? controller.getSnapshot() : currentScene?.content || null;
// Listeners for scene data changes
const listenersRef = useRef(new Set<() => void>());
@@ -69,8 +92,8 @@ export function SceneProvider({ children }: { children: React.ReactNode }) {
listenersRef.current.forEach((listener) => listener());
}, [sceneData]);
- // Update scene data with Immer
- const updateSceneData = useCallback(
+ // Update scene data with Immer (uncontrolled: write back to stage store)
+ const storeUpdateSceneData = useCallback(
(updater: (draft: unknown) => void) => {
if (!currentScene) return;
@@ -82,6 +105,10 @@ export function SceneProvider({ children }: { children: React.ReactNode }) {
[currentScene, updateScene],
);
+ const updateSceneData = controller
+ ? (controller.updateSceneData as (updater: (draft: unknown) => void) => void)
+ : storeUpdateSceneData;
+
const value = useMemo(
() => ({
sceneId,
@@ -94,8 +121,9 @@ export function SceneProvider({ children }: { children: React.ReactNode }) {
[sceneId, sceneType, sceneData, updateSceneData, subscribe, getSnapshot],
);
- // Don't render anything if there's no scene - let parent component handle this
- if (!currentScene) {
+ // Uncontrolled with no scene: render nothing (parent handles it).
+ // Controlled: the caller owns the data, so always render.
+ if (!controller && !currentScene) {
return null;
}
diff --git a/lib/edit/deleted-scene-recycle.ts b/lib/edit/deleted-scene-recycle.ts
new file mode 100644
index 0000000000..ece12e4501
--- /dev/null
+++ b/lib/edit/deleted-scene-recycle.ts
@@ -0,0 +1,41 @@
+import { create } from 'zustand';
+import type { Scene } from '@/lib/types/stage';
+
+/**
+ * Single-slot recycle bin for the Pro mode slide-nav rail's toast-undo.
+ *
+ * When the user deletes a slide from the rail, the deleted scene + its
+ * original array index are pushed here so a "Undo" affordance in the
+ * delete toast can restore the scene at its prior position. The slot
+ * holds at most one entry — a subsequent delete evicts the previous
+ * pending undo (matching Figma's recycle semantics).
+ *
+ * Restoring the scene happens at the call site by re-inserting it into
+ * `useStageStore.scenes` at the recorded index; this store only owns
+ * the snapshot, not the restoration logic.
+ */
+
+interface RecycleEntry {
+ readonly scene: Scene;
+ readonly index: number;
+ /** Cleared if the auto-dismiss timer has already fired. */
+ readonly stageId: string;
+}
+
+interface DeletedSceneRecycleState {
+ pending: RecycleEntry | null;
+ capture: (scene: Scene, index: number) => void;
+ consume: () => RecycleEntry | null;
+ clear: () => void;
+}
+
+export const useDeletedSceneRecycle = create()((set, get) => ({
+ pending: null,
+ capture: (scene, index) => set({ pending: { scene, index, stageId: scene.stageId } }),
+ consume: () => {
+ const entry = get().pending;
+ if (entry) set({ pending: null });
+ return entry;
+ },
+ clear: () => set({ pending: null }),
+}));
diff --git a/lib/edit/edit-mode-lock.ts b/lib/edit/edit-mode-lock.ts
new file mode 100644
index 0000000000..4163566288
--- /dev/null
+++ b/lib/edit/edit-mode-lock.ts
@@ -0,0 +1,113 @@
+/**
+ * Cross-tab edit-mode lock backed by localStorage. Ensures at most one
+ * tab in this browser owns "edit mode" for a given course at a time.
+ *
+ * Protocol:
+ * - Each tab generates a stable `tabId` once and reuses it for the
+ * session.
+ * - On entering edit mode: `tryAcquireEditLock(courseId, tabId)`. If
+ * it returns `false`, another tab is editing and the caller refuses
+ * entry (typically via `MultiTabEditConflictPrompt`).
+ * - While in edit mode: caller refreshes the lock periodically with
+ * `refreshEditLock(courseId, tabId)` (a heartbeat). The default
+ * `LOCK_STALE_MS` is three heartbeat intervals so a crashed tab's
+ * lock self-clears.
+ * - On exiting edit mode (or tab unload): `releaseEditLock(courseId,
+ * tabId)`. Release is a no-op if some other tab now holds it,
+ * preventing a stale release from trampling the new owner.
+ *
+ * All helpers swallow storage failures so the editor degrades to
+ * single-tab-only rather than crashing in private mode / when quota is
+ * exceeded.
+ */
+
+const KEY_PREFIX = 'maic-editor:edit-lock';
+export const LOCK_HEARTBEAT_MS = 5_000;
+export const LOCK_STALE_MS = LOCK_HEARTBEAT_MS * 3;
+
+export interface EditLockState {
+ readonly tabId: string;
+ readonly timestamp: number;
+}
+
+export function editLockKey(courseId: string): string {
+ return `${KEY_PREFIX}:${courseId}`;
+}
+
+export function readEditLock(courseId: string): EditLockState | null {
+ try {
+ const raw = localStorage.getItem(editLockKey(courseId));
+ if (raw === null) return null;
+ const parsed = JSON.parse(raw) as EditLockState;
+ if (typeof parsed?.tabId !== 'string' || typeof parsed?.timestamp !== 'number') {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function writeEditLock(courseId: string, state: EditLockState): void {
+ try {
+ localStorage.setItem(editLockKey(courseId), JSON.stringify(state));
+ } catch {
+ // Quota / disabled — caller falls back to single-tab semantics.
+ }
+}
+
+export function isEditLockHeldByOther(
+ courseId: string,
+ ownTabId: string,
+ now: number = Date.now(),
+): boolean {
+ const state = readEditLock(courseId);
+ if (state === null) return false;
+ if (state.tabId === ownTabId) return false;
+ return now - state.timestamp < LOCK_STALE_MS;
+}
+
+/**
+ * Atomically acquire the edit lock for this course. Returns `false` if
+ * another tab is the current fresh owner; in that case the caller must
+ * NOT enter edit mode.
+ */
+export function tryAcquireEditLock(
+ courseId: string,
+ ownTabId: string,
+ now: number = Date.now(),
+): boolean {
+ if (isEditLockHeldByOther(courseId, ownTabId, now)) return false;
+ writeEditLock(courseId, { tabId: ownTabId, timestamp: now });
+ return true;
+}
+
+/**
+ * Heartbeat update — refresh the timestamp so other tabs don't decide
+ * the lock has gone stale. Idempotent; no-op if another tab has taken
+ * ownership.
+ */
+export function refreshEditLock(
+ courseId: string,
+ ownTabId: string,
+ now: number = Date.now(),
+): void {
+ const state = readEditLock(courseId);
+ if (state !== null && state.tabId !== ownTabId) return;
+ writeEditLock(courseId, { tabId: ownTabId, timestamp: now });
+}
+
+/**
+ * Release the lock only if we still own it. Prevents a delayed
+ * `releaseEditLock` from a previous edit session from clobbering a new
+ * owner that came in after the lock went stale.
+ */
+export function releaseEditLock(courseId: string, ownTabId: string): void {
+ try {
+ const state = readEditLock(courseId);
+ if (state === null || state.tabId !== ownTabId) return;
+ localStorage.removeItem(editLockKey(courseId));
+ } catch {
+ // ignore
+ }
+}
diff --git a/lib/edit/element-id.ts b/lib/edit/element-id.ts
new file mode 100644
index 0000000000..b264a191f8
--- /dev/null
+++ b/lib/edit/element-id.ts
@@ -0,0 +1,6 @@
+import { nanoid } from 'nanoid';
+
+/** Stable element id for editor-inserted elements. */
+export function createElementId(prefix: string): string {
+ return `${prefix}-${nanoid(8)}`;
+}
diff --git a/lib/edit/noop-surface.tsx b/lib/edit/noop-surface.tsx
new file mode 100644
index 0000000000..41c0de19cd
--- /dev/null
+++ b/lib/edit/noop-surface.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { Eye } from 'lucide-react';
+import { useMemo } from 'react';
+import { SceneRenderer } from '@/components/stage/scene-renderer';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { useStageStore } from '@/lib/store/stage';
+import type { Scene, SceneContent } from '@/lib/types/stage';
+import type { SceneEditorSurface, SurfaceState } from './scene-editor-surface';
+
+/**
+ * NOOP_SURFACE — the read-only fallback surface used by the shell when no
+ * editor surface is registered for the current `scene.type` (today: quiz /
+ * interactive / pbl). The shell resolves `surface ?? NOOP_SURFACE`, so it
+ * always renders a single, structurally stable ` ` regardless of scene
+ * type. Switching from a slide to a non-slide scene therefore only swaps the
+ * `surface.CanvasComponent` inside the frame — `` and the
+ * `leftRail` slot stay mounted, eliminating the chrome remount flicker that
+ * the previous two-component-types branch caused.
+ *
+ * The canvas is `SceneRenderer mode="playback"` — feature-parity with the
+ * playback surface (interactive iframes load, quiz options render, PBL board
+ * paints) — plus a small "read-only" pill so the user knows why their
+ * formatting affordances are gone.
+ *
+ * `sceneType` is a placeholder ('slide'); NOOP_SURFACE is never `register()`d,
+ * only used as a fallback from `resolve(...) ?? NOOP_SURFACE`. The field is
+ * required by the surface contract but its value is never read in this path.
+ */
+
+function NoopCanvas() {
+ const scenes = useStageStore.use.scenes();
+ const currentSceneId = useStageStore.use.currentSceneId();
+ const scene = useMemo(
+ () => scenes.find((s) => s.id === currentSceneId) ?? null,
+ [scenes, currentSceneId],
+ );
+
+ if (!scene) return null;
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function ReadOnlyBadge({ sceneType }: { readonly sceneType: Scene['type'] }) {
+ const { t } = useI18n();
+ return (
+
+
+
+ {t('edit.readOnlyBadge', { type: t(`edit.sceneType.${sceneType}`) })}
+
+
+ );
+}
+
+const EMPTY_STATE: SurfaceState = {
+ content: {} as SceneContent,
+ selection: undefined,
+ hasSelection: false,
+ insertItems: [],
+ floatingActions: [],
+ commands: [],
+ hints: [],
+};
+
+function useNoopSurfaceState(): SurfaceState {
+ // No state, no subscriptions — the chrome shows nothing surface-specific for
+ // read-only scene types. Returning the module-level constant keeps the hook
+ // signature minimal (zero internal hooks) and equality-stable across renders.
+ return EMPTY_STATE;
+}
+
+export const NOOP_SURFACE: SceneEditorSurface = {
+ sceneType: 'slide',
+ CanvasComponent: NoopCanvas,
+ useSurfaceState: useNoopSurfaceState,
+};
diff --git a/lib/edit/preload-editor.ts b/lib/edit/preload-editor.ts
new file mode 100644
index 0000000000..deaab205ab
--- /dev/null
+++ b/lib/edit/preload-editor.ts
@@ -0,0 +1,27 @@
+/**
+ * Lazily load the editor-only side effects, keeping them out of the
+ * flag-off classroom/playback bundle:
+ * 1. `editor-fonts` — ~23 @fontsource font-face tables the slide font
+ * picker needs (CSS side effect).
+ * 2. `surfaces/slide` — registers the slide SceneEditorSurface into
+ * `sceneEditorRegistry` so EditShell can resolve it (otherwise it
+ * falls back to NOOP_SURFACE, i.e. a read-only flash).
+ *
+ * Called from the Pro Switch handler BEFORE flipping into edit mode, so
+ * the dynamic chunk is already downloaded/registered by the time the
+ * edit chrome mounts and animates in — no mid-animation "content pops in"
+ * jank, and the slide surface is registered before EditShell reads the
+ * registry. The promise is cached so repeated toggles and any belt-and-
+ * suspenders caller share one in-flight import.
+ */
+let editorReady: Promise | null = null;
+
+export function preloadEditor(): Promise {
+ if (!editorReady) {
+ editorReady = Promise.all([
+ import('@/app/editor-fonts'),
+ import('@/components/edit/surfaces/slide'),
+ ]).then(() => undefined);
+ }
+ return editorReady;
+}
diff --git a/lib/edit/regen-lock.ts b/lib/edit/regen-lock.ts
new file mode 100644
index 0000000000..85da39f58d
--- /dev/null
+++ b/lib/edit/regen-lock.ts
@@ -0,0 +1,32 @@
+/**
+ * AI regeneration lock predicate — the reverse direction of #564's
+ * auto-exit. `isCurrentSceneEditable` blocks ENTERING edit mode on a
+ * generating scene; this predicate is the check regenerate-this-scene
+ * call sites perform before STARTING generation, so a scene that is
+ * currently being edited cannot have its content silently replaced.
+ *
+ * Pure function — caller pulls `mode` + `currentSceneId` from the stage
+ * store and provides the candidate sceneId. Pessimistic semantics
+ * (refuse, do not queue) match the v0 design; callers surface the
+ * refusal however makes sense locally (toast, retry-later button, etc.)
+ *
+ * Wiring (slide-surface PR / future regen entry points):
+ * - `useSceneGenerator.retrySingleOutline` calls this before kicking
+ * off content generation; if locked, returns early.
+ * - Any future "regenerate a successful scene" feature does the same.
+ * - Current `retrySingleOutline` only operates on failed outlines and
+ * so cannot structurally hit this guard, but the pattern is in
+ * place for the moment a successful-scene regen ships.
+ */
+
+import type { StageMode } from '@/lib/types/stage';
+
+export interface SceneEditLockState {
+ readonly sceneId: string;
+ readonly mode: StageMode;
+ readonly currentSceneId: string | null;
+}
+
+export function isSceneEditLocked(state: SceneEditLockState): boolean {
+ return state.mode === 'edit' && state.currentSceneId === state.sceneId;
+}
diff --git a/lib/edit/scene-creation-enabled.ts b/lib/edit/scene-creation-enabled.ts
new file mode 100644
index 0000000000..dcdcdb9c10
--- /dev/null
+++ b/lib/edit/scene-creation-enabled.ts
@@ -0,0 +1,11 @@
+/**
+ * Editor-created slide scenes (blank insert + duplicate) ship without
+ * playback `actions`, so the playback engine gives them zero dwell and
+ * skips straight past them. Until inserted scenes are seeded with default
+ * actions, the editor hides its two scene-creation entry points — the
+ * inter-thumb "+" insertion zones and the per-slide Duplicate menu item —
+ * while keeping reorder / delete / rename, which are playback-safe.
+ *
+ * Flip to `true` once newly-created scenes are made playable.
+ */
+export const SCENE_CREATION_ENABLED = false;
diff --git a/lib/edit/scene-edit-bridge.ts b/lib/edit/scene-edit-bridge.ts
new file mode 100644
index 0000000000..da1945ba60
--- /dev/null
+++ b/lib/edit/scene-edit-bridge.ts
@@ -0,0 +1,126 @@
+/**
+ * Scene-context → slide-ops bridge.
+ *
+ * The slide renderer (`components/slide-renderer`) commits every edit by
+ * handing its scene-context provider a whole post-edit `SlideContent`
+ * (drag/resize/rotate hooks call `updateSlide({ elements })`). The edit
+ * surface owns a real `SlideEditHistory` of canonical `SlideEditOperation`s
+ * (so undo/redo, persistence and PPTX round-trip stay coherent), not an
+ * opaque content blob. This module diffs the committed snapshot back into
+ * the ops the kernel + export pipeline understand.
+ *
+ * Pure + dependency-free so it is unit-testable in isolation; the React
+ * wiring (which feeds `next` in and stores the resulting history) lives in
+ * the slide surface.
+ */
+
+import { isEqual } from 'lodash';
+import type { SlideContent } from '@/lib/types/stage';
+import type { PPTElement } from '@/lib/types/slides';
+import { applySlideEditOperation, MAX_HISTORY } from '@/lib/edit/slide-ops';
+import type { SlideEditHistory, SlideEditOperation } from '@/lib/edit/slide-ops';
+
+type AnyRecord = Record;
+
+function changedKeys(prev: AnyRecord, next: AnyRecord, skip: ReadonlySet) {
+ const patch: AnyRecord = {};
+ const removed: string[] = [];
+ for (const key of Object.keys(next)) {
+ if (skip.has(key)) continue;
+ if (!(key in prev) || !isEqual(prev[key], next[key])) {
+ patch[key] = next[key];
+ }
+ }
+ for (const key of Object.keys(prev)) {
+ if (skip.has(key)) continue;
+ if (!(key in next)) removed.push(key);
+ }
+ return { patch, removed };
+}
+
+const ELEMENT_SKIP = new Set(['id']);
+const SLIDE_META_SKIP = new Set(['elements', 'animations']);
+
+/**
+ * Diff two `SlideContent` snapshots into the canonical ops that transform
+ * `prev` into `next`. Returns `[]` when they are deep-equal.
+ */
+export function deriveSlideEditOperations(
+ prev: SlideContent,
+ next: SlideContent,
+): SlideEditOperation[] {
+ const ops: SlideEditOperation[] = [];
+
+ const prevById = new Map(prev.canvas.elements.map((el) => [el.id, el]));
+ const nextById = new Map(next.canvas.elements.map((el) => [el.id, el]));
+
+ for (const el of prev.canvas.elements) {
+ if (!nextById.has(el.id)) ops.push({ type: 'element.delete', elementId: el.id });
+ }
+
+ next.canvas.elements.forEach((el, index) => {
+ const before = prevById.get(el.id);
+ if (!before) {
+ ops.push({ type: 'element.add', element: el, index });
+ return;
+ }
+ if (isEqual(before, el)) return;
+ if (before.type !== el.type) {
+ // Identity reused for a different element type — model as replace so
+ // the kernel's per-type invariants stay intact.
+ ops.push({ type: 'element.delete', elementId: el.id });
+ ops.push({ type: 'element.add', element: el, index });
+ return;
+ }
+ const { patch, removed } = changedKeys(
+ before as unknown as AnyRecord,
+ el as unknown as AnyRecord,
+ ELEMENT_SKIP,
+ );
+ if (Object.keys(patch).length > 0) {
+ ops.push({ type: 'element.update', elementId: el.id, patch: patch as Partial });
+ }
+ if (removed.length > 0) {
+ ops.push({ type: 'element.removeProps', elementId: el.id, propNames: removed });
+ }
+ });
+
+ const { patch: metaPatch } = changedKeys(
+ prev.canvas as unknown as AnyRecord,
+ next.canvas as unknown as AnyRecord,
+ SLIDE_META_SKIP,
+ );
+ if (Object.keys(metaPatch).length > 0) {
+ ops.push({ type: 'slide.update', patch: metaPatch });
+ }
+
+ return ops;
+}
+
+/**
+ * Apply a renderer-committed `next` snapshot onto `history` as exactly ONE
+ * undo transaction.
+ *
+ * A single pointer gesture (one drag/resize/rotate, possibly affecting
+ * several selected elements) is one user action and must be one undo step.
+ * - No effective change → history is returned untouched (same reference).
+ * - Exactly one derived op → delegate to the kernel's history overload so
+ * capping / no-op-skip semantics are single-sourced.
+ * - Several ops (multi-element gesture) → fold them onto `present` via the
+ * content overload, then push a single past entry.
+ */
+export function commitSlideEdit(history: SlideEditHistory, next: SlideContent): SlideEditHistory {
+ const ops = deriveSlideEditOperations(history.present, next);
+ if (ops.length === 0) return history;
+ if (ops.length === 1) return applySlideEditOperation(history, ops[0]);
+
+ // Multi-element gesture = one undo step. Use the renderer's authoritative
+ // snapshot as `present` rather than replaying derived ops onto it: the
+ // diff intentionally doesn't model reorder/animations, so a replay could
+ // silently diverge from what the renderer actually rendered.
+ return {
+ past: [...history.past, history.present].slice(-MAX_HISTORY),
+ present: next,
+ future: [],
+ };
+}
diff --git a/lib/edit/scene-editor-registry.ts b/lib/edit/scene-editor-registry.ts
new file mode 100644
index 0000000000..574b03ec5c
--- /dev/null
+++ b/lib/edit/scene-editor-registry.ts
@@ -0,0 +1,25 @@
+import type { SceneType } from '@/lib/types/stage';
+import type { SceneEditorRegistry, SceneEditorSurface } from './scene-editor-surface';
+
+const surfaces = new Map();
+
+export const sceneEditorRegistry: SceneEditorRegistry = {
+ register: (surface) => {
+ // Re-registering the same instance is benign (HMR re-executes module init
+ // and the second pass passes the identical surface object). Only warn when
+ // a different surface tries to take the slot, since that silently masks
+ // bugs like accidental double-imports from divergent paths.
+ const existing = surfaces.get(surface.sceneType);
+ if (existing && existing !== surface && process.env.NODE_ENV !== 'production') {
+ console.warn(
+ `[sceneEditorRegistry] overwriting existing surface for "${surface.sceneType}". ` +
+ `If this is HMR, call unregister first.`,
+ );
+ }
+ surfaces.set(surface.sceneType, surface as SceneEditorSurface);
+ },
+ unregister: (sceneType) => {
+ surfaces.delete(sceneType);
+ },
+ resolve: (sceneType) => surfaces.get(sceneType),
+};
diff --git a/lib/edit/scene-editor-surface.ts b/lib/edit/scene-editor-surface.ts
new file mode 100644
index 0000000000..0799d350be
--- /dev/null
+++ b/lib/edit/scene-editor-surface.ts
@@ -0,0 +1,154 @@
+/**
+ * SceneEditorSurface — interface contract for scene-type editors.
+ *
+ * The edit-mode shell (workbench + command bar + insert strip + inspector +
+ * floating contextual bar) is scene-type-agnostic. Each SceneType registers a
+ * SceneEditorSurface; the shell calls `useSurfaceState()` and renders the
+ * returned slots. Phase 1 ships the slide surface; quiz / interactive / pbl
+ * surfaces can plug in later without touching the shell.
+ */
+
+import type { ComponentType, ReactNode } from 'react';
+import type { SceneContent, SceneType } from '@/lib/types/stage';
+
+// ---------------------------------------------------------------------------
+// Contribution primitives — shell renders these; surface only declares them.
+// All carry user-facing label/icon/tooltip so the same item can render in
+// novice-friendly big-label form (left strip) or compact form (floating bar).
+// ---------------------------------------------------------------------------
+
+export interface UiAffordance {
+ id: string;
+ label: string;
+ icon?: ReactNode;
+ tooltip?: string;
+ disabled?: boolean;
+ /** Optional grouping hint — shell may insert dividers between groups. */
+ group?: string;
+}
+
+/** Items in the left "insert" strip (kept always-visible for discoverability). */
+export interface InsertPaletteItem extends UiAffordance {
+ onInvoke: () => void;
+ /**
+ * Optional popover content. When provided, the button opens a popover with
+ * this content instead of firing onInvoke. Useful for sub-pickers like
+ * "choose a shape" or "choose an image source".
+ */
+ popoverContent?: () => ReactNode;
+ /**
+ * Whether the item is in an "armed" state — e.g. the surface is waiting for
+ * a canvas gesture to complete an insert. CommandBar renders this with the
+ * active/toggle style. Defaults to false.
+ */
+ active?: boolean;
+}
+
+/**
+ * Floating contextual actions — shown as an inline bar above the canvas when
+ * selection is non-empty. Each action is either a one-shot button (onInvoke)
+ * or a popover trigger (popoverContent) for property panels.
+ */
+export interface FloatingAction extends UiAffordance {
+ onInvoke?: () => void;
+ /**
+ * Optional popover content. When provided, the button opens a popover
+ * instead of (or in addition to, if onInvoke is also set) firing onInvoke.
+ * Used for property surfaces like color picker, font select, etc.
+ */
+ popoverContent?: () => ReactNode;
+}
+
+/**
+ * Editor commands — global actions surfaced in the top command bar.
+ * Element-scoped actions (align, delete, layer) belong in `floatingActions`,
+ * not here. `commands` is for things like Save / Export / Zoom / Exit-edit.
+ */
+export interface EditorCommand extends UiAffordance {
+ onInvoke: () => void;
+}
+
+/**
+ * AI inline coach hint — reserved slot, not used in Phase 1.
+ * The shell renders a hint rail when this slot has any items.
+ */
+export interface EditorHint {
+ id: string;
+ severity: 'info' | 'suggestion' | 'warning';
+ message: string;
+ action?: { label: string; onInvoke: () => void };
+}
+
+// ---------------------------------------------------------------------------
+// SurfaceState — what the surface's hook returns to the shell each render.
+// ---------------------------------------------------------------------------
+
+export interface SurfaceHistory {
+ canUndo: boolean;
+ canRedo: boolean;
+ undo: () => void;
+ redo: () => void;
+}
+
+/**
+ * **Maintenance note:** any new field added here that the chrome reads
+ * must also be added to `surfaceStateEqual` in
+ * `components/edit/EditShell/EditShell.tsx`. Surface hooks return a
+ * fresh object each render, so semantic equality is the gate that
+ * prevents an infinite publish loop — a new field outside the
+ * comparison goes silently stale in the rendered chrome.
+ */
+export interface SurfaceState {
+ content: TContent;
+ selection: TSelection;
+ /** True when the surface considers selection non-empty (drives floating bar). */
+ hasSelection: boolean;
+
+ /**
+ * Editable surfaces expose undo/redo here. Read-only surfaces (e.g. the
+ * NOOP fallback used for unregistered scene types) omit it; the shell
+ * hides undo/redo controls when undefined.
+ */
+ history?: SurfaceHistory;
+
+ insertItems: InsertPaletteItem[];
+ floatingActions: FloatingAction[];
+ commands: EditorCommand[];
+
+ /** Reserved for AI phase. Surface returns [] in Phase 1. */
+ hints?: EditorHint[];
+}
+
+// ---------------------------------------------------------------------------
+// SceneEditorSurface — the contract a scene type registers.
+// ---------------------------------------------------------------------------
+
+export interface SceneEditorSurface<
+ TContent extends SceneContent = SceneContent,
+ TSelection = unknown,
+> {
+ sceneType: SceneType;
+
+ /** Center canvas — surface fully owns rendering. */
+ CanvasComponent: ComponentType;
+
+ /**
+ * React hook called by the shell once per render. Owns selection, history,
+ * and op dispatch internally; returns the slot contributions.
+ */
+ useSurfaceState: () => SurfaceState;
+}
+
+// ---------------------------------------------------------------------------
+// Registry — shell resolves a surface by SceneType. Surfaces register once
+// at module init time; the shell never imports surfaces directly.
+// ---------------------------------------------------------------------------
+
+export interface SceneEditorRegistry {
+ register: (
+ surface: SceneEditorSurface,
+ ) => void;
+ /** Remove a registration. Mainly for HMR cleanup and tests. */
+ unregister: (sceneType: SceneType) => void;
+ resolve: (sceneType: SceneType) => SceneEditorSurface | undefined;
+}
diff --git a/lib/edit/slide-defaults.ts b/lib/edit/slide-defaults.ts
new file mode 100644
index 0000000000..e2cafb332f
--- /dev/null
+++ b/lib/edit/slide-defaults.ts
@@ -0,0 +1,92 @@
+import { nanoid } from 'nanoid';
+import type { Slide, SlideTheme, PPTElement } from '@/lib/types/slides';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import { createElementIdMap } from '@/lib/utils/element';
+import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema';
+
+const DEFAULT_THEME: SlideTheme = {
+ backgroundColor: '#ffffff',
+ themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
+ fontColor: '#333333',
+ fontName: 'Microsoft YaHei',
+ outline: { color: '#d14424', width: 2, style: 'solid' },
+ shadow: { h: 0, v: 0, blur: 10, color: '#000000' },
+};
+
+/**
+ * Build a fresh blank slide scene for `+ Add slide` in the Pro mode rail.
+ * Matches the SceneBuilder default theme so user-added slides look the
+ * same as AI-generated ones until customized.
+ */
+export function createBlankSlideScene(stageId: string, title: string, order: number): Scene {
+ const slide: Slide = {
+ id: nanoid(),
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: DEFAULT_THEME,
+ elements: [],
+ background: { type: 'solid', color: '#ffffff' },
+ };
+
+ const content: SlideContent = {
+ type: 'slide',
+ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ canvas: slide,
+ };
+
+ return {
+ id: nanoid(),
+ stageId,
+ type: 'slide',
+ title,
+ order,
+ content,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+}
+
+/**
+ * Build a duplicate of an existing slide scene. Deep-clones the slide
+ * payload and reassigns every element id (and group id) so React keys +
+ * downstream selection state can't collide with the source slide while
+ * grouped elements keep sharing a new common group id. The new scene
+ * gets a fresh scene id; caller is responsible for placing it in the
+ * scenes array (via `insertSceneAfter`).
+ */
+export function duplicateSlideScene(source: Scene, copySuffix: string, order: number): Scene {
+ if (source.type !== 'slide') {
+ throw new Error('duplicateSlideScene: source scene is not a slide');
+ }
+ const sourceContent = source.content as SlideContent;
+ const { elIdMap, groupIdMap } = createElementIdMap(sourceContent.canvas.elements);
+ const clonedElements: PPTElement[] = sourceContent.canvas.elements.map((element) => ({
+ ...element,
+ id: elIdMap[element.id],
+ ...(element.groupId ? { groupId: groupIdMap[element.groupId] } : {}),
+ }));
+
+ const clonedSlide: Slide = {
+ ...sourceContent.canvas,
+ id: nanoid(),
+ elements: clonedElements,
+ };
+
+ const content: SlideContent = {
+ ...sourceContent,
+ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ canvas: clonedSlide,
+ };
+
+ const title = copySuffix ? `${source.title} ${copySuffix}` : source.title;
+
+ return {
+ ...source,
+ id: nanoid(),
+ title,
+ order,
+ content,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+}
diff --git a/lib/edit/slide-edit-elements.ts b/lib/edit/slide-edit-elements.ts
new file mode 100644
index 0000000000..1450db2fc9
--- /dev/null
+++ b/lib/edit/slide-edit-elements.ts
@@ -0,0 +1,99 @@
+import type { ShapePathFormulasKeys } from '@/lib/types/slides';
+import type { PPTImageElement, PPTShapeElement, PPTTextElement, Slide } from '@/lib/types/slides';
+
+export interface ShapeSpec {
+ viewBox: [number, number];
+ path: string;
+ pathFormula?: ShapePathFormulasKeys;
+ pptxShapeType?: string;
+}
+
+export function plainTextToParagraphHtml(value: string) {
+ return `${escapeHtml(value)}
`;
+}
+
+export function htmlToPlainText(value: string) {
+ return value.replace(/<[^>]+>/g, '').trim();
+}
+
+export function createDefaultTextElement(id: string): PPTTextElement {
+ return {
+ id,
+ type: 'text',
+ left: 120,
+ top: 120,
+ width: 360,
+ height: 72,
+ rotate: 0,
+ content: 'New text
',
+ defaultFontName: 'Inter',
+ defaultColor: '#111827',
+ lineHeight: 1.4,
+ };
+}
+
+export function createDefaultShapeElement(id: string, spec?: ShapeSpec): PPTShapeElement {
+ const viewBox = spec?.viewBox ?? ([260, 140] as [number, number]);
+ // Picked shapes tend to be square (200x200) in the shape pool — scale to
+ // a reasonable canvas width while preserving aspect ratio.
+ const width = spec ? 200 : viewBox[0];
+ const height = spec ? 200 * (viewBox[1] / viewBox[0]) : viewBox[1];
+ return {
+ id,
+ type: 'shape',
+ left: 160,
+ top: 160,
+ width,
+ height,
+ rotate: 0,
+ viewBox,
+ path: spec?.path ?? 'M 0 0 L 260 0 L 260 140 L 0 140 Z',
+ pathFormula: spec?.pathFormula,
+ fixedRatio: false,
+ fill: '#dbeafe',
+ outline: {
+ width: 2,
+ color: '#2563eb',
+ style: 'solid',
+ },
+ };
+}
+
+export function createDefaultSlide(id: string): Slide {
+ return {
+ id,
+ viewportSize: 1000,
+ viewportRatio: 0.5625, // 16:9
+ theme: {
+ backgroundColor: '#ffffff',
+ themeColors: ['#5b8def', '#8b5cf6', '#10b981', '#f59e0b'],
+ fontColor: '#111827',
+ fontName: 'Inter',
+ },
+ elements: [],
+ background: { type: 'solid', color: '#ffffff' },
+ };
+}
+
+export function createDefaultImageElement(id: string, src: string): PPTImageElement {
+ return {
+ id,
+ type: 'image',
+ left: 180,
+ top: 140,
+ width: 360,
+ height: 220,
+ rotate: 0,
+ fixedRatio: true,
+ src,
+ };
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/lib/edit/slide-ops.ts b/lib/edit/slide-ops.ts
new file mode 100644
index 0000000000..e1182de576
--- /dev/null
+++ b/lib/edit/slide-ops.ts
@@ -0,0 +1,359 @@
+import { current, produce } from 'immer';
+import type { SlideContent } from '@/lib/types/stage';
+import type { PPTElement, Slide } from '@/lib/types/slides';
+import { getElementListRange } from '@/lib/utils/element';
+
+type ElementPatch = Partial;
+type ElementPropName = string;
+
+// Cap undo history so long editing sessions don't grow memory unbounded.
+export const MAX_HISTORY = 50;
+
+export type SlideElementAlignCommand =
+ | 'top'
+ | 'bottom'
+ | 'left'
+ | 'right'
+ | 'vertical'
+ | 'horizontal'
+ | 'center';
+
+// slide.update is for slide metadata only (theme, background, viewport, etc).
+// Element and animation collections must be mutated through their dedicated
+// ops so undo/redo, serialization, and (future) PPTX round-trip stay coherent.
+export type SlideMetaPatch = Partial>;
+
+export type SlideEditOperation =
+ | {
+ type: 'slide.update';
+ patch: SlideMetaPatch;
+ }
+ | {
+ type: 'element.add';
+ element: PPTElement;
+ index?: number;
+ }
+ | {
+ type: 'element.update';
+ elementId: string;
+ patch: ElementPatch;
+ }
+ | {
+ type: 'element.updateMany';
+ elementIds: string[];
+ patch: ElementPatch;
+ }
+ | {
+ type: 'element.delete';
+ elementId: string;
+ }
+ | {
+ type: 'element.deleteMany';
+ elementIds: string[];
+ }
+ | {
+ type: 'element.reorder';
+ elementId: string;
+ index: number;
+ }
+ | {
+ type: 'element.duplicate';
+ elementIds: string[];
+ idMap: Record;
+ offset?: {
+ x: number;
+ y: number;
+ };
+ }
+ | {
+ type: 'element.align';
+ elementIds: string[];
+ command: SlideElementAlignCommand;
+ }
+ | {
+ type: 'element.removeProps';
+ elementId: string;
+ propNames: ElementPropName[];
+ }
+ | {
+ type: 'text.updateContent';
+ elementId: string;
+ content: string;
+ };
+
+export interface SlideEditHistory {
+ past: SlideContent[];
+ present: SlideContent;
+ future: SlideContent[];
+}
+
+export function createSlideEditHistory(initial: SlideContent): SlideEditHistory {
+ return {
+ past: [],
+ // Defensive clone: initial comes from outside immer, so the caller could
+ // still mutate it after construction. Internal history snapshots are
+ // immer-produced and already frozen, so we never re-clone them.
+ present: cloneSlideContent(initial),
+ future: [],
+ };
+}
+
+export function applySlideEditOperation(
+ content: SlideContent,
+ operation: SlideEditOperation,
+): SlideContent;
+export function applySlideEditOperation(
+ history: SlideEditHistory,
+ operation: SlideEditOperation,
+): SlideEditHistory;
+export function applySlideEditOperation(
+ target: SlideContent | SlideEditHistory,
+ operation: SlideEditOperation,
+): SlideContent | SlideEditHistory {
+ if (isSlideEditHistory(target)) {
+ const next = applyOperationToContent(target.present, operation);
+ // immer's produce returns the same reference when the recipe didn't
+ // mutate the draft (e.g. element.update against a missing id). Skip the
+ // history push so undo doesn't replay empty steps.
+ if (next === target.present) return target;
+ return {
+ past: capHistory([...target.past, target.present]),
+ present: next,
+ future: [],
+ };
+ }
+
+ return applyOperationToContent(target, operation);
+}
+
+export function undoSlideEditOperation(history: SlideEditHistory): SlideEditHistory {
+ if (history.past.length === 0) return history;
+
+ const previous = history.past[history.past.length - 1];
+ return {
+ past: history.past.slice(0, -1),
+ present: previous,
+ future: [history.present, ...history.future],
+ };
+}
+
+export function redoSlideEditOperation(history: SlideEditHistory): SlideEditHistory {
+ if (history.future.length === 0) return history;
+
+ const next = history.future[0];
+ return {
+ past: capHistory([...history.past, history.present]),
+ present: next,
+ future: history.future.slice(1),
+ };
+}
+
+function applyOperationToContent(
+ content: SlideContent,
+ operation: SlideEditOperation,
+): SlideContent {
+ return produce(content, (draft) => {
+ switch (operation.type) {
+ case 'slide.update': {
+ // Type-level narrowing via SlideMetaPatch already forbids elements /
+ // animations, but a runtime guard closes the `as any` escape hatch
+ // at call sites. Those collections must go through their dedicated
+ // ops so undo/redo / serialization stays single-source.
+ if ('elements' in operation.patch || 'animations' in operation.patch) {
+ throw new Error(
+ 'slide.update: use dedicated element / animation ops to mutate those collections',
+ );
+ }
+ Object.assign(draft.canvas, operation.patch);
+ return;
+ }
+ case 'element.add': {
+ if (draft.canvas.elements.some((el) => el.id === operation.element.id)) {
+ throw new Error(`element.add: id "${operation.element.id}" already exists`);
+ }
+ const index =
+ typeof operation.index === 'number'
+ ? Math.max(0, Math.min(operation.index, draft.canvas.elements.length))
+ : draft.canvas.elements.length;
+ draft.canvas.elements.splice(index, 0, cloneElement(operation.element));
+ return;
+ }
+ case 'element.update': {
+ const element = draft.canvas.elements.find((item) => item.id === operation.elementId);
+ if (!element) return;
+ Object.assign(element, operation.patch);
+ return;
+ }
+ case 'element.updateMany': {
+ const elementIds = new Set(operation.elementIds);
+ draft.canvas.elements.forEach((element) => {
+ if (elementIds.has(element.id)) Object.assign(element, operation.patch);
+ });
+ return;
+ }
+ case 'element.delete': {
+ // Pre-check so deleting a missing id is a real no-op (same content ref)
+ // — without this, the unconditional .filter assignment would always
+ // count as a mutation and bloat undo history with empty steps.
+ if (!draft.canvas.elements.some((el) => el.id === operation.elementId)) return;
+ draft.canvas.elements = draft.canvas.elements.filter(
+ (element) => element.id !== operation.elementId,
+ );
+ if (draft.canvas.animations) {
+ draft.canvas.animations = draft.canvas.animations.filter(
+ (animation) => animation.elId !== operation.elementId,
+ );
+ }
+ return;
+ }
+ case 'element.deleteMany': {
+ const elementIds = new Set(operation.elementIds);
+ if (!draft.canvas.elements.some((el) => elementIds.has(el.id))) return;
+ draft.canvas.elements = draft.canvas.elements.filter(
+ (element) => !elementIds.has(element.id),
+ );
+ if (draft.canvas.animations) {
+ draft.canvas.animations = draft.canvas.animations.filter(
+ (animation) => !elementIds.has(animation.elId),
+ );
+ }
+ return;
+ }
+ case 'element.reorder': {
+ const currentIndex = draft.canvas.elements.findIndex(
+ (element) => element.id === operation.elementId,
+ );
+ if (currentIndex === -1) return;
+
+ const [element] = draft.canvas.elements.splice(currentIndex, 1);
+ const nextIndex = Math.max(0, Math.min(operation.index, draft.canvas.elements.length));
+ draft.canvas.elements.splice(nextIndex, 0, element);
+ return;
+ }
+ case 'element.duplicate': {
+ const missing = operation.elementIds.filter((id) => !operation.idMap[id]);
+ if (missing.length > 0) {
+ throw new Error(`element.duplicate: idMap missing entries for [${missing.join(', ')}]`);
+ }
+ const existing = new Set(draft.canvas.elements.map((el) => el.id));
+ const collisions = operation.elementIds
+ .map((id) => operation.idMap[id])
+ .filter((newId) => existing.has(newId));
+ if (collisions.length > 0) {
+ throw new Error(
+ `element.duplicate: new ids collide with existing elements: [${collisions.join(', ')}]`,
+ );
+ }
+
+ const offset = operation.offset ?? { x: 20, y: 20 };
+ const elementIds = new Set(operation.elementIds);
+ const duplicatedElements = draft.canvas.elements
+ .filter((element) => elementIds.has(element.id))
+ .map((element) => {
+ // Deep clone via current() + structuredClone so the duplicate
+ // doesn't share nested references (start/end tuples, outline,
+ // points, etc) with the source. immer's COW would handle most
+ // mutations safely, but future ops that operate on nested
+ // arrays in-place (sort/reverse/splice) would silently leak —
+ // keep the kernel's invariants independent of which mutation
+ // shape future op consumers pick.
+ const source = structuredClone(current(element)) as PPTElement;
+ return {
+ ...source,
+ id: operation.idMap[source.id],
+ left: source.left + offset.x,
+ top: source.top + offset.y,
+ };
+ });
+
+ draft.canvas.elements.push(...duplicatedElements);
+ return;
+ }
+ case 'element.align': {
+ alignElementsToCanvas(draft.canvas, operation.elementIds, operation.command);
+ return;
+ }
+ case 'element.removeProps': {
+ const element = draft.canvas.elements.find((item) => item.id === operation.elementId);
+ if (!element) return;
+ operation.propNames.forEach((propName) => {
+ delete (element as Record)[propName];
+ });
+ return;
+ }
+ case 'text.updateContent': {
+ const element = draft.canvas.elements.find((item) => item.id === operation.elementId);
+ if (!element || element.type !== 'text') return;
+ element.content = operation.content;
+ return;
+ }
+ }
+ });
+}
+
+function isSlideEditHistory(target: SlideContent | SlideEditHistory): target is SlideEditHistory {
+ return 'present' in target && 'past' in target && 'future' in target;
+}
+
+function cloneSlideContent(content: SlideContent): SlideContent {
+ return structuredClone(content);
+}
+
+function cloneElement(element: PPTElement): PPTElement {
+ return structuredClone(element);
+}
+
+function capHistory(past: SlideContent[]): SlideContent[] {
+ return past.length > MAX_HISTORY ? past.slice(past.length - MAX_HISTORY) : past;
+}
+
+function alignElementsToCanvas(
+ slide: Slide,
+ elementIds: string[],
+ command: SlideElementAlignCommand,
+) {
+ const selectedIds = new Set(elementIds);
+ const selectedElements = slide.elements.filter((element) => selectedIds.has(element.id));
+ if (selectedElements.length === 0) return;
+
+ // Reuse the canonical geometry helper so line/rotated elements compute the
+ // right bounding box. The local fork that lived here treated lines as
+ // height 0 and ignored rotation.
+ const range = getElementListRange(selectedElements);
+ const viewportWidth = slide.viewportSize;
+ const viewportHeight = slide.viewportSize * slide.viewportRatio;
+
+ let offsetX = 0;
+ let offsetY = 0;
+
+ switch (command) {
+ case 'center':
+ offsetX = range.minX + (range.maxX - range.minX) / 2 - viewportWidth / 2;
+ offsetY = range.minY + (range.maxY - range.minY) / 2 - viewportHeight / 2;
+ break;
+ case 'top':
+ offsetY = range.minY;
+ break;
+ case 'vertical':
+ offsetY = range.minY + (range.maxY - range.minY) / 2 - viewportHeight / 2;
+ break;
+ case 'bottom':
+ offsetY = range.maxY - viewportHeight;
+ break;
+ case 'left':
+ offsetX = range.minX;
+ break;
+ case 'horizontal':
+ offsetX = range.minX + (range.maxX - range.minX) / 2 - viewportWidth / 2;
+ break;
+ case 'right':
+ offsetX = range.maxX - viewportWidth;
+ break;
+ }
+
+ slide.elements.forEach((element) => {
+ if (!selectedIds.has(element.id)) return;
+ element.left -= offsetX;
+ element.top -= offsetY;
+ });
+}
diff --git a/lib/edit/slide-schema.ts b/lib/edit/slide-schema.ts
new file mode 100644
index 0000000000..5393119258
--- /dev/null
+++ b/lib/edit/slide-schema.ts
@@ -0,0 +1,59 @@
+/**
+ * SlideContent schema versioning. Slide-surface PRs will iterate the
+ * on-disk shape; this module is the single chokepoint for normalizing
+ * any incoming SlideContent (API response, snapshot restore, future
+ * localStorage restore, PPTX reimport) to the current version.
+ *
+ * Conventions:
+ * - `migrateSlideContent` is pure (returns a new reference only when
+ * it has to change something) and idempotent (running it twice is
+ * identical to running it once).
+ * - Each schema bump appends a step keyed by the previous version's
+ * number. v1 (current) needs no per-step migration body — just the
+ * guarantee that the field is present.
+ */
+
+import type { Scene, SceneContent, SlideContent } from '@/lib/types/stage';
+
+export const CURRENT_SLIDE_CONTENT_SCHEMA_VERSION = 1;
+
+export function migrateSlideContent(content: SlideContent): SlideContent {
+ // Forward-compatibility: if a future client has written content with a
+ // newer schemaVersion than we know about, return it untouched rather
+ // than silently downgrading. The slide may not render correctly here,
+ // but its on-disk shape stays intact for the next compatible client.
+ if (
+ content.schemaVersion !== undefined &&
+ content.schemaVersion >= CURRENT_SLIDE_CONTENT_SCHEMA_VERSION
+ ) {
+ return content;
+ }
+ // Legacy data (no schemaVersion) and any older intermediate versions
+ // fall through here. As schema versions accumulate, walk versions in
+ // order and apply each step's body before stamping the final version.
+ return {
+ ...content,
+ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ };
+}
+
+/**
+ * Top-level scene migrator — dispatches by scene-content type. Only
+ * SlideContent has a schema to version today; other content types pass
+ * through. Future surfaces declare their own migrators and wire them
+ * in here.
+ */
+export function migrateScene(scene: Scene): Scene {
+ const migratedContent = migrateSceneContent(scene.content);
+ if (migratedContent === scene.content) {
+ return scene;
+ }
+ return { ...scene, content: migratedContent };
+}
+
+function migrateSceneContent(content: SceneContent): SceneContent {
+ if (content.type === 'slide') {
+ return migrateSlideContent(content);
+ }
+ return content;
+}
diff --git a/lib/edit/stage-mode.ts b/lib/edit/stage-mode.ts
new file mode 100644
index 0000000000..8234cf5e0c
--- /dev/null
+++ b/lib/edit/stage-mode.ts
@@ -0,0 +1,27 @@
+import { PENDING_SCENE_ID } from '@/lib/store/stage';
+
+/**
+ * Inputs the edit-mode auto-exit guard reads. Kept as primitives so callers
+ * can derive the values cheaply without holding full Scene / SceneOutline
+ * objects, and so the predicate is trivially testable without rendering Stage.
+ */
+export interface StageEditModeContext {
+ currentSceneId: string | null;
+ sceneCount: number;
+ generatingOutlineCount: number;
+ hasCurrentScene: boolean;
+}
+
+/**
+ * Whether edit mode should remain active for the given stage state.
+ * Returns false in cases that would otherwise strand the user in an empty
+ * edit shell — pending scene, no scenes, generation in flight, or no current
+ * scene resolved yet.
+ */
+export function isCurrentSceneEditable(ctx: StageEditModeContext): boolean {
+ if (ctx.currentSceneId === PENDING_SCENE_ID) return false;
+ if (ctx.sceneCount === 0) return false;
+ if (ctx.generatingOutlineCount > 0) return false;
+ if (!ctx.hasCurrentScene) return false;
+ return true;
+}
diff --git a/lib/edit/transitions.ts b/lib/edit/transitions.ts
new file mode 100644
index 0000000000..86b2f64218
--- /dev/null
+++ b/lib/edit/transitions.ts
@@ -0,0 +1,29 @@
+/**
+ * Shared easing / duration constants for the Pro mode chrome transitions.
+ *
+ * Centralized so the choreography between stage.tsx (sidebar slide-out,
+ * canvas slot motion.layout) and EditShell (CommandBar drop, leftRail
+ * slide-in, content fade) shares one timing source.
+ *
+ * Ease curve [0.22, 1, 0.36, 1] is the cubic-bezier ease-out-quart shape —
+ * natural deceleration with a slight overshoot at the end, used elsewhere
+ * in the OpenMAIC playback chrome.
+ */
+export const CHROME_EASE = [0.22, 1, 0.36, 1] as const;
+
+/** Base duration for a chrome enter/exit step (seconds). */
+export const CHROME_DURATION = 0.28;
+
+/** Same duration in milliseconds (for CSS `transition` strings). */
+export const CHROME_DURATION_MS = Math.round(CHROME_DURATION * 1000);
+
+/** Ease curve as a CSS `cubic-bezier(...)` string for CSS `transition`. */
+export const CHROME_EASE_CSS = `cubic-bezier(${CHROME_EASE.join(', ')})`;
+
+/** Inter-element stagger between chrome layers (seconds). */
+export const CHROME_STAGGER = 0.1;
+
+export const CHROME_TRANSITION = {
+ duration: CHROME_DURATION,
+ ease: CHROME_EASE,
+} as const;
diff --git a/lib/export/use-export-pptx.ts b/lib/export/use-export-pptx.ts
index 3a003b2c89..589aa67c9a 100644
--- a/lib/export/use-export-pptx.ts
+++ b/lib/export/use-export-pptx.ts
@@ -361,7 +361,10 @@ function buildSpeakerNotes(scene: Scene): string {
return parts.join('\n');
}
-async function buildPptxBlob(
+// Exported for the round-trip integration test harness — the test wires its
+// own slides + ratios in and inspects the resulting PPTX bytes via JSZip.
+// The hook below is still the only intended runtime caller.
+export async function buildPptxBlob(
slides: Slide[],
slideScenes: Scene[],
viewportRatio: number,
diff --git a/lib/hooks/use-scene-generator.ts b/lib/hooks/use-scene-generator.ts
index 150c73b524..5937311227 100644
--- a/lib/hooks/use-scene-generator.ts
+++ b/lib/hooks/use-scene-generator.ts
@@ -2,6 +2,7 @@
import { useCallback, useRef } from 'react';
import { useStageStore } from '@/lib/store/stage';
+import { isSceneEditLocked } from '@/lib/edit/regen-lock';
import { getCurrentModelConfig } from '@/lib/utils/model-config';
import { useSettingsStore } from '@/lib/store/settings';
import { db } from '@/lib/utils/database';
@@ -475,6 +476,22 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) {
const params = lastParamsRef.current;
if (!outline || !state.stage || !params) return;
+ // Regen-lock (#571): never silently replace a scene that is open in
+ // edit mode. Failed outlines have no completed scene yet so this is
+ // structurally a no-op today, but the guard is in place for the
+ // moment a "regenerate a successful scene" path routes through here.
+ const lockedScene = state.scenes.find((s) => s.order === outline.order);
+ if (
+ lockedScene &&
+ isSceneEditLocked({
+ sceneId: lockedScene.id,
+ mode: state.mode,
+ currentSceneId: state.currentSceneId,
+ })
+ ) {
+ return;
+ }
+
const removeGeneratingOutline = () => {
const current = store.getState().generatingOutlines;
if (!current.some((o) => o.id === outlineId)) return;
diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json
index 5600ba5382..d2692cce9c 100644
--- a/lib/i18n/locales/ar-SA.json
+++ b/lib/i18n/locales/ar-SA.json
@@ -149,7 +149,72 @@
"generatingNextPage": "جارٍ توليد المشهد، يرجى الانتظار...",
"courseComplete": "اكتملت الدورة",
"fullscreen": "ملء الشاشة",
- "exitFullscreen": "الخروج من ملء الشاشة"
+ "exitFullscreen": "الخروج من ملء الشاشة",
+ "editCourse": "تحرير الدورة",
+ "doneEditing": "إنهاء التحرير"
+ },
+ "edit": {
+ "proMode": "احترافي",
+ "undo": "تراجع",
+ "redo": "إعادة",
+ "delete": "حذف",
+ "title": "تحرير · {{type}}",
+ "unsupportedScene": "{{type}} غير قابل للتحرير بعد",
+ "readOnlyBadge": "{{type}} · للعرض فقط",
+ "sceneType": {
+ "slide": "شريحة",
+ "quiz": "اختبار",
+ "interactive": "تفاعلي",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "علامة تبويب أخرى تحرّر هذه الدورة",
+ "body": "وضع التحرير محصور بعلامة تبويب واحدة في كل مرة لتجنب تعارض التغييرات. أغلق علامة التبويب الأخرى (أو انتظر انتهاء جلستها) قبل الدخول إلى وضع التحرير هنا.",
+ "actionDismiss": "حسناً"
+ }
+ },
+ "text": {
+ "label": "نص",
+ "font": "الخط",
+ "fontDefault": "افتراضي",
+ "sizeUp": "تكبير الحجم",
+ "sizeDown": "تصغير الحجم",
+ "fontSize": "حجم الخط",
+ "bold": "غامق",
+ "italic": "مائل",
+ "underline": "تسطير",
+ "color": "لون النص",
+ "alignLeft": "محاذاة لليسار",
+ "alignCenter": "توسيط",
+ "alignRight": "محاذاة لليمين",
+ "bullet": "قائمة نقطية"
+ },
+ "insert": {
+ "textBox": "مربع نص",
+ "expandToolbar": "إظهار أدوات الإدراج",
+ "collapseToolbar": "إخفاء أدوات الإدراج",
+ "image": "صورة",
+ "imageDrop": "أفلِت صورة أو انقر لاختيار ملف",
+ "imageOr": "أو الصق رابط صورة",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "إدراج"
+ },
+ "nav": {
+ "addSlide": "إضافة شريحة",
+ "duplicate": "تكرار",
+ "delete": "حذف",
+ "deleted": "تم الحذف",
+ "undo": "تراجع",
+ "copySuffix": "(نسخة)",
+ "untitledSlide": "شريحة بدون عنوان",
+ "collapse": "طي الشريط الجانبي",
+ "expand": "توسيع الشريط الجانبي",
+ "dragHandle": "اسحب لإعادة الترتيب",
+ "deckLabel": "المشاهد",
+ "moreActions": "إجراءات إضافية",
+ "rename": "إعادة تسمية"
+ }
},
"classroomComplete": {
"title": "اكتملت الدورة",
diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json
index b281156f14..b05eab3fc7 100644
--- a/lib/i18n/locales/en-US.json
+++ b/lib/i18n/locales/en-US.json
@@ -149,7 +149,72 @@
"generatingNextPage": "Scene is being generated, please wait...",
"courseComplete": "Course complete",
"fullscreen": "Fullscreen",
- "exitFullscreen": "Exit Fullscreen"
+ "exitFullscreen": "Exit Fullscreen",
+ "editCourse": "Edit course",
+ "doneEditing": "Done editing"
+ },
+ "edit": {
+ "proMode": "Pro",
+ "undo": "Undo",
+ "redo": "Redo",
+ "delete": "Delete",
+ "title": "Editing · {{type}}",
+ "unsupportedScene": "{{type}} is not editable yet",
+ "readOnlyBadge": "{{type}} · view-only",
+ "sceneType": {
+ "slide": "Slide",
+ "quiz": "Quiz",
+ "interactive": "Interactive",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "Another tab is editing this course",
+ "body": "Editing is locked to a single tab to avoid conflicting changes. Close the other tab (or wait for its session to time out) before entering edit mode here.",
+ "actionDismiss": "Got it"
+ }
+ },
+ "text": {
+ "label": "Text",
+ "font": "Font",
+ "fontDefault": "Default",
+ "sizeUp": "Increase size",
+ "sizeDown": "Decrease size",
+ "fontSize": "Font size",
+ "bold": "Bold",
+ "italic": "Italic",
+ "underline": "Underline",
+ "color": "Text color",
+ "alignLeft": "Align left",
+ "alignCenter": "Align center",
+ "alignRight": "Align right",
+ "bullet": "Bulleted list"
+ },
+ "insert": {
+ "textBox": "Text box",
+ "expandToolbar": "Show insert tools",
+ "collapseToolbar": "Hide insert tools",
+ "image": "Image",
+ "imageDrop": "Drop an image or click to choose a file",
+ "imageOr": "or paste an image URL",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "Insert"
+ },
+ "nav": {
+ "addSlide": "Add slide",
+ "duplicate": "Duplicate",
+ "delete": "Delete",
+ "deleted": "Deleted",
+ "undo": "Undo",
+ "copySuffix": "(copy)",
+ "untitledSlide": "Untitled slide",
+ "collapse": "Collapse rail",
+ "expand": "Expand rail",
+ "dragHandle": "Drag to reorder",
+ "deckLabel": "Scenes",
+ "moreActions": "More actions",
+ "rename": "Rename"
+ }
},
"classroomComplete": {
"title": "Course complete",
diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json
index 01549839a8..5dcfeb9a7f 100644
--- a/lib/i18n/locales/ja-JP.json
+++ b/lib/i18n/locales/ja-JP.json
@@ -149,7 +149,72 @@
"generatingNextPage": "シーンを生成中です。お待ちください...",
"courseComplete": "コース完了",
"fullscreen": "全画面表示",
- "exitFullscreen": "全画面表示を終了"
+ "exitFullscreen": "全画面表示を終了",
+ "editCourse": "コースを編集",
+ "doneEditing": "編集を完了"
+ },
+ "edit": {
+ "proMode": "プロモード",
+ "undo": "元に戻す",
+ "redo": "やり直し",
+ "delete": "削除",
+ "title": "編集 · {{type}}",
+ "unsupportedScene": "{{type}}はまだ編集できません",
+ "readOnlyBadge": "{{type}} · 表示のみ",
+ "sceneType": {
+ "slide": "スライド",
+ "quiz": "クイズ",
+ "interactive": "インタラクティブ",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "別のタブでこのコースを編集中です",
+ "body": "編集モードは同時に 1 つのタブのみが保持できます。他のタブを閉じる(またはセッションがタイムアウトするのを待つ)してから、このタブで編集を開始してください。",
+ "actionDismiss": "了解"
+ }
+ },
+ "text": {
+ "label": "テキスト",
+ "font": "フォント",
+ "fontDefault": "デフォルト",
+ "sizeUp": "文字を大きく",
+ "sizeDown": "文字を小さく",
+ "fontSize": "フォントサイズ",
+ "bold": "太字",
+ "italic": "斜体",
+ "underline": "下線",
+ "color": "文字色",
+ "alignLeft": "左揃え",
+ "alignCenter": "中央揃え",
+ "alignRight": "右揃え",
+ "bullet": "箇条書き"
+ },
+ "insert": {
+ "textBox": "テキストボックス",
+ "expandToolbar": "挿入ツールを表示",
+ "collapseToolbar": "挿入ツールを隠す",
+ "image": "画像",
+ "imageDrop": "画像をドロップまたはクリックして選択",
+ "imageOr": "または画像 URL を貼り付け",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "挿入"
+ },
+ "nav": {
+ "addSlide": "スライドを追加",
+ "duplicate": "複製",
+ "delete": "削除",
+ "deleted": "削除しました",
+ "undo": "元に戻す",
+ "copySuffix": "(コピー)",
+ "untitledSlide": "無題のスライド",
+ "collapse": "サイドを折りたたむ",
+ "expand": "サイドを開く",
+ "dragHandle": "ドラッグして並べ替え",
+ "deckLabel": "シーン",
+ "moreActions": "その他の操作",
+ "rename": "名前を変更"
+ }
},
"classroomComplete": {
"title": "コース完了",
diff --git a/lib/i18n/locales/pt-BR.json b/lib/i18n/locales/pt-BR.json
index 608329c4b8..b1e6d89443 100644
--- a/lib/i18n/locales/pt-BR.json
+++ b/lib/i18n/locales/pt-BR.json
@@ -139,6 +139,69 @@
"startListening": "Entrada por voz",
"stopListening": "Parar gravação"
},
+ "edit": {
+ "proMode": "Pro",
+ "undo": "Desfazer",
+ "redo": "Refazer",
+ "delete": "Excluir",
+ "title": "Editando · {{type}}",
+ "unsupportedScene": "{{type}} ainda não pode ser editada",
+ "readOnlyBadge": "{{type}} · somente visualização",
+ "sceneType": {
+ "slide": "Slide",
+ "quiz": "Questionário",
+ "interactive": "Interativa",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "Outra aba está editando este curso",
+ "body": "A edição fica restrita a uma única aba para evitar alterações conflitantes. Feche a outra aba (ou aguarde a sessão dela expirar) antes de entrar no modo de edição aqui.",
+ "actionDismiss": "Entendi"
+ }
+ },
+ "text": {
+ "label": "Texto",
+ "font": "Fonte",
+ "fontDefault": "Padrão",
+ "sizeUp": "Aumentar tamanho",
+ "sizeDown": "Diminuir tamanho",
+ "fontSize": "Tamanho da fonte",
+ "bold": "Negrito",
+ "italic": "Itálico",
+ "underline": "Sublinhado",
+ "color": "Cor do texto",
+ "alignLeft": "Alinhar à esquerda",
+ "alignCenter": "Centralizar",
+ "alignRight": "Alinhar à direita",
+ "bullet": "Lista com marcadores"
+ },
+ "insert": {
+ "textBox": "Caixa de texto",
+ "expandToolbar": "Mostrar ferramentas de inserção",
+ "collapseToolbar": "Ocultar ferramentas de inserção",
+ "image": "Imagem",
+ "imageDrop": "Arraste uma imagem ou clique para escolher um arquivo",
+ "imageOr": "ou cole a URL de uma imagem",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "Inserir"
+ },
+ "nav": {
+ "addSlide": "Adicionar slide",
+ "duplicate": "Duplicar",
+ "delete": "Excluir",
+ "deleted": "Excluído",
+ "undo": "Desfazer",
+ "copySuffix": "(cópia)",
+ "untitledSlide": "Slide sem título",
+ "collapse": "Recolher painel",
+ "expand": "Expandir painel",
+ "dragHandle": "Arraste para reordenar",
+ "deckLabel": "Cenas",
+ "moreActions": "Mais ações",
+ "rename": "Renomear"
+ }
+ },
"stage": {
"currentScene": "Cena Atual",
"generating": "Gerando...",
@@ -149,7 +212,9 @@
"generatingNextPage": "A cena está sendo gerada, aguarde...",
"courseComplete": "Curso concluído",
"fullscreen": "Tela cheia",
- "exitFullscreen": "Sair da tela cheia"
+ "exitFullscreen": "Sair da tela cheia",
+ "editCourse": "Editar curso",
+ "doneEditing": "Concluir edição"
},
"classroomComplete": {
"title": "Curso concluído",
diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json
index 065b64f6ec..1630829a2e 100644
--- a/lib/i18n/locales/ru-RU.json
+++ b/lib/i18n/locales/ru-RU.json
@@ -149,7 +149,72 @@
"generatingNextPage": "Сцена генерируется, пожалуйста подождите...",
"courseComplete": "Курс завершён",
"fullscreen": "Полный экран",
- "exitFullscreen": "Свернуть"
+ "exitFullscreen": "Свернуть",
+ "editCourse": "Редактировать курс",
+ "doneEditing": "Завершить редактирование"
+ },
+ "edit": {
+ "proMode": "Pro",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "delete": "Удалить",
+ "title": "Редактирование · {{type}}",
+ "unsupportedScene": "{{type}} пока нельзя редактировать",
+ "readOnlyBadge": "{{type}} · только просмотр",
+ "sceneType": {
+ "slide": "Слайд",
+ "quiz": "Тест",
+ "interactive": "Интерактив",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "Этот курс уже редактируется в другой вкладке",
+ "body": "Редактирование возможно только в одной вкладке одновременно, чтобы избежать конфликтных изменений. Закройте другую вкладку (или дождитесь истечения её сеанса) перед входом в режим редактирования здесь.",
+ "actionDismiss": "Понятно"
+ }
+ },
+ "text": {
+ "label": "Текст",
+ "font": "Шрифт",
+ "fontDefault": "По умолчанию",
+ "sizeUp": "Увеличить размер",
+ "sizeDown": "Уменьшить размер",
+ "fontSize": "Размер шрифта",
+ "bold": "Полужирный",
+ "italic": "Курсив",
+ "underline": "Подчёркнутый",
+ "color": "Цвет текста",
+ "alignLeft": "По левому краю",
+ "alignCenter": "По центру",
+ "alignRight": "По правому краю",
+ "bullet": "Маркированный список"
+ },
+ "insert": {
+ "textBox": "Текстовое поле",
+ "expandToolbar": "Показать инструменты вставки",
+ "collapseToolbar": "Скрыть инструменты вставки",
+ "image": "Изображение",
+ "imageDrop": "Перетащите изображение или нажмите, чтобы выбрать файл",
+ "imageOr": "или вставьте URL изображения",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "Вставить"
+ },
+ "nav": {
+ "addSlide": "Добавить слайд",
+ "duplicate": "Дублировать",
+ "delete": "Удалить",
+ "deleted": "Удалено",
+ "undo": "Отменить",
+ "copySuffix": "(копия)",
+ "untitledSlide": "Безымянный слайд",
+ "collapse": "Свернуть панель",
+ "expand": "Развернуть панель",
+ "dragHandle": "Перетащите, чтобы переупорядочить",
+ "deckLabel": "Сцены",
+ "moreActions": "Ещё действия",
+ "rename": "Переименовать"
+ }
},
"classroomComplete": {
"title": "Курс завершён",
diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json
index ced729524f..8562c02c18 100644
--- a/lib/i18n/locales/zh-CN.json
+++ b/lib/i18n/locales/zh-CN.json
@@ -149,7 +149,72 @@
"generatingNextPage": "场景正在生成,请稍候...",
"courseComplete": "课程完成",
"fullscreen": "全屏",
- "exitFullscreen": "退出全屏"
+ "exitFullscreen": "退出全屏",
+ "editCourse": "编辑课程",
+ "doneEditing": "完成编辑"
+ },
+ "edit": {
+ "proMode": "专业模式",
+ "undo": "撤销",
+ "redo": "重做",
+ "delete": "删除",
+ "title": "编辑 · {{type}}",
+ "unsupportedScene": "{{type}}暂不支持编辑",
+ "readOnlyBadge": "{{type}} · 仅查看",
+ "sceneType": {
+ "slide": "幻灯片",
+ "quiz": "测验",
+ "interactive": "互动",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "另一个标签页正在编辑此课程",
+ "body": "编辑模式同一时间只允许一个标签页持有,避免变更冲突。请先关闭其他标签页(或等待对方会话超时),再在此进入编辑模式。",
+ "actionDismiss": "知道了"
+ }
+ },
+ "text": {
+ "label": "文本",
+ "font": "字体",
+ "fontDefault": "默认",
+ "sizeUp": "增大字号",
+ "sizeDown": "减小字号",
+ "fontSize": "字号",
+ "bold": "加粗",
+ "italic": "斜体",
+ "underline": "下划线",
+ "color": "文字颜色",
+ "alignLeft": "左对齐",
+ "alignCenter": "居中对齐",
+ "alignRight": "右对齐",
+ "bullet": "项目符号"
+ },
+ "insert": {
+ "textBox": "文本框",
+ "expandToolbar": "展开插入工具",
+ "collapseToolbar": "收起插入工具",
+ "image": "图片",
+ "imageDrop": "拖入图片或点击选择文件",
+ "imageOr": "或粘贴图片 URL",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "插入"
+ },
+ "nav": {
+ "addSlide": "新增幻灯片",
+ "duplicate": "复制",
+ "delete": "删除",
+ "deleted": "已删除",
+ "undo": "撤销",
+ "copySuffix": "(副本)",
+ "untitledSlide": "未命名幻灯片",
+ "collapse": "收起",
+ "expand": "展开",
+ "dragHandle": "拖动",
+ "deckLabel": "场景",
+ "moreActions": "更多操作",
+ "rename": "重命名"
+ }
},
"classroomComplete": {
"title": "课程完成",
diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json
index a2a4e5de2b..fa3ca4ebfa 100644
--- a/lib/i18n/locales/zh-TW.json
+++ b/lib/i18n/locales/zh-TW.json
@@ -149,7 +149,72 @@
"generatingNextPage": "場景正在生成,請稍候...",
"fullscreen": "全螢幕",
"exitFullscreen": "離開全螢幕",
- "courseComplete": "課程完成"
+ "courseComplete": "課程完成",
+ "editCourse": "編輯課程",
+ "doneEditing": "完成編輯"
+ },
+ "edit": {
+ "proMode": "專業模式",
+ "undo": "復原",
+ "redo": "重做",
+ "delete": "刪除",
+ "title": "編輯 · {{type}}",
+ "unsupportedScene": "{{type}}暫不支援編輯",
+ "readOnlyBadge": "{{type}} · 僅檢視",
+ "sceneType": {
+ "slide": "投影片",
+ "quiz": "測驗",
+ "interactive": "互動",
+ "pbl": "PBL"
+ },
+ "multiTab": {
+ "conflict": {
+ "title": "另一個分頁正在編輯此課程",
+ "body": "編輯模式同一時間只允許一個分頁持有,以避免變更衝突。請先關閉其他分頁(或等待對方工作階段逾時),再於此處進入編輯模式。",
+ "actionDismiss": "知道了"
+ }
+ },
+ "text": {
+ "label": "文字",
+ "font": "字型",
+ "fontDefault": "預設",
+ "sizeUp": "放大字級",
+ "sizeDown": "縮小字級",
+ "fontSize": "字級",
+ "bold": "粗體",
+ "italic": "斜體",
+ "underline": "底線",
+ "color": "文字色彩",
+ "alignLeft": "靠左對齊",
+ "alignCenter": "置中對齊",
+ "alignRight": "靠右對齊",
+ "bullet": "項目符號"
+ },
+ "insert": {
+ "textBox": "文字方塊",
+ "expandToolbar": "展開插入工具",
+ "collapseToolbar": "收起插入工具",
+ "image": "圖片",
+ "imageDrop": "拖曳圖片或點按選擇檔案",
+ "imageOr": "或貼上圖片網址",
+ "imageUrlPlaceholder": "https://…",
+ "imageInsert": "插入"
+ },
+ "nav": {
+ "addSlide": "新增投影片",
+ "duplicate": "複製",
+ "delete": "刪除",
+ "deleted": "已刪除",
+ "undo": "復原",
+ "copySuffix": "(副本)",
+ "untitledSlide": "未命名投影片",
+ "collapse": "收合側欄",
+ "expand": "展開側欄",
+ "dragHandle": "拖曳重新排序",
+ "deckLabel": "場景",
+ "moreActions": "更多操作",
+ "rename": "重新命名"
+ }
},
"whiteboard": {
"title": "互動白板",
diff --git a/lib/prosemirror/active-editor-registry.ts b/lib/prosemirror/active-editor-registry.ts
new file mode 100644
index 0000000000..3eb7642510
--- /dev/null
+++ b/lib/prosemirror/active-editor-registry.ts
@@ -0,0 +1,41 @@
+/**
+ * Minimal additive bridge so the editor surface (chrome side) can drive the
+ * renderer's ProseMirror commands without the renderer importing chrome or the
+ * surface importing renderer internals. The renderer registers a runner while
+ * an element is being edited; the property bar calls it. PR1-shaped: the
+ * playback/uncontrolled path never registers (editable=false), so behavior
+ * there is unchanged.
+ */
+export interface TextCommandPayload {
+ command:
+ | 'bold'
+ | 'em'
+ | 'underline'
+ | 'fontname'
+ | 'fontsize'
+ | 'forecolor'
+ | 'align-left'
+ | 'align-center'
+ | 'align-right'
+ | 'bulletList';
+ value?: string;
+}
+
+type Runner = (payload: TextCommandPayload) => void;
+
+const runners = new Map();
+
+export function registerActiveTextEditor(elementId: string, run: Runner): () => void {
+ runners.set(elementId, run);
+ return () => {
+ if (runners.get(elementId) === run) runners.delete(elementId);
+ };
+}
+
+export function hasActiveTextEditor(elementId: string): boolean {
+ return runners.has(elementId);
+}
+
+export function runActiveTextCommand(elementId: string, payload: TextCommandPayload): void {
+ runners.get(elementId)?.(payload);
+}
diff --git a/lib/prosemirror/schema/marks.ts b/lib/prosemirror/schema/marks.ts
index 7e15f1123d..e80ba99ddf 100644
--- a/lib/prosemirror/schema/marks.ts
+++ b/lib/prosemirror/schema/marks.ts
@@ -134,7 +134,15 @@ const fontname: MarkSpec = {
toDOM: (mark) => {
const { fontname } = mark.attrs;
let style = '';
- if (fontname) style += `font-family: ${fontname};`;
+ // Quote the family name — unquoted, a name with spaces or a trailing digit
+ // (e.g. "Source Sans 3") is an invalid font-family value and gets dropped.
+ // parseDOM's getAttrs strips the quotes again, so the attr round-trips clean.
+ // Reject `"` or `\` (illegal in a CSS family name): rendering them unescaped
+ // here would let a hand-crafted mark close the quoted string and inject
+ // arbitrary CSS at `toDOM`.
+ if (fontname && !/["\\]/.test(fontname)) {
+ style += `font-family: "${fontname}";`;
+ }
return ['span', { style }, 0];
},
};
diff --git a/lib/prosemirror/selection-sync.ts b/lib/prosemirror/selection-sync.ts
new file mode 100644
index 0000000000..381b729229
--- /dev/null
+++ b/lib/prosemirror/selection-sync.ts
@@ -0,0 +1,6 @@
+import type { Transaction } from 'prosemirror-state';
+
+/** Push toolbar attrs when the selection moved or marks/doc changed. */
+export function shouldPushAttrs(tr: Transaction): boolean {
+ return tr.selectionSet || tr.docChanged || tr.storedMarksSet;
+}
diff --git a/lib/store/settings.ts b/lib/store/settings.ts
index f6ffebb2b1..8ec066ab2c 100644
--- a/lib/store/settings.ts
+++ b/lib/store/settings.ts
@@ -193,6 +193,9 @@ export interface SettingsState {
sidebarCollapsed: boolean;
chatAreaCollapsed: boolean;
chatAreaWidth: number;
+ editRailCollapsed: boolean;
+ editRailWidth: number;
+ editInsertToolbarCollapsed: boolean;
// Actions
setModel: (providerId: ProviderId, modelId: string) => void;
@@ -216,6 +219,9 @@ export interface SettingsState {
setSidebarCollapsed: (collapsed: boolean) => void;
setChatAreaCollapsed: (collapsed: boolean) => void;
setChatAreaWidth: (width: number) => void;
+ setEditRailCollapsed: (collapsed: boolean) => void;
+ setEditInsertToolbarCollapsed: (collapsed: boolean) => void;
+ setEditRailWidth: (width: number) => void;
// Audio actions
setTTSProvider: (providerId: TTSProviderId) => void;
@@ -801,6 +807,9 @@ export const useSettingsStore = create()(
sidebarCollapsed: true,
chatAreaCollapsed: true,
chatAreaWidth: 320,
+ editRailCollapsed: false,
+ editRailWidth: 220,
+ editInsertToolbarCollapsed: false,
// Audio settings (use defaults)
...defaultAudioConfig,
@@ -907,6 +916,10 @@ export const useSettingsStore = create()(
// Layout actions
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }),
+ setEditRailCollapsed: (collapsed) => set({ editRailCollapsed: collapsed }),
+ setEditRailWidth: (width) => set({ editRailWidth: width }),
+ setEditInsertToolbarCollapsed: (collapsed) =>
+ set({ editInsertToolbarCollapsed: collapsed }),
setChatAreaWidth: (width) => set({ chatAreaWidth: width }),
// Audio actions
diff --git a/lib/store/stage.ts b/lib/store/stage.ts
index d2732c5327..edff91eb71 100644
--- a/lib/store/stage.ts
+++ b/lib/store/stage.ts
@@ -4,6 +4,8 @@ import { createSelectors } from '@/lib/utils/create-selectors';
import type { ChatSession } from '@/lib/types/chat';
import type { SceneOutline } from '@/lib/types/generation';
import { createLogger } from '@/lib/logger';
+import { useCanvasStore } from '@/lib/store/canvas';
+import { migrateScene } from '@/lib/edit/slide-schema';
const log = createLogger('StageStore');
@@ -69,6 +71,7 @@ interface StageState {
setStage: (stage: Stage) => void;
setScenes: (scenes: Scene[]) => void;
addScene: (scene: Scene) => void;
+ insertSceneAfter: (anchorSceneId: string, scene: Scene) => void;
updateScene: (sceneId: string, updates: Partial) => void;
deleteScene: (sceneId: string) => void;
setCurrentSceneId: (sceneId: string | null) => void;
@@ -123,10 +126,14 @@ const useStageStoreBase = create()((set, get) => ({
},
setScenes: (scenes) => {
- set({ scenes });
+ // Funnel through migrateScene so any incoming slide content lacking
+ // a schemaVersion (API / snapshot / legacy) is normalized once at
+ // the store boundary.
+ const migrated = scenes.map(migrateScene);
+ set({ scenes: migrated });
// Auto-select first scene if no current scene
- if (!get().currentSceneId && scenes.length > 0) {
- set({ currentSceneId: scenes[0].id });
+ if (!get().currentSceneId && migrated.length > 0) {
+ set({ currentSceneId: migrated[0].id });
}
debouncedSave();
},
@@ -140,7 +147,7 @@ const useStageStoreBase = create()((set, get) => ({
);
return;
}
- const scenes = [...get().scenes, scene];
+ const scenes = [...get().scenes, migrateScene(scene)];
// Remove the matching outline from generatingOutlines (match by order)
const generatingOutlines = get().generatingOutlines.filter((o) => o.order !== scene.order);
// Auto-switch from pending page to the newly generated scene
@@ -153,6 +160,28 @@ const useStageStoreBase = create()((set, get) => ({
debouncedSave();
},
+ insertSceneAfter: (anchorSceneId, scene) => {
+ // Pro mode slide management entry point — inserts after the anchor and
+ // rebalances `order` so PPTX export / array position stay consistent.
+ // Edit mode is gated against active regeneration (see useEditModeLock),
+ // so rewriting `order` here is safe — no outline matcher is racing us.
+ const currentStage = get().stage;
+ if (!currentStage || scene.stageId !== currentStage.id) {
+ log.warn(
+ `insertSceneAfter ignored "${scene.title}" - stageId mismatch (scene: ${scene.stageId}, current: ${currentStage?.id})`,
+ );
+ return;
+ }
+ const current = get().scenes;
+ const anchorIndex = current.findIndex((s) => s.id === anchorSceneId);
+ const insertIndex = anchorIndex < 0 ? current.length : anchorIndex + 1;
+ const migrated = migrateScene(scene);
+ const next = [...current.slice(0, insertIndex), migrated, ...current.slice(insertIndex)];
+ const rebalanced = next.map((s, i) => (s.order === i + 1 ? s : { ...s, order: i + 1 }));
+ set({ scenes: rebalanced });
+ debouncedSave();
+ },
+
updateScene: (sceneId, updates) => {
const scenes = get().scenes.map((scene) =>
scene.id === sceneId ? { ...scene, ...updates } : scene,
@@ -189,7 +218,14 @@ const useStageStoreBase = create()((set, get) => ({
debouncedSave();
},
- setMode: (mode) => set({ mode }),
+ setMode: (mode) => {
+ const previousMode = get().mode;
+ set({ mode });
+
+ if (previousMode === 'edit' && mode !== 'edit') {
+ useCanvasStore.getState().resetCanvasState();
+ }
+ },
setToolbarState: (toolbarState) => set({ toolbarState }),
@@ -286,14 +322,25 @@ const useStageStoreBase = create()((set, get) => ({
const outlines = outlinesRecord?.outlines || [];
if (data) {
+ // Normalize legacy slide content (missing schemaVersion) at the load
+ // boundary, same as setScenes/addScene — IndexedDB snapshots predate
+ // the schema field, so they must be migrated on the way in.
+ const migrated = data.scenes.map(migrateScene);
set({
stage: data.stage,
- scenes: data.scenes,
+ scenes: migrated,
currentSceneId: data.currentSceneId,
chats: data.chats,
outlines,
// Compute generatingOutlines from persisted outlines minus completed scenes
- generatingOutlines: outlines.filter((o) => !data.scenes.some((s) => s.order === o.order)),
+ generatingOutlines: outlines.filter((o) => !migrated.some((s) => s.order === o.order)),
+ // `mode` is transient UI state, not persisted with the stage.
+ // Reset to 'playback' on every load so SPA navigation between
+ // classrooms doesn't carry Pro-mode state across — e.g. user
+ // enters edit in A, navigates to B → B was inheriting
+ // mode='edit'. Refresh already reset via initial store value;
+ // this normalises the SPA path to match.
+ mode: 'playback',
});
log.info('Loaded from storage:', stageId);
} else {
diff --git a/lib/types/stage.ts b/lib/types/stage.ts
index 458b46b311..5d43bfba40 100644
--- a/lib/types/stage.ts
+++ b/lib/types/stage.ts
@@ -6,7 +6,7 @@ import type { WidgetType, WidgetConfig, TeacherAction } from '@/lib/types/widget
export type SceneType = 'slide' | 'quiz' | 'interactive' | 'pbl';
-export type StageMode = 'autonomous' | 'playback';
+export type StageMode = 'autonomous' | 'playback' | 'edit';
export type Whiteboard = Omit;
@@ -97,10 +97,16 @@ export interface Scene {
export type SceneContent = SlideContent | QuizContent | InteractiveContent | PBLContent;
/**
- * Slide content - PPTist Canvas data
+ * Slide content - PPTist Canvas data.
+ *
+ * `schemaVersion` tags the on-disk shape of this content so future schema
+ * changes can ship behind a migration step (see `migrateSlideContent`).
+ * Optional for backward compatibility — legacy / pre-versioning data
+ * lacks the field and `migrateSlideContent` normalizes it.
*/
export interface SlideContent {
type: 'slide';
+ schemaVersion?: number;
// PPTist slide data structure
canvas: Slide;
}
diff --git a/package.json b/package.json
index 65c6f5c601..7a159e6dca 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,18 @@
"@copilotkit/backend": "^0.37.0",
"@copilotkit/runtime": "^1.51.2",
"@fontsource-variable/inter": "^5.2.8",
+ "@fontsource/jetbrains-mono": "^5.2.8",
+ "@fontsource/literata": "^5.2.8",
+ "@fontsource/lxgw-wenkai": "^5.2.5",
+ "@fontsource/merriweather": "^5.2.11",
+ "@fontsource/montserrat": "^5.2.8",
+ "@fontsource/noto-sans-sc": "^5.2.9",
+ "@fontsource/noto-serif-sc": "^5.2.8",
+ "@fontsource/open-sans": "^5.2.7",
+ "@fontsource/roboto": "^5.2.10",
+ "@fontsource/source-sans-3": "^5.2.9",
+ "@fontsource/source-serif-4": "^5.2.9",
+ "@fontsource/zcool-kuaile": "^5.2.8",
"@langchain/core": "^1.1.16",
"@langchain/langgraph": "^1.1.1",
"@modelcontextprotocol/sdk": "^1.27.1",
@@ -86,6 +98,7 @@
"prosemirror-view": "^1.41.5",
"radix-ui": "^1.4.3",
"react": "19.2.3",
+ "react-colorful": "^5.7.0",
"react-dom": "19.2.3",
"react-i18next": "^17.0.1",
"shadcn": "^3.6.3",
diff --git a/playwright.config.ts b/playwright.config.ts
index e3adb5f64b..c52bab69f9 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -23,6 +23,9 @@ export default defineConfig({
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
- env: { PORT: '3002' },
+ // Enable the MAIC Editor (Pro mode) so editor e2e can reach it. This is a
+ // build-time NEXT_PUBLIC_* flag, so it must be set when the webServer runs
+ // `pnpm build` (CI) or `pnpm dev` (local).
+ env: { PORT: '3002', NEXT_PUBLIC_MAIC_EDITOR_ENABLED: 'true' },
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a3c254919..cfedc3d18d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,42 @@ importers:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
+ '@fontsource/jetbrains-mono':
+ specifier: ^5.2.8
+ version: 5.2.8
+ '@fontsource/literata':
+ specifier: ^5.2.8
+ version: 5.2.8
+ '@fontsource/lxgw-wenkai':
+ specifier: ^5.2.5
+ version: 5.2.5
+ '@fontsource/merriweather':
+ specifier: ^5.2.11
+ version: 5.2.11
+ '@fontsource/montserrat':
+ specifier: ^5.2.8
+ version: 5.2.8
+ '@fontsource/noto-sans-sc':
+ specifier: ^5.2.9
+ version: 5.2.9
+ '@fontsource/noto-serif-sc':
+ specifier: ^5.2.8
+ version: 5.2.8
+ '@fontsource/open-sans':
+ specifier: ^5.2.7
+ version: 5.2.7
+ '@fontsource/roboto':
+ specifier: ^5.2.10
+ version: 5.2.10
+ '@fontsource/source-sans-3':
+ specifier: ^5.2.9
+ version: 5.2.9
+ '@fontsource/source-serif-4':
+ specifier: ^5.2.9
+ version: 5.2.9
+ '@fontsource/zcool-kuaile':
+ specifier: ^5.2.8
+ version: 5.2.8
'@langchain/core':
specifier: ^1.1.16
version: 1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))
@@ -194,6 +230,9 @@ importers:
react:
specifier: 19.2.3
version: 19.2.3
+ react-colorful:
+ specifier: ^5.7.0
+ version: 5.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
@@ -779,24 +818,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-arm64@1.9.4':
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-linux-x64-musl@1.9.4':
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-x64@1.9.4':
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-win32-arm64@1.9.4':
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
@@ -1120,6 +1163,42 @@ packages:
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
+ '@fontsource/jetbrains-mono@5.2.8':
+ resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==}
+
+ '@fontsource/literata@5.2.8':
+ resolution: {integrity: sha512-Rj3eEQeu7+yBw9ZjXxcRiKDDrhbhpCyb0b79VJVCjDF1fdgJ8gDr9C1SRFLwfi+VaHEkhJDnRGm25fq1Yk+wNg==}
+
+ '@fontsource/lxgw-wenkai@5.2.5':
+ resolution: {integrity: sha512-lIBouZW7qNw3VdVnjc0NC80iDfxjU7OnaKGaz6QHiHjC8z0fv9MyrHpeBMzkPnyEVabcpUu4Wf8DnaihfGVYtw==}
+
+ '@fontsource/merriweather@5.2.11':
+ resolution: {integrity: sha512-ZiIMeUh5iT8d73o6xlSF8GKgjV5pgiFrufYc5jZTVAfExtWKqM2vQHnsqXSFMv4ELhAcjt6Vf+5T3oVGXhAizQ==}
+
+ '@fontsource/montserrat@5.2.8':
+ resolution: {integrity: sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==}
+
+ '@fontsource/noto-sans-sc@5.2.9':
+ resolution: {integrity: sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==}
+
+ '@fontsource/noto-serif-sc@5.2.8':
+ resolution: {integrity: sha512-C7fAr+d1GjOAw1qIbntsnqbA3l5dkdzcmNNgeCXLC8QZ7VNubL7MTrX8UcYKHceX4mI//z8gGtXbOeeQrB6P7g==}
+
+ '@fontsource/open-sans@5.2.7':
+ resolution: {integrity: sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw==}
+
+ '@fontsource/roboto@5.2.10':
+ resolution: {integrity: sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==}
+
+ '@fontsource/source-sans-3@5.2.9':
+ resolution: {integrity: sha512-u3ymIq4rfmCCyB9MEw/sFR5lPVJ1yTNXmIMbUz+9kVCFIHvNtfzXOEBuvkg3Tk0zhmioPeJ28ZK5smZ7TurezQ==}
+
+ '@fontsource/source-serif-4@5.2.9':
+ resolution: {integrity: sha512-er/Pym9emsEVJNf947umJ4kXarXfsiN6CN7kTYinefKRaHLwiquiiHOZvKvxWgkV8JMCf3pV3g0NcsPFpVCH9w==}
+
+ '@fontsource/zcool-kuaile@5.2.8':
+ resolution: {integrity: sha512-pIeFNRM0rwrJkO+TP7WG+KXvqy0YFRrPecNNzHTNOQLYhxrqN67/wzyx3fH+Ky63Y/Am5heB5XIzsEx/Io+P4w==}
+
'@google/generative-ai@0.11.5':
resolution: {integrity: sha512-DviMgrnljEKh6qkDT2pVFW+NEuVhggqBUoEnyy2PNL7l4ewxXRJubk3PctC9yPl1AdRIlhqP7E076QQt+IWuTg==}
engines: {node: '>=18.0.0'}
@@ -1246,89 +1325,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1988,30 +2083,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.96':
resolution: {integrity: sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.96':
resolution: {integrity: sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.96':
resolution: {integrity: sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.96':
resolution: {integrity: sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.96':
resolution: {integrity: sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==}
@@ -2061,24 +2161,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.2':
resolution: {integrity: sha512-Sn6LxPIZcADe5AnqqMCfwBv6vRtDikhtrjwhu+19WM6jHZe31JDRcGuPZAlJrDk6aEbNBPUUAKmySJELkBOesg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@16.1.2':
resolution: {integrity: sha512-nwzesEQBfQIOOnQ7JArzB08w9qwvBQ7nC1i8gb0tiEFH94apzQM3IRpY19MlE8RBHxc9ArG26t1DEg2aaLaqVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@16.1.2':
resolution: {integrity: sha512-s60bLf16BDoICQHeKEm0lDgUNMsL1UpQCkRNZk08ZNnRpK0QUV+6TvVHuBcIA7oItzU0m7kVmXe8QjXngYxJVA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.2':
resolution: {integrity: sha512-Sq8k4SZd8Y8EokKdz304TvMO9HoiwGzo0CTacaiN1bBtbJSQ1BIwKzNFeFdxOe93SHn1YGnKXG6Mq3N+tVooyQ==}
@@ -2909,36 +3013,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==}
@@ -3031,66 +3141,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -3249,24 +3372,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -3572,41 +3699,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -6710,48 +6845,56 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -7913,6 +8056,12 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
+ react-colorful@5.7.0:
+ resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
@@ -10387,6 +10536,30 @@ snapshots:
'@fontsource-variable/inter@5.2.8': {}
+ '@fontsource/jetbrains-mono@5.2.8': {}
+
+ '@fontsource/literata@5.2.8': {}
+
+ '@fontsource/lxgw-wenkai@5.2.5': {}
+
+ '@fontsource/merriweather@5.2.11': {}
+
+ '@fontsource/montserrat@5.2.8': {}
+
+ '@fontsource/noto-sans-sc@5.2.9': {}
+
+ '@fontsource/noto-serif-sc@5.2.8': {}
+
+ '@fontsource/open-sans@5.2.7': {}
+
+ '@fontsource/roboto@5.2.10': {}
+
+ '@fontsource/source-sans-3@5.2.9': {}
+
+ '@fontsource/source-serif-4@5.2.9': {}
+
+ '@fontsource/zcool-kuaile@5.2.8': {}
+
'@google/generative-ai@0.11.5': {}
'@graphql-tools/executor@1.5.1(graphql@16.13.1)':
@@ -17845,6 +18018,11 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
+ react-colorful@5.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
diff --git a/tests/config/feature-flags.test.ts b/tests/config/feature-flags.test.ts
new file mode 100644
index 0000000000..86744e5955
--- /dev/null
+++ b/tests/config/feature-flags.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
+
+const FLAG = 'NEXT_PUBLIC_MAIC_EDITOR_ENABLED';
+
+describe('isMaicEditorEnabled', () => {
+ let original: string | undefined;
+
+ beforeEach(() => {
+ original = process.env[FLAG];
+ });
+
+ afterEach(() => {
+ if (original === undefined) {
+ delete process.env[FLAG];
+ } else {
+ process.env[FLAG] = original;
+ }
+ });
+
+ it('returns false when the env var is unset', () => {
+ delete process.env[FLAG];
+ expect(isMaicEditorEnabled()).toBe(false);
+ });
+
+ it("returns true for 'true'", () => {
+ process.env[FLAG] = 'true';
+ expect(isMaicEditorEnabled()).toBe(true);
+ });
+
+ it("returns true for '1'", () => {
+ process.env[FLAG] = '1';
+ expect(isMaicEditorEnabled()).toBe(true);
+ });
+
+ it("returns false for 'false'", () => {
+ process.env[FLAG] = 'false';
+ expect(isMaicEditorEnabled()).toBe(false);
+ });
+
+ it('returns false for an unrecognized string', () => {
+ process.env[FLAG] = 'yes';
+ expect(isMaicEditorEnabled()).toBe(false);
+ });
+});
diff --git a/tests/edit/deleted-scene-recycle.test.ts b/tests/edit/deleted-scene-recycle.test.ts
new file mode 100644
index 0000000000..5265bd3899
--- /dev/null
+++ b/tests/edit/deleted-scene-recycle.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle';
+import type { Scene } from '@/lib/types/stage';
+
+function makeScene(id: string, stageId = 'stage-1'): Scene {
+ return {
+ id,
+ stageId,
+ type: 'slide',
+ title: 'T',
+ order: 1,
+ content: {
+ type: 'slide',
+ canvas: {
+ id: 'c',
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#fff',
+ themeColors: ['#000'],
+ fontColor: '#000',
+ fontName: 'Inter',
+ },
+ elements: [],
+ },
+ },
+ };
+}
+
+afterEach(() => {
+ useDeletedSceneRecycle.getState().clear();
+});
+
+describe('useDeletedSceneRecycle', () => {
+ it('captures a scene with its original index', () => {
+ const s = makeScene('a');
+ useDeletedSceneRecycle.getState().capture(s, 3);
+ const pending = useDeletedSceneRecycle.getState().pending;
+ expect(pending?.scene).toBe(s);
+ expect(pending?.index).toBe(3);
+ expect(pending?.stageId).toBe('stage-1');
+ });
+
+ it('consume returns the entry and clears the slot', () => {
+ const s = makeScene('a');
+ useDeletedSceneRecycle.getState().capture(s, 2);
+ const entry = useDeletedSceneRecycle.getState().consume();
+ expect(entry?.scene).toBe(s);
+ expect(useDeletedSceneRecycle.getState().pending).toBeNull();
+ });
+
+ it('consume returns null when no entry is pending', () => {
+ expect(useDeletedSceneRecycle.getState().consume()).toBeNull();
+ });
+
+ it('a second capture evicts the previous pending entry', () => {
+ const first = makeScene('a');
+ const second = makeScene('b');
+ useDeletedSceneRecycle.getState().capture(first, 0);
+ useDeletedSceneRecycle.getState().capture(second, 5);
+ const pending = useDeletedSceneRecycle.getState().pending;
+ expect(pending?.scene).toBe(second);
+ expect(pending?.index).toBe(5);
+ });
+
+ it('clear empties the slot without returning anything', () => {
+ useDeletedSceneRecycle.getState().capture(makeScene('a'), 0);
+ useDeletedSceneRecycle.getState().clear();
+ expect(useDeletedSceneRecycle.getState().pending).toBeNull();
+ });
+});
diff --git a/tests/edit/edit-mode-lock.test.ts b/tests/edit/edit-mode-lock.test.ts
new file mode 100644
index 0000000000..4117cd4851
--- /dev/null
+++ b/tests/edit/edit-mode-lock.test.ts
@@ -0,0 +1,155 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ editLockKey,
+ isEditLockHeldByOther,
+ LOCK_HEARTBEAT_MS,
+ LOCK_STALE_MS,
+ readEditLock,
+ refreshEditLock,
+ releaseEditLock,
+ tryAcquireEditLock,
+} from '@/lib/edit/edit-mode-lock';
+
+class MemoryStorage {
+ private store = new Map();
+ get length() {
+ return this.store.size;
+ }
+ getItem(k: string) {
+ return this.store.has(k) ? (this.store.get(k) as string) : null;
+ }
+ setItem(k: string, v: string) {
+ this.store.set(k, v);
+ }
+ removeItem(k: string) {
+ this.store.delete(k);
+ }
+ clear() {
+ this.store.clear();
+ }
+ key(i: number) {
+ return Array.from(this.store.keys())[i] ?? null;
+ }
+}
+
+let original: typeof globalThis.localStorage | undefined;
+
+beforeEach(() => {
+ original = (globalThis as { localStorage?: typeof globalThis.localStorage }).localStorage;
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: new MemoryStorage(),
+ configurable: true,
+ writable: true,
+ });
+});
+
+afterEach(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: original,
+ configurable: true,
+ writable: true,
+ });
+});
+
+describe('editLockKey', () => {
+ it('is scoped per course id', () => {
+ expect(editLockKey('course-A')).not.toBe(editLockKey('course-B'));
+ });
+});
+
+describe('tryAcquireEditLock', () => {
+ it('grants the lock when nothing is held', () => {
+ expect(tryAcquireEditLock('c1', 'tab-A')).toBe(true);
+ expect(readEditLock('c1')?.tabId).toBe('tab-A');
+ });
+
+ it('grants the lock when our own tab already holds it', () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ expect(tryAcquireEditLock('c1', 'tab-A', 2000)).toBe(true);
+ expect(readEditLock('c1')?.timestamp).toBe(2000);
+ });
+
+ it('refuses when another tab holds a fresh lock', () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ expect(tryAcquireEditLock('c1', 'tab-B', 1000 + LOCK_HEARTBEAT_MS)).toBe(false);
+ // Original owner unchanged.
+ expect(readEditLock('c1')?.tabId).toBe('tab-A');
+ });
+
+ it('steals a stale lock from a crashed tab past LOCK_STALE_MS', () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ expect(tryAcquireEditLock('c1', 'tab-B', 1000 + LOCK_STALE_MS + 1)).toBe(true);
+ expect(readEditLock('c1')?.tabId).toBe('tab-B');
+ });
+
+ it('does not bleed across courses', () => {
+ tryAcquireEditLock('c1', 'tab-A');
+ expect(tryAcquireEditLock('c2', 'tab-B')).toBe(true);
+ });
+});
+
+describe('isEditLockHeldByOther', () => {
+ it('returns false when nobody holds the lock', () => {
+ expect(isEditLockHeldByOther('c1', 'tab-A')).toBe(false);
+ });
+
+ it('returns false when our tab holds the lock', () => {
+ tryAcquireEditLock('c1', 'tab-A');
+ expect(isEditLockHeldByOther('c1', 'tab-A')).toBe(false);
+ });
+
+ it('returns true when another tab holds a fresh lock', () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ expect(isEditLockHeldByOther('c1', 'tab-B', 1000 + LOCK_HEARTBEAT_MS)).toBe(true);
+ });
+
+ it("returns false when the other tab's lock is stale", () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ expect(isEditLockHeldByOther('c1', 'tab-B', 1000 + LOCK_STALE_MS + 1)).toBe(false);
+ });
+});
+
+describe('refreshEditLock', () => {
+ it('updates the timestamp for our tab', () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ refreshEditLock('c1', 'tab-A', 5000);
+ expect(readEditLock('c1')?.timestamp).toBe(5000);
+ });
+
+ it("does not overwrite another tab's lock", () => {
+ tryAcquireEditLock('c1', 'tab-A', 1000);
+ refreshEditLock('c1', 'tab-B', 2000);
+ expect(readEditLock('c1')?.tabId).toBe('tab-A');
+ expect(readEditLock('c1')?.timestamp).toBe(1000);
+ });
+});
+
+describe('releaseEditLock', () => {
+ it('removes the lock when we own it', () => {
+ tryAcquireEditLock('c1', 'tab-A');
+ releaseEditLock('c1', 'tab-A');
+ expect(readEditLock('c1')).toBeNull();
+ });
+
+ it('is a no-op when another tab owns the lock', () => {
+ tryAcquireEditLock('c1', 'tab-A');
+ releaseEditLock('c1', 'tab-B');
+ expect(readEditLock('c1')?.tabId).toBe('tab-A');
+ });
+
+ it('is a no-op when no lock exists', () => {
+ expect(() => releaseEditLock('c1', 'tab-A')).not.toThrow();
+ });
+});
+
+describe('graceful degradation', () => {
+ it('returns null when stored JSON is corrupted', () => {
+ localStorage.setItem(editLockKey('c1'), '{not json');
+ expect(readEditLock('c1')).toBeNull();
+ });
+
+ it('returns null when stored shape is wrong', () => {
+ localStorage.setItem(editLockKey('c1'), JSON.stringify({ wrong: 'shape' }));
+ expect(readEditLock('c1')).toBeNull();
+ });
+});
diff --git a/tests/edit/regen-lock.test.ts b/tests/edit/regen-lock.test.ts
new file mode 100644
index 0000000000..9191d7e198
--- /dev/null
+++ b/tests/edit/regen-lock.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { isSceneEditLocked } from '@/lib/edit/regen-lock';
+
+describe('isSceneEditLocked', () => {
+ it('returns true only when edit mode owns the same scene', () => {
+ expect(isSceneEditLocked({ sceneId: 'A', mode: 'edit', currentSceneId: 'A' })).toBe(true);
+ });
+
+ it('returns false when edit mode owns a different scene', () => {
+ expect(isSceneEditLocked({ sceneId: 'A', mode: 'edit', currentSceneId: 'B' })).toBe(false);
+ });
+
+ it('returns false when not in edit mode', () => {
+ expect(isSceneEditLocked({ sceneId: 'A', mode: 'playback', currentSceneId: 'A' })).toBe(false);
+ expect(isSceneEditLocked({ sceneId: 'A', mode: 'autonomous', currentSceneId: 'A' })).toBe(
+ false,
+ );
+ });
+
+ it('returns false when there is no current scene', () => {
+ expect(isSceneEditLocked({ sceneId: 'A', mode: 'edit', currentSceneId: null })).toBe(false);
+ });
+});
diff --git a/tests/edit/round-trip/fixtures.ts b/tests/edit/round-trip/fixtures.ts
new file mode 100644
index 0000000000..f0a632d071
--- /dev/null
+++ b/tests/edit/round-trip/fixtures.ts
@@ -0,0 +1,34 @@
+import { createDefaultSlide, createDefaultTextElement } from '@/lib/edit/slide-edit-elements';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import type { Slide } from '@/lib/types/slides';
+
+/**
+ * Build a minimal valid Scene/SlideContent/Slide trio for the round-trip
+ * harness. The slide carries a single default text element so each test
+ * has a stable target to act on.
+ */
+export function makeSlideFixture(): {
+ scene: Scene;
+ content: SlideContent;
+ slide: Slide;
+ textElementId: string;
+} {
+ const slide = createDefaultSlide('slide-1');
+ const text = createDefaultTextElement('text-1');
+ slide.elements.push(text);
+ const content: SlideContent = { type: 'slide', canvas: slide };
+ const scene: Scene = {
+ id: 'scene-1',
+ stageId: 'stage-1',
+ type: 'slide',
+ title: 'Fixture slide',
+ order: 1,
+ content,
+ };
+ return { scene, content, slide, textElementId: text.id };
+}
+
+export const VIEWPORT_SIZE = 1000;
+export const VIEWPORT_RATIO = 0.5625;
+export const RATIO_PX2_INCH = 96 * (VIEWPORT_SIZE / 960);
+export const RATIO_PX2_PT = (96 / 72) * (VIEWPORT_SIZE / 960);
diff --git a/tests/edit/round-trip/geometry.test.ts b/tests/edit/round-trip/geometry.test.ts
new file mode 100644
index 0000000000..17bb022cc9
--- /dev/null
+++ b/tests/edit/round-trip/geometry.test.ts
@@ -0,0 +1,74 @@
+import JSZip from 'jszip';
+import { describe, expect, it } from 'vitest';
+import { buildPptxBlob } from '@/lib/export/use-export-pptx';
+import { applySlideEditOperation } from '@/lib/edit/slide-ops';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import {
+ makeSlideFixture,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+} from './fixtures';
+
+/**
+ * Per-op round-trip for the `element.update` geometry op (PR1's only
+ * editing op). Asserts the moved/resized/rotated geometry survives the
+ * export pipeline as the exact PPTX `` / `` / `rot`
+ * attributes — and that the un-edited fixture does NOT already carry them,
+ * so the assertion proves the op drove the change rather than matching
+ * boilerplate. See the harness note in `text-content.test.ts`.
+ */
+async function exportSlideContent(content: SlideContent, scene: Scene): Promise {
+ return buildPptxBlob(
+ [content.canvas],
+ [scene],
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ );
+}
+
+async function readPptxEntry(blob: Blob, path: string): Promise {
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const entry = zip.file(path);
+ if (!entry) throw new Error(`PPTX did not contain entry: ${path}`);
+ return entry.async('string');
+}
+
+// PPTX uses EMU for position/size and 60000ths of a degree for rotation.
+const px2emu = (px: number) => Math.round((px * 914400) / RATIO_PX2_INCH);
+const deg2rot = (deg: number) => deg * 60000;
+
+describe('round-trip: element.update geometry', () => {
+ it('serializes moved/resized/rotated geometry into the slide xml', async () => {
+ const { scene, content, textElementId } = makeSlideFixture();
+ const GEO = { left: 333, top: 222, width: 444, height: 111, rotate: 45 };
+
+ const before = await readPptxEntry(
+ await exportSlideContent(content, scene),
+ 'ppt/slides/slide1.xml',
+ );
+ const offNeedle = ` `;
+ const extNeedle = ` `;
+ const rotNeedle = `rot="${deg2rot(GEO.rotate)}"`;
+ // Guard against a tautology: the default fixture must not already match.
+ expect(before).not.toContain(offNeedle);
+ expect(before).not.toContain(rotNeedle);
+
+ const after = applySlideEditOperation(content, {
+ type: 'element.update',
+ elementId: textElementId,
+ patch: GEO,
+ });
+ const slideXml = await readPptxEntry(
+ await exportSlideContent(after, scene),
+ 'ppt/slides/slide1.xml',
+ );
+
+ expect(slideXml).toContain(offNeedle);
+ expect(slideXml).toContain(extNeedle);
+ expect(slideXml).toContain(rotNeedle);
+ });
+});
diff --git a/tests/edit/round-trip/image-data-url.test.ts b/tests/edit/round-trip/image-data-url.test.ts
new file mode 100644
index 0000000000..c80852cd5d
--- /dev/null
+++ b/tests/edit/round-trip/image-data-url.test.ts
@@ -0,0 +1,58 @@
+import JSZip from 'jszip';
+import { describe, expect, it } from 'vitest';
+import { buildPptxBlob } from '@/lib/export/use-export-pptx';
+import { applySlideEditOperation } from '@/lib/edit/slide-ops';
+import { createDefaultImageElement } from '@/lib/edit/slide-edit-elements';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import {
+ makeSlideFixture,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+} from './fixtures';
+
+/**
+ * 1×1 transparent PNG as a data URL — the canonical output of a local-file
+ * upload when OpenMAIC has no upload backend.
+ */
+const DATA_URL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
+
+async function exportSlideContent(content: SlideContent, scene: Scene): Promise {
+ return buildPptxBlob(
+ [content.canvas],
+ [scene],
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ );
+}
+
+async function readPptxEntry(blob: Blob, path: string): Promise {
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const entry = zip.file(path);
+ if (!entry) throw new Error(`PPTX did not contain entry: ${path}`);
+ return entry.async('string');
+}
+
+describe('round-trip: image with data URL src (PR2 R1 gate)', () => {
+ it('exports a data-URL image element to a non-empty PPTX without network fetch', async () => {
+ const { scene, content } = makeSlideFixture();
+
+ const after = applySlideEditOperation(content, {
+ type: 'element.add',
+ element: createDefaultImageElement('img-dataurl-1', DATA_URL),
+ });
+
+ const blob = await exportSlideContent(after, scene);
+
+ // Basic size guard — a valid PPTX is always several KB at minimum.
+ expect(blob.size).toBeGreaterThan(0);
+
+ // The slide XML entry must be present and non-empty.
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+ expect(slideXml.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/edit/round-trip/insert.test.ts b/tests/edit/round-trip/insert.test.ts
new file mode 100644
index 0000000000..dea88ca269
--- /dev/null
+++ b/tests/edit/round-trip/insert.test.ts
@@ -0,0 +1,88 @@
+import JSZip from 'jszip';
+import { describe, expect, it } from 'vitest';
+import { buildPptxBlob } from '@/lib/export/use-export-pptx';
+import { applySlideEditOperation } from '@/lib/edit/slide-ops';
+import {
+ createDefaultImageElement,
+ createDefaultTextElement,
+} from '@/lib/edit/slide-edit-elements';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import {
+ makeSlideFixture,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+} from './fixtures';
+
+/**
+ * Round-trip gate: tests element.add on default text and image elements.
+ *
+ * (a) Text element: verifies default content survives PPTX export.
+ * (b) Image element with remote URL: verifies that the export pipeline
+ * does NOT crash when network-fetched images cannot be embedded (CI has no network);
+ * the real image round-trip (data-URL) is covered by image-data-url.test.ts.
+ */
+async function exportSlideContent(content: SlideContent, scene: Scene): Promise {
+ return buildPptxBlob(
+ [content.canvas],
+ [scene],
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ );
+}
+
+async function readPptxEntry(blob: Blob, path: string): Promise {
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const entry = zip.file(path);
+ if (!entry) throw new Error(`PPTX did not contain entry: ${path}`);
+ return entry.async('string');
+}
+
+describe('round-trip: element.add inserts (PR2 gate)', () => {
+ it('(a) inserted default text element — default content survives export', async () => {
+ const { scene, content } = makeSlideFixture();
+
+ // (a) Tests that createDefaultTextElement's default content ('New text
')
+ // survives the PPTX export pipeline — verifies element.add and export don't
+ // lose or corrupt the text element's content.
+ const DEFAULT_TEXT_NEEDLE = 'New text';
+
+ const after = applySlideEditOperation(content, {
+ type: 'element.add',
+ element: createDefaultTextElement('rt-text-1'),
+ });
+
+ const blob = await exportSlideContent(after, scene);
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+
+ expect(slideXml).toContain(DEFAULT_TEXT_NEEDLE);
+ });
+
+ it('(b) inserted default image element (remote URL) — slide XML is non-empty', async () => {
+ const { scene, content } = makeSlideFixture();
+
+ const after = applySlideEditOperation(content, {
+ type: 'element.add',
+ element: createDefaultImageElement('rt-img-1', 'https://example.com/x.png'),
+ });
+
+ const blob = await exportSlideContent(after, scene);
+
+ // (b) Tests that element.add on a remote-URL image does NOT crash or corrupt
+ // export. The remote URL cannot be fetched in CI (no network), so the exporter
+ // logs "Failed to convert image to base64, skipping element" and omits the image.
+ // This case gates that export pipeline is resilient; the REAL image round-trip
+ // (data-URL, the PR2 local-upload path) is covered by image-data-url.test.ts
+ // and is deliberately not duplicated here.
+
+ // Basic size guard — a valid PPTX is always several KB at minimum.
+ expect(blob.size).toBeGreaterThan(0);
+
+ // The slide XML entry must be present and non-empty.
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+ expect(slideXml.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/edit/round-trip/text-content.test.ts b/tests/edit/round-trip/text-content.test.ts
new file mode 100644
index 0000000000..a40e89c7f2
--- /dev/null
+++ b/tests/edit/round-trip/text-content.test.ts
@@ -0,0 +1,69 @@
+import JSZip from 'jszip';
+import { describe, expect, it } from 'vitest';
+import { buildPptxBlob } from '@/lib/export/use-export-pptx';
+import { applySlideEditOperation } from '@/lib/edit/slide-ops';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import {
+ makeSlideFixture,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+} from './fixtures';
+
+/**
+ * Round-trip harness — apply ops to a fixture SlideContent, run the
+ * existing export pipeline (`buildPptxBlob`), and inspect the resulting
+ * PPTX bytes via JSZip to verify the ops survived serialization.
+ *
+ * Caveat (documented for future PRs): the OpenMAIC codebase has no
+ * PPTX → Slide reimport path, so this harness is one-way (export side
+ * only). The "round-trip" property at the design-principle level is
+ * verified end-to-end by opening exports in a desktop tool; CI here
+ * catches export-pipeline regressions for each op as slide-surface PRs
+ * land. The shape `(fixture + ops) → blob → XML assertion` is the
+ * contract slide-surface PRs extend with per-op cases.
+ */
+async function exportSlideContent(content: SlideContent, scene: Scene): Promise {
+ return buildPptxBlob(
+ [content.canvas],
+ [scene],
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ );
+}
+
+async function readPptxEntry(blob: Blob, path: string): Promise {
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const entry = zip.file(path);
+ if (!entry) throw new Error(`PPTX did not contain entry: ${path}`);
+ return entry.async('string');
+}
+
+describe('round-trip harness (export side)', () => {
+ it('exports a noop fixture to a non-empty PPTX with the slide1 entry present', async () => {
+ const { scene, content } = makeSlideFixture();
+ const blob = await exportSlideContent(content, scene);
+ expect(blob.size).toBeGreaterThan(0);
+ // PPTX format always emits ppt/slides/slide1.xml for the first slide.
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+ expect(slideXml.length).toBeGreaterThan(0);
+ });
+
+ it('captures the new text after applying text.updateContent', async () => {
+ const { scene, content, textElementId } = makeSlideFixture();
+ // A distinctive needle so the assertion does not accidentally match
+ // pptxgenjs boilerplate or the original default text.
+ const NEEDLE = 'roundtrip-needle-abc123';
+ const after = applySlideEditOperation(content, {
+ type: 'text.updateContent',
+ elementId: textElementId,
+ content: `${NEEDLE}
`,
+ });
+ const blob = await exportSlideContent(after, scene);
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+ expect(slideXml).toContain(NEEDLE);
+ });
+});
diff --git a/tests/edit/round-trip/text-format.test.ts b/tests/edit/round-trip/text-format.test.ts
new file mode 100644
index 0000000000..ac26fb7c6d
--- /dev/null
+++ b/tests/edit/round-trip/text-format.test.ts
@@ -0,0 +1,63 @@
+import JSZip from 'jszip';
+import { describe, expect, it } from 'vitest';
+import { buildPptxBlob } from '@/lib/export/use-export-pptx';
+import { applySlideEditOperation } from '@/lib/edit/slide-ops';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import {
+ makeSlideFixture,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+} from './fixtures';
+
+/**
+ * Round-trip gate: formatted text (bold) survives the export pipeline.
+ *
+ * Discovery (mandatory per task spec): the exported slide1.xml was inspected
+ * via a temporary console.log test. The OpenMAIC exporter (pptxgenjs) emits
+ * bold as the `b="1"` attribute on ``, e.g.:
+ *
+ * The assertions below are pinned to this observed real output.
+ */
+async function exportSlideContent(content: SlideContent, scene: Scene): Promise {
+ return buildPptxBlob(
+ [content.canvas],
+ [scene],
+ VIEWPORT_RATIO,
+ VIEWPORT_SIZE,
+ RATIO_PX2_INCH,
+ RATIO_PX2_PT,
+ );
+}
+
+async function readPptxEntry(blob: Blob, path: string): Promise {
+ const zip = await JSZip.loadAsync(await blob.arrayBuffer());
+ const entry = zip.file(path);
+ if (!entry) throw new Error(`PPTX did not contain entry: ${path}`);
+ return entry.async('string');
+}
+
+describe('round-trip: formatted text (PR2 gate)', () => {
+ it('preserves bold text and emits b="1" on the run property after text.updateContent', async () => {
+ const { scene, content, textElementId } = makeSlideFixture();
+
+ const NEEDLE = 'RT_BOLD_NEEDLE';
+
+ const after = applySlideEditOperation(content, {
+ type: 'text.updateContent',
+ elementId: textElementId,
+ content: `${NEEDLE}
`,
+ });
+
+ const blob = await exportSlideContent(after, scene);
+ const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml');
+
+ // The needle text must survive serialisation.
+ expect(slideXml).toContain(NEEDLE);
+
+ // The exporter (pptxgenjs) encodes bold as b="1" on the element.
+ // Pinned to observed real output:
+ expect(slideXml).toContain('b="1"');
+ });
+});
diff --git a/tests/edit/scene-edit-bridge.test.ts b/tests/edit/scene-edit-bridge.test.ts
new file mode 100644
index 0000000000..7626299085
--- /dev/null
+++ b/tests/edit/scene-edit-bridge.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createDefaultImageElement,
+ createDefaultSlide,
+ createDefaultTextElement,
+} from '@/lib/edit/slide-edit-elements';
+import { commitSlideEdit, deriveSlideEditOperations } from '@/lib/edit/scene-edit-bridge';
+import {
+ createSlideEditHistory,
+ redoSlideEditOperation,
+ undoSlideEditOperation,
+} from '@/lib/edit/slide-ops';
+import type { PPTTextElement } from '@/lib/types/slides';
+import type { SlideContent } from '@/lib/types/stage';
+
+// The fixture's element 0 is always the default text element; narrow so
+// tests can touch text-only geometry props (PPTLineElement has no
+// height/rotate, so the bare union does not expose them).
+const txt = (c: SlideContent) => c.canvas.elements[0] as PPTTextElement;
+
+/**
+ * The slide renderer commits geometry edits by handing the surface a whole
+ * post-edit SlideContent (via the scene-context bridge). `deriveSlideEditOperations`
+ * turns that snapshot diff back into the canonical slide-ops the kernel + PPTX
+ * export understand, so the surface owns a real op/undo history instead of an
+ * opaque content blob.
+ */
+function makeContent(): SlideContent {
+ const slide = createDefaultSlide('slide-1');
+ slide.elements.push(createDefaultTextElement('text-1'));
+ return { type: 'slide', canvas: slide };
+}
+
+function clone(content: SlideContent): SlideContent {
+ return structuredClone(content);
+}
+
+describe('deriveSlideEditOperations', () => {
+ it('returns no ops when nothing changed', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ expect(deriveSlideEditOperations(prev, next)).toEqual([]);
+ });
+
+ it('emits a single element.update with only the moved geometry keys', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ next.canvas.elements[0].left = 320;
+ next.canvas.elements[0].top = 240;
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ { type: 'element.update', elementId: 'text-1', patch: { left: 320, top: 240 } },
+ ]);
+ });
+
+ it('captures resize (width/height) and rotate in the patch', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ txt(next).width = 500;
+ txt(next).height = 300;
+ txt(next).rotate = 45;
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ {
+ type: 'element.update',
+ elementId: 'text-1',
+ patch: { width: 500, height: 300, rotate: 45 },
+ },
+ ]);
+ });
+
+ it('emits one element.update per changed element for a multi-element move', () => {
+ const prev = makeContent();
+ prev.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
+ const next = clone(prev);
+ next.canvas.elements[0].left += 10;
+ next.canvas.elements[1].top += 25;
+
+ const ops = deriveSlideEditOperations(prev, next);
+ expect(ops).toHaveLength(2);
+ expect(ops).toContainEqual({
+ type: 'element.update',
+ elementId: 'text-1',
+ patch: { left: prev.canvas.elements[0].left + 10 },
+ });
+ expect(ops).toContainEqual({
+ type: 'element.update',
+ elementId: 'img-1',
+ patch: { top: prev.canvas.elements[1].top + 25 },
+ });
+ });
+
+ it('emits element.add for a new element with its insertion index', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ const added = createDefaultImageElement('img-9', 'https://example.com/x.png');
+ next.canvas.elements.push(added);
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ { type: 'element.add', element: added, index: 1 },
+ ]);
+ });
+
+ it('emits element.delete for a removed element', () => {
+ const prev = makeContent();
+ prev.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
+ const next = clone(prev);
+ next.canvas.elements = next.canvas.elements.filter((el) => el.id !== 'img-1');
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ { type: 'element.delete', elementId: 'img-1' },
+ ]);
+ });
+
+ it('emits element.removeProps when a top-level prop is dropped', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ delete txt(next).lineHeight;
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ { type: 'element.removeProps', elementId: 'text-1', propNames: ['lineHeight'] },
+ ]);
+ });
+
+ it('emits slide.update for canvas-meta changes (not elements)', () => {
+ const prev = makeContent();
+ const next = clone(prev);
+ next.canvas.background = { type: 'solid', color: '#ff0000' };
+
+ expect(deriveSlideEditOperations(prev, next)).toEqual([
+ { type: 'slide.update', patch: { background: { type: 'solid', color: '#ff0000' } } },
+ ]);
+ });
+});
+
+describe('commitSlideEdit', () => {
+ it('records a single-element commit as one undo step', () => {
+ const history = createSlideEditHistory(makeContent());
+ const next = clone(history.present);
+ next.canvas.elements[0].left = 400;
+
+ const after = commitSlideEdit(history, next);
+
+ expect(after.past).toHaveLength(1);
+ expect(after.future).toEqual([]);
+ expect(after.present.canvas.elements[0].left).toBe(400);
+ // The undo target is the pre-commit content.
+ expect(undoSlideEditOperation(after).present.canvas.elements[0].left).toBe(
+ history.present.canvas.elements[0].left,
+ );
+ });
+
+ it('coalesces a multi-element commit into exactly one undo step', () => {
+ const base = makeContent();
+ base.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
+ const history = createSlideEditHistory(base);
+ const next = clone(history.present);
+ next.canvas.elements[0].left += 30;
+ next.canvas.elements[1].top += 30;
+
+ const after = commitSlideEdit(history, next);
+
+ expect(after.past).toHaveLength(1);
+ const undone = undoSlideEditOperation(after);
+ expect(undone.present.canvas.elements[0].left).toBe(base.canvas.elements[0].left);
+ expect(undone.present.canvas.elements[1].top).toBe(base.canvas.elements[1].top);
+ });
+
+ it('is a no-op when the committed content is unchanged', () => {
+ const history = createSlideEditHistory(makeContent());
+ const after = commitSlideEdit(history, clone(history.present));
+ expect(after).toBe(history);
+ });
+
+ it('clears the redo stack on a fresh commit after undo', () => {
+ const history = createSlideEditHistory(makeContent());
+ const moved = clone(history.present);
+ moved.canvas.elements[0].left = 200;
+ const afterMove = commitSlideEdit(history, moved);
+ const afterUndo = undoSlideEditOperation(afterMove);
+ expect(redoSlideEditOperation(afterUndo).present.canvas.elements[0].left).toBe(200);
+
+ const resized = clone(afterUndo.present);
+ resized.canvas.elements[0].width = 999;
+ const afterCommit = commitSlideEdit(afterUndo, resized);
+
+ expect(afterCommit.future).toEqual([]);
+ expect(afterCommit.present.canvas.elements[0].width).toBe(999);
+ });
+});
diff --git a/tests/edit/slide-defaults.test.ts b/tests/edit/slide-defaults.test.ts
new file mode 100644
index 0000000000..7bb8183cde
--- /dev/null
+++ b/tests/edit/slide-defaults.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, it } from 'vitest';
+import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults';
+import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import type { PPTTextElement } from '@/lib/types/slides';
+
+function makeTextEl(id: string, groupId?: string): PPTTextElement {
+ return {
+ type: 'text',
+ id,
+ left: 0,
+ top: 0,
+ width: 200,
+ height: 80,
+ rotate: 0,
+ defaultColor: '#000',
+ defaultFontName: 'Inter',
+ lineHeight: 1.2,
+ content: 'x
',
+ ...(groupId ? { groupId } : {}),
+ };
+}
+
+function makeGroupedSlideScene(): Scene {
+ return makeSlideScene({
+ content: {
+ type: 'slide',
+ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ canvas: {
+ id: 'slide-1',
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#fff',
+ themeColors: ['#000'],
+ fontColor: '#000',
+ fontName: 'Inter',
+ },
+ elements: [makeTextEl('el-a', 'group-1'), makeTextEl('el-b', 'group-1')],
+ background: { type: 'solid', color: '#ffffff' },
+ },
+ },
+ });
+}
+
+function makeSlideScene(overrides: Partial = {}): Scene {
+ const slideContent: SlideContent = {
+ type: 'slide',
+ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ canvas: {
+ id: 'slide-1',
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#fff',
+ themeColors: ['#000'],
+ fontColor: '#000',
+ fontName: 'Inter',
+ },
+ elements: [makeTextEl('el-a'), makeTextEl('el-b')],
+ background: { type: 'solid', color: '#ffffff' },
+ },
+ };
+ return {
+ id: 'scene-source',
+ stageId: 'stage-1',
+ type: 'slide',
+ title: 'Source slide',
+ order: 1,
+ content: slideContent,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ };
+}
+
+describe('createBlankSlideScene', () => {
+ it('produces a slide scene with the current schema version', () => {
+ const s = createBlankSlideScene('stage-1', 'Untitled', 1);
+ expect(s.type).toBe('slide');
+ expect(s.stageId).toBe('stage-1');
+ expect(s.title).toBe('Untitled');
+ if (s.content.type !== 'slide') throw new Error('expected slide content');
+ expect(s.content.schemaVersion).toBe(CURRENT_SLIDE_CONTENT_SCHEMA_VERSION);
+ expect(s.content.canvas.elements).toEqual([]);
+ expect(s.content.canvas.background?.type).toBe('solid');
+ });
+
+ it('mints a fresh scene id + slide id on every call', () => {
+ const a = createBlankSlideScene('stage-1', 'A', 1);
+ const b = createBlankSlideScene('stage-1', 'B', 2);
+ expect(a.id).not.toBe(b.id);
+ if (a.content.type !== 'slide' || b.content.type !== 'slide') {
+ throw new Error('expected slide content');
+ }
+ expect(a.content.canvas.id).not.toBe(b.content.canvas.id);
+ });
+});
+
+describe('duplicateSlideScene', () => {
+ it('returns a deep-cloned slide with new scene id + new slide id', () => {
+ const source = makeSlideScene();
+ const dup = duplicateSlideScene(source, '(copy)', 2);
+ expect(dup.id).not.toBe(source.id);
+ if (source.content.type !== 'slide' || dup.content.type !== 'slide') {
+ throw new Error('expected slide content');
+ }
+ expect(dup.content.canvas.id).not.toBe(source.content.canvas.id);
+ expect(dup.content).not.toBe(source.content);
+ expect(dup.content.canvas).not.toBe(source.content.canvas);
+ });
+
+ it('reassigns every element id so React keys cannot collide', () => {
+ const source = makeSlideScene();
+ const dup = duplicateSlideScene(source, '(copy)', 2);
+ if (source.content.type !== 'slide' || dup.content.type !== 'slide') {
+ throw new Error('expected slide content');
+ }
+ const srcIds = source.content.canvas.elements.map((e) => e.id);
+ const dupIds = dup.content.canvas.elements.map((e) => e.id);
+ expect(dupIds).toHaveLength(srcIds.length);
+ for (const id of dupIds) {
+ expect(srcIds).not.toContain(id);
+ }
+ });
+
+ it('appends the copy suffix to the title', () => {
+ const source = makeSlideScene({ title: 'Hello' });
+ const dup = duplicateSlideScene(source, '(copy)', 2);
+ expect(dup.title).toBe('Hello (copy)');
+ });
+
+ it('passes title through unchanged when copy suffix is empty', () => {
+ const source = makeSlideScene({ title: 'Hello' });
+ const dup = duplicateSlideScene(source, '', 2);
+ expect(dup.title).toBe('Hello');
+ });
+
+ it('remaps grouped elements to a new shared group id', () => {
+ const source = makeGroupedSlideScene();
+ const dup = duplicateSlideScene(source, '(copy)', 2);
+ if (source.content.type !== 'slide' || dup.content.type !== 'slide') {
+ throw new Error('expected slide content');
+ }
+ const [a, b] = dup.content.canvas.elements;
+
+ // Element ids are freshly minted and distinct.
+ expect(a.id).not.toBe('el-a');
+ expect(b.id).not.toBe('el-b');
+ expect(a.id).not.toBe(b.id);
+
+ // Grouped clones share one NEW group id, not the source's dangling ref.
+ expect(a.groupId).toBeDefined();
+ expect(a.groupId).not.toBe('group-1');
+ expect(a.groupId).toBe(b.groupId);
+ });
+
+ it('throws when the source is not a slide scene', () => {
+ const quiz: Scene = {
+ id: 'q',
+ stageId: 'stage-1',
+ type: 'quiz',
+ title: 'Quiz',
+ order: 1,
+ content: { type: 'quiz', questions: [] },
+ };
+ expect(() => duplicateSlideScene(quiz, '(copy)', 2)).toThrow();
+ });
+});
diff --git a/tests/edit/slide-edit-elements.test.ts b/tests/edit/slide-edit-elements.test.ts
new file mode 100644
index 0000000000..cbdef1a9ae
--- /dev/null
+++ b/tests/edit/slide-edit-elements.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from 'vitest';
+import {
+ createDefaultImageElement,
+ createDefaultShapeElement,
+ createDefaultTextElement,
+ htmlToPlainText,
+ plainTextToParagraphHtml,
+} from '@/lib/edit/slide-edit-elements';
+
+describe('slide edit element factories', () => {
+ test('creates default text elements compatible with the slide schema', () => {
+ const element = createDefaultTextElement('text-1');
+
+ expect(element).toMatchObject({
+ id: 'text-1',
+ type: 'text',
+ content: 'New text
',
+ defaultFontName: 'Inter',
+ defaultColor: '#111827',
+ lineHeight: 1.4,
+ });
+ });
+
+ test('creates default shape elements with editable fill and outline', () => {
+ const element = createDefaultShapeElement('shape-1');
+
+ expect(element).toMatchObject({
+ id: 'shape-1',
+ type: 'shape',
+ fill: '#dbeafe',
+ outline: {
+ width: 2,
+ color: '#2563eb',
+ style: 'solid',
+ },
+ });
+ expect(element.viewBox).toEqual([260, 140]);
+ });
+
+ test('creates shape elements from a picked spec, preserving viewBox + path', () => {
+ const element = createDefaultShapeElement('shape-2', {
+ viewBox: [200, 200],
+ path: 'M 100 0 L 200 200 L 0 200 Z',
+ });
+
+ expect(element).toMatchObject({
+ id: 'shape-2',
+ type: 'shape',
+ viewBox: [200, 200],
+ path: 'M 100 0 L 200 200 L 0 200 Z',
+ });
+ expect(element.width).toBe(200);
+ expect(element.height).toBe(200);
+ });
+
+ test('creates default image elements from a source URL', () => {
+ const element = createDefaultImageElement('image-1', 'https://example.com/image.png');
+
+ expect(element).toMatchObject({
+ id: 'image-1',
+ type: 'image',
+ src: 'https://example.com/image.png',
+ fixedRatio: true,
+ width: 360,
+ height: 220,
+ });
+ });
+
+ test('converts plain text to escaped paragraph html', () => {
+ expect(plainTextToParagraphHtml('A < B & C')).toBe('A < B & C
');
+ });
+
+ test('converts stored html content into editable plain text', () => {
+ expect(htmlToPlainText('Hello
World
')).toBe('HelloWorld');
+ });
+});
diff --git a/tests/edit/slide-edit-session.test.ts b/tests/edit/slide-edit-session.test.ts
new file mode 100644
index 0000000000..e040035f86
--- /dev/null
+++ b/tests/edit/slide-edit-session.test.ts
@@ -0,0 +1,215 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createDefaultSlide, createDefaultTextElement } from '@/lib/edit/slide-edit-elements';
+import type { PPTTextElement } from '@/lib/types/slides';
+import type { SlideContent } from '@/lib/types/stage';
+
+// Mock the canonical stage store so we can assert write-through: every
+// history move in the session (applyOp / user commit / non-user commit /
+// undo / redo) must call updateScene with the new content. Seed must NOT
+// touch the stage store (it only adopts the existing content as the
+// in-memory baseline).
+const updateScene = vi.fn();
+vi.mock('@/lib/store/stage', () => ({
+ useStageStore: { getState: () => ({ updateScene }) },
+}));
+
+// Imported AFTER the mock setup (vi.mock is hoisted by Vitest).
+const { useSlideEditSession } = await import('@/components/edit/surfaces/slide/slide-edit-session');
+
+// Fixture element 0 is the default text element; narrow so we can read
+// text-only geometry props off the PPTElement union.
+const rotateOf = (c: SlideContent) => (c.canvas.elements[0] as PPTTextElement).rotate;
+
+function makeContent(): SlideContent {
+ const slide = createDefaultSlide('slide-1');
+ slide.elements.push(createDefaultTextElement('text-1'));
+ return { type: 'slide', canvas: slide };
+}
+
+describe('useSlideEditSession (auto-save to stage store)', () => {
+ beforeEach(() => {
+ useSlideEditSession.getState().end();
+ updateScene.mockClear();
+ });
+
+ it('seed adopts a baseline without touching the stage store', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ const { sceneId, history } = useSlideEditSession.getState();
+ expect(sceneId).toBe('scene-1');
+ expect(history?.past).toEqual([]);
+ expect(history?.future).toEqual([]);
+ expect(history?.present.canvas.elements[0].id).toBe('text-1');
+ // The stage already has this content; redundant writes are noise.
+ expect(updateScene).not.toHaveBeenCalled();
+ });
+
+ it('applyOp advances history by one step AND writes through to the stage store', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ useSlideEditSession.getState().applyOp({
+ type: 'element.update',
+ elementId: 'text-1',
+ patch: { left: 500 },
+ });
+ const { history } = useSlideEditSession.getState();
+ expect(history?.past).toHaveLength(1);
+ expect(history?.present.canvas.elements[0].left).toBe(500);
+ // Stage store sees the new content.
+ expect(updateScene).toHaveBeenCalledTimes(1);
+ expect(updateScene).toHaveBeenCalledWith(
+ 'scene-1',
+ expect.objectContaining({ content: history!.present }),
+ );
+ });
+
+ it('applyOp ignores a no-op against a missing element (no stage write)', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ useSlideEditSession.getState().applyOp({
+ type: 'element.update',
+ elementId: 'does-not-exist',
+ patch: { left: 1 },
+ });
+ expect(useSlideEditSession.getState().history?.past).toEqual([]);
+ // The kernel returns the same history reference; replace() short-circuits.
+ expect(updateScene).not.toHaveBeenCalled();
+ });
+
+ it('user-driven commit records one undo step + writes through', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ const next = structuredClone(useSlideEditSession.getState().history!.present);
+ next.canvas.elements[0].left = 88;
+ next.canvas.elements[0].top = 99;
+ useSlideEditSession.getState().commitContent(next, true);
+ const { history } = useSlideEditSession.getState();
+ expect(history?.past).toHaveLength(1);
+ expect(history?.present.canvas.elements[0]).toMatchObject({ left: 88, top: 99 });
+ expect(updateScene).toHaveBeenCalledTimes(1);
+ });
+
+ it('non-user (ResizeObserver) commit folds into present without an undo step AND still writes through', () => {
+ // Auto-fit height IS the new canonical state, so it must reach the
+ // stage store. But it must NOT push an undo step (the reflow can
+ // chase a user resize; wiping past/future would silently break undo).
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ const normalized = structuredClone(useSlideEditSession.getState().history!.present);
+ (normalized.canvas.elements[0] as PPTTextElement).height = 999;
+ useSlideEditSession.getState().commitContent(normalized, false);
+ const { history } = useSlideEditSession.getState();
+ expect(history?.past).toEqual([]);
+ expect((history!.present.canvas.elements[0] as PPTTextElement).height).toBe(999);
+ expect(updateScene).toHaveBeenCalledTimes(1);
+ expect(updateScene).toHaveBeenCalledWith(
+ 'scene-1',
+ expect.objectContaining({ content: history!.present }),
+ );
+ });
+
+ it('a non-user commit after a user edit preserves the undo stack (and writes through both)', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ const resized = structuredClone(useSlideEditSession.getState().history!.present);
+ resized.canvas.elements[0].width = 640;
+ useSlideEditSession.getState().commitContent(resized, true);
+ expect(useSlideEditSession.getState().history?.past).toHaveLength(1);
+
+ const reflowed = structuredClone(useSlideEditSession.getState().history!.present);
+ (reflowed.canvas.elements[0] as PPTTextElement).height = 333;
+ useSlideEditSession.getState().commitContent(reflowed, false);
+
+ const { history } = useSlideEditSession.getState();
+ expect(history?.past).toHaveLength(1); // undo step survives
+ expect((history!.present.canvas.elements[0] as PPTTextElement).height).toBe(333);
+ expect(updateScene).toHaveBeenCalledTimes(2); // user commit + non-user commit
+ // Undo returns to the pre-resize width, and writes that through too.
+ updateScene.mockClear();
+ useSlideEditSession.getState().undo();
+ expect(useSlideEditSession.getState().history?.present.canvas.elements[0].width).toBe(
+ makeContent().canvas.elements[0].width,
+ );
+ expect(updateScene).toHaveBeenCalledTimes(1);
+ });
+
+ it('a non-user commit after an undo clears the stale redo branch (no resurrected content)', () => {
+ // Regression: user edit → undo (content moves to `future`) → the
+ // ResizeObserver auto-height normalization fires a non-user commit. If
+ // that commit folds into `present` but keeps the now-stale `future[0]`,
+ // a later redo resurrects the pre-undo snapshot and silently discards
+ // the normalization (canvas/store diverge). The non-user commit must
+ // clear `future` so redo is a no-op.
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ const resized = structuredClone(useSlideEditSession.getState().history!.present);
+ resized.canvas.elements[0].width = 640;
+ useSlideEditSession.getState().commitContent(resized, true);
+ expect(useSlideEditSession.getState().history?.past).toHaveLength(1);
+
+ // Undo pushes the resized snapshot onto `future`.
+ useSlideEditSession.getState().undo();
+ expect(useSlideEditSession.getState().history?.future).toHaveLength(1);
+ expect(useSlideEditSession.getState().history?.present.canvas.elements[0].width).toBe(
+ makeContent().canvas.elements[0].width,
+ );
+
+ // Non-user (auto-height) commit lands on the undone present.
+ const reflowed = structuredClone(useSlideEditSession.getState().history!.present);
+ (reflowed.canvas.elements[0] as PPTTextElement).height = 333;
+ useSlideEditSession.getState().commitContent(reflowed, false);
+
+ const afterReflow = useSlideEditSession.getState().history;
+ expect(afterReflow?.future).toEqual([]); // stale redo branch dropped
+ expect((afterReflow!.present.canvas.elements[0] as PPTTextElement).height).toBe(333);
+
+ // Redo must be a no-op now: it cannot resurrect the stale width=640.
+ updateScene.mockClear();
+ useSlideEditSession.getState().redo();
+ const afterRedo = useSlideEditSession.getState().history;
+ expect(afterRedo?.present.canvas.elements[0].width).toBe(
+ makeContent().canvas.elements[0].width,
+ );
+ expect((afterRedo!.present.canvas.elements[0] as PPTTextElement).height).toBe(333);
+ // future was empty → redo short-circuits to the same ref → no write-through.
+ expect(updateScene).not.toHaveBeenCalled();
+ });
+
+ it('undo / redo move between history states AND write through on each move', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ useSlideEditSession.getState().applyOp({
+ type: 'element.update',
+ elementId: 'text-1',
+ patch: { rotate: 30 },
+ });
+ updateScene.mockClear();
+ useSlideEditSession.getState().undo();
+ expect(rotateOf(useSlideEditSession.getState().history!.present)).toBe(0);
+ expect(updateScene).toHaveBeenCalledTimes(1);
+ useSlideEditSession.getState().redo();
+ expect(rotateOf(useSlideEditSession.getState().history!.present)).toBe(30);
+ expect(updateScene).toHaveBeenCalledTimes(2);
+ });
+
+ it('repeated non-user commits each write through but never grow past/future', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ for (let i = 0; i < 3; i++) {
+ const n = structuredClone(useSlideEditSession.getState().history!.present);
+ (n.canvas.elements[0] as PPTTextElement).height = 100 + i;
+ useSlideEditSession.getState().commitContent(n, false);
+ }
+ expect(useSlideEditSession.getState().history?.past).toEqual([]);
+ expect(
+ (useSlideEditSession.getState().history!.present.canvas.elements[0] as PPTTextElement).height,
+ ).toBe(102);
+ expect(updateScene).toHaveBeenCalledTimes(3);
+ });
+
+ it('end clears the session (no further write-through possible)', () => {
+ useSlideEditSession.getState().seed('scene-1', makeContent());
+ useSlideEditSession.getState().end();
+ expect(useSlideEditSession.getState().sceneId).toBeNull();
+ expect(useSlideEditSession.getState().history).toBeNull();
+ // applyOp after end must be a no-op (no sceneId → no writeThrough either).
+ updateScene.mockClear();
+ useSlideEditSession.getState().applyOp({
+ type: 'element.update',
+ elementId: 'text-1',
+ patch: { left: 1 },
+ });
+ expect(updateScene).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/edit/slide-ops.test.ts b/tests/edit/slide-ops.test.ts
new file mode 100644
index 0000000000..cfceea5eab
--- /dev/null
+++ b/tests/edit/slide-ops.test.ts
@@ -0,0 +1,597 @@
+import { describe, expect, test } from 'vitest';
+import {
+ applySlideEditOperation,
+ createSlideEditHistory,
+ redoSlideEditOperation,
+ undoSlideEditOperation,
+} from '@/lib/edit/slide-ops';
+import type { SlideContent } from '@/lib/types/stage';
+import type { PPTElement, PPTLineElement, PPTTextElement } from '@/lib/types/slides';
+
+function textElement(overrides: Partial = {}): PPTTextElement {
+ return {
+ id: 'title',
+ type: 'text',
+ left: 100,
+ top: 80,
+ width: 420,
+ height: 90,
+ rotate: 0,
+ content: 'Original title
',
+ defaultFontName: 'Inter',
+ defaultColor: '#111827',
+ ...overrides,
+ };
+}
+
+function lineElement(overrides: Partial = {}): PPTLineElement {
+ return {
+ id: 'line-1',
+ type: 'line',
+ left: 100,
+ top: 100,
+ width: 200,
+ start: [0, 0],
+ end: [200, 100],
+ style: 'solid',
+ color: '#000000',
+ points: ['', ''],
+ ...overrides,
+ };
+}
+
+function slideContent(elements: PPTElement[] = [textElement()]): SlideContent {
+ return {
+ type: 'slide',
+ canvas: {
+ id: 'slide-1',
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#ffffff',
+ themeColors: ['#2563eb'],
+ fontColor: '#111827',
+ fontName: 'Inter',
+ },
+ elements,
+ },
+ };
+}
+
+describe('applySlideEditOperation', () => {
+ test('updates an element without mutating the original slide content', () => {
+ const original = slideContent();
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.update',
+ elementId: 'title',
+ patch: { left: 160, top: 120, rotate: 12 },
+ });
+
+ expect(updated.canvas.elements[0]).toMatchObject({ left: 160, top: 120, rotate: 12 });
+ expect(original.canvas.elements[0]).toMatchObject({ left: 100, top: 80, rotate: 0 });
+ });
+
+ test('updates text content only for text elements', () => {
+ const original = slideContent();
+
+ const updated = applySlideEditOperation(original, {
+ type: 'text.updateContent',
+ elementId: 'title',
+ content: 'Edited title
',
+ });
+
+ expect(updated.canvas.elements[0]).toMatchObject({ content: 'Edited title
' });
+ });
+
+ test('deletes an element and clears its animations', () => {
+ const original = slideContent([
+ textElement({ id: 'title' }),
+ textElement({ id: 'subtitle', content: 'Subtitle
' }),
+ ]);
+ original.canvas.animations = [
+ {
+ id: 'anim-1',
+ elId: 'subtitle',
+ effect: 'fade',
+ type: 'in',
+ duration: 600,
+ trigger: 'click',
+ },
+ ];
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.delete',
+ elementId: 'subtitle',
+ });
+
+ expect(updated.canvas.elements.map((element) => element.id)).toEqual(['title']);
+ expect(updated.canvas.animations).toEqual([]);
+ });
+
+ test('reorders an element by moving it to the requested index', () => {
+ const original = slideContent([
+ textElement({ id: 'background' }),
+ textElement({ id: 'title' }),
+ textElement({ id: 'caption' }),
+ ]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.reorder',
+ elementId: 'background',
+ index: 2,
+ });
+
+ expect(updated.canvas.elements.map((element) => element.id)).toEqual([
+ 'title',
+ 'caption',
+ 'background',
+ ]);
+ expect(original.canvas.elements.map((element) => element.id)).toEqual([
+ 'background',
+ 'title',
+ 'caption',
+ ]);
+ });
+
+ test('updates multiple selected elements with the same patch', () => {
+ const original = slideContent([textElement({ id: 'title' }), textElement({ id: 'caption' })]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.updateMany',
+ elementIds: ['title', 'caption'],
+ patch: { lock: true },
+ });
+
+ expect(updated.canvas.elements.map((element) => element.lock)).toEqual([true, true]);
+ expect(original.canvas.elements.map((element) => element.lock)).toEqual([undefined, undefined]);
+ });
+
+ test('duplicates selected elements with caller-provided ids and offsets', () => {
+ const original = slideContent([textElement({ id: 'title' })]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.duplicate',
+ elementIds: ['title'],
+ idMap: { title: 'title-copy' },
+ offset: { x: 24, y: 16 },
+ });
+
+ expect(updated.canvas.elements.map((element) => element.id)).toEqual(['title', 'title-copy']);
+ expect(updated.canvas.elements[1]).toMatchObject({ left: 124, top: 96 });
+ expect(original.canvas.elements).toHaveLength(1);
+ });
+
+ test('deletes multiple selected elements and clears their animations', () => {
+ const original = slideContent([
+ textElement({ id: 'title' }),
+ textElement({ id: 'caption' }),
+ textElement({ id: 'footer' }),
+ ]);
+ original.canvas.animations = [
+ { id: 'anim-1', elId: 'title', effect: 'fade', type: 'in', duration: 600, trigger: 'click' },
+ { id: 'anim-2', elId: 'footer', effect: 'fade', type: 'in', duration: 600, trigger: 'click' },
+ ];
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.deleteMany',
+ elementIds: ['title', 'caption'],
+ });
+
+ expect(updated.canvas.elements.map((element) => element.id)).toEqual(['footer']);
+ expect(updated.canvas.animations?.map((animation) => animation.elId)).toEqual(['footer']);
+ });
+
+ test('aligns selected elements horizontally to the slide canvas', () => {
+ const original = slideContent([
+ textElement({ id: 'title', left: 100, top: 80, width: 200, height: 90 }),
+ textElement({ id: 'caption', left: 360, top: 180, width: 100, height: 60 }),
+ ]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.align',
+ elementIds: ['title', 'caption'],
+ command: 'horizontal',
+ });
+
+ expect(updated.canvas.elements.map((element) => element.left)).toEqual([320, 580]);
+ });
+
+ test('removes element properties from selected elements', () => {
+ const original = slideContent([
+ textElement({
+ id: 'title',
+ outline: { width: 2, color: '#111111', style: 'solid' },
+ }),
+ ]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.removeProps',
+ elementId: 'title',
+ propNames: ['outline'],
+ });
+
+ expect('outline' in updated.canvas.elements[0]).toBe(false);
+ expect('outline' in original.canvas.elements[0]).toBe(true);
+ });
+});
+
+describe('element.add', () => {
+ test('appends to the end when no index is given', () => {
+ const original = slideContent([textElement({ id: 'a' })]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.add',
+ element: textElement({ id: 'b' }),
+ });
+
+ expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b']);
+ });
+
+ test('inserts at the requested index', () => {
+ const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'c' })]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.add',
+ element: textElement({ id: 'b' }),
+ index: 1,
+ });
+
+ expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ test('clamps out-of-range index to the end of the list', () => {
+ const original = slideContent([textElement({ id: 'a' })]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.add',
+ element: textElement({ id: 'b' }),
+ index: 999,
+ });
+
+ expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b']);
+ });
+
+ test('clamps negative index to 0', () => {
+ const original = slideContent([textElement({ id: 'a' })]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.add',
+ element: textElement({ id: 'b' }),
+ index: -5,
+ });
+
+ expect(updated.canvas.elements.map((e) => e.id)).toEqual(['b', 'a']);
+ });
+
+ test('throws when the new id collides with an existing element', () => {
+ const original = slideContent([textElement({ id: 'title' })]);
+ expect(() =>
+ applySlideEditOperation(original, {
+ type: 'element.add',
+ element: textElement({ id: 'title', content: 'Dup
' }),
+ }),
+ ).toThrow(/already exists/);
+ });
+});
+
+describe('slide.update contract', () => {
+ test('rejects element / animation collections at runtime even via type cast', () => {
+ const original = slideContent();
+ expect(() =>
+ applySlideEditOperation(original, {
+ type: 'slide.update',
+ // `as never` defeats the SlideMetaPatch type narrowing — the runtime
+ // guard is the second line of defense for misuse from JS callers or
+ // anywhere a cast slips through.
+ patch: { elements: [] } as never,
+ }),
+ ).toThrow(/dedicated/);
+ });
+
+ test('applies meta-only patches (theme/background) successfully', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'slide.update',
+ patch: { background: { type: 'solid', color: '#000000' } },
+ });
+ expect(updated.canvas.background).toEqual({ type: 'solid', color: '#000000' });
+ });
+});
+
+describe('element.duplicate contract', () => {
+ test('uses the default offset {x:20, y:20} when no offset is given', () => {
+ const original = slideContent([textElement({ id: 'a', left: 100, top: 50 })]);
+
+ const updated = applySlideEditOperation(original, {
+ type: 'element.duplicate',
+ elementIds: ['a'],
+ idMap: { a: 'a-copy' },
+ });
+
+ expect(updated.canvas.elements[1]).toMatchObject({ id: 'a-copy', left: 120, top: 70 });
+ });
+
+ test('throws when idMap is missing an entry for a selected id', () => {
+ const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'b' })]);
+
+ expect(() =>
+ applySlideEditOperation(original, {
+ type: 'element.duplicate',
+ elementIds: ['a', 'b'],
+ idMap: { a: 'a-copy' },
+ }),
+ ).toThrow(/missing entries/);
+ });
+
+ test('throws when a new id would collide with an existing element', () => {
+ const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'b' })]);
+
+ expect(() =>
+ applySlideEditOperation(original, {
+ type: 'element.duplicate',
+ elementIds: ['a'],
+ idMap: { a: 'b' },
+ }),
+ ).toThrow(/collide/);
+ });
+
+ test('deep-clones nested fields so the duplicate cannot leak mutations to the source', () => {
+ // A line element carries a mutable tuple (start). A shallow spread would
+ // share the same array between source and duplicate; a subsequent op
+ // that mutates the duplicate's start in place would silently mutate the
+ // source too. After the deep clone the two are independent.
+ const original = slideContent([lineElement({ id: 'l1', start: [0, 0], end: [10, 10] })]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.duplicate',
+ elementIds: ['l1'],
+ idMap: { l1: 'l1-copy' },
+ });
+ const source = updated.canvas.elements[0] as PPTLineElement;
+ const dup = updated.canvas.elements[1] as PPTLineElement;
+ expect(source.start).not.toBe(dup.start);
+ expect(source.end).not.toBe(dup.end);
+ expect(source.points).not.toBe(dup.points);
+ });
+});
+
+describe('element.align all directions', () => {
+ function twoBoxes(): SlideContent {
+ return slideContent([
+ textElement({ id: 'a', left: 100, top: 80, width: 200, height: 90 }),
+ textElement({ id: 'b', left: 360, top: 180, width: 100, height: 60 }),
+ ]);
+ }
+
+ test('top aligns the group to the top edge of the canvas', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'top',
+ });
+ // group's minY is 80, so subtract 80 from each top
+ expect(updated.canvas.elements.map((e) => e.top)).toEqual([0, 100]);
+ });
+
+ test('bottom aligns the group to the bottom edge of the canvas', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'bottom',
+ });
+ // viewportHeight = 1000 * 0.5625 = 562.5; group's maxY = max(80+90, 180+60) = 240
+ // offsetY = 240 - 562.5 = -322.5; new tops = 80 - (-322.5) = 402.5, 180 - (-322.5) = 502.5
+ expect(updated.canvas.elements.map((e) => e.top)).toEqual([402.5, 502.5]);
+ });
+
+ test('left aligns the group to the left edge of the canvas', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'left',
+ });
+ // group's minX = 100, so subtract 100 from each left
+ expect(updated.canvas.elements.map((e) => e.left)).toEqual([0, 260]);
+ });
+
+ test('right aligns the group to the right edge of the canvas', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'right',
+ });
+ // viewportWidth = 1000; group's maxX = max(100+200, 360+100) = 460
+ // offsetX = 460 - 1000 = -540; new lefts = 100 - (-540) = 640, 360 - (-540) = 900
+ expect(updated.canvas.elements.map((e) => e.left)).toEqual([640, 900]);
+ });
+
+ test('vertical centers the group on the vertical axis', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'vertical',
+ });
+ // group's midY = 80 + (240 - 80) / 2 = 160; canvasMidY = 562.5 / 2 = 281.25
+ // offsetY = 160 - 281.25 = -121.25
+ expect(updated.canvas.elements.map((e) => e.top)).toEqual([201.25, 301.25]);
+ });
+
+ test('center centers the group on both axes', () => {
+ const updated = applySlideEditOperation(twoBoxes(), {
+ type: 'element.align',
+ elementIds: ['a', 'b'],
+ command: 'center',
+ });
+ expect(updated.canvas.elements.map((e) => e.left)).toEqual([320, 580]);
+ expect(updated.canvas.elements.map((e) => e.top)).toEqual([201.25, 301.25]);
+ });
+
+ test('uses canonical geometry for line elements (start/end, not width/height=0)', () => {
+ // A line visually spanning (left+0, top+0) to (left+200, top+100).
+ // The old local fork ignored start/end and treated the line as height 0,
+ // so 'bottom' would have aligned by line.top alone. The canonical helper
+ // uses start/end so the real extent (top..top+end[1]) drives the offset.
+ const original = slideContent([
+ lineElement({ id: 'line-1', left: 100, top: 50, start: [0, 0], end: [200, 100] }),
+ ]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.align',
+ elementIds: ['line-1'],
+ command: 'bottom',
+ });
+ // viewportHeight = 562.5; line maxY = top + end[1] = 50 + 100 = 150
+ // offsetY = 150 - 562.5 = -412.5; new top = 50 - (-412.5) = 462.5
+ expect(updated.canvas.elements[0].top).toBe(462.5);
+ });
+
+ test('uses rotated bounding box for rotated elements', () => {
+ // A 100×100 square rotated 45° fills an axis-aligned box wider than 100,
+ // anchored at its center. The canonical helper accounts for rotation;
+ // the old local fork used the unrotated rect and would put left at 0.
+ const original = slideContent([
+ textElement({ id: 'r', left: 100, top: 100, width: 100, height: 100, rotate: 45 }),
+ ]);
+ const updated = applySlideEditOperation(original, {
+ type: 'element.align',
+ elementIds: ['r'],
+ command: 'left',
+ });
+ // Pre-fix: unrotated minX = 100 → after align left, left ends at 0.
+ // Post-fix: rotated OOBB minX < 100 → after align, left ends > 0.
+ expect(updated.canvas.elements[0].left).toBeGreaterThan(0);
+ expect(updated.canvas.elements[0].left).toBeLessThan(100);
+ });
+});
+
+describe('no-op operations skip history push', () => {
+ test('element.update against a missing id returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.update',
+ elementId: 'nope',
+ patch: { left: 999 },
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('element.delete against a missing id returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.delete',
+ elementId: 'nope',
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('element.deleteMany against unmatched ids returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.deleteMany',
+ elementIds: ['nope-1', 'nope-2'],
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('element.reorder against a missing id returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.reorder',
+ elementId: 'nope',
+ index: 0,
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('element.removeProps against a missing id returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.removeProps',
+ elementId: 'nope',
+ propNames: ['outline'],
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('text.updateContent against a missing id returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'text.updateContent',
+ elementId: 'nope',
+ content: 'X
',
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('element.align with empty selection returns the same content reference', () => {
+ const original = slideContent();
+ const updated = applySlideEditOperation(original, {
+ type: 'element.align',
+ elementIds: [],
+ command: 'center',
+ });
+ expect(updated).toBe(original);
+ });
+
+ test('history is unchanged when the underlying op is a no-op', () => {
+ const original = slideContent();
+ const history = createSlideEditHistory(original);
+ const next = applySlideEditOperation(history, {
+ type: 'element.update',
+ elementId: 'nope',
+ patch: { left: 1 },
+ });
+ expect(next).toBe(history);
+ expect(next.past).toHaveLength(0);
+ });
+});
+
+describe('slide edit history', () => {
+ test('undoes and redoes operations using immutable snapshots', () => {
+ const original = slideContent();
+ let history = createSlideEditHistory(original);
+
+ history = applySlideEditOperation(history, {
+ type: 'element.update',
+ elementId: 'title',
+ patch: { left: 200 },
+ });
+ expect(history.present.canvas.elements[0].left).toBe(200);
+
+ history = undoSlideEditOperation(history);
+ expect(history.present.canvas.elements[0].left).toBe(100);
+
+ history = redoSlideEditOperation(history);
+ expect(history.present.canvas.elements[0].left).toBe(200);
+ });
+
+ test('clears the redo stack after a new op following undo', () => {
+ let history = createSlideEditHistory(slideContent());
+
+ history = applySlideEditOperation(history, {
+ type: 'element.update',
+ elementId: 'title',
+ patch: { left: 200 },
+ });
+ history = undoSlideEditOperation(history);
+ expect(history.future).toHaveLength(1);
+
+ // A new op branches the timeline and should drop the redo stack.
+ history = applySlideEditOperation(history, {
+ type: 'element.update',
+ elementId: 'title',
+ patch: { left: 300 },
+ });
+ expect(history.future).toEqual([]);
+ expect(history.present.canvas.elements[0].left).toBe(300);
+ });
+
+ test('caps past length so long edit sessions do not grow unbounded', () => {
+ let history = createSlideEditHistory(slideContent());
+ // More ops than MAX_HISTORY (50) — past should be clamped, present is current.
+ for (let i = 0; i < 70; i++) {
+ history = applySlideEditOperation(history, {
+ type: 'element.update',
+ elementId: 'title',
+ patch: { left: 100 + i },
+ });
+ }
+ expect(history.past.length).toBeLessThanOrEqual(50);
+ expect(history.present.canvas.elements[0].left).toBe(169);
+ });
+});
diff --git a/tests/edit/slide-schema.test.ts b/tests/edit/slide-schema.test.ts
new file mode 100644
index 0000000000..3c575cc44b
--- /dev/null
+++ b/tests/edit/slide-schema.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest';
+import {
+ CURRENT_SLIDE_CONTENT_SCHEMA_VERSION,
+ migrateScene,
+ migrateSlideContent,
+} from '@/lib/edit/slide-schema';
+import type { Scene, SlideContent } from '@/lib/types/stage';
+import type { Slide } from '@/lib/types/slides';
+
+function makeSlide(): Slide {
+ return {
+ id: 'slide-1',
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#ffffff',
+ themeColors: ['#000000'],
+ fontColor: '#000000',
+ fontName: 'sans-serif',
+ },
+ elements: [],
+ };
+}
+
+function makeSlideContent(overrides: Partial = {}): SlideContent {
+ return { type: 'slide', canvas: makeSlide(), ...overrides };
+}
+
+function makeSlideScene(overrides: Partial = {}): Scene {
+ return {
+ id: 'scene-1',
+ stageId: 'stage-1',
+ type: 'slide',
+ title: 'Test slide',
+ order: 1,
+ content: makeSlideContent(),
+ ...overrides,
+ };
+}
+
+describe('migrateSlideContent', () => {
+ it('stamps the current schemaVersion on legacy content lacking the field', () => {
+ const legacy = makeSlideContent();
+ expect(legacy.schemaVersion).toBeUndefined();
+ const result = migrateSlideContent(legacy);
+ expect(result.schemaVersion).toBe(CURRENT_SLIDE_CONTENT_SCHEMA_VERSION);
+ });
+
+ it('returns the same reference when content is already at the current version', () => {
+ const current = makeSlideContent({ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION });
+ expect(migrateSlideContent(current)).toBe(current);
+ });
+
+ it('does not mutate its input', () => {
+ const input = makeSlideContent();
+ const snapshot = JSON.parse(JSON.stringify(input));
+ migrateSlideContent(input);
+ expect(input).toEqual(snapshot);
+ });
+
+ it('is idempotent', () => {
+ const input = makeSlideContent();
+ const once = migrateSlideContent(input);
+ const twice = migrateSlideContent(once);
+ expect(twice).toEqual(once);
+ });
+
+ it('preserves canvas data byte-for-byte', () => {
+ const canvas = makeSlide();
+ const input: SlideContent = { type: 'slide', canvas };
+ const out = migrateSlideContent(input);
+ expect(out.canvas).toBe(canvas);
+ });
+
+ it('does not downgrade content written with a future schemaVersion', () => {
+ // A newer client writes v2; this v1 client should leave it intact
+ // rather than silently truncating the schema down to v1.
+ const future = makeSlideContent({ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION + 1 });
+ expect(migrateSlideContent(future)).toBe(future);
+ });
+});
+
+describe('migrateScene', () => {
+ it('migrates the slide content for slide scenes', () => {
+ const scene = makeSlideScene();
+ const out = migrateScene(scene);
+ expect(out).not.toBe(scene);
+ if (out.content.type !== 'slide') throw new Error('expected slide content');
+ expect(out.content.schemaVersion).toBe(CURRENT_SLIDE_CONTENT_SCHEMA_VERSION);
+ });
+
+ it('returns the same reference for slide scenes already at the current version', () => {
+ const scene = makeSlideScene({
+ content: makeSlideContent({ schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION }),
+ });
+ expect(migrateScene(scene)).toBe(scene);
+ });
+
+ it('passes non-slide scenes through unchanged', () => {
+ const quizScene: Scene = {
+ id: 'q1',
+ stageId: 'stage-1',
+ type: 'quiz',
+ title: 'Quiz',
+ order: 1,
+ content: { type: 'quiz', questions: [] },
+ };
+ expect(migrateScene(quizScene)).toBe(quizScene);
+ });
+
+ it('is idempotent at the scene level', () => {
+ const scene = makeSlideScene();
+ const once = migrateScene(scene);
+ const twice = migrateScene(once);
+ expect(twice).toBe(once);
+ });
+});
diff --git a/tests/edit/stage-mode.test.ts b/tests/edit/stage-mode.test.ts
new file mode 100644
index 0000000000..db489a53b9
--- /dev/null
+++ b/tests/edit/stage-mode.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { useCanvasStore, useStageStore } from '@/lib/store';
+import { PENDING_SCENE_ID } from '@/lib/store/stage';
+import { isCurrentSceneEditable } from '@/lib/edit/stage-mode';
+import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
+import type { SceneEditorSurface } from '@/lib/edit/scene-editor-surface';
+import type { SceneType } from '@/lib/types/stage';
+
+describe('stage edit mode store', () => {
+ beforeEach(() => {
+ useStageStore.getState().clearStore();
+ useCanvasStore.getState().resetCanvasState();
+ });
+
+ test('supports a global edit mode', () => {
+ useStageStore.getState().setMode('edit');
+
+ expect(useStageStore.getState().mode).toBe('edit');
+ });
+
+ test('clears canvas selection when leaving edit mode', () => {
+ useStageStore.getState().setMode('edit');
+ useCanvasStore.getState().setActiveElementIdList(['title']);
+ useCanvasStore.getState().setEditingElementId('title');
+
+ useStageStore.getState().setMode('playback');
+
+ expect(useCanvasStore.getState().activeElementIdList).toEqual([]);
+ expect(useCanvasStore.getState().handleElementId).toBe('');
+ expect(useCanvasStore.getState().editingElementId).toBe('');
+ });
+});
+
+describe('isCurrentSceneEditable', () => {
+ test('returns true when a real scene is resolved and nothing is generating', () => {
+ expect(
+ isCurrentSceneEditable({
+ currentSceneId: 'scene-1',
+ sceneCount: 3,
+ generatingOutlineCount: 0,
+ hasCurrentScene: true,
+ }),
+ ).toBe(true);
+ });
+
+ test('returns false on the pending placeholder scene', () => {
+ expect(
+ isCurrentSceneEditable({
+ currentSceneId: PENDING_SCENE_ID,
+ sceneCount: 3,
+ generatingOutlineCount: 0,
+ hasCurrentScene: true,
+ }),
+ ).toBe(false);
+ });
+
+ test('returns false when no scenes have materialised yet', () => {
+ expect(
+ isCurrentSceneEditable({
+ currentSceneId: null,
+ sceneCount: 0,
+ generatingOutlineCount: 0,
+ hasCurrentScene: false,
+ }),
+ ).toBe(false);
+ });
+
+ test('returns false while outline generation is still in flight', () => {
+ expect(
+ isCurrentSceneEditable({
+ currentSceneId: 'scene-1',
+ sceneCount: 1,
+ generatingOutlineCount: 2,
+ hasCurrentScene: true,
+ }),
+ ).toBe(false);
+ });
+
+ test('returns false when current scene id does not resolve to a scene', () => {
+ expect(
+ isCurrentSceneEditable({
+ currentSceneId: 'scene-x',
+ sceneCount: 3,
+ generatingOutlineCount: 0,
+ hasCurrentScene: false,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('sceneEditorRegistry', () => {
+ function makeSurface(sceneType: SceneType, label = 'A'): SceneEditorSurface {
+ return {
+ sceneType,
+ CanvasComponent: () => null,
+ useSurfaceState: () => ({
+ // Cast through unknown because tests don't need a real surface state;
+ // we only exercise the registry contract here.
+ content: { type: sceneType, label } as unknown as never,
+ selection: null,
+ hasSelection: false,
+ history: { canUndo: false, canRedo: false, undo: () => {}, redo: () => {} },
+ insertItems: [],
+ floatingActions: [],
+ commands: [],
+ }),
+ };
+ }
+
+ afterEach(() => {
+ sceneEditorRegistry.unregister('slide');
+ sceneEditorRegistry.unregister('quiz');
+ });
+
+ test('register and resolve round-trip by sceneType', () => {
+ const surface = makeSurface('slide');
+ sceneEditorRegistry.register(surface);
+ expect(sceneEditorRegistry.resolve('slide')).toBe(surface);
+ });
+
+ test('resolve returns undefined for unregistered sceneType', () => {
+ expect(sceneEditorRegistry.resolve('pbl')).toBeUndefined();
+ });
+
+ test('re-registering the identical surface instance does not warn (HMR-safe)', () => {
+ const surface = makeSurface('slide');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ sceneEditorRegistry.register(surface);
+ sceneEditorRegistry.register(surface);
+
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ test('registering a different surface for the same sceneType warns in dev', () => {
+ const first = makeSurface('slide', 'A');
+ const second = makeSurface('slide', 'B');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ sceneEditorRegistry.register(first);
+ sceneEditorRegistry.register(second);
+
+ expect(warn).toHaveBeenCalledOnce();
+ expect(sceneEditorRegistry.resolve('slide')).toBe(second);
+ warn.mockRestore();
+ });
+
+ test('unregister removes the surface', () => {
+ const surface = makeSurface('quiz');
+ sceneEditorRegistry.register(surface);
+ expect(sceneEditorRegistry.resolve('quiz')).toBe(surface);
+
+ sceneEditorRegistry.unregister('quiz');
+ expect(sceneEditorRegistry.resolve('quiz')).toBeUndefined();
+ });
+});
diff --git a/tests/edit/surfaces/slide/editing-state.test.ts b/tests/edit/surfaces/slide/editing-state.test.ts
new file mode 100644
index 0000000000..2fbc7783de
--- /dev/null
+++ b/tests/edit/surfaces/slide/editing-state.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from 'vitest';
+import {
+ resolveEditingElementId,
+ resolveSelectedElement,
+} from '@/components/edit/surfaces/slide/editing-state';
+import {
+ createDefaultImageElement,
+ createDefaultTextElement,
+} from '@/lib/edit/slide-edit-elements';
+
+const text = createDefaultTextElement('t1');
+const image = createDefaultImageElement('i1', 'gen_img_x');
+
+describe('resolveSelectedElement', () => {
+ test('returns undefined when nothing is selected', () => {
+ expect(resolveSelectedElement([], [text])).toBeUndefined();
+ });
+
+ test('returns undefined for a multi-selection', () => {
+ expect(resolveSelectedElement(['t1', 'i1'], [text, image])).toBeUndefined();
+ });
+
+ test('returns undefined when the selected id is not found', () => {
+ expect(resolveSelectedElement(['ghost'], [text])).toBeUndefined();
+ });
+
+ test('returns the element for a single selection', () => {
+ expect(resolveSelectedElement(['i1'], [text, image])).toBe(image);
+ });
+});
+
+describe('resolveEditingElementId', () => {
+ test('returns "" when nothing is selected', () => {
+ expect(resolveEditingElementId([], [text])).toBe('');
+ });
+
+ test('returns "" for a multi-selection', () => {
+ expect(resolveEditingElementId(['t1', 'i1'], [text, image])).toBe('');
+ });
+
+ test('returns "" when the single selection is not a text element', () => {
+ expect(resolveEditingElementId(['i1'], [text, image])).toBe('');
+ });
+
+ test('returns "" when the selected id is not found', () => {
+ expect(resolveEditingElementId(['ghost'], [text])).toBe('');
+ });
+
+ test('returns the id when a single text element is selected', () => {
+ expect(resolveEditingElementId(['t1'], [text, image])).toBe('t1');
+ });
+});
diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts
new file mode 100644
index 0000000000..c99c0732a5
--- /dev/null
+++ b/tests/edit/surfaces/slide/insert-items.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ buildInsertItems,
+ deleteSlideElement,
+} from '@/components/edit/surfaces/slide/use-slide-surface';
+import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session';
+import { useCanvasStore } from '@/lib/store/canvas';
+
+function seedEmptySlideSession() {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ useSlideEditSession.setState({
+ history: {
+ past: [],
+ present: { type: 'slide', canvas: { id: 's', elements: [] } } as any,
+ future: [],
+ },
+ } as any);
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+}
+
+describe('slide insert palette', () => {
+ beforeEach(seedEmptySlideSession);
+ afterEach(() => vi.restoreAllMocks());
+
+ it('exposes a text-box and an image insert item', () => {
+ const items = buildInsertItems((k) => k, undefined);
+ expect(items.map((i) => i.id)).toEqual(['insert-text', 'insert-image']);
+ expect(items[1].popoverContent).toBeTypeOf('function');
+ expect(items[0].onInvoke).toBeTypeOf('function');
+ });
+
+ it('text-box invoke arms text-insertion (sets creatingElement)', () => {
+ const spy = vi.spyOn(useCanvasStore.getState(), 'setCreatingElement');
+ buildInsertItems((k) => k, undefined)[0].onInvoke();
+ expect(spy).toHaveBeenCalledWith({ type: 'text' });
+ });
+
+ it('text-box invoke when already armed disarms (sets creatingElement to null)', () => {
+ const spy = vi.spyOn(useCanvasStore.getState(), 'setCreatingElement');
+ buildInsertItems((k) => k, 'text')[0].onInvoke();
+ expect(spy).toHaveBeenCalledWith(null);
+ });
+
+ it('text-box reports active when creating-text is armed', () => {
+ expect(buildInsertItems((k) => k, 'text')[0].active).toBe(true);
+ expect(buildInsertItems((k) => k, undefined)[0].active).toBe(false);
+ });
+});
+
+describe('slide element deletion', () => {
+ beforeEach(seedEmptySlideSession);
+ afterEach(() => vi.restoreAllMocks());
+
+ it('deleteSlideElement dispatches an element.delete op', () => {
+ const spy = vi.spyOn(useSlideEditSession.getState(), 'applyOp');
+ deleteSlideElement('img-9');
+ expect(spy).toHaveBeenCalledWith({ type: 'element.delete', elementId: 'img-9' });
+ });
+});
diff --git a/tests/edit/surfaces/slide/text-format-bar.test.ts b/tests/edit/surfaces/slide/text-format-bar.test.ts
new file mode 100644
index 0000000000..6bf4831a2d
--- /dev/null
+++ b/tests/edit/surfaces/slide/text-format-bar.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi } from 'vitest';
+import * as registry from '@/lib/prosemirror/active-editor-registry';
+import { currentFontLabel, stepFontSize } from '@/components/edit/surfaces/slide/text-format-bar';
+
+describe('TextFormatBar — pure logic', () => {
+ it('stepFontSize increments and decrements by delta', () => {
+ expect(stepFontSize('16px', 2)).toBe('18px');
+ expect(stepFontSize('16px', -2)).toBe('14px');
+ });
+
+ it('stepFontSize clamps to [8, 96]', () => {
+ expect(stepFontSize('8px', -2)).toBe('8px');
+ expect(stepFontSize('96px', 2)).toBe('96px');
+ expect(stepFontSize('100px', 2)).toBe('96px');
+ expect(stepFontSize('4px', -2)).toBe('8px');
+ });
+
+ it('stepFontSize handles invalid input (defaults to 16)', () => {
+ expect(stepFontSize('', 2)).toBe('18px');
+ expect(stepFontSize('abc', -2)).toBe('14px');
+ });
+});
+
+describe('TextFormatBar — currentFontLabel', () => {
+ const t = (k: string) => `T:${k}`;
+
+ it('returns the i18n label for the default (empty) font', () => {
+ expect(currentFontLabel('', t)).toBe('T:edit.text.fontDefault');
+ });
+
+ it("returns the registry entry's label for a matched font", () => {
+ expect(currentFontLabel('Roboto', t)).toBe('Roboto');
+ expect(currentFontLabel('Noto Sans SC', t)).toBe('思源黑体');
+ });
+
+ it('returns the raw family name for an unmatched legacy font', () => {
+ expect(currentFontLabel('Microsoft YaHei', t)).toBe('Microsoft YaHei');
+ expect(currentFontLabel('PingFang SC', t)).toBe('PingFang SC');
+ });
+});
+
+describe('TextFormatBar — C1 integration (runActiveTextCommand)', () => {
+ it('runActiveTextCommand is callable for bold', () => {
+ const spy = vi.spyOn(registry, 'runActiveTextCommand').mockImplementation(() => {});
+ registry.runActiveTextCommand('el-1', { command: 'bold' });
+ expect(spy).toHaveBeenCalledWith('el-1', { command: 'bold' });
+ spy.mockRestore();
+ });
+
+ it('runActiveTextCommand supports all TextFormatBar commands', () => {
+ const spy = vi.spyOn(registry, 'runActiveTextCommand').mockImplementation(() => {});
+ const commands = [
+ { command: 'bold' as const },
+ { command: 'em' as const },
+ { command: 'underline' as const },
+ { command: 'forecolor' as const, value: '#ff0000' },
+ { command: 'align-left' as const },
+ { command: 'align-center' as const },
+ { command: 'align-right' as const },
+ { command: 'bulletList' as const },
+ { command: 'fontname' as const, value: 'Inter' },
+ { command: 'fontsize' as const, value: '18px' },
+ ] as const;
+
+ for (const payload of commands) {
+ registry.runActiveTextCommand('el-1', payload);
+ }
+ expect(spy).toHaveBeenCalledTimes(commands.length);
+ spy.mockRestore();
+ });
+});
diff --git a/tests/i18n/edit-pr2-locales.test.ts b/tests/i18n/edit-pr2-locales.test.ts
new file mode 100644
index 0000000000..891e5ab345
--- /dev/null
+++ b/tests/i18n/edit-pr2-locales.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import enUS from '@/lib/i18n/locales/en-US.json';
+import zhCN from '@/lib/i18n/locales/zh-CN.json';
+import zhTW from '@/lib/i18n/locales/zh-TW.json';
+import jaJP from '@/lib/i18n/locales/ja-JP.json';
+import ruRU from '@/lib/i18n/locales/ru-RU.json';
+import arSA from '@/lib/i18n/locales/ar-SA.json';
+
+const locales = {
+ 'en-US': enUS,
+ 'zh-CN': zhCN,
+ 'zh-TW': zhTW,
+ 'ja-JP': jaJP,
+ 'ru-RU': ruRU,
+ 'ar-SA': arSA,
+};
+const KEYS = [
+ 'edit.delete',
+ 'edit.text.label',
+ 'edit.text.font',
+ 'edit.text.fontDefault',
+ 'edit.text.sizeUp',
+ 'edit.text.sizeDown',
+ 'edit.text.bold',
+ 'edit.text.italic',
+ 'edit.text.underline',
+ 'edit.text.color',
+ 'edit.text.alignLeft',
+ 'edit.text.alignCenter',
+ 'edit.text.alignRight',
+ 'edit.text.bullet',
+ 'edit.insert.textBox',
+ 'edit.insert.image',
+ 'edit.insert.imageDrop',
+ 'edit.insert.imageOr',
+ 'edit.insert.imageUrlPlaceholder',
+ 'edit.insert.imageInsert',
+];
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- locale JSON traversal
+const get = (o: any, k: string) => k.split('.').reduce((a, p) => a?.[p], o);
+
+describe('PR2 edit locale coverage', () => {
+ it('every PR2 key exists, non-empty, not echoing the key, in all 6 locales', () => {
+ for (const [code, data] of Object.entries(locales)) {
+ for (const k of KEYS) {
+ const v = get(data, k);
+ expect(typeof v, `${code} missing ${k}`).toBe('string');
+ expect((v as string).trim(), `${code} empty ${k}`).not.toBe('');
+ expect(v, `${code} echoes ${k}`).not.toBe(k);
+ }
+ }
+ });
+});
diff --git a/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts
new file mode 100644
index 0000000000..b5a8a4240a
--- /dev/null
+++ b/tests/prosemirror/active-editor-registry.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, vi } from 'vitest';
+import type { Transaction } from 'prosemirror-state';
+import {
+ registerActiveTextEditor,
+ runActiveTextCommand,
+ hasActiveTextEditor,
+} from '@/lib/prosemirror/active-editor-registry';
+import { shouldPushAttrs } from '@/lib/prosemirror/selection-sync';
+
+/** Minimal transaction stub — only the three boolean fields shouldPushAttrs reads. */
+const tx = (p: { selectionSet?: boolean; docChanged?: boolean; storedMarksSet?: boolean }) =>
+ p as unknown as Transaction;
+
+describe('active text editor registry', () => {
+ it('routes a command to the registered element and clears on unregister', () => {
+ const run = vi.fn();
+ const off = registerActiveTextEditor('el-1', run);
+ expect(hasActiveTextEditor('el-1')).toBe(true);
+ runActiveTextCommand('el-1', { command: 'bold' });
+ expect(run).toHaveBeenCalledWith({ command: 'bold' });
+ off();
+ expect(hasActiveTextEditor('el-1')).toBe(false);
+ runActiveTextCommand('el-1', { command: 'bold' }); // no throw when absent
+ });
+});
+
+describe('selection sync gate', () => {
+ it('pushes on selection move, doc change, or stored-marks change', () => {
+ expect(
+ shouldPushAttrs(tx({ selectionSet: true, docChanged: false, storedMarksSet: false })),
+ ).toBe(true);
+ expect(
+ shouldPushAttrs(tx({ selectionSet: false, docChanged: true, storedMarksSet: false })),
+ ).toBe(true);
+ expect(
+ shouldPushAttrs(tx({ selectionSet: false, docChanged: false, storedMarksSet: true })),
+ ).toBe(true);
+ expect(
+ shouldPushAttrs(tx({ selectionSet: false, docChanged: false, storedMarksSet: false })),
+ ).toBe(false);
+ });
+});
diff --git a/tests/slide-renderer/use-resolved-image-src.test.ts b/tests/slide-renderer/use-resolved-image-src.test.ts
new file mode 100644
index 0000000000..a4a33e7ba7
--- /dev/null
+++ b/tests/slide-renderer/use-resolved-image-src.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from 'vitest';
+import type { PPTImageElement } from '@/lib/types/slides';
+import type { MediaTask } from '@/lib/store/media-generation';
+import { resolveImageSrc } from '@/components/slide-renderer/components/element/ImageElement/useResolvedImageSrc';
+
+const STAGE = 'stage-a';
+
+const PLACEHOLDER: PPTImageElement = {
+ id: 'el-placeholder',
+ type: 'image',
+ src: 'gen_img_alpha_001',
+ left: 0,
+ top: 0,
+ width: 100,
+ height: 100,
+ rotate: 0,
+ fixedRatio: false,
+};
+
+const CONCRETE: PPTImageElement = {
+ ...PLACEHOLDER,
+ id: 'el-concrete',
+ src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
+};
+
+function task(over: Partial): MediaTask {
+ return {
+ elementId: PLACEHOLDER.src,
+ type: 'image',
+ status: 'done',
+ prompt: '',
+ params: {} as MediaTask['params'],
+ retryCount: 0,
+ stageId: STAGE,
+ ...over,
+ };
+}
+
+describe('resolveImageSrc (pure)', () => {
+ it('returns the objectUrl when the placeholder task is done', () => {
+ const r = resolveImageSrc(
+ PLACEHOLDER,
+ STAGE,
+ task({ status: 'done', objectUrl: 'blob:fake-1' }),
+ );
+ expect(r.resolvedSrc).toBe('blob:fake-1');
+ expect(r.isPlaceholder).toBe(true);
+ expect(r.task?.status).toBe('done');
+ });
+
+ it('falls back to the raw placeholder src when no task is supplied', () => {
+ const r = resolveImageSrc(PLACEHOLDER, STAGE, undefined);
+ expect(r.resolvedSrc).toBe(PLACEHOLDER.src);
+ expect(r.isPlaceholder).toBe(true);
+ expect(r.task).toBeUndefined();
+ });
+
+ it.each(['pending', 'generating', 'failed'] as const)(
+ 'falls back when task status is %s',
+ (status) => {
+ const r = resolveImageSrc(PLACEHOLDER, STAGE, task({ status, objectUrl: undefined }));
+ expect(r.resolvedSrc).toBe(PLACEHOLDER.src);
+ expect(r.task?.status).toBe(status);
+ },
+ );
+
+ it('falls back when a done task has no objectUrl set', () => {
+ const r = resolveImageSrc(PLACEHOLDER, STAGE, task({ status: 'done', objectUrl: undefined }));
+ expect(r.resolvedSrc).toBe(PLACEHOLDER.src);
+ });
+
+ it('cross-stage isolation: drops a done task that belongs to a different stage', () => {
+ const r = resolveImageSrc(
+ PLACEHOLDER,
+ STAGE,
+ task({ status: 'done', objectUrl: 'blob:other-stage', stageId: 'stage-other' }),
+ );
+ expect(r.resolvedSrc).toBe(PLACEHOLDER.src);
+ expect(r.task).toBeUndefined();
+ });
+
+ it('does not consider anything a placeholder when there is no stageId', () => {
+ // Even if a "done" task is supplied, no stageId → skip placeholder logic entirely.
+ const r = resolveImageSrc(
+ PLACEHOLDER,
+ undefined,
+ task({ status: 'done', objectUrl: 'blob:leak' }),
+ );
+ expect(r.resolvedSrc).toBe(PLACEHOLDER.src);
+ expect(r.isPlaceholder).toBe(false);
+ expect(r.task).toBeUndefined();
+ });
+
+ it('passes through non-placeholder src unchanged (additive contract)', () => {
+ // Even with a done task supplied — the regex gate rejects non-placeholder src.
+ const r = resolveImageSrc(
+ CONCRETE,
+ STAGE,
+ task({ status: 'done', objectUrl: 'blob:should-not-touch' }),
+ );
+ expect(r.resolvedSrc).toBe(CONCRETE.src);
+ expect(r.isPlaceholder).toBe(false);
+ expect(r.task).toBeUndefined();
+ });
+});
diff --git a/tests/store/stage-insert-scene-after.test.ts b/tests/store/stage-insert-scene-after.test.ts
new file mode 100644
index 0000000000..f7565f508b
--- /dev/null
+++ b/tests/store/stage-insert-scene-after.test.ts
@@ -0,0 +1,97 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// IndexedDB / stage-storage modules are imported dynamically inside the
+// store's save/load actions. Mock them so the debounced save doesn't try
+// to talk to a real (or jsdom) IndexedDB in the test environment.
+vi.mock('@/lib/utils/stage-storage', () => ({
+ saveStageData: vi.fn().mockResolvedValue(undefined),
+ loadStageData: vi.fn().mockResolvedValue(null),
+}));
+vi.mock('@/lib/utils/database', () => ({
+ db: { stageOutlines: { put: vi.fn(), get: vi.fn() } },
+}));
+
+import { useStageStore } from '@/lib/store/stage';
+import type { Scene, Stage } from '@/lib/types/stage';
+
+function makeStage(): Stage {
+ return {
+ id: 'stage-1',
+ name: 'Test stage',
+ createdAt: 1,
+ updatedAt: 1,
+ };
+}
+
+function makeSlideScene(id: string, order: number, stageId = 'stage-1'): Scene {
+ return {
+ id,
+ stageId,
+ type: 'slide',
+ title: id,
+ order,
+ content: {
+ type: 'slide',
+ canvas: {
+ id: `canvas-${id}`,
+ viewportSize: 1000,
+ viewportRatio: 0.5625,
+ theme: {
+ backgroundColor: '#fff',
+ themeColors: ['#000'],
+ fontColor: '#000',
+ fontName: 'Inter',
+ },
+ elements: [],
+ },
+ },
+ };
+}
+
+beforeEach(() => {
+ useStageStore.setState({
+ stage: makeStage(),
+ scenes: [makeSlideScene('a', 1), makeSlideScene('b', 2), makeSlideScene('c', 3)],
+ currentSceneId: 'a',
+ });
+});
+
+afterEach(() => {
+ useStageStore.getState().clearStore();
+});
+
+describe('insertSceneAfter', () => {
+ it('inserts after the anchor index', () => {
+ const fresh = makeSlideScene('x', 99);
+ useStageStore.getState().insertSceneAfter('a', fresh);
+ const ids = useStageStore.getState().scenes.map((s) => s.id);
+ expect(ids).toEqual(['a', 'x', 'b', 'c']);
+ });
+
+ it('rebalances order to monotonic 1-based after insert', () => {
+ const fresh = makeSlideScene('x', 999);
+ useStageStore.getState().insertSceneAfter('b', fresh);
+ const orders = useStageStore.getState().scenes.map((s) => s.order);
+ expect(orders).toEqual([1, 2, 3, 4]);
+ });
+
+ it('rejects a scene whose stageId mismatches the current stage', () => {
+ const foreign = makeSlideScene('z', 4, 'stage-9');
+ useStageStore.getState().insertSceneAfter('a', foreign);
+ const ids = useStageStore.getState().scenes.map((s) => s.id);
+ expect(ids).toEqual(['a', 'b', 'c']);
+ });
+
+ it('falls through to append when the anchor is not found', () => {
+ const fresh = makeSlideScene('x', 7);
+ useStageStore.getState().insertSceneAfter('does-not-exist', fresh);
+ const ids = useStageStore.getState().scenes.map((s) => s.id);
+ expect(ids).toEqual(['a', 'b', 'c', 'x']);
+ });
+
+ it('does not switch currentSceneId — callers decide focus', () => {
+ const fresh = makeSlideScene('x', 99);
+ useStageStore.getState().insertSceneAfter('a', fresh);
+ expect(useStageStore.getState().currentSceneId).toBe('a');
+ });
+});