From e1064f7b660dd5689dcf3b2e2fecca90ffda492e Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 13 May 2026 10:54:26 +0800 Subject: [PATCH 01/38] feat(maic-editor): framework primitives + edit StageMode (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maic-editor): framework primitives + edit StageMode Phase 1 framework foundation for the MAIC Editor (RFC #547, tracking #560). Plumbing only — no UI consumers ship in this sub-PR; the EditShell chrome and slide surface registration land in follow-ups. - StageMode gains 'edit' alongside 'autonomous' | 'playback'; setMode resets canvas selection when leaving 'edit'. - auto-exits 'edit' whenever the current scene becomes uneditable (no scenes / pending generation / no current scene) so a follow-up Pro toggle can never strand the user in an empty edit shell. - SceneEditorSurface contract + tiny registry under lib/edit/ so each SceneType plugs in a surface without the shell importing surfaces directly. Surfaces declare CanvasComponent, useSurfaceState(), insert palette items, floating actions, commands, and (reserved for AI) inline coach hints. - Slide kernel (lib/edit/slide-ops.ts): immutable, history-aware operations covering slide-update, element add / update / updateMany / delete / deleteMany / reorder / duplicate / align / removeProps, and text content edit. - Slide element factories (lib/edit/slide-edit-elements.ts) for default text / shape / image elements + HTML <-> plain-text helpers. - i18n: stage.editCourse + stage.doneEditing across all 6 locales, consumed by the header toggle in the next sub-PR. - Vitest coverage for the slide kernel (operations + history) and the edit-mode store transition (entry + canvas reset on exit). PBLRenderer's mode prop is widened from the literal pair to StageMode so (which already passes StageMode) type-checks. The prop is unused inside the renderer. * style(maic-editor): apply prettier to lib/edit + tests/edit CI runs on PRs to main only, so the prettier check did not fire for this PR's target branch — applying formatting locally before the merge train reaches main avoids a follow-up style commit. * ci: also run on PRs targeting feat/maic-editor-v0 The MAIC Editor lands as a series of stacked sub-PRs against the long-lived feat/maic-editor-v0 branch. Without this entry, none of those sub-PRs get a CI gate — style/lint/type/test regressions only surface when feat/maic-editor-v0 finally merges back to main, at which point fixing them is a lot more disruptive than catching them per sub-PR. Push trigger is intentionally left main-only: nobody pushes directly to feat/maic-editor-v0, every change arrives through a PR that now runs the gate. * fix(maic-editor): address kernel review on #564 Addresses cosarah's review against #561 scope: Important: - element.align now uses the canonical lib/utils/element.ts geometry helper instead of a forked copy. The local fork ignored PPTLineElement start/end and rotation, so bounds were wrong for lines and rotated elements. - Cap slide-edit history at MAX_HISTORY = 50; drop oldest on overflow. - Narrow slide.update patch to Partial> via a new SlideMetaPatch alias, so element / animation collections can only be mutated through their dedicated ops. - element.add throws on id collision; element.duplicate throws when idMap is missing entries or when new ids would collide with existing elements. - scene-editor-registry dev-warns on overwriting a *different* surface for the same SceneType (HMR re-register of the same instance stays silent); add unregister() for HMR cleanup and tests. Minor: - Unify on structuredClone over JSON.parse(JSON.stringify(...)); inside immer's produce, un-proxy with current() first. - Skip history push when produce returns the same content reference (true no-op detection). element.delete / deleteMany pre-check membership so their unconditional filter assignments don't break the ref-equality signal. - Drop redundant cloneSlideContent calls in undo/redo/push paths; immer's structural sharing already guarantees immutability of the produced output. createSlideEditHistory keeps its defensive clone since the initial value comes from outside immer. Coverage: - Extract auto-exit predicate into lib/edit/stage-mode.ts so the policy can be unit-tested without rendering . - New tests: every align direction, line / rotated element align, no-op paths for update/delete/reorder/removeProps/text/align, element.add index clamping + id collision, element.duplicate default offset + contract errors, history future cleared after branching, history capped, registry register/unregister/HMR-safe re-register, and the auto-exit predicate. 371 vitest tests pass (was 335). tsc/lint/prettier/i18n/build all green locally. * fix(maic-editor): close kernel escape hatches (subagent CR follow-up) Two defense-in-depth fixes flagged by independent review after the prior commit: - element.duplicate now deep-clones the source via structuredClone(current(element)). The previous shallow spread shared nested mutable references (start/end tuples, outline, points) with the source; immer's COW would have handled most mutations but ops that operate on nested arrays in place (sort/reverse/splice) would silently leak between source and duplicate. The deep clone keeps the kernel's invariants independent of how downstream op consumers write their recipes. - slide.update gains a runtime guard that throws when patch contains elements / animations. The type-level SlideMetaPatch narrowing already forbids these keys, but the runtime guard closes the `as any` escape hatch for callers that might bypass the type system. New tests cover both paths: meta-only slide.update succeeds, an elements-containing patch throws, and a duplicated line element's start/end/points tuples are independent from the source. --- .github/workflows/ci.yml | 2 +- components/scene-renderers/pbl-renderer.tsx | 4 +- components/stage.tsx | 18 + lib/edit/scene-editor-registry.ts | 25 + lib/edit/scene-editor-surface.ts | 135 +++++ lib/edit/slide-edit-elements.ts | 99 ++++ lib/edit/slide-ops.ts | 359 ++++++++++++ lib/edit/stage-mode.ts | 27 + lib/i18n/locales/ar-SA.json | 4 +- lib/i18n/locales/en-US.json | 4 +- lib/i18n/locales/ja-JP.json | 4 +- lib/i18n/locales/ru-RU.json | 4 +- lib/i18n/locales/zh-CN.json | 4 +- lib/i18n/locales/zh-TW.json | 4 +- lib/store/stage.ts | 10 +- lib/types/stage.ts | 2 +- tests/edit/slide-edit-elements.test.ts | 76 +++ tests/edit/slide-ops.test.ts | 597 ++++++++++++++++++++ tests/edit/stage-mode.test.ts | 157 +++++ 19 files changed, 1524 insertions(+), 11 deletions(-) create mode 100644 lib/edit/scene-editor-registry.ts create mode 100644 lib/edit/scene-editor-surface.ts create mode 100644 lib/edit/slide-edit-elements.ts create mode 100644 lib/edit/slide-ops.ts create mode 100644 lib/edit/stage-mode.ts create mode 100644 tests/edit/slide-edit-elements.test.ts create mode 100644 tests/edit/slide-ops.test.ts create mode 100644 tests/edit/stage-mode.test.ts 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/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/stage.tsx b/components/stage.tsx index a887aa0522..8310a7375a 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useStageStore } from '@/lib/store'; import { PENDING_SCENE_ID } from '@/lib/store/stage'; +import { isCurrentSceneEditable } from '@/lib/edit/stage-mode'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; @@ -49,6 +50,7 @@ export function Stage({ const { t } = useI18n(); const { mode, + setMode, getCurrentScene, scenes, currentSceneId, @@ -260,6 +262,22 @@ export function Stage({ doSessionCleanup(); }, [doSessionCleanup]); + // Auto-exit edit mode whenever the current scene becomes uneditable + // (pending generation, no scenes, currently generating). Predicate lives in + // lib/edit/stage-mode so it can be unit-tested without rendering Stage. + useEffect(() => { + if (mode !== 'edit') return; + const editable = isCurrentSceneEditable({ + currentSceneId, + sceneCount: scenes.length, + generatingOutlineCount: generatingOutlines.length, + hasCurrentScene: !!currentScene, + }); + if (!editable) { + setMode('playback'); + } + }, [mode, currentSceneId, scenes.length, generatingOutlines.length, currentScene, setMode]); + const clearPresentationIdleTimer = useCallback(() => { if (presentationIdleTimerRef.current) { clearTimeout(presentationIdleTimerRef.current); 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..0b95ee41d1 --- /dev/null +++ b/lib/edit/scene-editor-surface.ts @@ -0,0 +1,135 @@ +/** + * 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; +} + +/** + * 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; +} + +export interface SurfaceState { + content: TContent; + selection: TSelection; + /** True when the surface considers selection non-empty (drives floating bar). */ + hasSelection: boolean; + + 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-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..ffc805c5d0 --- /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. +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/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/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 5600ba5382..8e8aa73c5d 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -149,7 +149,9 @@ "generatingNextPage": "جارٍ توليد المشهد، يرجى الانتظار...", "courseComplete": "اكتملت الدورة", "fullscreen": "ملء الشاشة", - "exitFullscreen": "الخروج من ملء الشاشة" + "exitFullscreen": "الخروج من ملء الشاشة", + "editCourse": "تحرير الدورة", + "doneEditing": "إنهاء التحرير" }, "classroomComplete": { "title": "اكتملت الدورة", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index b281156f14..c03722d220 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -149,7 +149,9 @@ "generatingNextPage": "Scene is being generated, please wait...", "courseComplete": "Course complete", "fullscreen": "Fullscreen", - "exitFullscreen": "Exit Fullscreen" + "exitFullscreen": "Exit Fullscreen", + "editCourse": "Edit course", + "doneEditing": "Done editing" }, "classroomComplete": { "title": "Course complete", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 01549839a8..3b7a1eb928 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -149,7 +149,9 @@ "generatingNextPage": "シーンを生成中です。お待ちください...", "courseComplete": "コース完了", "fullscreen": "全画面表示", - "exitFullscreen": "全画面表示を終了" + "exitFullscreen": "全画面表示を終了", + "editCourse": "コースを編集", + "doneEditing": "編集を完了" }, "classroomComplete": { "title": "コース完了", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 065b64f6ec..7309b6e235 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -149,7 +149,9 @@ "generatingNextPage": "Сцена генерируется, пожалуйста подождите...", "courseComplete": "Курс завершён", "fullscreen": "Полный экран", - "exitFullscreen": "Свернуть" + "exitFullscreen": "Свернуть", + "editCourse": "Редактировать курс", + "doneEditing": "Завершить редактирование" }, "classroomComplete": { "title": "Курс завершён", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index ced729524f..e36a03f14d 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -149,7 +149,9 @@ "generatingNextPage": "场景正在生成,请稍候...", "courseComplete": "课程完成", "fullscreen": "全屏", - "exitFullscreen": "退出全屏" + "exitFullscreen": "退出全屏", + "editCourse": "编辑课程", + "doneEditing": "完成编辑" }, "classroomComplete": { "title": "课程完成", diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index a2a4e5de2b..2509481f97 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -149,7 +149,9 @@ "generatingNextPage": "場景正在生成,請稍候...", "fullscreen": "全螢幕", "exitFullscreen": "離開全螢幕", - "courseComplete": "課程完成" + "courseComplete": "課程完成", + "editCourse": "編輯課程", + "doneEditing": "完成編輯" }, "whiteboard": { "title": "互動白板", diff --git a/lib/store/stage.ts b/lib/store/stage.ts index d2732c5327..c7e8950e93 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -4,6 +4,7 @@ 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'; const log = createLogger('StageStore'); @@ -189,7 +190,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 }), diff --git a/lib/types/stage.ts b/lib/types/stage.ts index 458b46b311..bdf5e6e670 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; 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-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/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(); + }); +}); From 512f63bdf104f6a629af78165305d76ee5308763 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 13 May 2026 15:24:19 +0800 Subject: [PATCH 02/38] feat(maic-editor): EditShell chrome and Pro mode toggle (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maic-editor): EditShell chrome and Pro mode toggle Adds the scene-type-agnostic editor chrome (EditShell + CommandBar + FloatingToolbar + HintRail), an edit-mode sidebar, and the header Pro toggle that flips into the 'edit' StageMode from #561. No scene editor surfaces are registered yet — the next sub-PR wires up the slide surface. In this PR every scene type falls through to the i18n unsupportedScene placeholder, which is the verifiable visible behavior. - canEdit gating reuses the canonical isCurrentSceneEditable predicate shipped in #561 so the toggle and the auto-exit effect are in lock-step. - handleToggleEditMode tears down live session / engine / TTS before entering edit mode. - ChatArea slides out in edit mode for a full-width canvas. - reorderScene extracted from EditModeSidebar with unit tests; the positional-order preservation is the part worth a guard test. - i18n scoped to keys this PR's components actually reference; surface-specific keys deferred to the slide-surface PR. * test(reorder-scenes): single-element + reference-inequality cases; zh-CN newSlide distinct from addSlide CR follow-ups: - reorderScene tests now cover a 1-element array (both directions return null) and explicitly assert the returned array is a new reference, not the input. - zh-CN edit.sidebar.newSlide was duplicating the addSlide label ("新建幻灯片" both); using "未命名幻灯片" for the default new-slide title to match the English Add slide / New slide distinction. --- components/canvas/canvas-area.tsx | 7 +- components/edit/EditModeSidebar.tsx | 247 ++++++++++++++++++ components/edit/EditShell/CommandBar.tsx | 142 ++++++++++ components/edit/EditShell/EditShell.tsx | 61 +++++ components/edit/EditShell/FloatingToolbar.tsx | 91 +++++++ components/edit/EditShell/HintRail.tsx | 62 +++++ components/edit/EditShell/index.ts | 1 + components/header.tsx | 51 +++- components/stage.tsx | 196 +++++++++----- components/stage/scene-renderer.tsx | 40 ++- lib/edit/reorder-scenes.ts | 29 ++ lib/i18n/locales/ar-SA.json | 23 ++ lib/i18n/locales/en-US.json | 23 ++ lib/i18n/locales/ja-JP.json | 23 ++ lib/i18n/locales/ru-RU.json | 23 ++ lib/i18n/locales/zh-CN.json | 23 ++ lib/i18n/locales/zh-TW.json | 23 ++ tests/edit/reorder-scenes.test.ts | 92 +++++++ 18 files changed, 1076 insertions(+), 81 deletions(-) create mode 100644 components/edit/EditModeSidebar.tsx create mode 100644 components/edit/EditShell/CommandBar.tsx create mode 100644 components/edit/EditShell/EditShell.tsx create mode 100644 components/edit/EditShell/FloatingToolbar.tsx create mode 100644 components/edit/EditShell/HintRail.tsx create mode 100644 components/edit/EditShell/index.ts create mode 100644 lib/edit/reorder-scenes.ts create mode 100644 tests/edit/reorder-scenes.test.ts diff --git a/components/canvas/canvas-area.tsx b/components/canvas/canvas-area.tsx index 356860b99b..e7572b9c68 100644 --- a/components/canvas/canvas-area.tsx +++ b/components/canvas/canvas-area.tsx @@ -114,7 +114,12 @@ export function CanvasArea({ {currentScene && !whiteboardOpen && (
- +
)} diff --git a/components/edit/EditModeSidebar.tsx b/components/edit/EditModeSidebar.tsx new file mode 100644 index 0000000000..dd3e02b947 --- /dev/null +++ b/components/edit/EditModeSidebar.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useCallback } from 'react'; +import { ChevronDown, ChevronUp, PanelLeftClose, PanelLeftOpen, Plus, Trash2 } from 'lucide-react'; +import { nanoid } from 'nanoid'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide'; +import { useStageStore } from '@/lib/store'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { createDefaultSlide } from '@/lib/edit/slide-edit-elements'; +import { reorderScene } from '@/lib/edit/reorder-scenes'; +import type { Scene } from '@/lib/types/stage'; +import { cn } from '@/lib/utils'; + +interface EditModeSidebarProps { + readonly collapsed: boolean; + readonly onCollapseChange: (collapsed: boolean) => void; + readonly onSceneSelect: (sceneId: string) => void; +} + +/** + * Edit-mode sidebar — distinct from playback's SceneSidebar. + * + * - Lists only real scenes (no virtual completion page) + * - Per-page actions: select / delete / move up / move down + * - Footer: "+ Add slide" creates a new blank slide and selects it + * - Collapsible via top-right chevron; collapsed state shows a slim strip + */ +export function EditModeSidebar({ + collapsed, + onCollapseChange, + onSceneSelect, +}: EditModeSidebarProps) { + const { t } = useI18n(); + const stage = useStageStore((s) => s.stage); + const scenes = useStageStore((s) => s.scenes); + const currentSceneId = useStageStore((s) => s.currentSceneId); + const setScenes = useStageStore((s) => s.setScenes); + const addScene = useStageStore((s) => s.addScene); + const deleteScene = useStageStore((s) => s.deleteScene); + const setCurrentSceneId = useStageStore((s) => s.setCurrentSceneId); + + const handleMove = useCallback( + (sceneId: string, direction: 'up' | 'down') => { + const reordered = reorderScene(scenes, sceneId, direction); + if (reordered) setScenes(reordered); + }, + [scenes, setScenes], + ); + + const handleAddSlide = useCallback(() => { + if (!stage) return; + const newId = nanoid(); + const lastOrder = scenes.length > 0 ? Math.max(...scenes.map((s) => s.order)) : 0; + const newScene: Scene = { + id: newId, + stageId: stage.id, + type: 'slide', + title: t('edit.sidebar.newSlide'), + order: lastOrder + 1, + content: { type: 'slide', canvas: createDefaultSlide(`slide-${newId}`) }, + }; + addScene(newScene); + setCurrentSceneId(newId); + }, [addScene, scenes, setCurrentSceneId, stage, t]); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function SidebarItem({ + scene, + index, + total, + isCurrent, + onSelect, + onMoveUp, + onMoveDown, + onDelete, +}: { + readonly scene: Scene; + readonly index: number; + readonly total: number; + readonly isCurrent: boolean; + readonly onSelect: () => void; + readonly onMoveUp: () => void; + readonly onMoveDown: () => void; + readonly onDelete: () => void; +}) { + const { t } = useI18n(); + const slideCanvas = + scene.type === 'slide' && scene.content.type === 'slide' ? scene.content.canvas : null; + + return ( +
+ + {index + 1} + + + + {/* Hover-revealed action column */} +
+ + + + + {t('edit.sidebar.moveUp')} + + + + + + {t('edit.sidebar.moveDown')} + + + + + + {t('edit.sidebar.delete')} + +
+
+ ); +} diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx new file mode 100644 index 0000000000..c0cef3ba70 --- /dev/null +++ b/components/edit/EditShell/CommandBar.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { PanelLeft, PanelLeftClose, Redo2, Undo2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import type { + EditorCommand, + InsertPaletteItem, + SurfaceHistory, +} from '@/lib/edit/scene-editor-surface'; + +interface CommandBarProps { + readonly title: string; + readonly history: SurfaceHistory; + readonly insertItems: readonly InsertPaletteItem[]; + readonly commands: readonly EditorCommand[]; + readonly sidebarCollapsed?: boolean; + readonly onToggleSidebar?: () => void; +} + +/** + * Top toolbar — Pitch-inspired: title on the left, insert primitives in the + * center as labeled icon buttons, global commands (undo/redo + view toggles) + * on the right. Borderless; relies on bottom hairline for separation. + */ +export function CommandBar({ + title, + history, + insertItems, + commands, + sidebarCollapsed, + onToggleSidebar, +}: CommandBarProps) { + const { t } = useI18n(); + + return ( +
+
+ {onToggleSidebar && ( + + {sidebarCollapsed ? ( + + ) : ( + + )} + + )} + + + + + + + + {title} + +
+ + {insertItems.length > 0 && ( +
+ {insertItems.map((item) => ( + + ))} +
+ )} + +
+ {commands.map((command) => ( + + {command.icon ?? {command.label}} + + ))} +
+
+ ); +} + +function InsertButton({ item }: { readonly item: InsertPaletteItem }) { + const button = ( + + ); + + const triggerWithTooltip = ( + + {button} + {item.tooltip && {item.tooltip}} + + ); + + if (!item.popoverContent) return triggerWithTooltip; + + return ( + + {triggerWithTooltip} + + {item.popoverContent()} + + + ); +} + +function IconButton({ + title, + children, + ...props +}: React.ComponentProps & { readonly title: string }) { + return ( + + + + + {title} + + ); +} diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx new file mode 100644 index 0000000000..5c6f8a538f --- /dev/null +++ b/components/edit/EditShell/EditShell.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { motion } from 'motion/react'; +import type { SceneEditorSurface } from '@/lib/edit/scene-editor-surface'; +import { CommandBar } from './CommandBar'; +import { FloatingToolbar } from './FloatingToolbar'; +import { HintRail } from './HintRail'; + +interface EditShellProps { + readonly surface: SceneEditorSurface; + readonly title: string; + readonly sidebarCollapsed?: boolean; + readonly onToggleSidebar?: () => void; +} + +const CHROME_TRANSITION = { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; + +/** + * Pitch-inspired shell: + * ┌──────────────────────────────────────────────┐ + * │ Top: [↶↷ title] [Insert items] [view] │ + * │ ┌──────────────────────────────────────┐ │ + * │ │ Canvas (full width below top bar) │ │ + * │ │ • FloatingToolbar (selection- │ │ + * │ │ contextual; holds property │ │ + * │ │ popovers + duplicate/delete) │ │ + * │ └──────────────────────────────────────┘ │ + * └──────────────────────────────────────────────┘ + * + * No fixed right inspector — properties live in the floating toolbar's + * popovers. Left edge belongs to the EditModeSidebar (slide thumbnails) + * provided by the parent Stage layout. + */ +export function EditShell({ surface, title, sidebarCollapsed, onToggleSidebar }: EditShellProps) { + const state = surface.useSurfaceState(); + const Canvas = surface.CanvasComponent; + + return ( +
+ + + +
+ + {state.hasSelection && } + +
+
+ ); +} diff --git a/components/edit/EditShell/FloatingToolbar.tsx b/components/edit/EditShell/FloatingToolbar.tsx new file mode 100644 index 0000000000..2b6a3f95e9 --- /dev/null +++ b/components/edit/EditShell/FloatingToolbar.tsx @@ -0,0 +1,91 @@ +'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 = ( + + ); + + const triggerWithTooltip = ( + + {button} + {action.tooltip ?? action.label} + + ); + + if (!action.popoverContent) return triggerWithTooltip; + + return ( + + {triggerWithTooltip} + + {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 && ( + + )} +
+ ); +} 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/header.tsx b/components/header.tsx index 00ae7f6052..50dcf1ee0e 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -12,6 +12,7 @@ import { Package, Archive, } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useTheme } from '@/lib/hooks/use-theme'; import { LanguageSwitcher } from './language-switcher'; @@ -23,12 +24,16 @@ 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'; 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(); @@ -97,12 +102,10 @@ export function Header({ currentSceneTitle }: HeaderProps) {
-
+
{/* Language Selector */} setThemeOpen(false)} /> -
- {/* Theme Selector */}
+ {/* Pro Mode (edit) toggle — caps label + switch. Surfaces only the + two i18n strings already shipped in stage.editCourse/doneEditing + from #561; consumer code lives behind the optional onToggleEditMode + prop so embedders without an edit affordance render the same header. */} + {onToggleEditMode && ( + + )} + {/* Export Dropdown */}
- - {t('edit.sidebar.expand')} - - - ); - } - - return ( - - ); -} - -function SidebarItem({ - scene, - index, - total, - isCurrent, - onSelect, - onMoveUp, - onMoveDown, - onDelete, -}: { - readonly scene: Scene; - readonly index: number; - readonly total: number; - readonly isCurrent: boolean; - readonly onSelect: () => void; - readonly onMoveUp: () => void; - readonly onMoveDown: () => void; - readonly onDelete: () => void; -}) { - const { t } = useI18n(); - const slideCanvas = - scene.type === 'slide' && scene.content.type === 'slide' ? scene.content.canvas : null; - - return ( -
- - {index + 1} - - - - {/* Hover-revealed action column */} -
- - - - - {t('edit.sidebar.moveUp')} - - - - - - {t('edit.sidebar.moveDown')} - - - - - - {t('edit.sidebar.delete')} - -
-
- ); -} diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx index c0cef3ba70..80b3cb8186 100644 --- a/components/edit/EditShell/CommandBar.tsx +++ b/components/edit/EditShell/CommandBar.tsx @@ -1,10 +1,11 @@ 'use client'; -import { PanelLeft, PanelLeftClose, Redo2, Undo2 } from 'lucide-react'; +import { Redo2, Undo2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { cn } from '@/lib/utils'; import type { EditorCommand, InsertPaletteItem, @@ -13,55 +14,46 @@ import type { interface CommandBarProps { readonly title: string; - readonly history: SurfaceHistory; - readonly insertItems: readonly InsertPaletteItem[]; - readonly commands: readonly EditorCommand[]; - readonly sidebarCollapsed?: boolean; - readonly onToggleSidebar?: () => void; + readonly history?: SurfaceHistory; + readonly insertItems?: readonly InsertPaletteItem[]; + readonly commands?: readonly EditorCommand[]; } /** - * Top toolbar — Pitch-inspired: title on the left, insert primitives in the - * center as labeled icon buttons, global commands (undo/redo + view toggles) - * on the right. Borderless; relies on bottom hairline for separation. + * 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 toggle in the playback Header (which stays + * mounted above this bar), not by a dedicated button here. */ -export function CommandBar({ - title, - history, - insertItems, - commands, - sidebarCollapsed, - onToggleSidebar, -}: CommandBarProps) { +export function CommandBar({ title, history, insertItems, commands }: CommandBarProps) { const { t } = useI18n(); return (
- {onToggleSidebar && ( - - {sidebarCollapsed ? ( - - ) : ( - - )} - + {history && ( + <> + + + + + + + )} - - - - - - - + {title}
- {insertItems.length > 0 && ( + {insertItems && insertItems.length > 0 && (
{insertItems.map((item) => ( @@ -69,18 +61,20 @@ export function CommandBar({
)} -
- {commands.map((command) => ( - - {command.icon ?? {command.label}} - - ))} -
+ {commands && commands.length > 0 && ( +
+ {commands.map((command) => ( + + {command.icon ?? {command.label}} + + ))} +
+ )}
); } diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index 5c6f8a538f..6e518823b4 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -1,40 +1,114 @@ 'use client'; import { motion } from 'motion/react'; +import type { ReactNode } from 'react'; import type { SceneEditorSurface } from '@/lib/edit/scene-editor-surface'; +import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import type { Scene } from '@/lib/types/stage'; import { CommandBar } from './CommandBar'; import { FloatingToolbar } from './FloatingToolbar'; import { HintRail } from './HintRail'; interface EditShellProps { - readonly surface: SceneEditorSurface; - readonly title: string; - readonly sidebarCollapsed?: boolean; - readonly onToggleSidebar?: () => void; + readonly scene: Scene; + /** + * Optional left-side navigator slot. v0 ships with this empty — a future + * sub-PR will plug in the redesigned slide-navigation surface here. The + * prop is preserved as an extension point so Stage doesn't need to grow a + * new layout when that lands. + */ + readonly leftRail?: ReactNode; } const CHROME_TRANSITION = { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; /** - * Pitch-inspired shell: - * ┌──────────────────────────────────────────────┐ - * │ Top: [↶↷ title] [Insert items] [view] │ - * │ ┌──────────────────────────────────────┐ │ - * │ │ Canvas (full width below top bar) │ │ - * │ │ • FloatingToolbar (selection- │ │ - * │ │ contextual; holds property │ │ - * │ │ popovers + duplicate/delete) │ │ - * │ └──────────────────────────────────────┘ │ - * └──────────────────────────────────────────────┘ + * 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) │ + * └──────────┴───────────────────────────────────┘ * - * No fixed right inspector — properties live in the floating toolbar's - * popovers. Left edge belongs to the EditModeSidebar (slide thumbnails) - * provided by the parent Stage layout. + * When a surface is registered for `scene.type`, EditShell renders that + * surface's canvas and reads its useSurfaceState() into the CommandBar / + * FloatingToolbar / HintRail slots. When none is registered, it falls + * through to the `edit.unsupportedScene` placeholder — the visible v0 + * behavior since no surfaces ship in this PR. */ -export function EditShell({ surface, title, sidebarCollapsed, onToggleSidebar }: EditShellProps) { +export function EditShell({ scene, leftRail }: EditShellProps) { + const surface = sceneEditorRegistry.resolve(scene.type); + + if (surface) { + return ; + } + return ; +} + +interface ResolvedShellProps { + readonly scene: Scene; + readonly leftRail?: ReactNode; +} + +function EditShellWithSurface({ + scene, + surface, + leftRail, +}: ResolvedShellProps & { readonly surface: SceneEditorSurface }) { + const { t } = useI18n(); + const sceneTypeLabel = t(`edit.sceneType.${scene.type}`); + const title = t('edit.title', { type: sceneTypeLabel }); const state = surface.useSurfaceState(); const Canvas = surface.CanvasComponent; + return ( + + + {state.hasSelection && } + + + ); +} + +function EditShellFallback({ scene, leftRail }: ResolvedShellProps) { + const { t } = useI18n(); + const sceneTypeLabel = t(`edit.sceneType.${scene.type}`); + const title = t('edit.title', { type: sceneTypeLabel }); + + return ( + +
+ {t('edit.unsupportedScene', { type: sceneTypeLabel })} +
+ + ); +} + +interface FrameProps { + readonly title: string; + readonly leftRail?: ReactNode; + readonly history?: React.ComponentProps['history']; + readonly insertItems?: React.ComponentProps['insertItems']; + readonly commands?: React.ComponentProps['commands']; + readonly children: ReactNode; +} + +function Frame({ title, leftRail, history, insertItems, commands, children }: FrameProps) { return (
- + -
- - {state.hasSelection && } - +
+ {leftRail} +
{children}
); diff --git a/components/stage.tsx b/components/stage.tsx index ab1ba8b688..98fac92463 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -10,7 +10,7 @@ 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 { EditModeSidebar } from '@/components/edit/EditModeSidebar'; +import { EditShell } from '@/components/edit/EditShell'; import { Roundtable } from '@/components/roundtable'; import { PlaybackEngine, computePlaybackView } from '@/lib/playback'; import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback'; @@ -35,7 +35,6 @@ import { } from '@/components/ui/alert-dialog'; import { AlertTriangle } from 'lucide-react'; import { VisuallyHidden } from 'radix-ui'; -import { AnimatePresence, motion } from 'motion/react'; /** * Stage Component @@ -998,16 +997,10 @@ export function Stage({ isPresenting && !controlsVisible && 'cursor-none', )} > - {/* Scene Sidebar — playback uses the full SceneSidebar (with completion - page entry, retry-failed-outline UI, etc); edit (Pro) mode swaps to a - purpose-built EditModeSidebar with add/delete/reorder. */} - {mode === 'edit' ? ( - - ) : ( + {/* Sidebar — only rendered on the playback / autonomous path. The + Pro mode v0 leaves the canvas full-width; a future PR will plug a + redesigned slide navigator into EditShell's leftRail slot. */} + {mode !== 'edit' && ( )} - {/* Canvas Area */} + {/* Canvas Area — Pro mode replaces CanvasArea with EditShell so the + edit chrome (CommandBar + canvas + floating toolbar + hint rail) + takes the same slot. Header above stays mounted because it owns + the global Pro toggle: exiting Pro mode closes the Switch in + Header (no dedicated Done-editing button). */}
- 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' || mode === 'edit' || (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 - } - /> + {mode === 'edit' && currentScene ? ( + + ) : ( + 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 */} @@ -1223,73 +1222,66 @@ export function Stage({ )}
- {/* Chat Area — slides out in edit (Pro) mode for full-canvas editing. */} - - {mode !== 'edit' && ( - - 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} - /> - - )} - + {/* Chat Area — only rendered on the playback / autonomous path. Pro (edit) + mode replaces CanvasArea with EditShell in the same slot; ChatArea + and the playback sidebar are simply not mounted in that state. */} + {mode !== 'edit' && ( +
+ 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 */} void; } -export function SceneRenderer({ - scene, - mode, - sidebarCollapsed, - onToggleSidebar, -}: SceneRendererProps) { - const { t } = useI18n(); - +/** + * 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(() => { - // Edit (Pro) mode: defer rendering to a registered SceneEditorSurface, or - // show a friendly fallback when no surface is registered for this scene - // type. Surfaces are wired up by later PRs (slide first); the shell only - // depends on the registry contract from #561. - if (mode === 'edit') { - const sceneTypeLabel = t(`edit.sceneType.${scene.type}`); - const surface = sceneEditorRegistry.resolve(scene.type); - if (!surface) { - return ( -
- {t('edit.unsupportedScene', { type: sceneTypeLabel })} -
- ); - } - return ( - - ); - } - switch (scene.type) { case 'slide': if (scene.content.type !== 'slide') return
Invalid slide content
; @@ -66,7 +35,7 @@ export function SceneRenderer({ default: return
Unknown scene type
; } - }, [scene, mode, t, sidebarCollapsed, onToggleSidebar]); + }, [scene, mode]); return
{renderer}
; } diff --git a/lib/edit/reorder-scenes.ts b/lib/edit/reorder-scenes.ts deleted file mode 100644 index 3012be3188..0000000000 --- a/lib/edit/reorder-scenes.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Scene } from '@/lib/types/stage'; - -/** - * Swap a scene with its previous (`'up'`) or next (`'down'`) sibling and - * preserve the original `order` values at each *position*. This is the - * subtle part: scenes can carry non-contiguous order values (e.g. 1, 2, 4) - * after deletions or insertions; if we kept each scene's own `order` after - * swapping, the persisted array would render in the wrong sequence on next - * load. Re-assigning by position keeps display-order == array-order. - * - * Returns the new array, or `null` when the move is a no-op (target out of - * range, or sceneId not found). - */ -export function reorderScene( - scenes: readonly Scene[], - sceneId: string, - direction: 'up' | 'down', -): Scene[] | null { - const idx = scenes.findIndex((s) => s.id === sceneId); - if (idx < 0) return null; - const targetIdx = direction === 'up' ? idx - 1 : idx + 1; - if (targetIdx < 0 || targetIdx >= scenes.length) return null; - - const swapped = scenes.slice(); - [swapped[idx], swapped[targetIdx]] = [swapped[targetIdx], swapped[idx]]; - - const positionalOrders = scenes.map((s) => s.order); - return swapped.map((s, i) => ({ ...s, order: positionalOrders[i] })); -} diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index dcc8218767..dda7064783 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -164,16 +164,6 @@ "quiz": "اختبار", "interactive": "تفاعلي", "pbl": "PBL" - }, - "sidebar": { - "pages": "الصفحات", - "addSlide": "إضافة شريحة", - "newSlide": "شريحة جديدة", - "expand": "توسيع الشريط الجانبي", - "collapse": "طي الشريط الجانبي", - "moveUp": "نقل لأعلى", - "moveDown": "نقل لأسفل", - "delete": "حذف" } }, "classroomComplete": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 3ae1b68aa2..e3d232e2bc 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -164,16 +164,6 @@ "quiz": "Quiz", "interactive": "Interactive", "pbl": "PBL" - }, - "sidebar": { - "pages": "Pages", - "addSlide": "Add slide", - "newSlide": "New slide", - "expand": "Expand sidebar", - "collapse": "Collapse sidebar", - "moveUp": "Move up", - "moveDown": "Move down", - "delete": "Delete" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 2083ae1d57..1c18285089 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -164,16 +164,6 @@ "quiz": "クイズ", "interactive": "インタラクティブ", "pbl": "PBL" - }, - "sidebar": { - "pages": "ページ", - "addSlide": "スライドを追加", - "newSlide": "新しいスライド", - "expand": "サイドバーを展開", - "collapse": "サイドバーを折りたたむ", - "moveUp": "上に移動", - "moveDown": "下に移動", - "delete": "削除" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 291857d18e..60798072d8 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -164,16 +164,6 @@ "quiz": "Тест", "interactive": "Интерактив", "pbl": "PBL" - }, - "sidebar": { - "pages": "Страницы", - "addSlide": "Добавить слайд", - "newSlide": "Новый слайд", - "expand": "Развернуть боковую панель", - "collapse": "Свернуть боковую панель", - "moveUp": "Поднять", - "moveDown": "Опустить", - "delete": "Удалить" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index e2026956bc..9541b2f21b 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -164,16 +164,6 @@ "quiz": "测验", "interactive": "互动", "pbl": "PBL" - }, - "sidebar": { - "pages": "页面", - "addSlide": "新建幻灯片", - "newSlide": "未命名幻灯片", - "expand": "展开侧栏", - "collapse": "折叠侧栏", - "moveUp": "上移", - "moveDown": "下移", - "delete": "删除" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 2cab33e28d..d800b4fac9 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -164,16 +164,6 @@ "quiz": "測驗", "interactive": "互動", "pbl": "PBL" - }, - "sidebar": { - "pages": "頁面", - "addSlide": "新增投影片", - "newSlide": "新投影片", - "expand": "展開側欄", - "collapse": "摺疊側欄", - "moveUp": "上移", - "moveDown": "下移", - "delete": "刪除" } }, "whiteboard": { diff --git a/tests/edit/reorder-scenes.test.ts b/tests/edit/reorder-scenes.test.ts deleted file mode 100644 index bb9fedd521..0000000000 --- a/tests/edit/reorder-scenes.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { reorderScene } from '@/lib/edit/reorder-scenes'; -import { createDefaultSlide } from '@/lib/edit/slide-edit-elements'; -import type { Scene } from '@/lib/types/stage'; - -function makeScene(id: string, order: number): Scene { - return { - id, - stageId: 'stage-1', - type: 'slide', - title: id, - order, - content: { type: 'slide', canvas: createDefaultSlide(`canvas-${id}`) }, - }; -} - -describe('reorderScene', () => { - test('moves a scene up by swapping with its predecessor', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2), makeScene('c', 3)]; - - const reordered = reorderScene(scenes, 'b', 'up'); - - expect(reordered).not.toBeNull(); - expect(reordered!.map((s) => s.id)).toEqual(['b', 'a', 'c']); - }); - - test('moves a scene down by swapping with its successor', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2), makeScene('c', 3)]; - - const reordered = reorderScene(scenes, 'b', 'down'); - - expect(reordered).not.toBeNull(); - expect(reordered!.map((s) => s.id)).toEqual(['a', 'c', 'b']); - }); - - test('returns null when moving the first scene up', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2)]; - - expect(reorderScene(scenes, 'a', 'up')).toBeNull(); - }); - - test('returns null when moving the last scene down', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2)]; - - expect(reorderScene(scenes, 'b', 'down')).toBeNull(); - }); - - test('returns null when the sceneId is not found', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2)]; - - expect(reorderScene(scenes, 'missing', 'up')).toBeNull(); - expect(reorderScene(scenes, 'missing', 'down')).toBeNull(); - }); - - test('preserves positional order values, not per-scene values, after a swap', () => { - // Non-contiguous order values (simulates a previous delete that left - // sparse orders). After swapping a↔b, position 0 must keep order 1 and - // position 1 must keep order 4 — so the persisted display sequence stays - // {b, a, c}, not {a (order=4), b (order=1), c} which would re-sort to {b, - // a, c} only by accident. - const scenes = [makeScene('a', 1), makeScene('b', 4), makeScene('c', 7)]; - - const reordered = reorderScene(scenes, 'a', 'down')!; - - expect(reordered.map((s) => s.id)).toEqual(['b', 'a', 'c']); - expect(reordered.map((s) => s.order)).toEqual([1, 4, 7]); - }); - - test('does not mutate the input array', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2)]; - const snapshot = scenes.map((s) => ({ id: s.id, order: s.order })); - - reorderScene(scenes, 'a', 'down'); - - expect(scenes.map((s) => ({ id: s.id, order: s.order }))).toEqual(snapshot); - }); - - test('returns null in both directions for a single-element list', () => { - const scenes = [makeScene('a', 1)]; - - expect(reorderScene(scenes, 'a', 'up')).toBeNull(); - expect(reorderScene(scenes, 'a', 'down')).toBeNull(); - }); - - test('returns a new array reference (never the input)', () => { - const scenes = [makeScene('a', 1), makeScene('b', 2)]; - - const reordered = reorderScene(scenes, 'a', 'down'); - - expect(reordered).not.toBe(scenes); - }); -}); From 077fee14621237d1a1a5344d75315e07a6972fe9 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 14:26:34 +0800 Subject: [PATCH 04/38] feat(maic-editor): enablement infrastructure (pre-slide-surface) (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maic-editor): enablement infrastructure (pre-slide-surface) Pre-requisite for the slide surface (#562). Ships the safety infrastructure so each subsequent surface PR is small and recoverable: 1. Feature flag NEXT_PUBLIC_MAIC_EDITOR_ENABLED, default OFF — gates the Pro toggle in Header. StageMode unchanged. 2. SlideContent.schemaVersion + pure idempotent migrateSlideContent / migrateScene; setScenes / addScene funnel legacy data through the migrate at the store boundary. 3. tests/edit/round-trip/ harness: apply ops -> buildPptxBlob -> JSZip parse -> assert content survived. No PPTX -> Slide reimport exists in the codebase, so the full reimport-diff shape isn't doable; per-op assertions extend the harness in #562. buildPptxBlob is now exported (hook is still the only runtime caller). 4. Per-scene slide-history persistence helpers (persist / load / has / clear, keyed maic-editor:slide-history:${sceneId}, swallow storage failures) + standalone SlideHistoryRestorePrompt dialog + 4 new i18n strings x 6 locales. Stage wiring deferred to #562. 5. Concurrency guards: isSceneEditLocked predicate (defensive; no current call path structurally hits it); localStorage-backed multi-tab edit lock with tryAcquire / refresh / release / heldByOther, stale-lock takeover after 3x heartbeat; standalone MultiTabEditConflictPrompt + 3 new i18n strings x 6 locales. Stage wiring deferred to #562. The slide-surface PR owns the edit-entry effect machinery (where the history-state lifecycle and per-tab tabId ref naturally live), so shipping half-wired dialogs here would speculatively build Stage state we know we'll restructure on contact with the surface. Co-Authored-By: Claude Opus 4.7 * fix(maic-editor): migrateSlideContent forward-compat — no silent downgrade CR follow-up: previously, content with schemaVersion newer than CURRENT (e.g. v2 written by a future client) was silently truncated back to the current version. Now: if schemaVersion >= CURRENT, return the content untouched. The slide may not render correctly on an older client, but its on-disk shape stays intact for the next compatible client to read. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .../edit/MultiTabEditConflictPrompt.tsx | 67 ++++++++ components/edit/SlideHistoryRestorePrompt.tsx | 76 +++++++++ components/stage.tsx | 3 +- lib/config/feature-flags.ts | 22 +++ lib/edit/edit-mode-lock.ts | 113 +++++++++++++ lib/edit/regen-lock.ts | 32 ++++ lib/edit/slide-history-persistence.ts | 61 +++++++ lib/edit/slide-schema.ts | 59 +++++++ lib/export/use-export-pptx.ts | 5 +- lib/i18n/locales/ar-SA.json | 15 ++ lib/i18n/locales/en-US.json | 15 ++ lib/i18n/locales/ja-JP.json | 15 ++ lib/i18n/locales/ru-RU.json | 15 ++ lib/i18n/locales/zh-CN.json | 15 ++ lib/i18n/locales/zh-TW.json | 15 ++ lib/store/stage.ts | 13 +- lib/types/stage.ts | 8 +- tests/config/feature-flags.test.ts | 45 +++++ tests/edit/edit-mode-lock.test.ts | 155 ++++++++++++++++++ tests/edit/regen-lock.test.ts | 23 +++ tests/edit/round-trip/fixtures.ts | 34 ++++ tests/edit/round-trip/text-content.test.ts | 69 ++++++++ tests/edit/slide-history-persistence.test.ts | 131 +++++++++++++++ tests/edit/slide-schema.test.ts | 117 +++++++++++++ 24 files changed, 1116 insertions(+), 7 deletions(-) create mode 100644 components/edit/MultiTabEditConflictPrompt.tsx create mode 100644 components/edit/SlideHistoryRestorePrompt.tsx create mode 100644 lib/config/feature-flags.ts create mode 100644 lib/edit/edit-mode-lock.ts create mode 100644 lib/edit/regen-lock.ts create mode 100644 lib/edit/slide-history-persistence.ts create mode 100644 lib/edit/slide-schema.ts create mode 100644 tests/config/feature-flags.test.ts create mode 100644 tests/edit/edit-mode-lock.test.ts create mode 100644 tests/edit/regen-lock.test.ts create mode 100644 tests/edit/round-trip/fixtures.ts create mode 100644 tests/edit/round-trip/text-content.test.ts create mode 100644 tests/edit/slide-history-persistence.test.ts create mode 100644 tests/edit/slide-schema.test.ts 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/SlideHistoryRestorePrompt.tsx b/components/edit/SlideHistoryRestorePrompt.tsx new file mode 100644 index 0000000000..361ed6ba61 --- /dev/null +++ b/components/edit/SlideHistoryRestorePrompt.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { History } from 'lucide-react'; +import { VisuallyHidden } from 'radix-ui'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface SlideHistoryRestorePromptProps { + readonly open: boolean; + readonly onRestore: () => void; + readonly onDiscard: () => void; + /** + * Mirrors Radix's AlertDialog `onOpenChange`. Closing the dialog via + * outside click or `Escape` lands here with `open === false`; parent + * decides whether that counts as discard or as "ask again later". The + * default action buttons (`Restore` / `Discard`) call their handlers + * directly and do NOT go through this callback first. + */ + readonly onOpenChange?: (open: boolean) => void; +} + +/** + * Standalone prompt the slide-surface PR will mount when entering edit + * mode and finds persisted history for the current scene. Pure + * presenter — wiring (handler bodies, gating on + * `hasPersistedSlideHistory(sceneId)`, etc.) belongs in the slide + * surface's edit-entry effect. + */ +export function SlideHistoryRestorePrompt({ + open, + onRestore, + onDiscard, + onOpenChange, +}: SlideHistoryRestorePromptProps) { + const { t } = useI18n(); + + return ( + + + + {t('edit.history.restore.title')} + +
+
+
+ +
+

+ {t('edit.history.restore.title')} +

+

+ {t('edit.history.restore.body')} +

+
+ + + {t('edit.history.restore.actionDiscard')} + + + {t('edit.history.restore.actionRestore')} + + + + + ); +} diff --git a/components/stage.tsx b/components/stage.tsx index 98fac92463..4613aef73f 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -9,6 +9,7 @@ import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; import { SceneSidebar } from './stage/scene-sidebar'; import { Header } from './header'; +import { isMaicEditorEnabled } from '@/lib/config/feature-flags'; import { CanvasArea } from '@/components/canvas/canvas-area'; import { EditShell } from '@/components/edit/EditShell'; import { Roundtable } from '@/components/roundtable'; @@ -1021,7 +1022,7 @@ export function Stage({ } mode={mode} canEdit={isEditable} - onToggleEditMode={handleToggleEditMode} + onToggleEditMode={isMaicEditorEnabled() ? handleToggleEditMode : undefined} /> )} 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/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/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/slide-history-persistence.ts b/lib/edit/slide-history-persistence.ts new file mode 100644 index 0000000000..a19f51b078 --- /dev/null +++ b/lib/edit/slide-history-persistence.ts @@ -0,0 +1,61 @@ +/** + * Per-scene slide-edit history persistence to localStorage. The slide-ops + * kernel from #564 produces `SlideEditHistory` (past/present/future, + * capped at 50); this module is the storage layer for autosaving and + * restoring that history across page reloads. + * + * Wiring (slide-surface PR's responsibility): + * - On each successful op application, call `persistSlideHistory(sceneId, + * history)`. + * - On entering edit mode for a scene, call `loadPersistedSlideHistory` + * and, if non-null, prompt the user via + * `SlideHistoryRestorePrompt` to restore or discard. + * - Restore handler seeds the slide surface's history state; discard + * handler calls `clearPersistedSlideHistory`. + * + * All helpers swallow storage failures (quota / disabled / private mode) + * so the editor degrades to in-memory-only history instead of crashing. + */ + +import type { SlideEditHistory } from '@/lib/edit/slide-ops'; + +const KEY_PREFIX = 'maic-editor:slide-history'; + +export function slideHistoryStorageKey(sceneId: string): string { + return `${KEY_PREFIX}:${sceneId}`; +} + +export function persistSlideHistory(sceneId: string, history: SlideEditHistory): void { + try { + localStorage.setItem(slideHistoryStorageKey(sceneId), JSON.stringify(history)); + } catch { + // Quota exceeded / disabled — degrade silently to in-memory only. + } +} + +export function loadPersistedSlideHistory(sceneId: string): SlideEditHistory | null { + try { + const raw = localStorage.getItem(slideHistoryStorageKey(sceneId)); + if (raw === null) return null; + return JSON.parse(raw) as SlideEditHistory; + } catch { + // Corrupted JSON or storage failure — treat as no persisted history. + return null; + } +} + +export function hasPersistedSlideHistory(sceneId: string): boolean { + try { + return localStorage.getItem(slideHistoryStorageKey(sceneId)) !== null; + } catch { + return false; + } +} + +export function clearPersistedSlideHistory(sceneId: string): void { + try { + localStorage.removeItem(slideHistoryStorageKey(sceneId)); + } catch { + // ignore + } +} 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/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/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index dda7064783..ba5fc5374c 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -164,6 +164,21 @@ "quiz": "اختبار", "interactive": "تفاعلي", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "استعادة التغييرات غير المحفوظة؟", + "body": "انتهت جلسة التحرير السابقة قبل الحفظ. يمكنك استعادة تلك التغييرات أو تجاهلها والبدء من جديد.", + "actionRestore": "استعادة", + "actionDiscard": "تجاهل" + } + }, + "multiTab": { + "conflict": { + "title": "علامة تبويب أخرى تحرّر هذه الدورة", + "body": "وضع التحرير محصور بعلامة تبويب واحدة في كل مرة لتجنب تعارض التغييرات. أغلق علامة التبويب الأخرى (أو انتظر انتهاء جلستها) قبل الدخول إلى وضع التحرير هنا.", + "actionDismiss": "حسناً" + } } }, "classroomComplete": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index e3d232e2bc..ea1555672a 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -164,6 +164,21 @@ "quiz": "Quiz", "interactive": "Interactive", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "Restore unsaved changes?", + "body": "Your last edit session ended before you saved. Restore those changes, or discard them and start fresh.", + "actionRestore": "Restore", + "actionDiscard": "Discard" + } + }, + "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" + } } }, "classroomComplete": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 1c18285089..e7a05332c0 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -164,6 +164,21 @@ "quiz": "クイズ", "interactive": "インタラクティブ", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "保存されていない変更を復元しますか?", + "body": "前回の編集セッションは保存される前に終了しました。変更を復元するか、破棄して新たに始めてください。", + "actionRestore": "復元", + "actionDiscard": "破棄" + } + }, + "multiTab": { + "conflict": { + "title": "別のタブでこのコースを編集中です", + "body": "編集モードは同時に 1 つのタブのみが保持できます。他のタブを閉じる(またはセッションがタイムアウトするのを待つ)してから、このタブで編集を開始してください。", + "actionDismiss": "了解" + } } }, "classroomComplete": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 60798072d8..a4da6fc58f 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -164,6 +164,21 @@ "quiz": "Тест", "interactive": "Интерактив", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "Восстановить несохранённые изменения?", + "body": "Предыдущий сеанс редактирования завершился до сохранения. Можно восстановить изменения или отбросить и начать заново.", + "actionRestore": "Восстановить", + "actionDiscard": "Отбросить" + } + }, + "multiTab": { + "conflict": { + "title": "Этот курс уже редактируется в другой вкладке", + "body": "Редактирование возможно только в одной вкладке одновременно, чтобы избежать конфликтных изменений. Закройте другую вкладку (или дождитесь истечения её сеанса) перед входом в режим редактирования здесь.", + "actionDismiss": "Понятно" + } } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 9541b2f21b..95d71f47d6 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -164,6 +164,21 @@ "quiz": "测验", "interactive": "互动", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "恢复未保存的更改?", + "body": "上次编辑在保存前结束。可以恢复那次的更改,或丢弃后从头开始。", + "actionRestore": "恢复", + "actionDiscard": "丢弃" + } + }, + "multiTab": { + "conflict": { + "title": "另一个标签页正在编辑此课程", + "body": "编辑模式同一时间只允许一个标签页持有,避免变更冲突。请先关闭其他标签页(或等待对方会话超时),再在此进入编辑模式。", + "actionDismiss": "知道了" + } } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index d800b4fac9..3085294b58 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -164,6 +164,21 @@ "quiz": "測驗", "interactive": "互動", "pbl": "PBL" + }, + "history": { + "restore": { + "title": "還原未儲存的變更?", + "body": "上次編輯在儲存前結束。可以還原那次的變更,或捨棄後重新開始。", + "actionRestore": "還原", + "actionDiscard": "捨棄" + } + }, + "multiTab": { + "conflict": { + "title": "另一個分頁正在編輯此課程", + "body": "編輯模式同一時間只允許一個分頁持有,以避免變更衝突。請先關閉其他分頁(或等待對方工作階段逾時),再於此處進入編輯模式。", + "actionDismiss": "知道了" + } } }, "whiteboard": { diff --git a/lib/store/stage.ts b/lib/store/stage.ts index c7e8950e93..66bfd7b45e 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -5,6 +5,7 @@ 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'); @@ -124,10 +125,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(); }, @@ -141,7 +146,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 diff --git a/lib/types/stage.ts b/lib/types/stage.ts index bdf5e6e670..5d43bfba40 100644 --- a/lib/types/stage.ts +++ b/lib/types/stage.ts @@ -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/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/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/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/slide-history-persistence.test.ts b/tests/edit/slide-history-persistence.test.ts new file mode 100644 index 0000000000..58afa6e1dc --- /dev/null +++ b/tests/edit/slide-history-persistence.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearPersistedSlideHistory, + hasPersistedSlideHistory, + loadPersistedSlideHistory, + persistSlideHistory, + slideHistoryStorageKey, +} from '@/lib/edit/slide-history-persistence'; +import type { SlideEditHistory } from '@/lib/edit/slide-ops'; +import type { SlideContent } from '@/lib/types/stage'; + +function makeContent(): SlideContent { + return { + type: 'slide', + canvas: { + id: 'slide-1', + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#ffffff', + themeColors: ['#000000'], + fontColor: '#000000', + fontName: 'sans-serif', + }, + elements: [], + }, + }; +} + +function makeHistory(): SlideEditHistory { + return { + past: [], + present: makeContent(), + future: [], + }; +} + +// Minimal in-memory localStorage shim — Node 18+ test env doesn't include +// the Web Storage API by default. Restored in afterEach. +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 originalLocalStorage: typeof globalThis.localStorage | undefined; + +beforeEach(() => { + originalLocalStorage = (globalThis as { localStorage?: typeof globalThis.localStorage }) + .localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + configurable: true, + writable: true, + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + configurable: true, + writable: true, + }); +}); + +describe('slideHistoryStorageKey', () => { + it('is scoped per scene id', () => { + expect(slideHistoryStorageKey('scene-A')).not.toBe(slideHistoryStorageKey('scene-B')); + }); + + it('includes a stable prefix so callers can detect editor-owned keys', () => { + expect(slideHistoryStorageKey('scene-A')).toMatch(/^maic-editor:slide-history:/); + }); +}); + +describe('persist / load round-trip', () => { + it('returns null when no history is stored for the scene', () => { + expect(loadPersistedSlideHistory('scene-A')).toBeNull(); + expect(hasPersistedSlideHistory('scene-A')).toBe(false); + }); + + it('round-trips a history through localStorage', () => { + const history = makeHistory(); + persistSlideHistory('scene-A', history); + expect(hasPersistedSlideHistory('scene-A')).toBe(true); + expect(loadPersistedSlideHistory('scene-A')).toEqual(history); + }); + + it('does not bleed across scene ids', () => { + persistSlideHistory('scene-A', makeHistory()); + expect(hasPersistedSlideHistory('scene-B')).toBe(false); + expect(loadPersistedSlideHistory('scene-B')).toBeNull(); + }); + + it('clearPersistedSlideHistory removes the entry', () => { + persistSlideHistory('scene-A', makeHistory()); + clearPersistedSlideHistory('scene-A'); + expect(hasPersistedSlideHistory('scene-A')).toBe(false); + }); +}); + +describe('graceful degradation', () => { + it('returns null when JSON in storage is corrupted', () => { + localStorage.setItem(slideHistoryStorageKey('scene-A'), '{not json'); + expect(loadPersistedSlideHistory('scene-A')).toBeNull(); + }); + + it('swallows storage write failures without throwing', () => { + const setItem = vi.spyOn(globalThis.localStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(() => persistSlideHistory('scene-A', makeHistory())).not.toThrow(); + setItem.mockRestore(); + }); +}); 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); + }); +}); From cdb15ddc4fa54bc27997b4dabf19f372e3ca061f Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 19:08:13 +0800 Subject: [PATCH 05/38] feat(maic-editor): slide surface skeleton + #571 wiring + geometry (#562) (#579) PR1 of the slide-surface work (infra-first slice). Registers the slide SceneEditorSurface so EditShell lights up Pro mode for slide scenes. - SceneEditorSurface impl + sceneEditorRegistry registration; the surface owns a SlideEditHistory via the #564 kernel. - Reuse the unmodified slide renderer Canvas through a surface-owned scene context; geometry drag/resize/rotate commits funnel into element.update ops (scene-edit bridge), one gesture = one undo step. - Geometry numeric x/y/w/h/rotate popover as the precise fallback; gated off for line elements (PPTLineElement omits height/rotate). - Wire #571 infra: cross-tab edit lock + conflict prompt, slide-history persistence + restore prompt, regen-lock guard. - Renderer-commit classification: a real geometry gesture commits synchronously inside a pointer interaction; the renderer's ResizeObserver text-normalization commits with none, so it is folded into the baseline (no undo step / no persist / no spurious restore prompt on entry) instead of being staged as a user edit. - Per-op round-trip test for element.update geometry; bridge + session unit tests; edit.geometry i18n across all 6 locales. Upstream-shared changes are kept minimal and additive: an optional `controller` prop on SceneProvider (uncontrolled/playback path unchanged) so staged edits don't write through to the live stage store, and a FloatingToolbar trigger-nesting fix (it wrapped PopoverTrigger around , a provider, so no popoverContent action could open). Co-authored-by: Claude Opus 4.7 --- components/edit/EditShell/FloatingToolbar.tsx | 11 +- .../edit/surfaces/slide/GeometryPopover.tsx | 54 +++++ .../edit/surfaces/slide/SlideCanvas.tsx | 35 +++ .../edit/surfaces/slide/SlideSurface.tsx | 16 ++ components/edit/surfaces/slide/index.ts | 4 + components/edit/surfaces/slide/register.ts | 7 + .../edit/surfaces/slide/slide-edit-session.ts | 148 ++++++++++++ .../edit/surfaces/slide/use-slide-surface.ts | 217 ++++++++++++++++++ components/edit/use-edit-mode-lock.ts | 80 +++++++ components/stage.tsx | 29 ++- lib/contexts/scene-context.tsx | 48 +++- lib/edit/scene-edit-bridge.ts | 126 ++++++++++ lib/edit/slide-ops.ts | 2 +- lib/hooks/use-scene-generator.ts | 17 ++ lib/i18n/locales/ar-SA.json | 9 + lib/i18n/locales/en-US.json | 9 + lib/i18n/locales/ja-JP.json | 9 + lib/i18n/locales/ru-RU.json | 9 + lib/i18n/locales/zh-CN.json | 9 + lib/i18n/locales/zh-TW.json | 9 + tests/edit/round-trip/geometry.test.ts | 74 ++++++ tests/edit/scene-edit-bridge.test.ts | 190 +++++++++++++++ tests/edit/slide-edit-session.test.ts | 189 +++++++++++++++ 23 files changed, 1288 insertions(+), 13 deletions(-) create mode 100644 components/edit/surfaces/slide/GeometryPopover.tsx create mode 100644 components/edit/surfaces/slide/SlideCanvas.tsx create mode 100644 components/edit/surfaces/slide/SlideSurface.tsx create mode 100644 components/edit/surfaces/slide/index.ts create mode 100644 components/edit/surfaces/slide/register.ts create mode 100644 components/edit/surfaces/slide/slide-edit-session.ts create mode 100644 components/edit/surfaces/slide/use-slide-surface.ts create mode 100644 components/edit/use-edit-mode-lock.ts create mode 100644 lib/edit/scene-edit-bridge.ts create mode 100644 tests/edit/round-trip/geometry.test.ts create mode 100644 tests/edit/scene-edit-bridge.test.ts create mode 100644 tests/edit/slide-edit-session.test.ts diff --git a/components/edit/EditShell/FloatingToolbar.tsx b/components/edit/EditShell/FloatingToolbar.tsx index 2b6a3f95e9..8ff1a54e10 100644 --- a/components/edit/EditShell/FloatingToolbar.tsx +++ b/components/edit/EditShell/FloatingToolbar.tsx @@ -63,9 +63,18 @@ function ActionButton({ action }: { readonly action: FloatingAction }) { if (!action.popoverContent) return triggerWithTooltip; + // Chain both triggers' asChild Slots directly onto the real
{/* Roundtable Area */} 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/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/slide-ops.ts b/lib/edit/slide-ops.ts index ffc805c5d0..e1182de576 100644 --- a/lib/edit/slide-ops.ts +++ b/lib/edit/slide-ops.ts @@ -7,7 +7,7 @@ type ElementPatch = Partial; type ElementPropName = string; // Cap undo history so long editing sessions don't grow memory unbounded. -const MAX_HISTORY = 50; +export const MAX_HISTORY = 50; export type SlideElementAlignCommand = | 'top' 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 ba5fc5374c..5231aadc75 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -179,6 +179,15 @@ "body": "وضع التحرير محصور بعلامة تبويب واحدة في كل مرة لتجنب تعارض التغييرات. أغلق علامة التبويب الأخرى (أو انتظر انتهاء جلستها) قبل الدخول إلى وضع التحرير هنا.", "actionDismiss": "حسناً" } + }, + "geometry": { + "label": "الموضع والحجم", + "tooltip": "الموضع والحجم", + "x": "X", + "y": "Y", + "width": "العرض", + "height": "الارتفاع", + "rotate": "التدوير" } }, "classroomComplete": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index ea1555672a..f7815c7a24 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -179,6 +179,15 @@ "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" } + }, + "geometry": { + "label": "Position & size", + "tooltip": "Position & size", + "x": "X", + "y": "Y", + "width": "Width", + "height": "Height", + "rotate": "Rotation" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index e7a05332c0..8552c812a9 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -179,6 +179,15 @@ "body": "編集モードは同時に 1 つのタブのみが保持できます。他のタブを閉じる(またはセッションがタイムアウトするのを待つ)してから、このタブで編集を開始してください。", "actionDismiss": "了解" } + }, + "geometry": { + "label": "位置とサイズ", + "tooltip": "位置とサイズ", + "x": "X", + "y": "Y", + "width": "幅", + "height": "高さ", + "rotate": "回転" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index a4da6fc58f..96f45116f4 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -179,6 +179,15 @@ "body": "Редактирование возможно только в одной вкладке одновременно, чтобы избежать конфликтных изменений. Закройте другую вкладку (или дождитесь истечения её сеанса) перед входом в режим редактирования здесь.", "actionDismiss": "Понятно" } + }, + "geometry": { + "label": "Положение и размер", + "tooltip": "Положение и размер", + "x": "X", + "y": "Y", + "width": "Ширина", + "height": "Высота", + "rotate": "Поворот" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 95d71f47d6..3aca5d94d4 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -179,6 +179,15 @@ "body": "编辑模式同一时间只允许一个标签页持有,避免变更冲突。请先关闭其他标签页(或等待对方会话超时),再在此进入编辑模式。", "actionDismiss": "知道了" } + }, + "geometry": { + "label": "位置与尺寸", + "tooltip": "位置与尺寸", + "x": "X", + "y": "Y", + "width": "宽", + "height": "高", + "rotate": "旋转" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 3085294b58..42392c2819 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -179,6 +179,15 @@ "body": "編輯模式同一時間只允許一個分頁持有,以避免變更衝突。請先關閉其他分頁(或等待對方工作階段逾時),再於此處進入編輯模式。", "actionDismiss": "知道了" } + }, + "geometry": { + "label": "位置與尺寸", + "tooltip": "位置與尺寸", + "x": "X", + "y": "Y", + "width": "寬", + "height": "高", + "rotate": "旋轉" } }, "whiteboard": { 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/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-edit-session.test.ts b/tests/edit/slide-edit-session.test.ts new file mode 100644 index 0000000000..8faac0a16f --- /dev/null +++ b/tests/edit/slide-edit-session.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createDefaultSlide, createDefaultTextElement } from '@/lib/edit/slide-edit-elements'; +import { createSlideEditHistory } from '@/lib/edit/slide-ops'; +import { hasPersistedSlideHistory } from '@/lib/edit/slide-history-persistence'; +import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; +import type { PPTTextElement } from '@/lib/types/slides'; +import type { SlideContent } from '@/lib/types/stage'; + +// In-memory localStorage so persist/clear is observable (the node test +// env has none; slide-history-persistence swallows the absence silently). +const mem = new Map(); +globalThis.localStorage = { + getItem: (k: string) => (mem.has(k) ? (mem.get(k) as string) : null), + setItem: (k: string, v: string) => void mem.set(k, String(v)), + removeItem: (k: string) => void mem.delete(k), + clear: () => mem.clear(), + key: (i: number) => Array.from(mem.keys())[i] ?? null, + get length() { + return mem.size; + }, +} as Storage; + +// 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; + +/** + * Module-level session store shared by the slide surface's `useSurfaceState` + * and its `CanvasComponent` (EditShell invokes them as siblings, so the + * SlideEditHistory cannot live in a single component's state). The store is + * pure orchestration over the already-tested kernel + bridge; these tests + * pin the history transitions (localStorage persistence degrades silently + * in the node test env and is covered by slide-history-persistence itself). + */ +function makeContent(): SlideContent { + const slide = createDefaultSlide('slide-1'); + slide.elements.push(createDefaultTextElement('text-1')); + return { type: 'slide', canvas: slide }; +} + +describe('useSlideEditSession', () => { + beforeEach(() => { + useSlideEditSession.getState().end(); + mem.clear(); + }); + + it('clears persisted history once undo returns to baseline (no stale restore prompt)', () => { + // Edit → undo back to baseline → exit. past is empty again, so there + // is nothing to restore; the persisted key must be gone, otherwise + // re-entry would fire a spurious SlideHistoryRestorePrompt. + useSlideEditSession.getState().seed('scene-1', makeContent()); + expect(hasPersistedSlideHistory('scene-1')).toBe(false); // seed never persists + useSlideEditSession.getState().applyOp({ + type: 'element.update', + elementId: 'text-1', + patch: { left: 200 }, + }); + expect(hasPersistedSlideHistory('scene-1')).toBe(true); // a real edit persists + useSlideEditSession.getState().undo(); + expect(useSlideEditSession.getState().history?.past).toEqual([]); + expect(hasPersistedSlideHistory('scene-1')).toBe(false); // back to pristine → cleared + }); + + it('seed creates a fresh history pinned to the scene', () => { + 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'); + }); + + it('applyOp advances history by one undo step', () => { + 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); + }); + + it('applyOp ignores a no-op against a missing element', () => { + 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([]); + }); + + it('absorbs non-user renderer commits into the baseline (no undo step)', () => { + // The slide renderer normalizes content via a ResizeObserver (text + // auto-height) with no user gesture; that must not become an + // undoable, persisted "edit" that later triggers the restore prompt. + 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); + }); + + it('records a user-driven commit as one undo transaction', () => { + 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 }); + }); + + it('a non-user commit after a user edit preserves the undo stack', () => { + // Text auto-height reflows AFTER a user resizes a text box, with no + // gesture in flight — that ResizeObserver commit must not wipe the + // undo step the resize just created. + 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); + // Undo still returns to the pre-resize width. + useSlideEditSession.getState().undo(); + expect(useSlideEditSession.getState().history?.present.canvas.elements[0].width).toBe( + makeContent().canvas.elements[0].width, + ); + }); + + it('undo / redo move between history states', () => { + useSlideEditSession.getState().seed('scene-1', makeContent()); + useSlideEditSession.getState().applyOp({ + type: 'element.update', + elementId: 'text-1', + patch: { rotate: 30 }, + }); + useSlideEditSession.getState().undo(); + expect(rotateOf(useSlideEditSession.getState().history!.present)).toBe(0); + useSlideEditSession.getState().redo(); + expect(rotateOf(useSlideEditSession.getState().history!.present)).toBe(30); + }); + + it('restore adopts a persisted history wholesale', () => { + const persisted = createSlideEditHistory(makeContent()); + persisted.past.push(makeContent()); + useSlideEditSession.getState().restore('scene-1', persisted); + const { sceneId, history } = useSlideEditSession.getState(); + expect(sceneId).toBe('scene-1'); + expect(history?.past).toHaveLength(1); + }); + + it('seed has no pending restore in a clean env', () => { + useSlideEditSession.getState().seed('scene-1', makeContent()); + expect(useSlideEditSession.getState().pendingRestore).toBe(false); + }); + + it('a non-user commit never persists or grows history even repeatedly', () => { + 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); + }); + + it('end clears the session', () => { + useSlideEditSession.getState().seed('scene-1', makeContent()); + useSlideEditSession.getState().end(); + expect(useSlideEditSession.getState().sceneId).toBeNull(); + expect(useSlideEditSession.getState().history).toBeNull(); + }); +}); From 9baea345f2891d02eac0459d735d0b2bfee4654a Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:06:38 -0400 Subject: [PATCH 06/38] test(maic-editor): lock data-URL image PPTX round-trip (PR2 R1 gate) Co-Authored-By: Claude Opus 4.7 --- tests/edit/round-trip/image-data-url.test.ts | 58 ++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/edit/round-trip/image-data-url.test.ts 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); + }); +}); From 474890a53ef44d70f1b975b25429ce9f033e672b Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:15:45 -0400 Subject: [PATCH 07/38] =?UTF-8?q?feat(maic-editor):=20insert=20palette=20?= =?UTF-8?q?=E2=80=94=20text=20box=20+=20image=20(data-URL/URL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/ImagePicker.tsx | 51 +++++++++++++++++++ .../edit/surfaces/slide/use-slide-surface.ts | 39 ++++++++++++-- lib/edit/element-id.ts | 6 +++ .../edit/surfaces/slide/insert-items.test.ts | 24 +++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 components/edit/surfaces/slide/ImagePicker.tsx create mode 100644 lib/edit/element-id.ts create mode 100644 tests/edit/surfaces/slide/insert-items.test.ts diff --git a/components/edit/surfaces/slide/ImagePicker.tsx b/components/edit/surfaces/slide/ImagePicker.tsx new file mode 100644 index 0000000000..3910a4515a --- /dev/null +++ b/components/edit/surfaces/slide/ImagePicker.tsx @@ -0,0 +1,51 @@ +'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; + onPick(await fileToDataUrl(file)); + } + + return ( +
+ + void handleFiles(e.target.files)} /> +
{t('edit.insert.imageOr')}
+
+ setUrl(e.target.value)} placeholder={t('edit.insert.imageUrlPlaceholder')} /> + +
+
+ ); +} diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index eece7a8273..b8c39e1614 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -1,27 +1,58 @@ 'use client'; import { produce } from 'immer'; -import { Move } from 'lucide-react'; +import { Image as ImageIcon, Move, Type } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { clearPersistedSlideHistory, loadPersistedSlideHistory, } from '@/lib/edit/slide-history-persistence'; import type { SceneDataController } from '@/lib/contexts/scene-context'; -import type { FloatingAction, SurfaceState } from '@/lib/edit/scene-editor-surface'; +import type { FloatingAction, InsertPaletteItem, SurfaceState } from '@/lib/edit/scene-editor-surface'; import { useI18n } from '@/lib/hooks/use-i18n'; -import { createDefaultSlide } from '@/lib/edit/slide-edit-elements'; +import { createElementId } from '@/lib/edit/element-id'; +import { + createDefaultImageElement, + createDefaultSlide, + createDefaultTextElement, +} from '@/lib/edit/slide-edit-elements'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; import type { PPTElement } from '@/lib/types/slides'; import type { SlideContent } from '@/lib/types/stage'; import { GeometryPopover } from './GeometryPopover'; +import { ImagePicker } from './ImagePicker'; import { useSlideEditSession } from './slide-edit-session'; export interface SlideSelection { readonly activeElementIds: readonly string[]; } +export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] { + const addElement = (element: PPTElement) => + useSlideEditSession.getState().applyOp({ type: 'element.add', element }); + return [ + { + id: 'insert-text', + label: t('edit.insert.textBox'), + tooltip: t('edit.insert.textBox'), + icon: React.createElement(Type, { className: 'h-4 w-4' }), + onInvoke: () => addElement(createDefaultTextElement(createElementId('text'))), + }, + { + id: 'insert-image', + label: t('edit.insert.image'), + tooltip: t('edit.insert.image'), + icon: React.createElement(ImageIcon, { className: 'h-4 w-4' }), + onInvoke: () => {}, + popoverContent: () => + React.createElement(ImagePicker, { + onPick: (src: string) => addElement(createDefaultImageElement(createElementId('image'), src)), + }), + }, + ]; +} + const EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') }; function currentSlideContent(sceneId: string): SlideContent | null { @@ -89,7 +120,7 @@ export function useSlideSurfaceState(): SurfaceState useSlideEditSession.getState().undo(), redo: () => useSlideEditSession.getState().redo(), }, - insertItems: [], + insertItems: buildInsertItems(t), floatingActions: [geometryAction], commands: [], hints: [], 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/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts new file mode 100644 index 0000000000..55e285f2b3 --- /dev/null +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { buildInsertItems } from '@/components/edit/surfaces/slide/use-slide-surface'; +import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; + +describe('slide insert palette', () => { + beforeEach(() => { + useSlideEditSession.setState({ history: { past: [], present: { type: 'slide', canvas: { id: 's', elements: [] } } as any, future: [] } } as any); + }); + + it('exposes a text-box and an image insert item', () => { + const items = buildInsertItems((k) => k); + 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 dispatches element.add with a text element', () => { + const spy = vi.spyOn(useSlideEditSession.getState(), 'applyOp'); + buildInsertItems((k) => k)[0].onInvoke(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'element.add', element: expect.objectContaining({ type: 'text' }) }), + ); + }); +}); From a83da027ed471ae5cd68dfd5398a3ab72df2149c Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:23:57 -0400 Subject: [PATCH 08/38] =?UTF-8?q?refactor(maic-editor):=20address=20Task?= =?UTF-8?q?=201=20review=20=E2=80=94=20spy=20cleanup,=20popover-only=20com?= =?UTF-8?q?ment,=20ImagePicker=20error=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/ImagePicker.tsx | 6 +++++- components/edit/surfaces/slide/use-slide-surface.ts | 2 +- tests/edit/surfaces/slide/insert-items.test.ts | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/components/edit/surfaces/slide/ImagePicker.tsx b/components/edit/surfaces/slide/ImagePicker.tsx index 3910a4515a..3ba1e7e236 100644 --- a/components/edit/surfaces/slide/ImagePicker.tsx +++ b/components/edit/surfaces/slide/ImagePicker.tsx @@ -26,7 +26,11 @@ export function ImagePicker({ onPick }: ImagePickerProps) { async function handleFiles(files: FileList | null) { const file = files?.[0]; if (!file || !file.type.startsWith('image/')) return; - onPick(await fileToDataUrl(file)); + try { + onPick(await fileToDataUrl(file)); + } catch (err) { + console.error('ImagePicker: failed to read image file', err); + } } return ( diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index b8c39e1614..59df76f0f5 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -44,7 +44,7 @@ export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] label: t('edit.insert.image'), tooltip: t('edit.insert.image'), icon: React.createElement(ImageIcon, { className: 'h-4 w-4' }), - onInvoke: () => {}, + onInvoke: () => {}, // popover-only: CommandBar's InsertButton ignores onInvoke when popoverContent is set popoverContent: () => React.createElement(ImagePicker, { onPick: (src: string) => addElement(createDefaultImageElement(createElementId('image'), src)), diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts index 55e285f2b3..35ed3788f0 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { buildInsertItems } from '@/components/edit/surfaces/slide/use-slide-surface'; import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; @@ -7,6 +7,10 @@ describe('slide insert palette', () => { useSlideEditSession.setState({ history: { past: [], present: { type: 'slide', canvas: { id: 's', elements: [] } } as any, future: [] } } as any); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('exposes a text-box and an image insert item', () => { const items = buildInsertItems((k) => k); expect(items.map((i) => i.id)).toEqual(['insert-text', 'insert-image']); From f268056e1014ec7ad057c208e66e4a3a8e01d968 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:27:29 -0400 Subject: [PATCH 09/38] refactor(maic-editor): drop PR1 debug geometry toolbar; element-aware floating bar Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/GeometryPopover.tsx | 54 ------------------ .../edit/surfaces/slide/use-slide-surface.ts | 55 ++++++------------- .../edit/surfaces/slide/insert-items.test.ts | 7 ++- 3 files changed, 22 insertions(+), 94 deletions(-) delete mode 100644 components/edit/surfaces/slide/GeometryPopover.tsx diff --git a/components/edit/surfaces/slide/GeometryPopover.tsx b/components/edit/surfaces/slide/GeometryPopover.tsx deleted file mode 100644 index 0d9d6f3949..0000000000 --- a/components/edit/surfaces/slide/GeometryPopover.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client'; - -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useI18n } from '@/lib/hooks/use-i18n'; -import type { PPTElement } from '@/lib/types/slides'; - -interface GeometryPopoverProps { - readonly element: PPTElement; - readonly onPatch: (patch: Partial) => void; -} - -const FIELDS = [ - { key: 'left', labelKey: 'edit.geometry.x' }, - { key: 'top', labelKey: 'edit.geometry.y' }, - { key: 'width', labelKey: 'edit.geometry.width' }, - { key: 'height', labelKey: 'edit.geometry.height' }, - { key: 'rotate', labelKey: 'edit.geometry.rotate' }, -] as const; - -/** - * Numeric x/y/w/h/rotate editor — the precise fallback to canvas - * drag-resize. Each commit is one `element.update` op (one undo step), - * applied through the surface session so it shares history with the - * canvas gestures. - */ -export function GeometryPopover({ element, onPatch }: GeometryPopoverProps) { - const { t } = useI18n(); - // The surface only mounts this for box-geometry elements (line elements - // are gated out in useSlideSurfaceState), so left/top/width/height/rotate - // are all present — this numeric view is safe by construction. - const geom = element as unknown as Record; - - return ( -
- {FIELDS.map(({ key, labelKey }) => ( -
- - { - const value = Number(e.target.value); - if (Number.isFinite(value)) onPatch({ [key]: value } as Partial); - }} - /> -
- ))} -
- ); -} diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 59df76f0f5..0a5bf6beda 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -1,7 +1,7 @@ 'use client'; import { produce } from 'immer'; -import { Image as ImageIcon, Move, Type } from 'lucide-react'; +import { Image as ImageIcon, Type } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { clearPersistedSlideHistory, @@ -18,9 +18,8 @@ import { } from '@/lib/edit/slide-edit-elements'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; -import type { PPTElement } from '@/lib/types/slides'; +import type { PPTElement, PPTTextElement } from '@/lib/types/slides'; import type { SlideContent } from '@/lib/types/stage'; -import { GeometryPopover } from './GeometryPopover'; import { ImagePicker } from './ImagePicker'; import { useSlideEditSession } from './slide-edit-session'; @@ -53,6 +52,14 @@ export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] ]; } +export function buildFloatingActions( + _t: (k: string) => string, + textTarget: PPTTextElement | undefined, +): FloatingAction[] { + if (!textTarget) return []; + return []; // text formatting actions added in Task 5 +} + const EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') }; function currentSlideContent(sceneId: string): SlideContent | null { @@ -62,9 +69,7 @@ function currentSlideContent(sceneId: string): SlideContent | null { /** * The slide surface's `useSurfaceState`. Pure read over the shared - * session store + the renderer's selection store; the only PR1 editing - * affordance is the geometry numeric popover (canvas drag-resize is the - * primary path and flows through the scene-context bridge). + * session store + the renderer's selection store. */ export function useSlideSurfaceState(): SurfaceState { const { t } = useI18n(); @@ -77,38 +82,10 @@ export function useSlideSurfaceState(): SurfaceState el.id === selectedId) ?? null) - : null; - - // Lines model geometry as start/end/points and omit height/rotate - // (PPTLineElement = Omit); the x/y/w/h - // /rotate panel would write meaningless props onto them, so it's only - // offered for box-geometry elements. - const geomTarget = selectedEl && selectedEl.type !== 'line' ? selectedEl : null; - - // No hand-written memo: the React Compiler auto-memoizes, and a manual - // useMemo with `t` in the closure trips its "could not preserve" rule. - const geometryAction: FloatingAction = { - id: 'geometry', - label: t('edit.geometry.label'), - tooltip: t('edit.geometry.tooltip'), - icon: React.createElement(Move, { className: 'h-4 w-4' }), - disabled: !geomTarget, - popoverContent: geomTarget - ? () => - React.createElement(GeometryPopover, { - element: geomTarget, - onPatch: (patch: Partial) => - useSlideEditSession.getState().applyOp({ - type: 'element.update', - elementId: geomTarget.id, - patch, - }), - }) - : undefined, - }; + const onlyEl = activeElementIds.length === 1 + ? (content.canvas.elements.find((el) => el.id === activeElementIds[0]) ?? undefined) + : undefined; + const textTarget = onlyEl && onlyEl.type === 'text' ? (onlyEl as PPTTextElement) : undefined; return { content, @@ -121,7 +98,7 @@ export function useSlideSurfaceState(): SurfaceState useSlideEditSession.getState().redo(), }, insertItems: buildInsertItems(t), - floatingActions: [geometryAction], + floatingActions: buildFloatingActions(t, textTarget), commands: [], hints: [], }; diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts index 35ed3788f0..0e71f87cfd 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildInsertItems } from '@/components/edit/surfaces/slide/use-slide-surface'; +import { buildInsertItems, buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; describe('slide insert palette', () => { @@ -25,4 +25,9 @@ describe('slide insert palette', () => { expect.objectContaining({ type: 'element.add', element: expect.objectContaining({ type: 'text' }) }), ); }); + + it('no longer contributes a geometry floating action', () => { + const actions = buildFloatingActions((k) => k, undefined); + expect(actions.find((a) => a.id === 'geometry')).toBeUndefined(); + }); }); From 35a4d52ae2e91c2f7518b904322253e2e664d60a Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:33:23 -0400 Subject: [PATCH 10/38] refactor(maic-editor): drop redundant PPTTextElement cast (Task 2 review) Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/use-slide-surface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 0a5bf6beda..7d3f7bf651 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -85,7 +85,7 @@ export function useSlideSurfaceState(): SurfaceState el.id === activeElementIds[0]) ?? undefined) : undefined; - const textTarget = onlyEl && onlyEl.type === 'text' ? (onlyEl as PPTTextElement) : undefined; + const textTarget = onlyEl && onlyEl.type === 'text' ? onlyEl : undefined; return { content, From 022433abf8feaad69d75b2faf68c90542172f051 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:38:09 -0400 Subject: [PATCH 11/38] feat(maic-editor): additive ProseMirror command bridge for the property bar (C1) Co-Authored-By: Claude Opus 4.7 --- .../components/element/ProsemirrorEditor.tsx | 55 +++++++++++++++++++ lib/prosemirror/active-editor-registry.ts | 35 ++++++++++++ .../active-editor-registry.test.ts | 19 +++++++ 3 files changed, 109 insertions(+) create mode 100644 lib/prosemirror/active-editor-registry.ts create mode 100644 tests/prosemirror/active-editor-registry.test.ts diff --git a/components/slide-renderer/components/element/ProsemirrorEditor.tsx b/components/slide-renderer/components/element/ProsemirrorEditor.tsx index 5bc01ec5be..484eb6f66a 100644 --- a/components/slide-renderer/components/element/ProsemirrorEditor.tsx +++ b/components/slide-renderer/components/element/ProsemirrorEditor.tsx @@ -25,6 +25,10 @@ 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 type { TextFormatPainterKeys } from '@/lib/types/edit'; import { KEYS } from '@/configs/hotkey'; import { toast } from 'sonner'; @@ -360,6 +364,57 @@ export const ProsemirrorEditor = forwardRef { + if (!editorView.current) return; + switch (payload.command) { + case 'bold': + case 'em': + case 'underline': + 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; + } + }, + [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, (payload) => runCommand(payload)); + return off; + }, [editable, elementId, runCommand]); + // Handle mouseup for format painter const handleMouseup = useCallback(() => { if (!textFormatPainter || !editorView.current) return; diff --git a/lib/prosemirror/active-editor-registry.ts b/lib/prosemirror/active-editor-registry.ts new file mode 100644 index 0000000000..95d02e0319 --- /dev/null +++ b/lib/prosemirror/active-editor-registry.ts @@ -0,0 +1,35 @@ +/** + * 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/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts new file mode 100644 index 0000000000..25d81ffc1e --- /dev/null +++ b/tests/prosemirror/active-editor-registry.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + registerActiveTextEditor, + runActiveTextCommand, + hasActiveTextEditor, +} from '@/lib/prosemirror/active-editor-registry'; + +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 + }); +}); From 7d9148681e2f56948c5cca7978ef233d9f6036ae Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:46:29 -0400 Subject: [PATCH 12/38] refactor(maic-editor): exhaustiveness guard + tidy C1 adapter (Task 3 review) Co-Authored-By: Claude Opus 4.7 --- .../components/element/ProsemirrorEditor.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/slide-renderer/components/element/ProsemirrorEditor.tsx b/components/slide-renderer/components/element/ProsemirrorEditor.tsx index 484eb6f66a..ac6b65c5b2 100644 --- a/components/slide-renderer/components/element/ProsemirrorEditor.tsx +++ b/components/slide-renderer/components/element/ProsemirrorEditor.tsx @@ -376,6 +376,7 @@ export const ProsemirrorEditor = forwardRef { if (!editable) return; - const off = registerActiveTextEditor(elementId, (payload) => runCommand(payload)); + const off = registerActiveTextEditor(elementId, runCommand); return off; }, [editable, elementId, runCommand]); From 7d00209f173c1b48f385f8c535c09cbf60c76014 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 08:53:04 -0400 Subject: [PATCH 13/38] feat(maic-editor): refresh property-bar attrs on caret/keyboard selection (C2) Co-Authored-By: Claude Opus 4.7 --- .../components/element/ProsemirrorEditor.tsx | 25 +++++++++++++++++++ lib/prosemirror/selection-sync.ts | 6 +++++ .../active-editor-registry.test.ts | 10 ++++++++ 3 files changed, 41 insertions(+) create mode 100644 lib/prosemirror/selection-sync.ts diff --git a/components/slide-renderer/components/element/ProsemirrorEditor.tsx b/components/slide-renderer/components/element/ProsemirrorEditor.tsx index ac6b65c5b2..d209493d9f 100644 --- a/components/slide-renderer/components/element/ProsemirrorEditor.tsx +++ b/components/slide-renderer/components/element/ProsemirrorEditor.tsx @@ -29,6 +29,7 @@ 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'; @@ -72,6 +73,9 @@ 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 textFormatPainter = useCanvasStore.use.textFormatPainter(); @@ -138,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) => { @@ -455,6 +470,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) { 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/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts index 25d81ffc1e..e57865fba3 100644 --- a/tests/prosemirror/active-editor-registry.test.ts +++ b/tests/prosemirror/active-editor-registry.test.ts @@ -4,6 +4,7 @@ import { runActiveTextCommand, hasActiveTextEditor, } from '@/lib/prosemirror/active-editor-registry'; +import { shouldPushAttrs } from '@/lib/prosemirror/selection-sync'; describe('active text editor registry', () => { it('routes a command to the registered element and clears on unregister', () => { @@ -17,3 +18,12 @@ describe('active text editor registry', () => { 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({ selectionSet: true, docChanged: false, storedMarksSet: false } as any)).toBe(true); + expect(shouldPushAttrs({ selectionSet: false, docChanged: true, storedMarksSet: false } as any)).toBe(true); + expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: true } as any)).toBe(true); + expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: false } as any)).toBe(false); + }); +}); From 747ee8a06a208264cd4b4acaaf23cada37348be8 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:01:35 -0400 Subject: [PATCH 14/38] test(maic-editor): satisfy no-explicit-any in PR2 test stubs (Task 1+4 review) Co-Authored-By: Claude Opus 4.7 --- tests/edit/surfaces/slide/insert-items.test.ts | 1 + tests/prosemirror/active-editor-registry.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts index 0e71f87cfd..b6cc87c020 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -4,6 +4,7 @@ import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit describe('slide insert palette', () => { beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal store-state stub for unit test useSlideEditSession.setState({ history: { past: [], present: { type: 'slide', canvas: { id: 's', elements: [] } } as any, future: [] } } as any); }); diff --git a/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts index e57865fba3..7fa27ebcf1 100644 --- a/tests/prosemirror/active-editor-registry.test.ts +++ b/tests/prosemirror/active-editor-registry.test.ts @@ -21,9 +21,13 @@ describe('active text editor registry', () => { describe('selection sync gate', () => { it('pushes on selection move, doc change, or stored-marks change', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect(shouldPushAttrs({ selectionSet: true, docChanged: false, storedMarksSet: false } as any)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect(shouldPushAttrs({ selectionSet: false, docChanged: true, storedMarksSet: false } as any)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: true } as any)).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: false } as any)).toBe(false); }); }); From 12fb9a07b5d24cafbf63b6478dd93734e65e32e9 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:13:16 -0400 Subject: [PATCH 15/38] feat(maic-editor): compact text property bar in the reused floating slot Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/text-format-bar.tsx | 127 ++++++++++++++++++ .../edit/surfaces/slide/use-slide-surface.ts | 14 +- .../surfaces/slide/text-format-bar.test.ts | 83 ++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 components/edit/surfaces/slide/text-format-bar.tsx create mode 100644 tests/edit/surfaces/slide/text-format-bar.test.ts 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..a5fa42138a --- /dev/null +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -0,0 +1,127 @@ +'use client'; + +import React from 'react'; +import { Bold, Italic, Underline } from 'lucide-react'; +import type { TextAttrs } from '@/lib/prosemirror/utils'; +import { runActiveTextCommand, type TextCommandPayload } from '@/lib/prosemirror/active-editor-registry'; +import { useCanvasStore } from '@/lib/store/canvas'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface TextFormatBarProps { + readonly elementId: string; + readonly attrs: TextAttrs; +} + +interface ToggleButtonProps { + readonly label: string; + readonly active: boolean; + readonly payload: TextCommandPayload; + readonly run: (payload: TextCommandPayload) => void; + readonly children: React.ReactNode; +} + +function ToggleButton({ label, active, payload, run, children }: ToggleButtonProps) { + return ( + + ); +} + +export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { + const { t } = useI18n(); + const run = (payload: TextCommandPayload) => runActiveTextCommand(elementId, payload); + + return ( +
+ +
+ + {parseInt(attrs.fontsize, 10) || 16} + +
+
+ + + + + + + + + + +
+ + ≣ + + + ≡ + + + ≢ + + + • + +
+ ); +} + +/** + * 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 React.createElement(TextFormatBar, { elementId, attrs }); +} + +export function stepFontSize(current: string, delta: number): string { + const n = parseInt(current, 10) || 16; + return `${Math.max(8, Math.min(96, n + delta))}px`; +} diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 7d3f7bf651..12aca65ccb 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -3,6 +3,7 @@ import { produce } from 'immer'; import { Image as ImageIcon, Type } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ConnectedTextFormatBar } from './text-format-bar'; import { clearPersistedSlideHistory, loadPersistedSlideHistory, @@ -53,11 +54,20 @@ export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] } export function buildFloatingActions( - _t: (k: string) => string, + t: (k: string) => string, textTarget: PPTTextElement | undefined, ): FloatingAction[] { if (!textTarget) return []; - return []; // text formatting actions added in Task 5 + // NOTE: The bar is surfaced via FloatingToolbar's popover slot (one button → popover → bar), + // not always-inline. This is an ergonomics tradeoff to be addressed in Task 8 polish. + return [ + { + id: 'text-format', + label: t('edit.text.label'), + tooltip: t('edit.text.label'), + popoverContent: () => React.createElement(ConnectedTextFormatBar, { elementId: textTarget.id }), + }, + ]; } const EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') }; 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..fe6966d158 --- /dev/null +++ b/tests/edit/surfaces/slide/text-format-bar.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import * as registry from '@/lib/prosemirror/active-editor-registry'; +import { stepFontSize } from '@/components/edit/surfaces/slide/text-format-bar'; +import { buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; +import type { PPTTextElement } from '@/lib/types/slides'; + +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 — 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(); + }); +}); + +describe('buildFloatingActions — text-format wiring', () => { + const t = (k: string) => k; + + it('returns [] when no text target', () => { + expect(buildFloatingActions(t, undefined)).toEqual([]); + }); + + it('returns a single text-format action when text element is selected', () => { + const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; + const actions = buildFloatingActions(t, textEl); + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe('text-format'); + }); + + it('text-format action has a popoverContent factory', () => { + const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; + const actions = buildFloatingActions(t, textEl); + expect(typeof actions[0].popoverContent).toBe('function'); + }); + + it('text-format action label and tooltip are i18n keys', () => { + const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; + const actions = buildFloatingActions(t, textEl); + expect(actions[0].label).toBe('edit.text.label'); + expect(actions[0].tooltip).toBe('edit.text.label'); + }); +}); From 4b6cc02a9fdb03a9c10605314efad9a3631d94d3 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:23:28 -0400 Subject: [PATCH 16/38] =?UTF-8?q?refactor(maic-editor):=20Task=205=20revie?= =?UTF-8?q?w=20=E2=80=94=20uniform=20selection-guard,=20Lucide=20icons,=20?= =?UTF-8?q?JSX,=20memo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/text-format-bar.tsx | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index a5fa42138a..78cb66b3c0 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -1,7 +1,9 @@ 'use client'; -import React from 'react'; -import { Bold, Italic, Underline } from 'lucide-react'; +// i18n keys (edit.text.*) and font-name labels are added in Task 6; they render as raw keys until then. + +import { useCallback } from 'react'; +import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List } from 'lucide-react'; import type { TextAttrs } from '@/lib/prosemirror/utils'; import { runActiveTextCommand, type TextCommandPayload } from '@/lib/prosemirror/active-editor-registry'; import { useCanvasStore } from '@/lib/store/canvas'; @@ -20,24 +22,50 @@ interface ToggleButtonProps { readonly children: React.ReactNode; } -function ToggleButton({ label, active, payload, run, children }: ToggleButtonProps) { +// preventDefault on mousedown keeps ProseMirror focused/selected so the command applies to the +// live selection (the intentionally omit this — they need native focus). +function BarButton({ + label, + onClick, + className, + children, +}: { + readonly label: string; + readonly onClick: () => void; + readonly className?: string; + readonly children: React.ReactNode; +}) { return ( + ); +} + +function ToggleButton({ label, active, payload, run, children }: ToggleButtonProps) { + return ( + run(payload)} className={`flex h-8 w-8 items-center justify-center rounded-md text-sm ${active ? 'bg-zinc-200 dark:bg-zinc-700' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`} > {children} - + ); } export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { const { t } = useI18n(); - const run = (payload: TextCommandPayload) => runActiveTextCommand(elementId, payload); + const run = useCallback( + (payload: TextCommandPayload) => runActiveTextCommand(elementId, payload), + [elementId], + ); return (
@@ -53,23 +81,21 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) {
- + {parseInt(attrs.fontsize, 10) || 16} - +
@@ -97,16 +123,16 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) {
- ≣ + - ≡ + - ≢ + - • +
); @@ -118,7 +144,7 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { */ export function ConnectedTextFormatBar({ elementId }: { readonly elementId: string }) { const attrs = useCanvasStore.use.richTextAttrs(); - return React.createElement(TextFormatBar, { elementId, attrs }); + return ; } export function stepFontSize(current: string, delta: number): string { From 2f1f80374b0734cb2383e744b23092646689831a Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:28:44 -0400 Subject: [PATCH 17/38] i18n(maic-editor): edit.text.* + edit.insert.* across 6 locales Co-Authored-By: Claude Opus 4.7 --- lib/i18n/locales/ar-SA.json | 23 +++++++++++++++++++++ lib/i18n/locales/en-US.json | 23 +++++++++++++++++++++ lib/i18n/locales/ja-JP.json | 23 +++++++++++++++++++++ lib/i18n/locales/ru-RU.json | 23 +++++++++++++++++++++ lib/i18n/locales/zh-CN.json | 23 +++++++++++++++++++++ lib/i18n/locales/zh-TW.json | 23 +++++++++++++++++++++ tests/i18n/edit-pr2-locales.test.ts | 31 +++++++++++++++++++++++++++++ 7 files changed, 169 insertions(+) create mode 100644 tests/i18n/edit-pr2-locales.test.ts diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 5231aadc75..4ef89e383b 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -188,6 +188,29 @@ "width": "العرض", "height": "الارتفاع", "rotate": "التدوير" + }, + "text": { + "label": "نص", + "font": "الخط", + "fontDefault": "افتراضي", + "sizeUp": "تكبير الحجم", + "sizeDown": "تصغير الحجم", + "bold": "غامق", + "italic": "مائل", + "underline": "تسطير", + "color": "لون النص", + "alignLeft": "محاذاة لليسار", + "alignCenter": "توسيط", + "alignRight": "محاذاة لليمين", + "bullet": "قائمة نقطية" + }, + "insert": { + "textBox": "مربع نص", + "image": "صورة", + "imageDrop": "أفلِت صورة أو انقر لاختيار ملف", + "imageOr": "أو الصق رابط صورة", + "imageUrlPlaceholder": "https://…", + "imageInsert": "إدراج" } }, "classroomComplete": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index f7815c7a24..1eaf62e7ee 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -188,6 +188,29 @@ "width": "Width", "height": "Height", "rotate": "Rotation" + }, + "text": { + "label": "Text", + "font": "Font", + "fontDefault": "Default", + "sizeUp": "Increase size", + "sizeDown": "Decrease 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", + "image": "Image", + "imageDrop": "Drop an image or click to choose a file", + "imageOr": "or paste an image URL", + "imageUrlPlaceholder": "https://…", + "imageInsert": "Insert" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 8552c812a9..2bd39780d4 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -188,6 +188,29 @@ "width": "幅", "height": "高さ", "rotate": "回転" + }, + "text": { + "label": "テキスト", + "font": "フォント", + "fontDefault": "デフォルト", + "sizeUp": "文字を大きく", + "sizeDown": "文字を小さく", + "bold": "太字", + "italic": "斜体", + "underline": "下線", + "color": "文字色", + "alignLeft": "左揃え", + "alignCenter": "中央揃え", + "alignRight": "右揃え", + "bullet": "箇条書き" + }, + "insert": { + "textBox": "テキストボックス", + "image": "画像", + "imageDrop": "画像をドロップまたはクリックして選択", + "imageOr": "または画像 URL を貼り付け", + "imageUrlPlaceholder": "https://…", + "imageInsert": "挿入" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 96f45116f4..b799d52d59 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -188,6 +188,29 @@ "width": "Ширина", "height": "Высота", "rotate": "Поворот" + }, + "text": { + "label": "Текст", + "font": "Шрифт", + "fontDefault": "По умолчанию", + "sizeUp": "Увеличить размер", + "sizeDown": "Уменьшить размер", + "bold": "Полужирный", + "italic": "Курсив", + "underline": "Подчёркнутый", + "color": "Цвет текста", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "bullet": "Маркированный список" + }, + "insert": { + "textBox": "Текстовое поле", + "image": "Изображение", + "imageDrop": "Перетащите изображение или нажмите, чтобы выбрать файл", + "imageOr": "или вставьте URL изображения", + "imageUrlPlaceholder": "https://…", + "imageInsert": "Вставить" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 3aca5d94d4..ef8a3b6154 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -188,6 +188,29 @@ "width": "宽", "height": "高", "rotate": "旋转" + }, + "text": { + "label": "文本", + "font": "字体", + "fontDefault": "默认", + "sizeUp": "增大字号", + "sizeDown": "减小字号", + "bold": "加粗", + "italic": "斜体", + "underline": "下划线", + "color": "文字颜色", + "alignLeft": "左对齐", + "alignCenter": "居中对齐", + "alignRight": "右对齐", + "bullet": "项目符号" + }, + "insert": { + "textBox": "文本框", + "image": "图片", + "imageDrop": "拖入图片或点击选择文件", + "imageOr": "或粘贴图片 URL", + "imageUrlPlaceholder": "https://…", + "imageInsert": "插入" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 42392c2819..86c5928435 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -188,6 +188,29 @@ "width": "寬", "height": "高", "rotate": "旋轉" + }, + "text": { + "label": "文字", + "font": "字型", + "fontDefault": "預設", + "sizeUp": "放大字級", + "sizeDown": "縮小字級", + "bold": "粗體", + "italic": "斜體", + "underline": "底線", + "color": "文字色彩", + "alignLeft": "靠左對齊", + "alignCenter": "置中對齊", + "alignRight": "靠右對齊", + "bullet": "項目符號" + }, + "insert": { + "textBox": "文字方塊", + "image": "圖片", + "imageDrop": "拖曳圖片或點按選擇檔案", + "imageOr": "或貼上圖片網址", + "imageUrlPlaceholder": "https://…", + "imageInsert": "插入" } }, "whiteboard": { diff --git a/tests/i18n/edit-pr2-locales.test.ts b/tests/i18n/edit-pr2-locales.test.ts new file mode 100644 index 0000000000..8608e1d253 --- /dev/null +++ b/tests/i18n/edit-pr2-locales.test.ts @@ -0,0 +1,31 @@ +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.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); + } + } + }); +}); From d4e87b1a168d190400bd6d1e9b4dc1128bfcffe3 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:37:16 -0400 Subject: [PATCH 18/38] test(maic-editor): round-trip gate for formatted text + inserts Co-Authored-By: Claude Opus 4.7 --- tests/edit/round-trip/insert.test.ts | 79 +++++++++++++++++++++++ tests/edit/round-trip/text-format.test.ts | 63 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/edit/round-trip/insert.test.ts create mode 100644 tests/edit/round-trip/text-format.test.ts diff --git a/tests/edit/round-trip/insert.test.ts b/tests/edit/round-trip/insert.test.ts new file mode 100644 index 0000000000..c058278998 --- /dev/null +++ b/tests/edit/round-trip/insert.test.ts @@ -0,0 +1,79 @@ +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: inserted elements (text + image) survive the export + * pipeline. Mirrors the helper pattern established in text-content.test.ts + * and image-data-url.test.ts. + * + * Default content for createDefaultTextElement is '

New text

' (from + * lib/edit/slide-edit-elements.ts), so the needle asserted below is the + * literal inner text "New text". + */ +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(); + + // createDefaultTextElement sets content to '

New text

'; the inner + // text "New text" must appear in the exported slide XML. + 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); + + // 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; mirrors + // image-data-url.test.ts assertion style for the image-present check. + const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml'); + expect(slideXml.length).toBeGreaterThan(0); + }); +}); 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"'); + }); +}); From d846cd67665857f06fe149781cf6ecdc18a7d623 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 09:46:19 -0400 Subject: [PATCH 19/38] docs(maic-editor): clarify remote-URL image round-trip scope (Task 7 review) Co-Authored-By: Claude Opus 4.7 --- tests/edit/round-trip/insert.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/edit/round-trip/insert.test.ts b/tests/edit/round-trip/insert.test.ts index c058278998..43679d9c2b 100644 --- a/tests/edit/round-trip/insert.test.ts +++ b/tests/edit/round-trip/insert.test.ts @@ -13,13 +13,12 @@ import { } from './fixtures'; /** - * Round-trip gate: inserted elements (text + image) survive the export - * pipeline. Mirrors the helper pattern established in text-content.test.ts - * and image-data-url.test.ts. + * Round-trip gate: tests element.add on default text and image elements. * - * Default content for createDefaultTextElement is '

New text

' (from - * lib/edit/slide-edit-elements.ts), so the needle asserted below is the - * literal inner text "New text". + * (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( @@ -43,8 +42,9 @@ describe('round-trip: element.add inserts (PR2 gate)', () => { it('(a) inserted default text element — default content survives export', async () => { const { scene, content } = makeSlideFixture(); - // createDefaultTextElement sets content to '

New text

'; the inner - // text "New text" must appear in the exported slide XML. + // (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, { @@ -68,11 +68,17 @@ describe('round-trip: element.add inserts (PR2 gate)', () => { 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; mirrors - // image-data-url.test.ts assertion style for the image-present check. + // The slide XML entry must be present and non-empty. const slideXml = await readPptxEntry(blob, 'ppt/slides/slide1.xml'); expect(slideXml.length).toBeGreaterThan(0); }); From 39338b497745319cf8fe853a0662c108990b0922 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 10:00:13 -0400 Subject: [PATCH 20/38] chore(maic-editor): drop stale scaffolding comment + orphaned geometry i18n keys Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/text-format-bar.tsx | 2 -- components/edit/surfaces/slide/use-slide-surface.ts | 2 +- lib/i18n/locales/ar-SA.json | 9 --------- lib/i18n/locales/en-US.json | 9 --------- lib/i18n/locales/ja-JP.json | 9 --------- lib/i18n/locales/ru-RU.json | 9 --------- lib/i18n/locales/zh-CN.json | 9 --------- lib/i18n/locales/zh-TW.json | 9 --------- 8 files changed, 1 insertion(+), 57 deletions(-) diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 78cb66b3c0..a53e4e98bc 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -1,7 +1,5 @@ 'use client'; -// i18n keys (edit.text.*) and font-name labels are added in Task 6; they render as raw keys until then. - import { useCallback } from 'react'; import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List } from 'lucide-react'; import type { TextAttrs } from '@/lib/prosemirror/utils'; diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 12aca65ccb..71e316a5fd 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -59,7 +59,7 @@ export function buildFloatingActions( ): FloatingAction[] { if (!textTarget) return []; // NOTE: The bar is surfaced via FloatingToolbar's popover slot (one button → popover → bar), - // not always-inline. This is an ergonomics tradeoff to be addressed in Task 8 polish. + // not always-inline. This is a popover-vs-inline ergonomics tradeoff deferred for future polish. return [ { id: 'text-format', diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 4ef89e383b..5b66834af6 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -180,15 +180,6 @@ "actionDismiss": "حسناً" } }, - "geometry": { - "label": "الموضع والحجم", - "tooltip": "الموضع والحجم", - "x": "X", - "y": "Y", - "width": "العرض", - "height": "الارتفاع", - "rotate": "التدوير" - }, "text": { "label": "نص", "font": "الخط", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 1eaf62e7ee..f1a7f029ad 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -180,15 +180,6 @@ "actionDismiss": "Got it" } }, - "geometry": { - "label": "Position & size", - "tooltip": "Position & size", - "x": "X", - "y": "Y", - "width": "Width", - "height": "Height", - "rotate": "Rotation" - }, "text": { "label": "Text", "font": "Font", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 2bd39780d4..aedf4c8ba4 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -180,15 +180,6 @@ "actionDismiss": "了解" } }, - "geometry": { - "label": "位置とサイズ", - "tooltip": "位置とサイズ", - "x": "X", - "y": "Y", - "width": "幅", - "height": "高さ", - "rotate": "回転" - }, "text": { "label": "テキスト", "font": "フォント", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index b799d52d59..53fa62478a 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -180,15 +180,6 @@ "actionDismiss": "Понятно" } }, - "geometry": { - "label": "Положение и размер", - "tooltip": "Положение и размер", - "x": "X", - "y": "Y", - "width": "Ширина", - "height": "Высота", - "rotate": "Поворот" - }, "text": { "label": "Текст", "font": "Шрифт", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index ef8a3b6154..ee8e672353 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -180,15 +180,6 @@ "actionDismiss": "知道了" } }, - "geometry": { - "label": "位置与尺寸", - "tooltip": "位置与尺寸", - "x": "X", - "y": "Y", - "width": "宽", - "height": "高", - "rotate": "旋转" - }, "text": { "label": "文本", "font": "字体", diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 86c5928435..4bfbfc4194 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -180,15 +180,6 @@ "actionDismiss": "知道了" } }, - "geometry": { - "label": "位置與尺寸", - "tooltip": "位置與尺寸", - "x": "X", - "y": "Y", - "width": "寬", - "height": "高", - "rotate": "旋轉" - }, "text": { "label": "文字", "font": "字型", From 41d27298bddd4ed9d2c3bf455ac8fec79bda8e7f Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 10:28:51 -0400 Subject: [PATCH 21/38] style(maic-editor): prettier --write PR2 files (pre-push check) Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/ImagePicker.tsx | 23 ++++++-- .../edit/surfaces/slide/text-format-bar.tsx | 59 ++++++++++++++++--- .../edit/surfaces/slide/use-slide-surface.ts | 19 ++++-- lib/prosemirror/active-editor-registry.ts | 12 +++- tests/edit/round-trip/insert.test.ts | 5 +- .../edit/surfaces/slide/insert-items.test.ts | 18 +++++- tests/i18n/edit-pr2-locales.test.ts | 33 +++++++++-- .../active-editor-registry.test.ts | 16 +++-- 8 files changed, 149 insertions(+), 36 deletions(-) diff --git a/components/edit/surfaces/slide/ImagePicker.tsx b/components/edit/surfaces/slide/ImagePicker.tsx index 3ba1e7e236..ddf28da002 100644 --- a/components/edit/surfaces/slide/ImagePicker.tsx +++ b/components/edit/surfaces/slide/ImagePicker.tsx @@ -39,16 +39,31 @@ export function ImagePicker({ onPick }: ImagePickerProps) { type="button" onClick={() => inputRef.current?.click()} onDragOver={(e) => e.preventDefault()} - onDrop={(e) => { e.preventDefault(); void handleFiles(e.dataTransfer.files); }} + 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)} /> + void handleFiles(e.target.files)} + />
{t('edit.insert.imageOr')}
- setUrl(e.target.value)} placeholder={t('edit.insert.imageUrlPlaceholder')} /> - + setUrl(e.target.value)} + placeholder={t('edit.insert.imageUrlPlaceholder')} + /> +
); diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index a53e4e98bc..b63e328ec9 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -3,7 +3,10 @@ import { useCallback } from 'react'; import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List } from 'lucide-react'; import type { TextAttrs } from '@/lib/prosemirror/utils'; -import { runActiveTextCommand, type TextCommandPayload } from '@/lib/prosemirror/active-editor-registry'; +import { + runActiveTextCommand, + type TextCommandPayload, +} from '@/lib/prosemirror/active-editor-registry'; import { useCanvasStore } from '@/lib/store/canvas'; import { useI18n } from '@/lib/hooks/use-i18n'; @@ -96,20 +99,38 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) {
- + - + - +
- + - + - + - +
diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 71e316a5fd..c5e8b17d16 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -9,7 +9,11 @@ import { loadPersistedSlideHistory, } from '@/lib/edit/slide-history-persistence'; import type { SceneDataController } from '@/lib/contexts/scene-context'; -import type { FloatingAction, InsertPaletteItem, SurfaceState } from '@/lib/edit/scene-editor-surface'; +import type { + FloatingAction, + InsertPaletteItem, + SurfaceState, +} from '@/lib/edit/scene-editor-surface'; import { useI18n } from '@/lib/hooks/use-i18n'; import { createElementId } from '@/lib/edit/element-id'; import { @@ -47,7 +51,8 @@ export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] onInvoke: () => {}, // popover-only: CommandBar's InsertButton ignores onInvoke when popoverContent is set popoverContent: () => React.createElement(ImagePicker, { - onPick: (src: string) => addElement(createDefaultImageElement(createElementId('image'), src)), + onPick: (src: string) => + addElement(createDefaultImageElement(createElementId('image'), src)), }), }, ]; @@ -65,7 +70,8 @@ export function buildFloatingActions( id: 'text-format', label: t('edit.text.label'), tooltip: t('edit.text.label'), - popoverContent: () => React.createElement(ConnectedTextFormatBar, { elementId: textTarget.id }), + popoverContent: () => + React.createElement(ConnectedTextFormatBar, { elementId: textTarget.id }), }, ]; } @@ -92,9 +98,10 @@ export function useSlideSurfaceState(): SurfaceState el.id === activeElementIds[0]) ?? undefined) - : undefined; + const onlyEl = + activeElementIds.length === 1 + ? (content.canvas.elements.find((el) => el.id === activeElementIds[0]) ?? undefined) + : undefined; const textTarget = onlyEl && onlyEl.type === 'text' ? onlyEl : undefined; return { diff --git a/lib/prosemirror/active-editor-registry.ts b/lib/prosemirror/active-editor-registry.ts index 95d02e0319..3eb7642510 100644 --- a/lib/prosemirror/active-editor-registry.ts +++ b/lib/prosemirror/active-editor-registry.ts @@ -8,9 +8,15 @@ */ export interface TextCommandPayload { command: - | 'bold' | 'em' | 'underline' - | 'fontname' | 'fontsize' | 'forecolor' - | 'align-left' | 'align-center' | 'align-right' + | 'bold' + | 'em' + | 'underline' + | 'fontname' + | 'fontsize' + | 'forecolor' + | 'align-left' + | 'align-center' + | 'align-right' | 'bulletList'; value?: string; } diff --git a/tests/edit/round-trip/insert.test.ts b/tests/edit/round-trip/insert.test.ts index 43679d9c2b..dea88ca269 100644 --- a/tests/edit/round-trip/insert.test.ts +++ b/tests/edit/round-trip/insert.test.ts @@ -2,7 +2,10 @@ 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 { + createDefaultImageElement, + createDefaultTextElement, +} from '@/lib/edit/slide-edit-elements'; import type { Scene, SlideContent } from '@/lib/types/stage'; import { makeSlideFixture, diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts index b6cc87c020..4912e4e985 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -1,11 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildInsertItems, buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; +import { + buildInsertItems, + buildFloatingActions, +} from '@/components/edit/surfaces/slide/use-slide-surface'; import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; describe('slide insert palette', () => { beforeEach(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal store-state stub for unit test - useSlideEditSession.setState({ history: { past: [], present: { type: 'slide', canvas: { id: 's', elements: [] } } as any, future: [] } } as any); + useSlideEditSession.setState({ + history: { + past: [], + present: { type: 'slide', canvas: { id: 's', elements: [] } } as any, + future: [], + }, + } as any); }); afterEach(() => { @@ -23,7 +32,10 @@ describe('slide insert palette', () => { const spy = vi.spyOn(useSlideEditSession.getState(), 'applyOp'); buildInsertItems((k) => k)[0].onInvoke(); expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ type: 'element.add', element: expect.objectContaining({ type: 'text' }) }), + expect.objectContaining({ + type: 'element.add', + element: expect.objectContaining({ type: 'text' }), + }), ); }); diff --git a/tests/i18n/edit-pr2-locales.test.ts b/tests/i18n/edit-pr2-locales.test.ts index 8608e1d253..76369474b7 100644 --- a/tests/i18n/edit-pr2-locales.test.ts +++ b/tests/i18n/edit-pr2-locales.test.ts @@ -6,13 +6,34 @@ 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 locales = { + 'en-US': enUS, + 'zh-CN': zhCN, + 'zh-TW': zhTW, + 'ja-JP': jaJP, + 'ru-RU': ruRU, + 'ar-SA': arSA, +}; const KEYS = [ - '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', + '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); diff --git a/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts index 7fa27ebcf1..e7b516545d 100644 --- a/tests/prosemirror/active-editor-registry.test.ts +++ b/tests/prosemirror/active-editor-registry.test.ts @@ -22,12 +22,20 @@ describe('active text editor registry', () => { describe('selection sync gate', () => { it('pushes on selection move, doc change, or stored-marks change', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test - expect(shouldPushAttrs({ selectionSet: true, docChanged: false, storedMarksSet: false } as any)).toBe(true); + expect( + shouldPushAttrs({ selectionSet: true, docChanged: false, storedMarksSet: false } as any), + ).toBe(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test - expect(shouldPushAttrs({ selectionSet: false, docChanged: true, storedMarksSet: false } as any)).toBe(true); + expect( + shouldPushAttrs({ selectionSet: false, docChanged: true, storedMarksSet: false } as any), + ).toBe(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test - expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: true } as any)).toBe(true); + expect( + shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: true } as any), + ).toBe(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test - expect(shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: false } as any)).toBe(false); + expect( + shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: false } as any), + ).toBe(false); }); }); From 4237c025fd36af4177d7cc599cd252a00af73ca1 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 17 May 2026 10:32:12 -0400 Subject: [PATCH 22/38] test(maic-editor): make no-explicit-any suppression prettier-robust Co-Authored-By: Claude Opus 4.7 --- tests/edit/surfaces/slide/insert-items.test.ts | 3 ++- .../prosemirror/active-editor-registry.test.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/edit/surfaces/slide/insert-items.test.ts b/tests/edit/surfaces/slide/insert-items.test.ts index 4912e4e985..436ba5e562 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -7,7 +7,7 @@ import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit describe('slide insert palette', () => { beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal store-state stub for unit test + /* eslint-disable @typescript-eslint/no-explicit-any */ useSlideEditSession.setState({ history: { past: [], @@ -15,6 +15,7 @@ describe('slide insert palette', () => { future: [], }, } as any); + /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { diff --git a/tests/prosemirror/active-editor-registry.test.ts b/tests/prosemirror/active-editor-registry.test.ts index e7b516545d..b5a8a4240a 100644 --- a/tests/prosemirror/active-editor-registry.test.ts +++ b/tests/prosemirror/active-editor-registry.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import type { Transaction } from 'prosemirror-state'; import { registerActiveTextEditor, runActiveTextCommand, @@ -6,6 +7,10 @@ import { } 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(); @@ -21,21 +26,17 @@ describe('active text editor registry', () => { describe('selection sync gate', () => { it('pushes on selection move, doc change, or stored-marks change', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect( - shouldPushAttrs({ selectionSet: true, docChanged: false, storedMarksSet: false } as any), + shouldPushAttrs(tx({ selectionSet: true, docChanged: false, storedMarksSet: false })), ).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect( - shouldPushAttrs({ selectionSet: false, docChanged: true, storedMarksSet: false } as any), + shouldPushAttrs(tx({ selectionSet: false, docChanged: true, storedMarksSet: false })), ).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect( - shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: true } as any), + shouldPushAttrs(tx({ selectionSet: false, docChanged: false, storedMarksSet: true })), ).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal transaction stub for unit test expect( - shouldPushAttrs({ selectionSet: false, docChanged: false, storedMarksSet: false } as any), + shouldPushAttrs(tx({ selectionSet: false, docChanged: false, storedMarksSet: false })), ).toBe(false); }); }); From 08f267862f1a09b66167462f64ccd61a70ea7dae Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 19 May 2026 10:20:22 -0400 Subject: [PATCH 23/38] fix(maic-editor): CommandBar insert popover never opened (PopoverTrigger wrapped a Tooltip provider) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert→Image was unreachable: InsertButton wrapped around (a context provider, no DOM node), so Radix's Slot bound no element. Chain both triggers onto the real + )} +
+
+ {COMMON.map((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 ( + + ); +} diff --git a/components/edit/surfaces/slide/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx index 0903d3a64b..aacdcb1d60 100644 --- a/components/edit/surfaces/slide/SlideCanvas.tsx +++ b/components/edit/surfaces/slide/SlideCanvas.tsx @@ -1,8 +1,17 @@ 'use client'; +import { useEffect } from 'react'; import Canvas from '@/components/slide-renderer/Editor/Canvas'; import { SceneProvider } from '@/lib/contexts/scene-context'; -import { useSlideCanvasController } from './use-slide-surface'; +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 @@ -10,9 +19,41 @@ import { useSlideCanvasController } from './use-slide-surface'; * 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); + }, []); + + // Mark the body while the editor is mounted, so the editor-scoped CSS rule + // in globals.css that pins `body.padding-right` to 0 only fires here — not + // on non-editor pages where Radix's react-remove-scroll compensation is + // still wanted. + useEffect(() => { + document.body.dataset.maicEditor = 'true'; + return () => { + delete document.body.dataset.maicEditor; + }; + }, []); return ( // gestureProps marks pointer-gesture windows so a renderer commit is @@ -22,6 +63,8 @@ export function SlideCanvas() { + +
); } 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/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 84a0dbd3fe..eaad3cbde4 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -1,21 +1,45 @@ 'use client'; -import { useCallback } from 'react'; -import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List } from 'lucide-react'; +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; @@ -24,8 +48,9 @@ interface ToggleButtonProps { readonly children: React.ReactNode; } -// preventDefault on mousedown keeps ProseMirror focused/selected so the command applies to the -// live selection (the intentionally omit this — they need native focus). +// 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, @@ -55,59 +80,121 @@ function ToggleButton({ label, active, payload, run, children }: ToggleButtonPro run(payload)} - className={`flex h-8 w-8 items-center justify-center rounded-md text-sm ${active ? 'bg-zinc-200 dark:bg-zinc-700' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`} + 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 + [&>*]:shrink-0 → the row keeps its natural width and no control - // gets squished; the popover (w-auto) sizes to this. Single clean line, - // no overflow/clip. -
- {/* Fonts come from OpenMAIC's canonical FONTS registry (configs/font.ts) - — the web fonts the renderer actually loads, so a pick renders the - same on every platform. (A prior hardcoded SimSun/SimHei list was - Windows-only and had no visible effect on macOS.) */} - run({ command: 'fontname', value: v === DEFAULT_FONT ? '' : v })} > - {FONTS.map((f) => ( - - ))} - -
+ + {/* 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="px-2 text-sm" + className={STEP_BUTTON} > - − + - {parseInt(attrs.fontsize, 10) || 16} + 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="px-2 text-sm" + className={STEP_BUTTON} > - + +
-
+ + + - -
+ run({ command: 'forecolor', value: c })} + onCommit={(c) => { + run({ command: 'forecolor', value: c }); + setColorOpen(false); + }} + /> + + + + + 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 index 3d5993e1f0..2254d97bf8 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -1,43 +1,45 @@ 'use client'; import { produce } from 'immer'; -import { Image as ImageIcon, Trash2, Type } from 'lucide-react'; -import React, { useEffect, useMemo, useRef } from 'react'; -import { ConnectedTextFormatBar } from './text-format-bar'; +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 { - FloatingAction, - InsertPaletteItem, - SurfaceState, -} from '@/lib/edit/scene-editor-surface'; +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, - createDefaultTextElement, -} from '@/lib/edit/slide-edit-elements'; +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 { PPTElement } from '@/lib/types/slides'; 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): InsertPaletteItem[] { - const addElement = (element: PPTElement) => - useSlideEditSession.getState().applyOp({ type: 'element.add', element }); +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' }), - onInvoke: () => addElement(createDefaultTextElement(createElementId('text'))), + active: creatingType === 'text', + onInvoke: armText, }, { id: 'insert-image', @@ -47,45 +49,58 @@ export function buildInsertItems(t: (k: string) => string): InsertPaletteItem[] onInvoke: () => {}, // popover-only: CommandBar's InsertButton ignores onInvoke when popoverContent is set popoverContent: () => React.createElement(ImagePicker, { - onPick: (src: string) => - addElement(createDefaultImageElement(createElementId('image'), src)), + onPick: insertImageElement, }), }, ]; } -export function buildFloatingActions( - t: (k: string) => string, - selected: PPTElement | undefined, -): FloatingAction[] { - if (!selected) return []; - const actions: FloatingAction[] = []; - if (selected.type === 'text') { - // The text property bar is surfaced via FloatingToolbar's popover slot - // (button → popover → bar), not always-inline — a popover-vs-inline - // ergonomics tradeoff deferred for future polish. - actions.push({ - id: 'text-format', - label: t('edit.text.label'), - tooltip: t('edit.text.label'), - popoverContent: () => React.createElement(ConnectedTextFormatBar, { elementId: selected.id }), - }); +// 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'); + const dispatch = (width?: number, height?: number) => { + 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; } - // Delete affordance for any single selected element (text or image). The - // renderer's own delete lives only in a right-click menu; this is the - // discoverable, button-only entry (keyboard shortcuts deferred — see #560). - actions.push({ - id: 'delete', - label: t('edit.delete'), - tooltip: t('edit.delete'), - icon: React.createElement(Trash2, { className: 'h-4 w-4' }), - group: 'danger', - onInvoke: () => { - useSlideEditSession.getState().applyOp({ type: 'element.delete', elementId: selected.id }); - useCanvasStore.getState().setActiveElementIdList([]); - }, - }); - return actions; + 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('') }; @@ -95,6 +110,18 @@ function currentSlideContent(sceneId: string): SlideContent | null { 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. @@ -102,18 +129,9 @@ function currentSlideContent(sceneId: string): SlideContent | null { export function useSlideSurfaceState(): SurfaceState { const { t } = useI18n(); const history = useSlideEditSession((s) => s.history); - const sessionSceneId = useSlideEditSession((s) => s.sceneId); const activeElementIds = useCanvasStore.use.activeElementIdList(); - - const content: SlideContent = - history?.present ?? - (sessionSceneId ? currentSlideContent(sessionSceneId) : null) ?? - EMPTY_SLIDE; - - const onlyEl = - activeElementIds.length === 1 - ? (content.canvas.elements.find((el) => el.id === activeElementIds[0]) ?? undefined) - : undefined; + const creatingElement = useCanvasStore.use.creatingElement(); + const content = useResolvedSlideContent(); return { content, @@ -125,8 +143,11 @@ export function useSlideSurfaceState(): SurfaceState useSlideEditSession.getState().undo(), redo: () => useSlideEditSession.getState().redo(), }, - insertItems: buildInsertItems(t), - floatingActions: buildFloatingActions(t, onlyEl), + 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: [], }; @@ -220,3 +241,56 @@ export function useSlideCanvasController(): SlideCanvasController { 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/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, 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/lib/edit/scene-editor-surface.ts b/lib/edit/scene-editor-surface.ts index 0b95ee41d1..c4838e64da 100644 --- a/lib/edit/scene-editor-surface.ts +++ b/lib/edit/scene-editor-surface.ts @@ -36,6 +36,12 @@ export interface InsertPaletteItem extends UiAffordance { * "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; } /** diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 790ee846f1..96d75fd832 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -179,6 +179,7 @@ "fontDefault": "افتراضي", "sizeUp": "تكبير الحجم", "sizeDown": "تصغير الحجم", + "fontSize": "حجم الخط", "bold": "غامق", "italic": "مائل", "underline": "تسطير", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index e05b4a5660..516510cc2f 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -179,6 +179,7 @@ "fontDefault": "Default", "sizeUp": "Increase size", "sizeDown": "Decrease size", + "fontSize": "Font size", "bold": "Bold", "italic": "Italic", "underline": "Underline", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index c62da55db4..471c25b193 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -179,6 +179,7 @@ "fontDefault": "デフォルト", "sizeUp": "文字を大きく", "sizeDown": "文字を小さく", + "fontSize": "フォントサイズ", "bold": "太字", "italic": "斜体", "underline": "下線", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 3dca937dad..a4637b24a3 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -179,6 +179,7 @@ "fontDefault": "По умолчанию", "sizeUp": "Увеличить размер", "sizeDown": "Уменьшить размер", + "fontSize": "Размер шрифта", "bold": "Полужирный", "italic": "Курсив", "underline": "Подчёркнутый", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 94e9ca7ca9..49af7163f4 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -179,6 +179,7 @@ "fontDefault": "默认", "sizeUp": "增大字号", "sizeDown": "减小字号", + "fontSize": "字号", "bold": "加粗", "italic": "斜体", "underline": "下划线", diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index ccdd447847..046753a17c 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -179,6 +179,7 @@ "fontDefault": "預設", "sizeUp": "放大字級", "sizeDown": "縮小字級", + "fontSize": "字級", "bold": "粗體", "italic": "斜體", "underline": "底線", 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/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/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/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 index af5cf6199b..c99c0732a5 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -1,93 +1,59 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { buildInsertItems, - buildFloatingActions, + deleteSlideElement, } from '@/components/edit/surfaces/slide/use-slide-surface'; import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session'; -import { - createDefaultImageElement, - createDefaultTextElement, -} from '@/lib/edit/slide-edit-elements'; +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(() => { - /* 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 */ - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); + beforeEach(seedEmptySlideSession); + afterEach(() => vi.restoreAllMocks()); it('exposes a text-box and an image insert item', () => { - const items = buildInsertItems((k) => k); + 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 dispatches element.add with a text element', () => { - const spy = vi.spyOn(useSlideEditSession.getState(), 'applyOp'); - buildInsertItems((k) => k)[0].onInvoke(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'element.add', - element: expect.objectContaining({ type: 'text' }), - }), - ); - }); - - it('no longer contributes a geometry floating action', () => { - const actions = buildFloatingActions((k) => k, undefined); - expect(actions.find((a) => a.id === 'geometry')).toBeUndefined(); - }); -}); - -describe('slide floating actions', () => { - beforeEach(() => { - /* 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 */ + 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' }); }); - afterEach(() => { - vi.restoreAllMocks(); + 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('returns no actions when nothing is selected', () => { - expect(buildFloatingActions((k) => k, undefined)).toEqual([]); - }); - - it('a selected text element gets the text-format bar plus a delete action', () => { - const actions = buildFloatingActions((k) => k, createDefaultTextElement('text-9')); - expect(actions.map((a) => a.id)).toEqual(['text-format', 'delete']); + 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); }); +}); - it('a selected image element gets only a delete action (no text-format)', () => { - const actions = buildFloatingActions((k) => k, createDefaultImageElement('img-9', 'gen_img_x')); - expect(actions.map((a) => a.id)).toEqual(['delete']); - }); +describe('slide element deletion', () => { + beforeEach(seedEmptySlideSession); + afterEach(() => vi.restoreAllMocks()); - it('the delete action dispatches element.delete for the selected element', () => { + it('deleteSlideElement dispatches an element.delete op', () => { const spy = vi.spyOn(useSlideEditSession.getState(), 'applyOp'); - const del = buildFloatingActions( - (k) => k, - createDefaultImageElement('img-9', 'gen_img_x'), - ).find((a) => a.id === 'delete'); - del?.onInvoke?.(); + 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 index 4164e98e8e..6bf4831a2d 100644 --- a/tests/edit/surfaces/slide/text-format-bar.test.ts +++ b/tests/edit/surfaces/slide/text-format-bar.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import * as registry from '@/lib/prosemirror/active-editor-registry'; -import { stepFontSize } from '@/components/edit/surfaces/slide/text-format-bar'; -import { buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; -import type { PPTTextElement } from '@/lib/types/slides'; +import { currentFontLabel, stepFontSize } from '@/components/edit/surfaces/slide/text-format-bar'; describe('TextFormatBar — pure logic', () => { it('stepFontSize increments and decrements by delta', () => { @@ -23,6 +21,24 @@ describe('TextFormatBar — pure logic', () => { }); }); +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(() => {}); @@ -53,30 +69,3 @@ describe('TextFormatBar — C1 integration (runActiveTextCommand)', () => { spy.mockRestore(); }); }); - -describe('buildFloatingActions — text-format wiring', () => { - const t = (k: string) => k; - - it('returns [] when no text target', () => { - expect(buildFloatingActions(t, undefined)).toEqual([]); - }); - - it('leads with the text-format action when a text element is selected', () => { - const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; - const actions = buildFloatingActions(t, textEl); - expect(actions[0].id).toBe('text-format'); - }); - - it('text-format action has a popoverContent factory', () => { - const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; - const actions = buildFloatingActions(t, textEl); - expect(typeof actions[0].popoverContent).toBe('function'); - }); - - it('text-format action label and tooltip are i18n keys', () => { - const textEl = { id: 'el-42', type: 'text' } as PPTTextElement; - const actions = buildFloatingActions(t, textEl); - expect(actions[0].label).toBe('edit.text.label'); - expect(actions[0].tooltip).toBe('edit.text.label'); - }); -}); From d7f2a05a0347263683664b16e31c63d87b3e5bde Mon Sep 17 00:00:00 2001 From: wyuc Date: Thu, 28 May 2026 10:39:49 +0800 Subject: [PATCH 31/38] feat(maic-editor): nav rail, scene management, Pro mode chrome rework (#601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maic-editor): slide nav rail + scene management (3/3) PR3a Phase 1 ships the Pro mode left rail + slide-level management, the last user-visible block of #562. Closes the gap where Pro mode locked the user on the current scene with no way to navigate or manage the deck. SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar` visually — index badge + title above an aspect-video thumbnail card — so the two sidebars read as the same component family across mode toggle): - Vertical thumbnail strip via `motion.dev` `Reorder.Group` with drag-to-reorder. `Reorder.Item layout="position"` keeps the layout animation on y-axis only; width changes from rail resize don't fight. - Drag-to-resize handle on the right edge writes `style.width` directly on the DOM during the gesture and commits to settings store only on mouse-up; matches playback drag feel exactly and skips the per-frame `persist` serialization that would otherwise burn the frame budget at 60 Hz. - Collapsed and expanded modes; width and collapsed flag persist in `useSettingsStore` (`editRailWidth`, `editRailCollapsed`). - All scene types are first-class — slides render a live `ThumbnailSlide` (now with optional `size` prop → self-measures via `ResizeObserver` when omitted, so the rail width is the single source of truth), non-slide scenes render the same stylised mockups playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`). Slide management: - `+ Add` in the rail header inserts a blank slide after the current scene; new store action `useStageStore.insertSceneAfter` validates stage id, migrates the scene, splices, rebalances `order`, and triggers `debouncedSave`. - Three-dot menu per tile: Rename / Duplicate / Delete. Rename also reachable via double-click on the title; Enter commits, Escape cancels, blur commits, empty input reverts. - Duplicate deep-clones slide content with fresh element IDs (avoid React key collisions) and a `(copy)` title suffix. - Delete uses a toast with Undo action; deleted scene is held in a small `useDeletedSceneRecycle` zustand store and re-inserted at its original index on Undo. Deck-empty guard at the rail layer. - Inter-thumb `InsertionZone` reveals a violet `+` badge on hover, right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`) + drop shadow + `z-20` so it lifts above the active tile's violet ring. Zero layout shift. Chrome bar: - `HeaderControls` (settings pill + Pro Switch) extracted from `Header` so Pro mode can mount it in the CommandBar's trailing slot — single top chrome bar in Pro mode instead of stacking Header + CommandBar. - Back-to-home button in CommandBar mirrors the playback Header's leftmost button. i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP / ar-SA / ru-RU. Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`, `createBlankSlideScene` / `duplicateSlideScene`. Co-Authored-By: Claude Opus 4.7 * refactor(maic-editor): split Stage chrome into mode-specific roots Bug-driven architectural rework. Two symptoms motivated this: 1. Switching from a slide scene to a non-slide one (interactive / quiz / pbl) flickered the entire edit chrome — CommandBar and SlideNavRail remounted along with the canvas. Root cause: EditShell returned a different component type (EditShellWithSurface vs EditShellReadOnly) based on whether a SceneEditorSurface was registered for the scene type, so React reconciled the change as an unmount/remount of the whole subtree. 2. `components/stage.tsx` had grown to 1391 lines — playback engine state, chat / TTS / discussion wiring, presentation/fullscreen, keyboard handling, AND the edit-mode dispatcher all in one place. Any change to mode coordination meant touching this god component. Changes: - New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op SceneEditorSurface used as a fallback when a scene type has no registered editor surface. `SurfaceState.history` is now optional so read-only surfaces can omit undo/redo cleanly. EditShell falls back to NOOP for unregistered types. - EditShell now mounts a single Frame across all scene types. Surface state is published from a child `SurfaceStateRunner` keyed by `scene.type` (so it remounts only when the runner's hook signature changes — rules-of-hooks compliant), with a custom shallow equality so the chrome doesn't re-render every render cycle for reference-fresh state objects. Result: slide ↔ interactive no longer remounts the CommandBar or the leftRail. - `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock coordination + Pro-Switch toggle wiring + multi-tab conflict prompt only. Everything else moved into one of two new components: - `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`): owns the entire playback / autonomous chrome — PlaybackEngine, chat, discussion TTS, presentation mode, keyboard shortcuts, SceneSidebar, Header, CanvasArea, Roundtable, ChatArea, AlertDialog. Exposes `teardown()` via forwardRef so the toggle can `await` SSE / engine / TTS shutdown before unmounting it. - `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro mode chrome wrapper — EditShell + SlideNavRail + HeaderControls trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from SlideCanvas so it covers read-only Pro-mode scene types too). - New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named- slot layout shell with top / left / center / right / bottom areas for the Pro mode chrome. Future right panel (properties / AI) and bottom timeline plug in as props with no structural code change. EditShell's Frame now uses StageGrid internally. - Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from the original A3 transition plan that this rework supersedes). Co-Authored-By: Claude Opus 4.7 * refactor(maic-editor): cross-fade chrome roots on Pro mode toggle Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a 180 ms opacity fade-out / fade-in. The outgoing root fully exits before the incoming one mounts, so: - The single-canvasStore-writer guarantee from the chrome split is preserved (ScreenCanvas and Editor/Canvas never coexist). - Mode toggle reads as a smooth fade instead of a hard cut. Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900` background so neither root reveals raw page colour while it passes through opacity 0. `initial={false}` skips the entry animation on first mount so the initial playback render is instant. Co-Authored-By: Claude Opus 4.7 * feat(maic-editor): drawer-style mode swap transition Pro toggle was a hard cut — playback chrome vanished, edit chrome popped into place. Now wraps the swap in `AnimatePresence` with the two chrome roots layered via `absolute inset-0` so they coexist for ~280ms: - Edit chrome enters from above (`translateY: -32 → 0`) + fades in, giving a "drawer drops down" feel that matches the inner CommandBar/leftRail stagger choreography. - Playback chrome cross-fades opacity-only; no transform so its active slide canvas stays put underneath while edit drops over it. Both roots keep rendering during the overlap, so `canvasStore`'s scale writer doesn't briefly read zero and snap the slide to a stale size when one root exits ahead of the other. Duration 280ms / `CHROME_EASE` matches the inner Frame timing source. Co-Authored-By: Claude Opus 4.7 * feat(maic-editor): Pro Switch as a shared layout element across modes The Pro Switch is the click anchor for the mode swap, but it lives in two different positions: the 80px playback Header (top-right) vs the 56px edit CommandBar trailing slot (also top-right but at a different y and with different padding). After the click the switch "jumped" — it visibly moved + restyled — which felt unsmooth even though the chrome itself was cross-fading. Tag the Pro Switch label (and the settings pill) with `motion.layoutId` so motion treats them as shared elements across the AnimatePresence swap. During the ~280ms transition, motion measures both instances and morphs position + size between them — the user's click target slides into its new home instead of teleporting. Same easing source as the chrome cross-fade so the two animations stay locked together. Co-Authored-By: Claude Opus 4.7 * refactor(maic-editor): unify chrome shell across modes Pro Switch + settings pill + download icon are the user's mode-toggle "anchors" — they need to sit at the same screen pixel across playback ↔ edit. They didn't, because the two chromes had different shapes: Playback: SceneSidebar (left, full height) | Header (h-20, 80px) on top of CanvasArea + Roundtable Edit: CommandBar (h-14, 56px, FULL width) on top of a row of (SlideNavRail | content), so the rail sat *below* the bar So when the user clicked Pro, the bar collapsed by 24px AND the rail shifted down by 56px AND the right-side controls re-styled (compact variant) — three simultaneous moves. layoutId masked some of it but the underlying structure was wrong. Unify the shells: - `StageGrid` template flipped from `top top top / left center right / bottom bottom bottom` to `left top top / left center right / left bottom bottom`. The left column now spans all rows so the sidebar always reaches the absolute top edge, matching playback exactly. - `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback Header. - `EditChromeRoot` drops the `variant="compact"` flag on `HeaderControls` so the settings pill renders at the same h-9 pill it does in playback. - `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC logo (click → home), matching `SceneSidebar`'s shape so the sidebar top reads as the same component family in both modes. - Download / Export dropdown moves out of `Header` and into `HeaderControls` so it's present in both playback and edit chrome at the same right-cluster position (was previously playback-only). `Header.tsx` slimmed accordingly. Net effect: the right-edge cluster (EN, theme, settings, download, Pro Switch) lives at the same screen pixel across modes; the cross-fade transition only animates the *contents* inside the bars + sidebar lists, not the bar/rail positions themselves. Co-Authored-By: Claude Opus 4.7 * feat(maic-editor): drop sidebar header + button, add insert-before-first zone The header `+` was a duplicate affordance — every gap between thumbs already has its own `InsertionZone`. Remove the header button; insert flows entirely through the gap zones now (with hover-popup + and right-anchored visual). Add one extra `InsertionZone` rendered BEFORE the first thumb so the top padding of the rail is also clickable / hoverable. Insert-before- first is implemented inline via `setScenes([blank, ...scenes])` because the `insertSceneAfter` store API only handles insertion after an existing anchor. `PlusCircle` import dropped (no longer used). Co-Authored-By: Claude Opus 4.7 * fix(maic-editor): move Download out of settings pill, place right of Pro Switch Download isn't a settings function (it's an export/share action), so it shouldn't sit inside the pill that hosts language/theme/settings. Move it back to a standalone button on the right side of the Pro Switch — both in playback and edit chrome. Right cluster now reads: [ EN · theme · settings ] [ PRO switch ] [ Download ] Co-Authored-By: Claude Opus 4.7 * feat(maic-editor): floating insert toolbar above canvas (collapsible) Text box / Image / future shape buttons no longer share CommandBar with global stage controls (back / undo / redo / title / settings / Pro Switch / Download). Insert is a content-creation action, not a stage-navigation one — mixing them blurred the chrome's role. Lift insert items into a new `FloatingInsertToolbar` that floats centered ~12px above the slide canvas card. Default expanded; collapse arrow tucks it into a small chevron handle at the same anchor. State persists in `settings.editInsertToolbarCollapsed`. Reuses the existing `InsertButton` (extracted from CommandBar into a sibling module so both surfaces — the now-removed CommandBar slot and the floating bar — can share styling). CommandBar drops its `insertItems` prop / middle slot entirely; right-side controls collapse to a single `flex shrink-0` cluster matching playback Header's shape. i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales. Co-Authored-By: Claude Opus 4.7 * fix(maic-editor): auto-focus text element after toolbar insert Inserting a Text box via the FloatingInsertToolbar + click/drag on the canvas left the user one click short — the new element was selected and the AnchoredTextBar opened, but the ProseMirror editor never received focus, so the first keystroke went nowhere and the user had to click inside the element again before typing. `useEditingTextElementId` already mirrors the surface's editing-target choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor` watch that flag in an effect: whenever its own elementId becomes the editing target (insert, programmatic selection, etc.) and it doesn't already have focus, push focus into the view. `hasFocus()` guard keeps this from re-focusing on every re-render of an already-active editor. Co-Authored-By: Claude Opus 4.7 * chore(maic-editor): prettier + drop ThumbItem rename sync effect - prettier --write on three files touched by the recent edits. - ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })` external-title sync that tripped `react-hooks/set-state-in-effect`. Idle display now reads from `scene.title` directly (derived rather than mirrored); `startRename` seeds `draft` at session start and `cancelRename` resets it so the next session starts clean. Rename e2e still passes the menu + double-click + Escape paths. Co-Authored-By: Claude Opus 4.7 * fix(maic-editor): CR-loop pass — pointer capture, stage-scoped recycle, equality docs PR #601 reviewer feedback. **Drag handle uses Pointer Events with `setPointerCapture`** so the rail no longer gets stuck in "still dragging" state when the cursor leaves the window, the OS reclaims focus, or a tab interrupt suppresses the mouseup that the old document-bound mousemove/mouseup pair relied on. The handle's onPointerMove/Up/Cancel are now bound directly on the element; capture guarantees event delivery for the lifetime of the gesture. Drag tracking e2e still PASS (1 px cursor lock). **Toast Undo guards stage identity** before re-inserting the deleted scene. If the user 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. New check drops the undo cleanly when stage ids don't match. The `stageId` field was already captured on RecycleEntry; just wasn't consulted. **`surfaceStateEqual` extended** to compare per-item `id` / `disabled` / `label` on `floatingActions` (was length-only) and per-item `id`/`severity`/`message` on `hints` (was length-only). Today's slide surface returns `floatingActions: []` and `hints: []` so this is dormant, but PR3b's z-order actions land in `floatingActions` — pinning the equality semantics now keeps a future state field from silently going stale in the chrome. SurfaceState gets a maintenance note cross-linking to the equality function. **Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard around the title block isn't dead — it covers the ~280ms AnimatePresence exit window where playback chrome is still rendering its exit animation while mode has flipped to 'edit'. Without the guard, this title would briefly stack on top of the incoming EditChromeRoot's CommandBar title during the cross-fade. Co-Authored-By: Claude Opus 4.7 * docs(maic-editor): CR-loop round 2 minors — sharpen JSDocs Round-2 reviewer flagged two doc-only refinements: - `surfaceStateEqual`: clarify that callback identity (`onInvoke`, `popoverContent`) is intentionally NOT compared, and that today's safety comes from slide-surface returning `floatingActions: []` rather than the per-item compare covering callbacks. A future surface that emits closure-capturing actions must fold its own change signal into the comparison or the stale callback fires at click time. - `setPointerCapture` catch: spell out that this is paranoia, not a real fallback — if capture genuinely fails the gesture still tracks for in-window moves but out-of-window `pointerup` won't route here. Acceptable degradation; the catch exists only because the spec permits an `InvalidPointerId` throw that browsers we ship to don't actually emit on same-pointer `pointerdown`. No functional changes. Co-Authored-By: Claude Opus 4.7 * fix(maic-editor): undo restore at index 0, reset mode on classroom load Two issues from PR #601 manual-verification review: **Undo of the first slide restored it as the second.** The toast undo handler clamps `entry.index - 1` to 0 then calls `insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at position 1 instead of position 0 — no scene exists before scenes[0] to anchor on. Fall back to `setScenes([entry.scene, ...live])` when `entry.index === 0` (or when the deck is empty). The store's existing non-rebalancing `deleteScene` keeps the surviving scenes at orders 2..N, so the prepended entry's original order=1 lines up naturally; StageGrid auto-selects the restored scene as current. **`mode` survived SPA navigation between classrooms.** Refresh reset mode to 'playback' via the initial store value, but switching classrooms via Next.js navigation kept the zustand singleton intact; entering Pro mode in A and then opening B left B in edit mode. `loadFromStorage` and the server-side classroom-load path both now set `mode: 'playback'` on every classroom load, normalising the SPA path to match the refresh path. Mode stays transient UI state, not persisted with the stage. e2e: delete Slide 1 → Undo → restored to position 1 (was position 2 before fix). Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- app/classroom/[id]/page.tsx | 5 + components/edit/EditChromeRoot.tsx | 63 + components/edit/EditShell/CommandBar.tsx | 129 +- components/edit/EditShell/EditShell.tsx | 284 +++- .../edit/EditShell/FloatingInsertToolbar.tsx | 96 ++ components/edit/EditShell/InsertButton.tsx | 58 + components/edit/PlaybackChromeRoot.tsx | 1340 ++++++++++++++++ .../edit/SlideNavRail/InsertionZone.tsx | 63 + components/edit/SlideNavRail/SlideNavRail.tsx | 512 ++++++ components/edit/SlideNavRail/ThumbItem.tsx | 283 ++++ components/edit/SlideNavRail/index.ts | 1 + components/edit/StageGrid.tsx | 72 + .../edit/surfaces/slide/SlideCanvas.tsx | 20 +- components/header.tsx | 282 +--- components/language-switcher.tsx | 87 +- .../components/ThumbnailSlide/index.tsx | 75 +- .../components/element/ProsemirrorEditor.tsx | 17 + components/stage.tsx | 1386 +---------------- components/stage/header-controls.tsx | 318 ++++ components/stage/scene-thumbnail-content.tsx | 177 +++ lib/edit/deleted-scene-recycle.ts | 41 + lib/edit/noop-surface.tsx | 81 + lib/edit/scene-editor-surface.ts | 15 +- lib/edit/slide-defaults.ts | 89 ++ lib/edit/transitions.ts | 29 + lib/i18n/locales/ar-SA.json | 18 + lib/i18n/locales/en-US.json | 18 + lib/i18n/locales/ja-JP.json | 18 + lib/i18n/locales/ru-RU.json | 18 + lib/i18n/locales/zh-CN.json | 18 + lib/i18n/locales/zh-TW.json | 18 + lib/store/settings.ts | 13 + lib/store/stage.ts | 30 + tests/edit/deleted-scene-recycle.test.ts | 71 + tests/edit/slide-defaults.test.ts | 127 ++ tests/store/stage-insert-scene-after.test.ts | 97 ++ 36 files changed, 4164 insertions(+), 1805 deletions(-) create mode 100644 components/edit/EditChromeRoot.tsx create mode 100644 components/edit/EditShell/FloatingInsertToolbar.tsx create mode 100644 components/edit/EditShell/InsertButton.tsx create mode 100644 components/edit/PlaybackChromeRoot.tsx create mode 100644 components/edit/SlideNavRail/InsertionZone.tsx create mode 100644 components/edit/SlideNavRail/SlideNavRail.tsx create mode 100644 components/edit/SlideNavRail/ThumbItem.tsx create mode 100644 components/edit/SlideNavRail/index.ts create mode 100644 components/edit/StageGrid.tsx create mode 100644 components/stage/header-controls.tsx create mode 100644 components/stage/scene-thumbnail-content.tsx create mode 100644 lib/edit/deleted-scene-recycle.ts create mode 100644 lib/edit/noop-surface.tsx create mode 100644 lib/edit/slide-defaults.ts create mode 100644 lib/edit/transitions.ts create mode 100644 tests/edit/deleted-scene-recycle.test.ts create mode 100644 tests/edit/slide-defaults.test.ts create mode 100644 tests/store/stage-insert-scene-after.test.ts diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 14a6f82e4b..6f0cc4ea43 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -49,6 +49,11 @@ export default function ClassroomDetailPage() { useStageStore.setState({ scenes, currentSceneId: scenes[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/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx new file mode 100644 index 0000000000..e49f6fd7cc --- /dev/null +++ b/components/edit/EditChromeRoot.tsx @@ -0,0 +1,63 @@ +'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 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; + }; + }, []); + + return ( + } + commandTrailing={ + + } + /> + ); +} diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx index 09c3751e73..5b2a4c7721 100644 --- a/components/edit/EditShell/CommandBar.tsx +++ b/components/edit/EditShell/CommandBar.tsx @@ -1,38 +1,49 @@ 'use client'; -import { Redo2, Undo2 } from 'lucide-react'; +import { ArrowLeft, Redo2, Undo2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/hooks/use-i18n'; import { cn } from '@/lib/utils'; -import type { - EditorCommand, - InsertPaletteItem, - SurfaceHistory, -} from '@/lib/edit/scene-editor-surface'; +import type { EditorCommand, SurfaceHistory } from '@/lib/edit/scene-editor-surface'; interface CommandBarProps { readonly title: string; readonly history?: SurfaceHistory; - readonly insertItems?: readonly InsertPaletteItem[]; 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 toggle in the playback Header (which stays - * mounted above this bar), not by a dedicated button here. + * 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, insertItems, commands }: CommandBarProps) { +export function CommandBar({ title, history, commands, trailing }: CommandBarProps) { const { t } = useI18n(); + const router = useRouter(); return ( -
+
+ {/* Back-to-home — mirrors playback Header's leftmost button so the + user has the same global-out affordance across modes. */} + router.push('/')}> + + {history && ( <> @@ -44,88 +55,34 @@ export function CommandBar({ title, history, insertItems, commands }: CommandBar )} {title}
- {insertItems && insertItems.length > 0 && ( -
- {insertItems.map((item) => ( - - ))} -
- )} - - {commands && commands.length > 0 && ( -
- {commands.map((command) => ( - - {command.icon ?? {command.label}} - - ))} -
- )} +
+ {commands && commands.length > 0 && ( +
+ {commands.map((command) => ( + + {command.icon ?? {command.label}} + + ))} +
+ )} + {trailing} +
); } -function InsertButton({ item }: { readonly item: InsertPaletteItem }) { - const button = ( - - ); - - const triggerWithTooltip = ( - - {button} - {item.tooltip && {item.tooltip}} - - ); - - if (!item.popoverContent) return triggerWithTooltip; - - // Chain both triggers' asChild Slots directly onto the real + + )} + +
+ ); +} 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 ` + ); + + 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/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..c77288639f --- /dev/null +++ b/components/edit/SlideNavRail/InsertionZone.tsx @@ -0,0 +1,63 @@ +'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..114d9e495f --- /dev/null +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -0,0 +1,512 @@ +'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 { 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 ( + + ); +} + +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 ( +
  1. + +
  2. + ); + })} +
+ ); +} diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx new file mode 100644 index 0000000000..56cbf33f98 --- /dev/null +++ b/components/edit/SlideNavRail/ThumbItem.tsx @@ -0,0 +1,283 @@ +'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 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()} + > + {t('edit.nav.rename')} + + {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/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx index aacdcb1d60..6768f0a392 100644 --- a/components/edit/surfaces/slide/SlideCanvas.tsx +++ b/components/edit/surfaces/slide/SlideCanvas.tsx @@ -44,22 +44,16 @@ export function SlideCanvas() { return () => document.removeEventListener('keydown', handler); }, []); - // Mark the body while the editor is mounted, so the editor-scoped CSS rule - // in globals.css that pins `body.padding-right` to 0 only fires here — not - // on non-editor pages where Radix's react-remove-scroll compensation is - // still wanted. - useEffect(() => { - document.body.dataset.maicEditor = 'true'; - return () => { - delete document.body.dataset.maicEditor; - }; - }, []); - 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). -
+ // (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/header.tsx b/components/header.tsx index 50dcf1ee0e..c718668f00 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,30 +1,10 @@ 'use client'; -import { - Settings, - Sun, - Moon, - Monitor, - ArrowLeft, - Loader2, - Download, - FileDown, - Package, - Archive, -} from 'lucide-react'; -import { Switch } from '@/components/ui/switch'; +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; @@ -35,48 +15,7 @@ interface 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 ( <> @@ -89,207 +28,32 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H > -
- - {t('stage.currentScene')} - -

- {currentSceneTitle || t('common.loading')} -

-
-
- -
- {/* Language Selector */} - setThemeOpen(false)} /> - - {/* Theme Selector */} -
- - {themeOpen && ( -
- - - -
- )} -
- - {/* Settings Button */} -
- -
-
- - {/* Pro Mode (edit) toggle — caps label + switch. Surfaces only the - two i18n strings already shipped in stage.editCourse/doneEditing - from #561; consumer code lives behind the optional onToggleEditMode - prop so embedders without an edit affordance render the same header. */} - {onToggleEditMode && ( - - )} - - {/* Export Dropdown */} -
- - {exportMenuOpen && ( -
- - - + {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 ( -
- - {open && ( -
- {supportedLocales.map((l) => ( - - ))} -
- )} -
+ { + if (open) onOpen?.(); + }} + > + + + + + {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/slide-renderer/components/ThumbnailSlide/index.tsx b/components/slide-renderer/components/ThumbnailSlide/index.tsx index 18442a9b33..0252fe3b43 100644 --- a/components/slide-renderer/components/ThumbnailSlide/index.tsx +++ b/components/slide-renderer/components/ThumbnailSlide/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import type { Slide } from '@/lib/types/slides'; import { useSlideBackgroundStyle } from '@/lib/hooks/use-slide-background-style'; import { ThumbnailElement } from './ThumbnailElement'; @@ -6,8 +6,15 @@ import { ThumbnailElement } from './ThumbnailElement'; interface ThumbnailSlideProps { /** Slide data */ readonly slide: Slide; - /** Thumbnail width */ - readonly size: number; + /** + * Thumbnail width. When omitted, the thumbnail self-measures and fills its + * parent's clientWidth via ResizeObserver. Use auto-size in any container + * that already constrains width via CSS (e.g. `aspect-video w-full`) — this + * removes the double-signal problem where a JS-computed size prop is + * recomputed every pointer-tick while a CSS-driven outer container + * concurrently reflows. + */ + readonly size?: number; /** Viewport width base (default 1000px) */ readonly viewportSize: number; /** Viewport aspect ratio (default 0.5625 i.e. 16:9) */ @@ -19,8 +26,17 @@ interface ThumbnailSlideProps { /** * Thumbnail slide component * - * Renders a thumbnail preview of a single slide - * Uses CSS transform scale to resize the entire view for better performance + * Renders a thumbnail preview of a single slide. Uses CSS transform scale to + * resize the entire view for better performance. + * + * Sizing modes: + * - **Explicit (`size` prop)**: outer card is sized to `size × size*ratio` px. + * Used by playback `SceneSidebar` and `app/page.tsx` outline preview. + * - **Auto (no `size` prop)**: outer card fills its parent (`w-full h-full`) + * and the internal scale is computed from `ResizeObserver(self.clientWidth)`. + * Used by the editor `SlideNavRail` ThumbItem, which sits inside an + * `aspect-video w-full` shell and must not depend on a JS-computed size + * that re-renders every pointer-tick during rail drag. */ export function ThumbnailSlide({ slide, @@ -29,21 +45,44 @@ export function ThumbnailSlide({ viewportRatio, visible = true, }: ThumbnailSlideProps) { - // Calculate scale ratio - const scale = useMemo(() => 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 ( -
+
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(); @@ -515,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 97b87dd051..eae4187596 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -1,280 +1,49 @@ '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 { isCurrentSceneEditable } from '@/lib/edit/stage-mode'; -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 { isMaicEditorEnabled } from '@/lib/config/feature-flags'; -import { CanvasArea } from '@/components/canvas/canvas-area'; -import { EditShell } from '@/components/edit/EditShell'; +import { EditChromeRoot } from '@/components/edit/EditChromeRoot'; +import { + 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'; // Side-effect: registers the slide SceneEditorSurface so EditShell can // resolve it the moment Pro mode is entered (the shell never imports // surfaces directly). import '@/components/edit/surfaces/slide'; -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'; /** - * Stage Component + * Stage — top-level classroom container. Dispatches between the two + * chrome roots based on `useStageStore.mode`: + * + * mode === 'edit' → EditChromeRoot + * mode === 'playback' / 'autonomous' → PlaybackChromeRoot * - * The main container for the classroom/course. - * Combines sidebar (scene navigation) and content area (scene viewer). - * Supports two modes: autonomous and playback. + * 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, - setMode, - getCurrentScene, - scenes, - currentSceneId, - setCurrentSceneId, - generatingOutlines, - outlines, - stage, - } = 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]); + const { mode, setMode, scenes, currentSceneId, generatingOutlines, stage } = useStageStore(); + const currentScene = useStageStore((s) => s.getCurrentScene()); - // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) - const handleStopDiscussion = useCallback(async () => { - await chatAreaRef.current?.endActiveSession(); - doSessionCleanup(); - }, [doSessionCleanup]); - - // Single source of truth for "is the current scene editable?" — feeds both - // the Pro toggle's disabled state in the Header and the auto-exit effect - // below. Using one predicate keeps the header and auto-exit in lock-step so - // we never offer a click that would immediately auto-exit. + // 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, sceneCount: scenes.length, @@ -282,1080 +51,91 @@ export function Stage({ hasCurrentScene: !!currentScene, }); - // Cross-tab edit lock (#571). Course identity is the stage id; degrades - // to single-tab when absent. Wired here because entry must be refused - // BEFORE the live session is torn down. + // 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); - // Toggle Pro (edit) mode. Entering edit mode tears down any live - // session so the user enters a quiet canvas. + const playbackRef = useRef(null); + + // 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; } - // Refuse entry (and surface the conflict prompt) when another tab - // already holds this course's edit lock — before any teardown. if (!editLock.acquire()) return; - await chatAreaRef.current?.endActiveSession(); - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - engineRef.current?.stop(); - discussionTTS.cleanup(); - resetSceneState(); + await playbackRef.current?.teardown(); setMode('edit'); - }, [discussionTTS, editLock, mode, resetSceneState, setMode]); + }, [editLock, mode, setMode]); - // Auto-exit edit mode whenever the current scene becomes uneditable - // (pending generation, no scenes, currently generating). Predicate lives in - // lib/edit/stage-mode so it can be unit-tested without rendering Stage. + // Auto-exit edit mode when the current scene becomes uneditable + // (pending generation, no scenes, currently generating). useEffect(() => { if (mode === 'edit' && !isEditable) { setMode('playback'); } }, [mode, isEditable, setMode]); - // Release the cross-tab lock whenever we are not in edit mode. Covers - // manual exit, the auto-exit above, and scene-becomes-uneditable; the - // hook also self-releases on unmount / tab close. + // 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(() => { if (mode !== 'edit') releaseEditLock(); }, [mode, releaseEditLock]); - 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; - - // 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)`; - })(); + const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined; + // Mode swap choreography — drawer feel. Edit chrome enters from above + // (translateY: -32 → 0) + fades in; playback chrome fades. Both layer + // via `absolute inset-0` so they coexist for the ~250ms cross-fade + // 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. return ( -
- {/* Sidebar — only rendered on the playback / autonomous path. The - Pro mode v0 leaves the canvas full-width; a future PR will plug a - redesigned slide navigator into EditShell's leftRail slot. */} - {mode !== 'edit' && ( - - )} - - {/* Main Content Area */} -
- {/* Header */} - {!isPresenting && ( -
- )} - - {/* Canvas Area — Pro mode replaces CanvasArea with EditShell so the - edit chrome (CommandBar + canvas + floating toolbar + hint rail) - takes the same slot. Header above stays mounted because it owns - the global Pro toggle: exiting Pro mode closes the Switch in - Header (no dedicated Done-editing button). */} -
- {mode === 'edit' && currentScene ? ( - - ) : ( - 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 - } +
+ + {mode === 'edit' && currentScene ? ( + + - )} - -
- - {/* 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 — only rendered on the playback / autonomous path. Pro (edit) - mode replaces CanvasArea with EditShell in the same slot; ChatArea - and the playback sidebar are simply not mounted in that state. */} - {mode !== 'edit' && ( -
- 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..c409cbcf49 --- /dev/null +++ b/components/stage/header-controls.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { motion } from 'motion/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 { CHROME_EASE } from '@/lib/edit/transitions'; +import type { StageMode } from '@/lib/types/stage'; + +// Stable layout IDs used by `motion.layoutId` so the Pro Switch and the +// settings pill morph between their positions in the playback Header and +// the edit-mode CommandBar (instead of "jumping" when the mode swap +// re-renders the trees). The transition is automatic — motion measures +// both instances and animates the shared element across the gap. +const PRO_SWITCH_LAYOUT_ID = 'maic-pro-switch'; +const SETTINGS_PILL_LAYOUT_ID = 'maic-settings-pill'; +const SHARED_LAYOUT_TRANSITION = { duration: 0.28, ease: CHROME_EASE } as const; + +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'; + + 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. */} + + + + + + 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 */} + + + + {/* 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.). + `layoutId` makes it a shared element between the playback + Header and the edit CommandBar — motion morphs its position + and size across the mode swap instead of letting the user + watch the click target jump. */} + {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. */} +
+ + {exportMenuOpen && ( +
+ + + +
+ )} +
+ + + + ); +} 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/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/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/scene-editor-surface.ts b/lib/edit/scene-editor-surface.ts index c4838e64da..0799d350be 100644 --- a/lib/edit/scene-editor-surface.ts +++ b/lib/edit/scene-editor-surface.ts @@ -90,13 +90,26 @@ export interface SurfaceHistory { 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; - history: SurfaceHistory; + /** + * 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[]; diff --git a/lib/edit/slide-defaults.ts b/lib/edit/slide-defaults.ts new file mode 100644 index 0000000000..92991276ef --- /dev/null +++ b/lib/edit/slide-defaults.ts @@ -0,0 +1,89 @@ +import { nanoid } from 'nanoid'; +import type { Slide, SlideTheme, PPTElement } from '@/lib/types/slides'; +import type { Scene, SlideContent } from '@/lib/types/stage'; +import { createElementId } from '@/lib/edit/element-id'; +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 so React keys + downstream + * selection state can't collide with the source slide. 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 clonedElements: PPTElement[] = sourceContent.canvas.elements.map((element) => ({ + ...element, + id: createElementId(element.type), + })); + + 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/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/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 96d75fd832..d2692cce9c 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -160,6 +160,7 @@ "delete": "حذف", "title": "تحرير · {{type}}", "unsupportedScene": "{{type}} غير قابل للتحرير بعد", + "readOnlyBadge": "{{type}} · للعرض فقط", "sceneType": { "slide": "شريحة", "quiz": "اختبار", @@ -191,11 +192,28 @@ }, "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": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 516510cc2f..b05eab3fc7 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -160,6 +160,7 @@ "delete": "Delete", "title": "Editing · {{type}}", "unsupportedScene": "{{type}} is not editable yet", + "readOnlyBadge": "{{type}} · view-only", "sceneType": { "slide": "Slide", "quiz": "Quiz", @@ -191,11 +192,28 @@ }, "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": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 471c25b193..5dcfeb9a7f 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -160,6 +160,7 @@ "delete": "削除", "title": "編集 · {{type}}", "unsupportedScene": "{{type}}はまだ編集できません", + "readOnlyBadge": "{{type}} · 表示のみ", "sceneType": { "slide": "スライド", "quiz": "クイズ", @@ -191,11 +192,28 @@ }, "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": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index a4637b24a3..1630829a2e 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -160,6 +160,7 @@ "delete": "Удалить", "title": "Редактирование · {{type}}", "unsupportedScene": "{{type}} пока нельзя редактировать", + "readOnlyBadge": "{{type}} · только просмотр", "sceneType": { "slide": "Слайд", "quiz": "Тест", @@ -191,11 +192,28 @@ }, "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": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 49af7163f4..8562c02c18 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -160,6 +160,7 @@ "delete": "删除", "title": "编辑 · {{type}}", "unsupportedScene": "{{type}}暂不支持编辑", + "readOnlyBadge": "{{type}} · 仅查看", "sceneType": { "slide": "幻灯片", "quiz": "测验", @@ -191,11 +192,28 @@ }, "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": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 046753a17c..fa3ca4ebfa 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -160,6 +160,7 @@ "delete": "刪除", "title": "編輯 · {{type}}", "unsupportedScene": "{{type}}暫不支援編輯", + "readOnlyBadge": "{{type}} · 僅檢視", "sceneType": { "slide": "投影片", "quiz": "測驗", @@ -191,11 +192,28 @@ }, "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": { 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 66bfd7b45e..ace4ab8ea8 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -71,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; @@ -159,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, @@ -307,6 +330,13 @@ const useStageStoreBase = create()((set, get) => ({ outlines, // Compute generatingOutlines from persisted outlines minus completed scenes generatingOutlines: outlines.filter((o) => !data.scenes.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/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/slide-defaults.test.ts b/tests/edit/slide-defaults.test.ts new file mode 100644 index 0000000000..34909efba6 --- /dev/null +++ b/tests/edit/slide-defaults.test.ts @@ -0,0 +1,127 @@ +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): PPTTextElement { + return { + type: 'text', + id, + left: 0, + top: 0, + width: 200, + height: 80, + rotate: 0, + defaultColor: '#000', + defaultFontName: 'Inter', + lineHeight: 1.2, + content: '

x

', + }; +} + +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('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/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'); + }); +}); From 732bdba052e2f75f653baec015970e98417f3891 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 29 May 2026 12:01:03 +0800 Subject: [PATCH 32/38] feat(maic-editor): gate slide scene creation until inserted scenes are playable (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maic-editor): gate slide scene creation until inserted scenes are playable Editor-created slide scenes (blank insert + duplicate) ship without playback actions, so the playback engine gives them zero dwell and skips straight past them — a freshly inserted slide is effectively unplayable. Seeding default actions on new scenes is a separate change; until then, hide the two scene-creation entry points so the editor stays coherent as an in-place "fine-tune the generated deck" tool. - add lib/edit/scene-creation-enabled.ts (SCENE_CREATION_ENABLED=false) - hide inter-thumb "+" insertion zones (SlideNavRail) - hide per-slide Duplicate menu item (ThumbItem) - keep reorder / delete / rename, which are playback-safe Re-enable by flipping the flag once new scenes get default actions. Co-Authored-By: Claude Opus 4.7 * test(maic-editor): e2e guard for slide scene-creation gate Adds an e2e that generates a classroom (mocked), enters Pro mode, and asserts the slide rail exposes no insertion "+" zones and the per-slide overflow menu has only Rename + Delete (no Duplicate). Fails if SCENE_CREATION_ENABLED is flipped back on without removing the gate. Two stable test ids support locale-independent assertions: - slide-nav-insert (InsertionZone button) - slide-nav-more (ThumbItem overflow trigger) Co-Authored-By: Claude Opus 4.7 * test(maic-editor): enable editor flag for the e2e webServer The scene-creation gate e2e needs the Pro Switch, which only renders when NEXT_PUBLIC_MAIC_EDITOR_ENABLED is on. It's a build-time NEXT_PUBLIC_* flag, so set it in the Playwright webServer env (applies to `pnpm build` in CI and `pnpm dev` locally). Fixes the e2e failure on CI. Co-Authored-By: Claude Opus 4.7 * test(maic-editor): attach gate screenshot to report instead of fixed path CR: e2e-artifacts/ is not gitignored, so writing the screenshot to a fixed path left an untracked file that could be committed by accident. Use testInfo.attach so the image lands in the (ignored) Playwright report. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .../edit/SlideNavRail/InsertionZone.tsx | 1 + components/edit/SlideNavRail/SlideNavRail.tsx | 13 ++-- components/edit/SlideNavRail/ThumbItem.tsx | 10 ++- e2e/tests/slide-scene-creation-gate.spec.ts | 63 +++++++++++++++++++ lib/edit/scene-creation-enabled.ts | 11 ++++ playwright.config.ts | 5 +- 6 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 e2e/tests/slide-scene-creation-gate.spec.ts create mode 100644 lib/edit/scene-creation-enabled.ts diff --git a/components/edit/SlideNavRail/InsertionZone.tsx b/components/edit/SlideNavRail/InsertionZone.tsx index c77288639f..1c20472c49 100644 --- a/components/edit/SlideNavRail/InsertionZone.tsx +++ b/components/edit/SlideNavRail/InsertionZone.tsx @@ -30,6 +30,7 @@ export function InsertionZone({ label, onInsert }: InsertionZoneProps) { onClick={onInsert} aria-label={label} title={label} + data-testid="slide-nav-insert" className="absolute inset-0 z-10 outline-none focus-visible:opacity-100" > {label} diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index 114d9e495f..39697cfb3e 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -11,6 +11,7 @@ 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'; @@ -441,7 +442,7 @@ export function SlideNavRail() { {/* Leading zone — hover the top padding to insert before the first thumb. Hits the `+ at top` use case the user called out. */} - {scenes[0] ? ( + {SCENE_CREATION_ENABLED && scenes[0] ? ( handleInsertBefore(scenes[0].id)} @@ -458,10 +459,12 @@ export function SlideNavRail() { onDuplicate={() => handleDuplicate(scene.id)} onDelete={() => handleDelete(scene.id)} /> - handleInsertAt(scene.id)} - /> + {SCENE_CREATION_ENABLED && ( + handleInsertAt(scene.id)} + /> + )} ))} diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx index 56cbf33f98..89da732cec 100644 --- a/components/edit/SlideNavRail/ThumbItem.tsx +++ b/components/edit/SlideNavRail/ThumbItem.tsx @@ -11,6 +11,7 @@ import { 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'; @@ -194,6 +195,7 @@ function ThumbItemComponent({ onClick={(e) => 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', @@ -211,9 +213,11 @@ function ThumbItemComponent({ onClick={(e) => e.stopPropagation()} > {t('edit.nav.rename')} - - {t('edit.nav.duplicate')} - + {SCENE_CREATION_ENABLED && ( + + {t('edit.nav.duplicate')} + + )} {t('edit.nav.delete')} 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/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/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' }, }, }); From 0374dae327f96e8b17760b225b65ba5508f1caa3 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 29 May 2026 05:32:51 -0400 Subject: [PATCH 33/38] i18n(maic-editor): add pt-BR translations for edit.* / stage.* keys pt-BR locale (added on main post-stack) lacked the 51 editor keys, so check:i18n-keys failed after rebase onto main. Co-Authored-By: Claude Opus 4.8 --- lib/i18n/locales/pt-BR.json | 67 ++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) 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", From f159af66cdfdd1d35350eed5ff4e816d60bddc80 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 29 May 2026 05:32:51 -0400 Subject: [PATCH 34/38] fix(maic-editor): defer editor-only side effects behind Pro mode mount editor-fonts (~23 @fontsource CSS tables) and slide-surface registration were top-level static imports in app/layout.tsx and components/stage.tsx, so flag-off classroom/playback users paid the font-face CSS + slide-edit module-init cost on every page load. Move both to a dynamic import in EditChromeRoot (mounts only when mode==='edit', which requires NEXT_PUBLIC_MAIC_EDITOR_ENABLED). Hold the EditShell render until the slide surface registers to avoid a NOOP/ read-only flash on first Pro mode paint. Co-Authored-By: Claude Opus 4.8 --- app/layout.tsx | 1 - components/edit/EditChromeRoot.tsx | 25 ++++++++++++++++++++++++- components/stage.tsx | 4 ---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index ec58f8e547..dd76d74924 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,6 @@ import { GeistMono } from 'geist/font/mono'; import './globals.css'; import 'animate.css'; import 'katex/dist/katex.min.css'; -import './editor-fonts'; import { ThemeProvider } from '@/lib/hooks/use-theme'; import { I18nProvider } from '@/lib/hooks/use-i18n'; import { Toaster } from '@/components/ui/sonner'; diff --git a/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx index e49f6fd7cc..9cc9bb54be 100644 --- a/components/edit/EditChromeRoot.tsx +++ b/components/edit/EditChromeRoot.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { EditShell } from '@/components/edit/EditShell'; import { SlideNavRail } from '@/components/edit/SlideNavRail'; import { HeaderControls } from '@/components/stage/header-controls'; @@ -47,6 +47,29 @@ export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChro }; }, []); + // Editor-only side effects are deferred to here so flag-off + // classroom/playback users never pay for them at module load: + // 1. `editor-fonts` injects ~23 @fontsource font-face tables that + // only the slide font picker needs (CSS side effect, fire-and-forget). + // 2. `surfaces/slide` registers the slide SceneEditorSurface into + // `sceneEditorRegistry`. EditShell resolves the registry and falls + // back to NOOP_SURFACE (read-only) for an unregistered type, so we + // hold the EditShell render until the slide surface has registered + // to avoid a NOOP/read-only flash on first paint of Pro mode. + const [surfaceReady, setSurfaceReady] = useState(false); + useEffect(() => { + let active = true; + void import('@/app/editor-fonts'); + void import('@/components/edit/surfaces/slide').then(() => { + if (active) setSurfaceReady(true); + }); + return () => { + active = false; + }; + }, []); + + if (!surfaceReady) return null; + return ( Date: Fri, 29 May 2026 06:29:17 -0400 Subject: [PATCH 35/38] fix(maic-editor): CR-loop correctness fixes (redo/groupId/edit-lock) Three confirmed issues from the rebase code-review pass: - slide-edit-session: a non-user (ResizeObserver auto-height) commit updated history.present while preserving a now-stale future, so a redo after undo silently resurrected pre-undo content. Clear future on the non-user path (present has diverged from the redo branch); past is left untouched so no spurious undo step is created. - slide-defaults: duplicateSlideScene reassigned element ids inline, leaving grouped elements pointing at the source slide's groupId. Use the existing createElementIdMap so clones get a new shared groupId. (Path is gated off today via SCENE_CREATION_ENABLED; fixes a latent defect.) - stage: wrap playback teardown on Pro-mode entry in try/catch and release the just-acquired cross-tab lock on failure, so a rejected teardown can't strand the lock with the UI stuck in playback. Adds regression tests for the redo-stale and groupId cases. Co-Authored-By: Claude Opus 4.8 --- .../edit/surfaces/slide/slide-edit-session.ts | 21 ++++++--- components/stage.tsx | 14 +++++- lib/edit/slide-defaults.ts | 11 +++-- tests/edit/slide-defaults.test.ts | 44 ++++++++++++++++++- tests/edit/slide-edit-session.test.ts | 41 +++++++++++++++++ 5 files changed, 118 insertions(+), 13 deletions(-) diff --git a/components/edit/surfaces/slide/slide-edit-session.ts b/components/edit/surfaces/slide/slide-edit-session.ts index 6df9d718a5..e4043f06dd 100644 --- a/components/edit/surfaces/slide/slide-edit-session.ts +++ b/components/edit/surfaces/slide/slide-edit-session.ts @@ -43,9 +43,12 @@ interface SlideEditSessionState { * 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, and crucially do - * NOT reset past/future (the reflow can fire right after a user resize, - * and wiping the stack would silently break undo/redo). + * 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; @@ -103,11 +106,15 @@ export const useSlideEditSession = create((set, 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/future - // would silently break undo), but DO write through — the auto-fit - // height IS the new canonical state. + // 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 } }); + set({ history: { ...history, present: next, future: [] } }); return; } replace(commitSlideEdit(history, next)); diff --git a/components/stage.tsx b/components/stage.tsx index 47c3db315b..65a5140f4b 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -64,7 +64,19 @@ export function Stage({ return; } if (!editLock.acquire()) return; - await playbackRef.current?.teardown(); + try { + await playbackRef.current?.teardown(); + } 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]); diff --git a/lib/edit/slide-defaults.ts b/lib/edit/slide-defaults.ts index 92991276ef..e2cafb332f 100644 --- a/lib/edit/slide-defaults.ts +++ b/lib/edit/slide-defaults.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid'; import type { Slide, SlideTheme, PPTElement } from '@/lib/types/slides'; import type { Scene, SlideContent } from '@/lib/types/stage'; -import { createElementId } from '@/lib/edit/element-id'; +import { createElementIdMap } from '@/lib/utils/element'; import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema'; const DEFAULT_THEME: SlideTheme = { @@ -48,8 +48,9 @@ export function createBlankSlideScene(stageId: string, title: string, order: num /** * Build a duplicate of an existing slide scene. Deep-clones the slide - * payload and reassigns every element id so React keys + downstream - * selection state can't collide with the source slide. The new scene + * 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`). */ @@ -58,9 +59,11 @@ export function duplicateSlideScene(source: Scene, copySuffix: string, order: nu 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: createElementId(element.type), + id: elIdMap[element.id], + ...(element.groupId ? { groupId: groupIdMap[element.groupId] } : {}), })); const clonedSlide: Slide = { diff --git a/tests/edit/slide-defaults.test.ts b/tests/edit/slide-defaults.test.ts index 34909efba6..7bb8183cde 100644 --- a/tests/edit/slide-defaults.test.ts +++ b/tests/edit/slide-defaults.test.ts @@ -4,7 +4,7 @@ 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): PPTTextElement { +function makeTextEl(id: string, groupId?: string): PPTTextElement { return { type: 'text', id, @@ -17,9 +17,32 @@ function makeTextEl(id: string): PPTTextElement { 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', @@ -113,6 +136,25 @@ describe('duplicateSlideScene', () => { 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', diff --git a/tests/edit/slide-edit-session.test.ts b/tests/edit/slide-edit-session.test.ts index c10aac315e..e040035f86 100644 --- a/tests/edit/slide-edit-session.test.ts +++ b/tests/edit/slide-edit-session.test.ts @@ -127,6 +127,47 @@ describe('useSlideEditSession (auto-save to stage store)', () => { 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({ From c9b3fd7c45d5876133af59281eb6e5b7f6293c16 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 29 May 2026 06:29:17 -0400 Subject: [PATCH 36/38] fix(maic-editor): scope editor list-marker CSS to Pro mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .editable-element-text ul/ol/li rules used a bare selector, but that class is the playback text wrapper rendered for every classroom user — so the !important list-style overrides leaked into normal playback. Scope them to body[data-maic-editor='true'] (set only while Pro mode is mounted) so flag-off playback rendering stays unchanged; markers still show while editing. Co-Authored-By: Claude Opus 4.8 --- app/globals.css | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/globals.css b/app/globals.css index 520a5c699f..b9499f3ad4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -162,21 +162,29 @@ `
    `/`
      `. The text element's `bulletList` / `orderedList` commands genuinely wrap content in `
      • ` / `
        1. `, but without those resets undone no marker would be visible. `!important` defeats any - layered preflight specificity we'd otherwise have to chase. Dual - selector covers both the playback wrapper (`.editable-element-text`, - present in edit mode too) and the editor wrapper (`.prosemirror-editor`) - in case the markup ever nests differently. */ -.editable-element-text ul, + 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; } -.editable-element-text ol, +body[data-maic-editor='true'] .editable-element-text ol, .prosemirror-editor ol { list-style: decimal outside !important; padding-inline-start: 1.5rem !important; } -.editable-element-text li, +body[data-maic-editor='true'] .editable-element-text li, .prosemirror-editor li { display: list-item !important; } From 050f7348fdb2e66afbaa0e798cefec4a10318827 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 30 May 2026 00:59:09 -0400 Subject: [PATCH 37/38] fix(maic-editor): smooth Pro mode transition and stabilize header controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pro mode enter/exit animation janked and the right-side header controls (settings pill + Pro Switch) drifted in width/position across the swap. Three causes, all addressed: - The flag-gating dynamic import gated EditShell behind surfaceReady, so the chrome animated in empty and content popped in once the slide-surface chunk loaded. Preload the editor chunk (fonts + surface registration) in the Pro Switch handler BEFORE flipping mode (lib/edit/preload-editor.ts), and drop the render gate — content is present when the animation starts. - Mode-swap layers and EditShell chrome layers used translateY/translateX slides; with backdrop-blur on the rail and pills that forced a per-frame backdrop-filter recompute (dropped frames) and, as transform ancestors, distorted the layoutId measurement. Switched all chrome enter animations to pure opacity fades. - HeaderControls rendered a fragment whose children were spaced by the host's flex gap (Header gap-4 vs CommandBar trailing gap-2), so the control cluster changed width/anchor between modes. Wrapped it in a self-contained gap-4 container and dropped the cross-bar layoutId morph so the cluster is pixel-stable across the swap. Co-Authored-By: Claude Opus 4.8 --- components/edit/EditChromeRoot.tsx | 31 ++++++------------ components/edit/EditShell/EditShell.tsx | 19 +++++++---- components/stage.tsx | 33 +++++++++++++------ components/stage/header-controls.tsx | 43 ++++++++++--------------- lib/edit/preload-editor.ts | 27 ++++++++++++++++ 5 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 lib/edit/preload-editor.ts diff --git a/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx index 9cc9bb54be..33009d466b 100644 --- a/components/edit/EditChromeRoot.tsx +++ b/components/edit/EditChromeRoot.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +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 { @@ -47,29 +48,17 @@ export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChro }; }, []); - // Editor-only side effects are deferred to here so flag-off - // classroom/playback users never pay for them at module load: - // 1. `editor-fonts` injects ~23 @fontsource font-face tables that - // only the slide font picker needs (CSS side effect, fire-and-forget). - // 2. `surfaces/slide` registers the slide SceneEditorSurface into - // `sceneEditorRegistry`. EditShell resolves the registry and falls - // back to NOOP_SURFACE (read-only) for an unregistered type, so we - // hold the EditShell render until the slide surface has registered - // to avoid a NOOP/read-only flash on first paint of Pro mode. - const [surfaceReady, setSurfaceReady] = useState(false); + // 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(() => { - let active = true; - void import('@/app/editor-fonts'); - void import('@/components/edit/surfaces/slide').then(() => { - if (active) setSurfaceReady(true); - }); - return () => { - active = false; - }; + void preloadEditor(); }, []); - if (!surfaceReady) return null; - return ( {mode === 'edit' && currentScene ? ( diff --git a/components/stage/header-controls.tsx b/components/stage/header-controls.tsx index c409cbcf49..0d7b234ec8 100644 --- a/components/stage/header-controls.tsx +++ b/components/stage/header-controls.tsx @@ -1,7 +1,6 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { motion } from 'motion/react'; import { Archive, Download, @@ -29,18 +28,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; -import { CHROME_EASE } from '@/lib/edit/transitions'; import type { StageMode } from '@/lib/types/stage'; -// Stable layout IDs used by `motion.layoutId` so the Pro Switch and the -// settings pill morph between their positions in the playback Header and -// the edit-mode CommandBar (instead of "jumping" when the mode swap -// re-renders the trees). The transition is automatic — motion measures -// both instances and animates the shared element across the gap. -const PRO_SWITCH_LAYOUT_ID = 'maic-pro-switch'; -const SETTINGS_PILL_LAYOUT_ID = 'maic-settings-pill'; -const SHARED_LAYOUT_TRANSITION = { duration: 0.28, ease: CHROME_EASE } as const; - interface HeaderControlsProps { readonly mode?: StageMode; readonly canEdit?: boolean; @@ -113,11 +102,16 @@ export function HeaderControls({ 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 ( - <> - +
          - +
          {/* 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.). - `layoutId` makes it a shared element between the playback - Header and the edit CommandBar — motion morphs its position - and size across the mode swap instead of letting the user - watch the click target jump. */} + 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 && ( - - + )} {/* Export / Download — lives to the right of the Pro Switch. @@ -313,6 +304,6 @@ export function HeaderControls({
- +
); } 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; +} From 7dfd97b4e2e664dab5268a07dba119d4245fa1b2 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 30 May 2026 12:41:39 -0400 Subject: [PATCH 38/38] =?UTF-8?q?fix(maic-editor):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20image=20insert=20race,=20scene=20migration,=20i18n?= =?UTF-8?q?=20staleness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from @cosarah on the integration PR: - Image insert resolved size via Image.onload then applied the op to whatever slide session was current at callback time, so switching slides before the image loaded inserted it into the wrong slide. Bind the insert to the scene active at click time and drop the op if the session changed before onload fires. - Classroom scenes loaded from IndexedDB (loadFromStorage) and from the server API (classroom page) bypassed migrateScene, so legacy slide content was not normalized with schemaVersion. Both load paths now migrate on the way in, matching setScenes/addScene. - surfaceStateEqual compared insert-item/command id/active/disabled but not label/tooltip, so the Pro-mode insert toolbar text stayed stale after a language switch. Compare label/tooltip too. Co-Authored-By: Claude Opus 4.8 --- app/classroom/[id]/page.tsx | 10 ++++++++-- components/edit/EditShell/EditShell.tsx | 6 ++++++ components/edit/surfaces/slide/use-slide-surface.ts | 7 +++++++ lib/store/stage.ts | 8 ++++++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 6f0cc4ea43..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,13 @@ 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 diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index fa5936d920..9bc4d45b1c 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -175,11 +175,17 @@ function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean { 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++) { diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 2254d97bf8..51b1780704 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -69,7 +69,14 @@ const IMAGE_MAX_H = 400; */ 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 }); diff --git a/lib/store/stage.ts b/lib/store/stage.ts index ace4ab8ea8..edff91eb71 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -322,14 +322,18 @@ 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