chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { SceneEditorSurface, SurfaceState } from '@/lib/edit/scene-editor-surface';
|
||||
import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry';
|
||||
import { NOOP_SURFACE } from '@/lib/edit/noop-surface';
|
||||
import type { Scene } from '@/lib/types/stage';
|
||||
import { CHROME_DURATION, CHROME_EASE, CHROME_STAGGER } from '@/lib/edit/transitions';
|
||||
import { StageGrid } from '@/components/edit/StageGrid';
|
||||
import { CommandBar } from './CommandBar';
|
||||
import { FloatingInsertToolbar } from './FloatingInsertToolbar';
|
||||
import { FloatingToolbar } from './FloatingToolbar';
|
||||
import { HintRail } from './HintRail';
|
||||
|
||||
interface EditShellProps {
|
||||
readonly scene: Scene;
|
||||
/**
|
||||
* Optional left-side navigator slot. In v0 this is the SlideNavRail
|
||||
* passed from Stage when mode === 'edit'. Surface code never imports
|
||||
* the rail (the prop is the only handoff seam), keeping chrome and
|
||||
* surface separable.
|
||||
*/
|
||||
readonly leftRail?: ReactNode;
|
||||
/**
|
||||
* Right-edge slot of the CommandBar — Stage uses this to hand in the
|
||||
* global controls (settings pill + Pro Switch) when the Stage Header
|
||||
* is hidden, so the entire top chrome reduces to a single bar.
|
||||
*/
|
||||
readonly commandTrailing?: ReactNode;
|
||||
/**
|
||||
* Optional right-side panel slot. Used by the MAIC Agent PoC to mount the
|
||||
* AI sidebar. Like `leftRail`, it is a pure chrome handoff — surface code
|
||||
* never imports it. Collapses to zero width when absent.
|
||||
*/
|
||||
readonly rightRail?: ReactNode;
|
||||
/** Optional bottom bar (under the canvas) — used for the actions timeline. */
|
||||
readonly bottomRail?: ReactNode;
|
||||
}
|
||||
|
||||
const CHROME_TRANSITION = { duration: CHROME_DURATION, ease: CHROME_EASE } as const;
|
||||
const COMMANDBAR_DELAY = CHROME_STAGGER;
|
||||
const LEFT_RAIL_DELAY = CHROME_STAGGER * 2;
|
||||
|
||||
/**
|
||||
* Pro mode (edit) chrome — mounts inside the canvas slot of Stage, replacing
|
||||
* CanvasArea. The playback Header above stays mounted because it owns the
|
||||
* global Pro toggle Switch: exiting Pro mode is done by flipping that Switch
|
||||
* off, not by a dedicated button here.
|
||||
*
|
||||
* ┌──────────────────────────────────────────────┐ (Stage Header above)
|
||||
* ├──────────────────────────────────────────────┤
|
||||
* │ CommandBar (undo/redo · title · insert · │
|
||||
* │ surface commands) │
|
||||
* ├──────────┬───────────────────────────────────┤
|
||||
* │ leftRail │ Canvas / unsupported-scene │
|
||||
* │ (opt) │ FloatingToolbar (when selected) │
|
||||
* │ │ HintRail (AI, reserved) │
|
||||
* └──────────┴───────────────────────────────────┘
|
||||
*
|
||||
* Mount choreography: CommandBar drops in from top, leftRail slides in
|
||||
* from left after a stagger, content opacity-fades in. All three share
|
||||
* the single `CHROME_*` source in `lib/edit/transitions.ts` so timing
|
||||
* stays consistent with the outer Stage-level cross-fade.
|
||||
*
|
||||
* Architecture: this shell resolves `scene.type` to a registered surface
|
||||
* (or falls back to NOOP_SURFACE for unregistered types) and **never
|
||||
* branches into a different component type**. The same `<Frame>` mounts
|
||||
* across every scene-type change — only the `surface.SurfaceComponent`
|
||||
* inside the center slot swaps. That guarantees CommandBar and `leftRail`
|
||||
* never remount during scene navigation, removing the chrome flicker that
|
||||
* the previous two-branch design caused (PR3a rearch).
|
||||
*/
|
||||
export function EditShell({
|
||||
scene,
|
||||
leftRail,
|
||||
commandTrailing,
|
||||
rightRail,
|
||||
bottomRail,
|
||||
}: EditShellProps) {
|
||||
const surface = sceneEditorRegistry.resolve(scene.type) ?? NOOP_SURFACE;
|
||||
// Surface state is published from a child runner (keyed by sceneType so it
|
||||
// remounts when the surface identity changes — that's the boundary at which
|
||||
// rules-of-hooks naturally allows a different hook signature). The chrome
|
||||
// around it stays mounted and consumes state via these props.
|
||||
const [state, setState] = useState<SurfaceState | null>(null);
|
||||
const SurfaceComponent = surface.SurfaceComponent;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* `key={scene.type}` is the remount boundary. We can't use
|
||||
`surface.sceneType` here because NOOP_SURFACE deliberately reuses
|
||||
'slide' as a placeholder (the SceneType union is closed and NOOP
|
||||
isn't a real type). The scene's own `type` is the actual signal
|
||||
that the hook signature inside `useSurfaceState` is about to
|
||||
change — so we remount the runner exactly when it does, keeping
|
||||
rules-of-hooks happy across the slide ↔ read-only surface swap
|
||||
while the rest of the chrome stays mounted. */}
|
||||
<SurfaceStateRunner key={scene.type} surface={surface} onChange={setState} />
|
||||
<Frame
|
||||
title={scene.title}
|
||||
leftRail={leftRail}
|
||||
history={state?.history}
|
||||
commands={state?.commands}
|
||||
trailing={commandTrailing}
|
||||
rightRail={rightRail}
|
||||
bottomRail={bottomRail}
|
||||
>
|
||||
<SurfaceComponent />
|
||||
{state?.insertItems && state.insertItems.length > 0 && (
|
||||
<FloatingInsertToolbar items={state.insertItems} />
|
||||
)}
|
||||
{state?.hasSelection && <FloatingToolbar actions={state.floatingActions} />}
|
||||
<HintRail hints={state?.hints} />
|
||||
</Frame>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden runner that owns the surface state hook. `key={surface.sceneType}`
|
||||
* ensures it remounts when the surface itself changes (slide → noop) — the
|
||||
* only point at which the hook call signature can vary. Within a single mount
|
||||
* the hook signature is fixed (the surface object is constant), so React's
|
||||
* rules-of-hooks are respected.
|
||||
*
|
||||
* Renders no DOM; state flows up to the chrome via `onChange`. A custom
|
||||
* shallow comparison gates the publish — surface hooks (e.g. slideSurface's
|
||||
* `useSlideSurfaceState`) return a fresh object literal every render, so naive
|
||||
* reference equality would loop infinitely (every publish causes the parent to
|
||||
* re-render, which re-runs this hook, which yields a new ref, which publishes
|
||||
* again, etc.). We only publish when one of the fields the chrome actually
|
||||
* reads has materially changed.
|
||||
*/
|
||||
function SurfaceStateRunner({
|
||||
surface,
|
||||
onChange,
|
||||
}: {
|
||||
readonly surface: SceneEditorSurface;
|
||||
readonly onChange: (state: SurfaceState) => void;
|
||||
}) {
|
||||
const state = surface.useSurfaceState();
|
||||
const lastRef = useRef<SurfaceState | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (surfaceStateEqual(state, lastRef.current)) return;
|
||||
lastRef.current = state;
|
||||
onChange(state);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-by-field equality for the subset of SurfaceState that the chrome
|
||||
* reads. Surface hooks (e.g. `useSlideSurfaceState`) return a fresh object
|
||||
* literal every render, so naive reference equality would publish on
|
||||
* every render and trip an infinite render loop via the runner →
|
||||
* setState → re-render cycle. We compare semantic content instead.
|
||||
*
|
||||
* **When you extend `SurfaceState`** (new field on `EditorCommand`,
|
||||
* `InsertPaletteItem`, `FloatingAction`, `EditorHint`, or a new top-level
|
||||
* field), update this function in lock-step. A field that's read by the
|
||||
* chrome but missing from the comparison silently goes stale.
|
||||
*
|
||||
* - `content` is reference-equal as long as the in-memory slide buffer
|
||||
* hasn't been committed — the canonical "real change" signal.
|
||||
* - History flags compared individually (functions like undo/redo are
|
||||
* stable references via `useSlideEditSession.getState()`).
|
||||
* - InsertPaletteItem / EditorCommand / FloatingAction arrays: length +
|
||||
* per-item `id` + per-item flags that drive visual state.
|
||||
* - EditorHint compared by length + per-item severity/message.
|
||||
*
|
||||
* **Callback identity (`onInvoke`, `popoverContent`) is intentionally
|
||||
* NOT compared.** Per-render closure rebinding is normal and would
|
||||
* trip equality every render. Today this is safe because slide is the
|
||||
* only registered surface and its `floatingActions` is `[]` — the only
|
||||
* `onInvoke` set the chrome reads is on `InsertPaletteItem`, which is
|
||||
* a stable module-level closure from `buildInsertItems`. A future
|
||||
* surface that returns non-empty `floatingActions` with closures
|
||||
* capturing per-render state must fold its own change signal (a
|
||||
* content ref / version counter) into the comparison, otherwise the
|
||||
* stale callback fires at click time.
|
||||
*/
|
||||
function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean {
|
||||
if (!b) return false;
|
||||
if (a.content !== b.content) return false;
|
||||
if (a.hasSelection !== b.hasSelection) return false;
|
||||
if ((a.history?.canUndo ?? null) !== (b.history?.canUndo ?? null)) return false;
|
||||
if ((a.history?.canRedo ?? null) !== (b.history?.canRedo ?? null)) return false;
|
||||
if (a.insertItems.length !== b.insertItems.length) return false;
|
||||
for (let i = 0; i < a.insertItems.length; i++) {
|
||||
if (a.insertItems[i].id !== b.insertItems[i].id) return false;
|
||||
if (a.insertItems[i].active !== b.insertItems[i].active) return false;
|
||||
if (a.insertItems[i].disabled !== b.insertItems[i].disabled) return false;
|
||||
// Label/tooltip are user-facing and locale-dependent: without them the
|
||||
// insert toolbar text stays stale after a language switch.
|
||||
if (a.insertItems[i].label !== b.insertItems[i].label) return false;
|
||||
if (a.insertItems[i].tooltip !== b.insertItems[i].tooltip) return false;
|
||||
}
|
||||
if (a.commands.length !== b.commands.length) return false;
|
||||
for (let i = 0; i < a.commands.length; i++) {
|
||||
if (a.commands[i].id !== b.commands[i].id) return false;
|
||||
if (a.commands[i].disabled !== b.commands[i].disabled) return false;
|
||||
if (a.commands[i].label !== b.commands[i].label) return false;
|
||||
if (a.commands[i].tooltip !== b.commands[i].tooltip) return false;
|
||||
}
|
||||
if (a.floatingActions.length !== b.floatingActions.length) return false;
|
||||
for (let i = 0; i < a.floatingActions.length; i++) {
|
||||
if (a.floatingActions[i].id !== b.floatingActions[i].id) return false;
|
||||
if (a.floatingActions[i].disabled !== b.floatingActions[i].disabled) return false;
|
||||
if (a.floatingActions[i].label !== b.floatingActions[i].label) return false;
|
||||
}
|
||||
const aHints = a.hints ?? [];
|
||||
const bHints = b.hints ?? [];
|
||||
if (aHints.length !== bHints.length) return false;
|
||||
for (let i = 0; i < aHints.length; i++) {
|
||||
if (aHints[i].id !== bHints[i].id) return false;
|
||||
if (aHints[i].severity !== bHints[i].severity) return false;
|
||||
if (aHints[i].message !== bHints[i].message) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface FrameProps {
|
||||
readonly title: string;
|
||||
readonly leftRail?: ReactNode;
|
||||
readonly history?: React.ComponentProps<typeof CommandBar>['history'];
|
||||
readonly commands?: React.ComponentProps<typeof CommandBar>['commands'];
|
||||
readonly trailing?: ReactNode;
|
||||
readonly rightRail?: ReactNode;
|
||||
readonly bottomRail?: ReactNode;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function Frame({
|
||||
title,
|
||||
leftRail,
|
||||
history,
|
||||
commands,
|
||||
trailing,
|
||||
rightRail,
|
||||
bottomRail,
|
||||
children,
|
||||
}: FrameProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
// Chrome layers fade in (opacity only) — deliberately NO transform (x/y)
|
||||
// slide. Two reasons: (1) the CommandBar's trailing slot and the rail host
|
||||
// the Pro Switch / settings pill, which animate across the mode swap via
|
||||
// `layoutId`; a transform on an ancestor distorts motion's layout
|
||||
// measurement and makes the shared element drift. (2) the rail
|
||||
// (`backdrop-blur-xl`) and the pills (`backdrop-blur-md`) would force a
|
||||
// per-frame backdrop-filter recompute while transforming, which drops
|
||||
// frames. A pure opacity fade composites cleanly and keeps the layout
|
||||
// static so layoutId morphs land precisely.
|
||||
const cmdInitial = { opacity: 0 };
|
||||
const cmdAnimate = { opacity: 1 };
|
||||
const railInitial = { opacity: 0 };
|
||||
const railAnimate = { opacity: 1 };
|
||||
|
||||
const stepTransition = prefersReducedMotion
|
||||
? { duration: 0.12, ease: CHROME_EASE }
|
||||
: CHROME_TRANSITION;
|
||||
|
||||
return (
|
||||
<StageGrid
|
||||
className="bg-gradient-to-b from-zinc-100 to-zinc-200 dark:from-zinc-950 dark:to-zinc-900"
|
||||
topSlot={
|
||||
<motion.div
|
||||
initial={cmdInitial}
|
||||
animate={cmdAnimate}
|
||||
transition={{ ...stepTransition, delay: prefersReducedMotion ? 0 : COMMANDBAR_DELAY }}
|
||||
>
|
||||
<CommandBar title={title} history={history} commands={commands} trailing={trailing} />
|
||||
</motion.div>
|
||||
}
|
||||
leftSlot={
|
||||
leftRail ? (
|
||||
<motion.div
|
||||
initial={railInitial}
|
||||
animate={railAnimate}
|
||||
transition={{ ...stepTransition, delay: prefersReducedMotion ? 0 : LEFT_RAIL_DELAY }}
|
||||
className="h-full shrink-0"
|
||||
>
|
||||
{leftRail}
|
||||
</motion.div>
|
||||
) : null
|
||||
}
|
||||
centerSlot={
|
||||
// Padded studio frame around the actual scene renderer. Lifted
|
||||
// up from SlideCanvas so the slide and the non-slide read-only
|
||||
// renderers share the exact same canvas bounding rect (no
|
||||
// layout jump when switching scene type). Children render
|
||||
// inside an inner ring/shadow card that the playback
|
||||
// CanvasArea visually mirrors.
|
||||
<div className="relative h-full w-full p-3 sm:p-4">
|
||||
<div className="relative h-full w-full overflow-hidden rounded-xl bg-white ring-1 ring-zinc-200/80 dark:bg-zinc-900 dark:ring-zinc-800/80 shadow-[0_10px_40px_-12px_rgba(15,23,42,0.18)] dark:shadow-[0_10px_40px_-12px_rgba(0,0,0,0.6)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightSlot={rightRail ? <div className="h-full shrink-0">{rightRail}</div> : null}
|
||||
bottomSlot={bottomRail ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user