Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b8b0bd3
feat(maic-editor): add resolveEditingElementId text-editing policy
wyuc May 22, 2026
6b4bf4d
feat(maic-editor): drop text-format floating action (moves to anchore…
wyuc May 22, 2026
84bf19a
feat(maic-editor): surface hooks to derive and sync editingElementId
wyuc May 22, 2026
5e646ae
chore(ui): export PopoverAnchor from the popover wrapper
wyuc May 22, 2026
9ca40fc
feat(maic-editor): add useTrackedRect for element screen-rect tracking
wyuc May 22, 2026
2c5c78c
feat(maic-editor): add AnchoredTextBar selection-anchored format bar
wyuc May 22, 2026
b53e0b5
feat(maic-editor): wire anchored text bar + editing flag into SlideCa…
wyuc May 22, 2026
259d725
feat(maic-editor): draw a clean solid frame for the text element bein…
wyuc May 22, 2026
2c33757
fix(maic-editor): drop the editor focus ring so text editing shows on…
wyuc May 22, 2026
b54ff37
style(maic-editor): prettier-format the editing-state test import
wyuc May 22, 2026
1e68745
fix(maic-editor): anchor the bar to the text element node, not the wr…
wyuc May 22, 2026
116f29c
feat(maic-editor): modernize the text format bar UI
wyuc May 22, 2026
ca34b3b
fix(maic-editor): curate the font picker to fonts the app actually loads
wyuc May 22, 2026
e629cc9
feat(maic-editor): load the picker fonts via @fontsource
wyuc May 22, 2026
7482ba6
fix(maic-editor): quote font-family names so spaced/numeric ones work
wyuc May 22, 2026
84a5153
fix(maic-editor): make the editing frame pointer-events-none
wyuc May 22, 2026
d8d4bd3
chore(maic-editor): drop the "font is loading" toast
wyuc May 22, 2026
38a0da4
feat(maic-editor): move the delete action onto the anchored text bar
wyuc May 22, 2026
fa69ca6
feat(maic-editor): anchor the delete action for image elements
wyuc May 22, 2026
91e7ba8
style(maic-editor): tighten the anchored bar padding (p-2 → p-1)
wyuc May 22, 2026
825af26
feat(maic-editor): anchor the delete bar for every element type
wyuc May 22, 2026
4043f37
fix(maic-editor): show legacy font names in the picker trigger
wyuc May 23, 2026
e81a8bd
chore(maic-editor): address cr minors
wyuc May 23, 2026
27efa30
fix: pin body padding-right so popovers don't reflow the page
wyuc May 23, 2026
68d4f28
fix(maic-editor): surface legacy font names via SelectValue children
wyuc May 23, 2026
8a114a0
fix(maic-editor): preventDefault on pointer-down-outside so drag/resi…
wyuc May 23, 2026
a7f4a0d
feat(maic-editor): arm-and-place insertion for text boxes
wyuc May 23, 2026
92fb14a
fix(maic-editor): render list bullets in slide text
wyuc May 23, 2026
d4a796c
feat(maic-editor): editable font-size input in the text format bar
wyuc May 23, 2026
1ed5cb8
fix(maic-editor): force list markers visible (defeat preflight specif…
wyuc May 23, 2026
25b3053
fix(maic-editor): reset richTextAttrs when the editing element changes
wyuc May 23, 2026
9fe11a9
feat(maic-editor): replace OS color dialog with a curated palette pop…
wyuc May 23, 2026
e89db51
feat(maic-editor): replace flat swatch popover with a real color picker
wyuc May 23, 2026
aceb519
fix(maic-editor): keep the color popover open while dragging the picker
wyuc May 23, 2026
6206e37
fix(maic-editor): scope body padding override + gate ColorPicker mid-…
wyuc May 23, 2026
f53e7a6
chore(maic-editor): polish from self-CR
wyuc May 23, 2026
1cb07e7
chore(maic-editor): listen to every gesture-end channel in ColorPicker
wyuc May 23, 2026
975e405
fix(maic-editor): preserve image aspect ratio on insert
wyuc May 24, 2026
55a9a71
chore(maic-editor): drop the now-dead addElement helper
wyuc May 24, 2026
9b6bf00
chore(maic-editor): drop now-unused PPTElement import in use-slide-su…
wyuc May 24, 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
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';
66 changes: 66 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,78 @@
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. 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,
.prosemirror-editor ul {
list-style: disc outside !important;
padding-inline-start: 1.5rem !important;
}
.editable-element-text ol,
.prosemirror-editor ol {
list-style: decimal outside !important;
padding-inline-start: 1.5rem !important;
}
.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
1 change: 1 addition & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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';
Expand Down
6 changes: 5 additions & 1 deletion components/edit/EditShell/CommandBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ function InsertButton({ item }: { readonly item: InsertPaletteItem }) {
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 text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
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}
Expand Down
79 changes: 79 additions & 0 deletions components/edit/surfaces/slide/AnchoredBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import type { ReactNode } from 'react';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { useCanvasStore } from '@/lib/store/canvas';
import { useTrackedRect } from './use-tracked-rect';

interface AnchoredBarProps {
/** The element to anchor to, or "" when the bar should not show. */
readonly elementId: string;
readonly children: ReactNode;
}

/**
* The generic selection-anchored bar shell — a Radix Popover positioned against
* a virtual anchor: an invisible fixed-positioned box at the element's live
* screen rect (from useTrackedRect). PopoverContent is portaled, so the canvas's
* overflow-hidden never clips it, and Radix flips it below / clamps it
* horizontally on its own. AnchoredTextBar and AnchoredDeleteBar supply the
* contents.
*/
export function AnchoredBar({ elementId, children }: AnchoredBarProps) {
const rect = useTrackedRect(elementId);
const open = elementId !== '' && rect !== null;

return (
<Popover
open={open}
// Selection-driven close path: when Radix wants to close (Esc, or any
// dismiss our hardening doesn't intercept) we clear the canvas selection
// — which then closes us via the controlled `open` prop. Also silences
// Radix's controlled-without-onOpenChange dev warning and keeps Esc /
// SR dismissal working.
onOpenChange={(o) => {
if (!o) useCanvasStore.getState().setActiveElementIdList([]);
}}
>
{rect && (
<PopoverAnchor asChild>
<div
aria-hidden
style={{
position: 'fixed',
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
)}
{open && (
<PopoverContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
// Mirrors the FloatingToolbar popover hardening: opening the bar must
// not pull focus off the canvas selection, and commands that refocus
// the editor must not dismiss it — so it stays up across consecutive
// clicks. Visibility is fully selection-driven: the bar closes when
// the canvas selection clears or changes (via the controlled `open`),
// *not* via Radix's pointer/focus-outside dismiss. preventDefault on
// pointer-down-outside too is essential — otherwise mousedown on the
// selected element (to drag-to-move or to resize-handle) triggers
// Radix's dismiss → `onOpenChange(false)` → we'd clear the selection
// before the drag could start. Esc still routes through onOpenChange.
onOpenAutoFocus={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onFocusOutside={(e) => e.preventDefault()}
className="w-auto max-w-[92vw] p-1"
>
{children}
</PopoverContent>
)}
</Popover>
);
}
22 changes: 22 additions & 0 deletions components/edit/surfaces/slide/AnchoredDeleteBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { AnchoredBar } from './AnchoredBar';
import { DeleteButton } from './DeleteButton';

interface AnchoredDeleteBarProps {
/** The selected non-text element, or "" when none is selected. */
readonly elementId: string;
}

/**
* The selection-anchored bar for non-text elements — image, shape, line,
* table, chart, … They have no format controls, so it carries just the delete
* action. Hugs the selected element — see AnchoredBar.
*/
export function AnchoredDeleteBar({ elementId }: AnchoredDeleteBarProps) {
return (
<AnchoredBar elementId={elementId}>
<DeleteButton elementId={elementId} />
</AnchoredBar>
);
}
26 changes: 26 additions & 0 deletions components/edit/surfaces/slide/AnchoredTextBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { AnchoredBar } from './AnchoredBar';
import { ConnectedTextFormatBar } from './text-format-bar';
import { DeleteButton } from './DeleteButton';

interface AnchoredTextBarProps {
/** The text element being edited, or "" when no text element is being edited. */
readonly editingElementId: string;
}

/**
* The selection-anchored bar for a text element — the format controls plus
* delete, hugging the element being edited. See AnchoredBar for the shell.
*/
export function AnchoredTextBar({ editingElementId }: AnchoredTextBarProps) {
return (
<AnchoredBar elementId={editingElementId}>
<div className="flex items-center gap-1">
<ConnectedTextFormatBar elementId={editingElementId} />
<div className="h-5 w-px bg-zinc-200 dark:bg-zinc-800" />
<DeleteButton elementId={editingElementId} />
</div>
</AnchoredBar>
);
}
Loading
Loading