Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
63 changes: 63 additions & 0 deletions components/edit/EditChromeRoot.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EditShell
scene={scene}
leftRail={<SlideNavRail />}
commandTrailing={
<HeaderControls
mode="edit"
canEdit={isEditable}
onToggleEditMode={isMaicEditorEnabled() ? onToggleEditMode : undefined}
/>
}
/>
);
}
129 changes: 43 additions & 86 deletions components/edit/EditShell/CommandBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-zinc-200/60 px-5 dark:border-zinc-800/60">
<header className="flex h-20 shrink-0 items-center gap-3 border-b border-zinc-200/60 px-8 dark:border-zinc-800/60">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Back-to-home — mirrors playback Header's leftmost button so the
user has the same global-out affordance across modes. */}
<IconButton title={t('generation.backToHome')} onClick={() => router.push('/')}>
<ArrowLeft className="h-4 w-4" />
</IconButton>
{history && (
<>
<IconButton title={t('edit.undo')} disabled={!history.canUndo} onClick={history.undo}>
Expand All @@ -44,88 +55,34 @@ export function CommandBar({ title, history, insertItems, commands }: CommandBar
</>
)}
<span
className={cn(
'truncate text-sm font-medium text-zinc-700 dark:text-zinc-300',
history && 'ml-2',
)}
className={cn('ml-2 truncate text-sm font-semibold text-zinc-700 dark:text-zinc-200')}
title={title}
>
{title}
</span>
</div>

{insertItems && insertItems.length > 0 && (
<div className="flex shrink-0 items-center gap-1">
{insertItems.map((item) => (
<InsertButton key={item.id} item={item} />
))}
</div>
)}

{commands && commands.length > 0 && (
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
{commands.map((command) => (
<IconButton
key={command.id}
title={command.tooltip ?? command.label}
disabled={command.disabled}
onClick={command.onInvoke}
>
{command.icon ?? <span className="px-1 text-xs">{command.label}</span>}
</IconButton>
))}
</div>
)}
<div className="flex shrink-0 items-center gap-2">
{commands && commands.length > 0 && (
<div className="flex shrink-0 items-center gap-1">
{commands.map((command) => (
<IconButton
key={command.id}
title={command.tooltip ?? command.label}
disabled={command.disabled}
onClick={command.onInvoke}
>
{command.icon ?? <span className="px-1 text-xs">{command.label}</span>}
</IconButton>
))}
</div>
)}
{trailing}
</div>
</header>
);
}

function InsertButton({ item }: { readonly item: InsertPaletteItem }) {
const button = (
<button
type="button"
disabled={item.disabled}
onClick={item.popoverContent ? undefined : item.onInvoke}
className={`group flex h-9 items-center gap-1.5 rounded-xl px-3 transition-colors disabled:pointer-events-none disabled:opacity-40 ${
item.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'
}`}
>
<span className="flex h-4 w-4 items-center justify-center [&>svg]:h-4 [&>svg]:w-4">
{item.icon}
</span>
<span className="text-xs font-medium">{item.label}</span>
</button>
);

const triggerWithTooltip = (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
{item.tooltip && <TooltipContent>{item.tooltip}</TooltipContent>}
</Tooltip>
);

if (!item.popoverContent) return triggerWithTooltip;

// Chain both triggers' asChild Slots directly onto the real <button>.
// Wrapping PopoverTrigger around <Tooltip> (a provider, not a DOM node)
// dropped the popover trigger handler, so the popover never opened —
// mirrors the PR1 fix in FloatingToolbar's ActionButton.
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>{button}</PopoverTrigger>
</TooltipTrigger>
{item.tooltip && <TooltipContent>{item.tooltip}</TooltipContent>}
</Tooltip>
<PopoverContent side="bottom" align="center" className="w-80 p-3">
{item.popoverContent()}
</PopoverContent>
</Popover>
);
}

function IconButton({
title,
children,
Expand Down
Loading
Loading