Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e1064f7
feat(maic-editor): framework primitives + edit StageMode (#564)
wyuc May 13, 2026
512f63b
feat(maic-editor): EditShell chrome and Pro mode toggle (#565)
wyuc May 13, 2026
c8664d2
refactor(maic-editor): drop EditModeSidebar; clean Pro mode chrome (#…
wyuc May 13, 2026
077fee1
feat(maic-editor): enablement infrastructure (pre-slide-surface) (#571)
wyuc May 17, 2026
cdb15dd
feat(maic-editor): slide surface skeleton + #571 wiring + geometry (#…
wyuc May 17, 2026
9baea34
test(maic-editor): lock data-URL image PPTX round-trip (PR2 R1 gate)
wyuc May 17, 2026
474890a
feat(maic-editor): insert palette — text box + image (data-URL/URL)
wyuc May 17, 2026
a83da02
refactor(maic-editor): address Task 1 review — spy cleanup, popover-o…
wyuc May 17, 2026
f268056
refactor(maic-editor): drop PR1 debug geometry toolbar; element-aware…
wyuc May 17, 2026
35a4d52
refactor(maic-editor): drop redundant PPTTextElement cast (Task 2 rev…
wyuc May 17, 2026
022433a
feat(maic-editor): additive ProseMirror command bridge for the proper…
wyuc May 17, 2026
7d91486
refactor(maic-editor): exhaustiveness guard + tidy C1 adapter (Task 3…
wyuc May 17, 2026
7d00209
feat(maic-editor): refresh property-bar attrs on caret/keyboard selec…
wyuc May 17, 2026
747ee8a
test(maic-editor): satisfy no-explicit-any in PR2 test stubs (Task 1+…
wyuc May 17, 2026
12fb9a0
feat(maic-editor): compact text property bar in the reused floating slot
wyuc May 17, 2026
4b6cc02
refactor(maic-editor): Task 5 review — uniform selection-guard, Lucid…
wyuc May 17, 2026
2f1f803
i18n(maic-editor): edit.text.* + edit.insert.* across 6 locales
wyuc May 17, 2026
d4e87b1
test(maic-editor): round-trip gate for formatted text + inserts
wyuc May 17, 2026
d846cd6
docs(maic-editor): clarify remote-URL image round-trip scope (Task 7 …
wyuc May 17, 2026
39338b4
chore(maic-editor): drop stale scaffolding comment + orphaned geometr…
wyuc May 17, 2026
41d2729
style(maic-editor): prettier --write PR2 files (pre-push check)
wyuc May 17, 2026
4237c02
test(maic-editor): make no-explicit-any suppression prettier-robust
wyuc May 17, 2026
08f2678
fix(maic-editor): CommandBar insert popover never opened (PopoverTrig…
wyuc May 19, 2026
9450fd3
fix(maic-editor): text property bar no longer clips/overflows in the …
wyuc May 19, 2026
0cef8ca
fix(maic-editor): property bar stays open across consecutive formatti…
wyuc May 19, 2026
12b0640
fix(maic-editor): editor canvas now resolves gen_img_* media placehol…
wyuc May 21, 2026
4842d46
test(maic-editor): unit-test gen_img placeholder resolution (9 cases)
wyuc May 21, 2026
106a976
refactor(maic-editor): auto-save edits to stage store, drop staging UX
wyuc May 21, 2026
dcf5654
fix(maic-editor): address PR review — element delete affordance + cro…
wyuc May 22, 2026
674f54b
feat(maic-editor): selection-anchored text editing for the slide surf…
wyuc May 24, 2026
d7f2a05
feat(maic-editor): nav rail, scene management, Pro mode chrome rework…
wyuc May 28, 2026
732bdba
feat(maic-editor): gate slide scene creation until inserted scenes ar…
wyuc May 29, 2026
0374dae
i18n(maic-editor): add pt-BR translations for edit.* / stage.* keys
wyuc May 29, 2026
f159af6
fix(maic-editor): defer editor-only side effects behind Pro mode mount
wyuc May 29, 2026
f5245b9
fix(maic-editor): CR-loop correctness fixes (redo/groupId/edit-lock)
wyuc May 29, 2026
c9b3fd7
fix(maic-editor): scope editor list-marker CSS to Pro mode
wyuc May 29, 2026
050f734
fix(maic-editor): smooth Pro mode transition and stabilize header con…
wyuc May 30, 2026
7dfd97b
fix(maic-editor): address review — image insert race, scene migration…
wyuc May 30, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/maic-editor-v0]

concurrency:
group: ci-${{ github.ref }}
Expand Down
15 changes: 13 additions & 2 deletions app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -46,9 +48,18 @@ export default function ClassroomDetailPage() {
if (json.success && json.classroom) {
const { stage, scenes } = json.classroom;
useStageStore.getState().setStage(stage);
// Normalize legacy slide content (missing schemaVersion) on the
// way in, same as the store's setScenes/loadFromStorage paths —
// server snapshots predate the schema field.
const migrated = (scenes as Scene[]).map(migrateScene);
useStageStore.setState({
scenes,
currentSceneId: scenes[0]?.id ?? null,
scenes: migrated,
currentSceneId: migrated[0]?.id ?? null,
// Match `loadFromStorage` semantics: mode is transient UI
// state, not persisted with the stage. Reset on every
// classroom load so SPA navigation doesn't carry Pro
// mode across.
mode: 'playback',
});
log.info('Loaded from server-side storage:', classroomId);

Expand Down
39 changes: 39 additions & 0 deletions app/editor-fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Loads the web fonts offered in the slide editor's text-format picker.
*
* `@fontsource` ships the font files via npm (no binaries committed to the
* repo) and `unicode-range`-subsets the CJK faces, so a CJK font downloads
* lazily — only the glyph-range chunks a slide actually uses. Imported once
* from the root layout.
*
* The picker list lives in `configs/font.ts`; each entry's `value` must match
* the `@font-face` family name of a package imported here. Inter is loaded
* separately via `next/font` in `app/layout.tsx`.
*/

// Latin
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/700.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/700.css';
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/700.css';
import '@fontsource/source-sans-3/400.css';
import '@fontsource/source-sans-3/700.css';
import '@fontsource/merriweather/400.css';
import '@fontsource/merriweather/700.css';
import '@fontsource/literata/400.css';
import '@fontsource/literata/700.css';
import '@fontsource/source-serif-4/400.css';
import '@fontsource/source-serif-4/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/700.css';

// Chinese — @fontsource unicode-range-subsets these, so each loads lazily.
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/noto-serif-sc/400.css';
import '@fontsource/noto-serif-sc/700.css';
import '@fontsource/lxgw-wenkai/500.css';
import '@fontsource/lxgw-wenkai/700.css';
import '@fontsource/zcool-kuaile/400.css';
74 changes: 74 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,86 @@
body {
@apply bg-background text-foreground;
}
/* Radix Select / Popover wrap with `react-remove-scroll`, which adds a
compensation `padding-right` to <body> when they open. Our <html>
already reserves the scrollbar gutter (above), so that compensation
creates a visible layout shift on every dropdown open. Scope the
override to editor mode — the SlideCanvas sets `data-maic-editor` on
the body while mounted — so Radix's compensation still works on the
rest of the app (modals, sheets, etc. on non-editor pages). */
body[data-maic-editor='true'] {
padding-right: 0 !important;
}
}
/* ProseMirror Editor Styles */
.prosemirror-editor {
cursor: text;
}

/* The slide editor draws a text element's frame via the renderer's Operate
layer. The focused contenteditable must not also paint a UA focus ring on
top of it (the base `* { @apply outline-ring/50 }` rule gives every focused
element an outline). `.prosemirror-editor` is an editor-only class —
playback's BaseTextElement never carries it, so playback is unaffected. */
.prosemirror-editor :focus,
.prosemirror-editor :focus-visible {
outline: none;
}

/* Tailwind's preflight resets `list-style: none` and `padding: 0` on
`<ul>`/`<ol>`. The text element's `bulletList` / `orderedList` commands
genuinely wrap content in `<ul><li>` / `<ol><li>`, but without those
resets undone no marker would be visible. `!important` defeats any
layered preflight specificity we'd otherwise have to chase.

`.editable-element-text` is the PLAYBACK text wrapper, rendered for every
classroom/playback user — not just in the editor. So its rules MUST be
scoped to editor mode (`body[data-maic-editor='true']`, set by
EditChromeRoot while Pro mode is mounted) to keep flag-off playback
rendering byte-unchanged: the editor surface isn't GA yet, so playback
display of editor-authored lists is deferred. Scoping keeps list markers
visible while editing (the user needs to see their bullets) without
altering how the same wrapper renders during normal playback for all
users. `.prosemirror-editor` is an editor-only class already (playback's
BaseTextElement never carries it), so it stays unscoped. */
body[data-maic-editor='true'] .editable-element-text ul,
.prosemirror-editor ul {
list-style: disc outside !important;
padding-inline-start: 1.5rem !important;
}
body[data-maic-editor='true'] .editable-element-text ol,
.prosemirror-editor ol {
list-style: decimal outside !important;
padding-inline-start: 1.5rem !important;
}
body[data-maic-editor='true'] .editable-element-text li,
.prosemirror-editor li {
display: list-item !important;
}

/* Compact react-colorful for the slide editor's color popover. Defaults are
a 200×200 square with a thick separator — we squeeze it tight + round it
so the popover feels intentional, not stock. */
.color-picker .react-colorful {
width: 100%;
height: auto;
}
.color-picker .react-colorful__saturation {
height: 128px;
border-radius: 6px;
border-bottom: none;
}
.color-picker .react-colorful__hue {
height: 10px;
margin-top: 10px;
border-radius: 999px;
}
.color-picker .react-colorful__pointer {
width: 14px;
height: 14px;
border-width: 2px;
}

.prosemirror-editor.format-painter {
cursor:
url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+')
Expand Down
75 changes: 75 additions & 0 deletions components/edit/EditChromeRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { useEffect } from 'react';
import { EditShell } from '@/components/edit/EditShell';
import { SlideNavRail } from '@/components/edit/SlideNavRail';
import { HeaderControls } from '@/components/stage/header-controls';
import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
import { preloadEditor } from '@/lib/edit/preload-editor';
import type { Scene } from '@/lib/types/stage';

interface EditChromeRootProps {
readonly scene: Scene;
readonly isEditable: boolean;
readonly onToggleEditMode?: () => void;
}

/**
* Edit-mode root — wraps the Pro mode chrome assembly so `stage.tsx`
* has a single component to mount in the edit branch instead of a
* 13-line inline JSX with three children.
*
* Owned here: `EditShell` (Frame + CommandBar + canvas + overlays),
* `SlideNavRail` (leftRail slot), and the `HeaderControls` trailing
* (settings pill + Pro Switch) that rides in CommandBar's right slot.
*
* NOT owned here:
* - `MultiTabEditConflictPrompt` — must mount even in playback mode so
* the lock-conflict dialog can be shown when entering edit mode is
* refused (mode is still 'playback' at that point).
* - `useEditModeLock` — the lock is acquired by the Pro toggle in
* stage.tsx BEFORE the live session is torn down, so it can't live
* in a component that only mounts after the switch.
*
* `scene` is required (non-null). The parent gates mounting on
* `mode === 'edit' && currentScene` to satisfy this contract.
*/
export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChromeRootProps) {
// Mark the body while edit mode is mounted, so the editor-scoped CSS
// rule in globals.css that pins `body.padding-right` to 0 only fires
// in Pro mode — not on non-editor pages where Radix's
// react-remove-scroll compensation is still wanted. Lifted from
// SlideCanvas (which was mounted only for slide scenes) so the
// attribute now covers read-only scene types in Pro mode too.
useEffect(() => {
document.body.dataset.maicEditor = 'true';
return () => {
delete document.body.dataset.maicEditor;
};
}, []);

// Safety net: the editor chunk (fonts + slide surface registration) is
// normally preloaded by the Pro Switch handler in stage.tsx BEFORE mode
// flips, so by the time we mount the surface is already registered and
// EditShell resolves it immediately (no NOOP flash). This call is a
// promise-cached no-op in that path; it only does real work if edit mode
// is ever entered without going through the handler. Render is NOT gated
// on it — the preload-before-flip contract keeps the chrome smooth.
useEffect(() => {
void preloadEditor();
}, []);

return (
<EditShell
scene={scene}
leftRail={<SlideNavRail />}
commandTrailing={
<HeaderControls
mode="edit"
canEdit={isEditable}
onToggleEditMode={isMaicEditorEnabled() ? onToggleEditMode : undefined}
/>
}
/>
);
}
106 changes: 106 additions & 0 deletions components/edit/EditShell/CommandBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';

import { ArrowLeft, Redo2, Undo2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import type { EditorCommand, SurfaceHistory } from '@/lib/edit/scene-editor-surface';

interface CommandBarProps {
readonly title: string;
readonly history?: SurfaceHistory;
readonly commands?: readonly EditorCommand[];
/**
* Right-edge slot owned by Stage. In Pro mode it carries the
* HeaderControls (settings pill + Pro Switch + Download) since Stage
* Header is unmounted to keep top chrome to a single bar.
*/
readonly trailing?: ReactNode;
}

/**
* Top bar of the Pro mode chrome. Undo/redo + title on the left, insert
* primitives in the center, surface commands on the right. History /
* insertItems / commands are all optional so the bar renders cleanly when
* no surface is registered for the current scene type.
*
* Exiting Pro mode is handled by the global Pro Switch in the playback
* Header (which stays mounted above this bar) — Pro mode is a toggle,
* not a one-way state, so we deliberately do *not* place a "Done" pill
* here that would compete with the Switch's affordance.
*/
export function CommandBar({ title, history, commands, trailing }: CommandBarProps) {
const { t } = useI18n();
const router = useRouter();

return (
<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}>
<Undo2 className="h-4 w-4" />
</IconButton>
<IconButton title={t('edit.redo')} disabled={!history.canRedo} onClick={history.redo}>
<Redo2 className="h-4 w-4" />
</IconButton>
</>
)}
<span
className={cn('ml-2 truncate text-sm font-semibold text-zinc-700 dark:text-zinc-200')}
title={title}
>
{title}
</span>
</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 IconButton({
title,
children,
...props
}: React.ComponentProps<typeof Button> & { readonly title: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="h-8 w-8 shrink-0 rounded-xl text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
}
Loading
Loading