From b8b0bd343006ea91f68aef687a69a07ea4b128c7 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:03:09 -0400 Subject: [PATCH 01/40] feat(maic-editor): add resolveEditingElementId text-editing policy Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/editing-state.ts | 21 +++++++++ tests/edit/slide-editing-state.test.ts | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 components/edit/surfaces/slide/editing-state.ts create mode 100644 tests/edit/slide-editing-state.test.ts diff --git a/components/edit/surfaces/slide/editing-state.ts b/components/edit/surfaces/slide/editing-state.ts new file mode 100644 index 0000000000..08403c1b9e --- /dev/null +++ b/components/edit/surfaces/slide/editing-state.ts @@ -0,0 +1,21 @@ +import type { PPTElement } from '@/lib/types/slides'; + +/** + * 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 — empty selection, + * multi-selection, a non-text element — resolves to "" (not editing). + * + * 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 { + if (activeElementIdList.length !== 1) return ''; + const id = activeElementIdList[0]; + const element = elements.find((el) => el.id === id); + return element?.type === 'text' ? id : ''; +} diff --git a/tests/edit/slide-editing-state.test.ts b/tests/edit/slide-editing-state.test.ts new file mode 100644 index 0000000000..254be39c53 --- /dev/null +++ b/tests/edit/slide-editing-state.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; +import { resolveEditingElementId } from '@/components/edit/surfaces/slide/editing-state'; +import type { PPTElement, PPTTextElement } from '@/lib/types/slides'; + +function textElement(id: string): PPTTextElement { + return { + id, + type: 'text', + left: 0, + top: 0, + width: 200, + height: 60, + rotate: 0, + content: '

x

', + defaultFontName: 'Inter', + defaultColor: '#111827', + }; +} + +function nonTextElement(id: string): PPTElement { + return { id, type: 'image' } as unknown as PPTElement; +} + +describe('resolveEditingElementId', () => { + test('returns "" when nothing is selected', () => { + expect(resolveEditingElementId([], [textElement('t1')])).toBe(''); + }); + + test('returns "" for a multi-selection', () => { + expect( + resolveEditingElementId(['t1', 'i1'], [textElement('t1'), nonTextElement('i1')]), + ).toBe(''); + }); + + test('returns "" when the single selection is not a text element', () => { + expect(resolveEditingElementId(['i1'], [nonTextElement('i1')])).toBe(''); + }); + + test('returns "" when the selected id is not found', () => { + expect(resolveEditingElementId(['ghost'], [textElement('t1')])).toBe(''); + }); + + test('returns the id when a single text element is selected', () => { + expect(resolveEditingElementId(['t1'], [textElement('t1'), nonTextElement('i1')])).toBe('t1'); + }); +}); From 6b4bf4dcce43183730fc9df8f9e6d482267be53f Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:04:50 -0400 Subject: [PATCH 02/40] feat(maic-editor): drop text-format floating action (moves to anchored bar) Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/use-slide-surface.ts | 44 +++++++------------ tests/edit/slide-floating-actions.test.ts | 43 ++++++++++++++++++ 2 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 tests/edit/slide-floating-actions.test.ts diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 3d5993e1f0..13fde288e1 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -3,7 +3,6 @@ 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 type { SceneDataController } from '@/lib/contexts/scene-context'; import type { FloatingAction, @@ -59,33 +58,24 @@ export function buildFloatingActions( 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 }), - }); - } - // 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([]); + // Text formatting is now surfaced by the selection-anchored AnchoredTextBar + // (it hugs the element instead of sitting in the top-center FloatingToolbar). + // The FloatingToolbar keeps only the delete affordance — the renderer's own + // delete lives in a right-click menu; this is the discoverable button entry + // for any single selected element (keyboard shortcuts deferred — see #560). + return [ + { + 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 EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') }; diff --git a/tests/edit/slide-floating-actions.test.ts b/tests/edit/slide-floating-actions.test.ts new file mode 100644 index 0000000000..befe7f0a14 --- /dev/null +++ b/tests/edit/slide-floating-actions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest'; +import { buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; +import type { PPTElement, PPTTextElement } from '@/lib/types/slides'; + +const t = (key: string) => key; + +function textElement(id = 't1'): PPTTextElement { + return { + id, + type: 'text', + left: 0, + top: 0, + width: 200, + height: 60, + rotate: 0, + content: '

x

', + defaultFontName: 'Inter', + defaultColor: '#111827', + }; +} + +function nonTextElement(id = 'i1'): PPTElement { + return { id, type: 'image' } as unknown as PPTElement; +} + +describe('buildFloatingActions', () => { + test('returns no actions when nothing is selected', () => { + expect(buildFloatingActions(t, undefined)).toEqual([]); + }); + + test('a selected text element no longer surfaces a text-format action', () => { + const actions = buildFloatingActions(t, textElement()); + expect(actions.some((action) => action.id === 'text-format')).toBe(false); + }); + + test('a selected text element surfaces exactly the delete action', () => { + expect(buildFloatingActions(t, textElement()).map((a) => a.id)).toEqual(['delete']); + }); + + test('a selected image element still surfaces the delete action', () => { + expect(buildFloatingActions(t, nonTextElement()).map((a) => a.id)).toEqual(['delete']); + }); +}); From 84bf19a6781abea6dd99276cec3964f3d15aa551 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:07:25 -0400 Subject: [PATCH 03/40] feat(maic-editor): surface hooks to derive and sync editingElementId Add useResolvedSlideContent / useEditingTextElementId / useSyncEditingElementId. Realign the PR2 buildFloatingActions tests with the new behavior (text formatting moved off the FloatingToolbar) and co-locate the editing-state test. Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/use-slide-surface.ts | 52 ++++++++++++++++--- tests/edit/slide-editing-state.test.ts | 46 ---------------- tests/edit/slide-floating-actions.test.ts | 43 --------------- .../edit/surfaces/slide/editing-state.test.ts | 28 ++++++++++ .../edit/surfaces/slide/insert-items.test.ts | 4 +- .../surfaces/slide/text-format-bar.test.ts | 29 ----------- 6 files changed, 75 insertions(+), 127 deletions(-) delete mode 100644 tests/edit/slide-editing-state.test.ts delete mode 100644 tests/edit/slide-floating-actions.test.ts create mode 100644 tests/edit/surfaces/slide/editing-state.test.ts diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 13fde288e1..3fca9ba8ae 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -2,7 +2,7 @@ import { produce } from 'immer'; import { Image as ImageIcon, Trash2, Type } from 'lucide-react'; -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import type { SceneDataController } from '@/lib/contexts/scene-context'; import type { FloatingAction, @@ -22,6 +22,7 @@ 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 } from './editing-state'; export interface SlideSelection { readonly activeElementIds: readonly string[]; @@ -85,6 +86,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. @@ -92,13 +105,8 @@ 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 content = useResolvedSlideContent(); const onlyEl = activeElementIds.length === 1 @@ -210,3 +218,33 @@ 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); +} + +/** + * 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(); + useLayoutEffect(() => { + setEditingElementId(editingElementId); + }, [editingElementId, setEditingElementId]); + useLayoutEffect( + () => () => { + setEditingElementId(''); + }, + [setEditingElementId], + ); +} diff --git a/tests/edit/slide-editing-state.test.ts b/tests/edit/slide-editing-state.test.ts deleted file mode 100644 index 254be39c53..0000000000 --- a/tests/edit/slide-editing-state.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { resolveEditingElementId } from '@/components/edit/surfaces/slide/editing-state'; -import type { PPTElement, PPTTextElement } from '@/lib/types/slides'; - -function textElement(id: string): PPTTextElement { - return { - id, - type: 'text', - left: 0, - top: 0, - width: 200, - height: 60, - rotate: 0, - content: '

x

', - defaultFontName: 'Inter', - defaultColor: '#111827', - }; -} - -function nonTextElement(id: string): PPTElement { - return { id, type: 'image' } as unknown as PPTElement; -} - -describe('resolveEditingElementId', () => { - test('returns "" when nothing is selected', () => { - expect(resolveEditingElementId([], [textElement('t1')])).toBe(''); - }); - - test('returns "" for a multi-selection', () => { - expect( - resolveEditingElementId(['t1', 'i1'], [textElement('t1'), nonTextElement('i1')]), - ).toBe(''); - }); - - test('returns "" when the single selection is not a text element', () => { - expect(resolveEditingElementId(['i1'], [nonTextElement('i1')])).toBe(''); - }); - - test('returns "" when the selected id is not found', () => { - expect(resolveEditingElementId(['ghost'], [textElement('t1')])).toBe(''); - }); - - test('returns the id when a single text element is selected', () => { - expect(resolveEditingElementId(['t1'], [textElement('t1'), nonTextElement('i1')])).toBe('t1'); - }); -}); diff --git a/tests/edit/slide-floating-actions.test.ts b/tests/edit/slide-floating-actions.test.ts deleted file mode 100644 index befe7f0a14..0000000000 --- a/tests/edit/slide-floating-actions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { buildFloatingActions } from '@/components/edit/surfaces/slide/use-slide-surface'; -import type { PPTElement, PPTTextElement } from '@/lib/types/slides'; - -const t = (key: string) => key; - -function textElement(id = 't1'): PPTTextElement { - return { - id, - type: 'text', - left: 0, - top: 0, - width: 200, - height: 60, - rotate: 0, - content: '

x

', - defaultFontName: 'Inter', - defaultColor: '#111827', - }; -} - -function nonTextElement(id = 'i1'): PPTElement { - return { id, type: 'image' } as unknown as PPTElement; -} - -describe('buildFloatingActions', () => { - test('returns no actions when nothing is selected', () => { - expect(buildFloatingActions(t, undefined)).toEqual([]); - }); - - test('a selected text element no longer surfaces a text-format action', () => { - const actions = buildFloatingActions(t, textElement()); - expect(actions.some((action) => action.id === 'text-format')).toBe(false); - }); - - test('a selected text element surfaces exactly the delete action', () => { - expect(buildFloatingActions(t, textElement()).map((a) => a.id)).toEqual(['delete']); - }); - - test('a selected image element still surfaces the delete action', () => { - expect(buildFloatingActions(t, nonTextElement()).map((a) => a.id)).toEqual(['delete']); - }); -}); 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..2bdceccf5b --- /dev/null +++ b/tests/edit/surfaces/slide/editing-state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; +import { resolveEditingElementId } from '@/components/edit/surfaces/slide/editing-state'; +import { createDefaultImageElement, createDefaultTextElement } from '@/lib/edit/slide-edit-elements'; + +describe('resolveEditingElementId', () => { + const text = createDefaultTextElement('t1'); + const image = createDefaultImageElement('i1', 'gen_img_x'); + + 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..6a75f82f82 100644 --- a/tests/edit/surfaces/slide/insert-items.test.ts +++ b/tests/edit/surfaces/slide/insert-items.test.ts @@ -71,9 +71,9 @@ describe('slide floating actions', () => { expect(buildFloatingActions((k) => k, undefined)).toEqual([]); }); - it('a selected text element gets the text-format bar plus a delete action', () => { + it('a selected text element gets only a delete action (text formatting moved to the anchored bar)', () => { const actions = buildFloatingActions((k) => k, createDefaultTextElement('text-9')); - expect(actions.map((a) => a.id)).toEqual(['text-format', 'delete']); + expect(actions.map((a) => a.id)).toEqual(['delete']); }); it('a selected image element gets only a delete action (no text-format)', () => { diff --git a/tests/edit/surfaces/slide/text-format-bar.test.ts b/tests/edit/surfaces/slide/text-format-bar.test.ts index 4164e98e8e..bc4aacdcdd 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'; describe('TextFormatBar — pure logic', () => { it('stepFontSize increments and decrements by delta', () => { @@ -53,30 +51,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 5e646aef2cfc8bb8417b4f02ceefb4990b45e7fc Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:07:49 -0400 Subject: [PATCH 04/40] chore(ui): export PopoverAnchor from the popover wrapper Co-Authored-By: Claude Opus 4.7 --- components/ui/popover.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx index f80793912e..a97b7c37a5 100644 --- a/components/ui/popover.tsx +++ b/components/ui/popover.tsx @@ -9,6 +9,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }; From 9ca40fc591caf4d0530551a50ae738ec7a0b7291 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:09:41 -0400 Subject: [PATCH 05/40] feat(maic-editor): add useTrackedRect for element screen-rect tracking Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/use-tracked-rect.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 components/edit/surfaces/slide/use-tracked-rect.ts 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..4427af3e1e --- /dev/null +++ b/components/edit/surfaces/slide/use-tracked-rect.ts @@ -0,0 +1,53 @@ +'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 the rendered element `#editable-element-{id}`. + * A requestAnimationFrame loop re-measures via getBoundingClientRect — that + * one call already resolves canvas scale, viewport offset, rotation and page + * scroll, so the anchored bar follows the element through every gesture + * (drag, resize, zoom) without separate store subscriptions or listeners. + * Returns null while `elementId` is "" or the node is not mounted. + */ +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 node = document.getElementById(`editable-element-${elementId}`); + 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; +} From 2c5c78ca463530fdcb28a03e245836afdf747350 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:09:41 -0400 Subject: [PATCH 06/40] feat(maic-editor): add AnchoredTextBar selection-anchored format bar Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/AnchoredTextBar.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 components/edit/surfaces/slide/AnchoredTextBar.tsx diff --git a/components/edit/surfaces/slide/AnchoredTextBar.tsx b/components/edit/surfaces/slide/AnchoredTextBar.tsx new file mode 100644 index 0000000000..9c24c6b8f8 --- /dev/null +++ b/components/edit/surfaces/slide/AnchoredTextBar.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'; +import { ConnectedTextFormatBar } from './text-format-bar'; +import { useTrackedRect } from './use-tracked-rect'; + +interface AnchoredTextBarProps { + /** The text element being edited, or "" when no text element is being edited. */ + readonly editingElementId: string; +} + +/** + * The selection-anchored text-format bar. Replaces the top-center FloatingToolbar + * popover: it hugs the text element being edited (Figma/Pitch feel) and tracks it + * live. A virtual Radix PopoverAnchor — an invisible fixed-positioned box at the + * element's screen rect — is what the bar positions against; the rect comes 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. + */ +export function AnchoredTextBar({ editingElementId }: AnchoredTextBarProps) { + const rect = useTrackedRect(editingElementId); + const open = editingElementId !== '' && rect !== null; + + return ( + + {rect && ( + +
+ + )} + {open && ( + e.preventDefault()} + onFocusOutside={(e) => e.preventDefault()} + className="w-auto max-w-[92vw] p-2" + > + + + )} + + ); +} From b53e0b5fff05b78ed23634e1111739a564f8f731 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:09:41 -0400 Subject: [PATCH 07/40] feat(maic-editor): wire anchored text bar + editing flag into SlideCanvas Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/SlideCanvas.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/components/edit/surfaces/slide/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx index 0903d3a64b..8f1555c6a5 100644 --- a/components/edit/surfaces/slide/SlideCanvas.tsx +++ b/components/edit/surfaces/slide/SlideCanvas.tsx @@ -2,7 +2,12 @@ import Canvas from '@/components/slide-renderer/Editor/Canvas'; import { SceneProvider } from '@/lib/contexts/scene-context'; -import { useSlideCanvasController } from './use-slide-surface'; +import { + useEditingTextElementId, + useSlideCanvasController, + useSyncEditingElementId, +} from './use-slide-surface'; +import { AnchoredTextBar } from './AnchoredTextBar'; /** * The slide surface's canvas. Reuses the unmodified slide renderer @@ -10,9 +15,15 @@ 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 text-editing chrome: it derives the editing text element, + * mirrors it into the canvas store's `editingElementId` (which the renderer + * reads to draw a clean frame), and renders the selection-anchored format bar. */ export function SlideCanvas() { const { controller, gestureProps } = useSlideCanvasController(); + const editingElementId = useEditingTextElementId(); + useSyncEditingElementId(editingElementId); return ( // gestureProps marks pointer-gesture windows so a renderer commit is @@ -22,6 +33,7 @@ export function SlideCanvas() { +
); } From 259d725063d28d89b3fd5a2f6378af820ee14a33 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:10:49 -0400 Subject: [PATCH 08/40] feat(maic-editor): draw a clean solid frame for the text element being edited Gated on the canvas store's editingElementId (default ""), so the dashed select frame is unchanged for multi-select and for any consumer that never sets the flag. Editor-path only; playback never renders Operate. Co-Authored-By: Claude Opus 4.7 --- .../Canvas/Operate/TextElementOperate.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx b/components/slide-renderer/Editor/Canvas/Operate/TextElementOperate.tsx index 26dac0be44..41aab3d44a 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,25 @@ export function TextElementOperate({ return (
- {borderLines.map((line) => ( - - ))} + ) : ( + borderLines.map((line) => ( + + )) + )} {handlerVisible && ( <> {resizeHandlers.map((point) => ( From 2c33757f097988658f1e6a859f4915a23135a96a Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:11:25 -0400 Subject: [PATCH 09/40] fix(maic-editor): drop the editor focus ring so text editing shows one frame Co-Authored-By: Claude Opus 4.7 --- app/globals.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/globals.css b/app/globals.css index d21d190f54..4c9937d30b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -138,6 +138,16 @@ 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; +} + .prosemirror-editor.format-painter { cursor: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+') From b54ff37a3e74b96162510f476d42ab70ded17820 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:12:58 -0400 Subject: [PATCH 10/40] style(maic-editor): prettier-format the editing-state test import Co-Authored-By: Claude Opus 4.7 --- tests/edit/surfaces/slide/editing-state.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/edit/surfaces/slide/editing-state.test.ts b/tests/edit/surfaces/slide/editing-state.test.ts index 2bdceccf5b..382ee9bfb5 100644 --- a/tests/edit/surfaces/slide/editing-state.test.ts +++ b/tests/edit/surfaces/slide/editing-state.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; import { resolveEditingElementId } from '@/components/edit/surfaces/slide/editing-state'; -import { createDefaultImageElement, createDefaultTextElement } from '@/lib/edit/slide-edit-elements'; +import { + createDefaultImageElement, + createDefaultTextElement, +} from '@/lib/edit/slide-edit-elements'; describe('resolveEditingElementId', () => { const text = createDefaultTextElement('t1'); From 1e687450ae141791933164cfadc1c4025a506223 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 00:21:30 -0400 Subject: [PATCH 11/40] fix(maic-editor): anchor the bar to the text element node, not the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review caught that #editable-element-{id} is a zero-size absolute wrapper — measuring it would pin the bar to the canvas origin. Measure the .editable-element-text child, which carries the real geometry. Also correct the dismiss-behavior comment: the bar is purely selection-driven. Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/AnchoredTextBar.tsx | 6 ++++-- .../edit/surfaces/slide/use-tracked-rect.ts | 21 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/components/edit/surfaces/slide/AnchoredTextBar.tsx b/components/edit/surfaces/slide/AnchoredTextBar.tsx index 9c24c6b8f8..fd8e472c4f 100644 --- a/components/edit/surfaces/slide/AnchoredTextBar.tsx +++ b/components/edit/surfaces/slide/AnchoredTextBar.tsx @@ -47,8 +47,10 @@ export function AnchoredTextBar({ editingElementId }: AnchoredTextBarProps) { // Mirrors the FloatingToolbar popover hardening: opening the bar must // not pull focus off the canvas selection, and format commands that // refocus the editor must not dismiss it — so it stays up across - // consecutive formatting clicks. Escape / a true outside click still - // change the selection, which closes it via the controlled `open`. + // consecutive formatting clicks. Visibility is fully selection-driven + // (controlled `open`, no `onOpenChange`): the bar closes when the + // canvas selection clears or changes — e.g. a click elsewhere on the + // canvas — not via Radix's own dismiss events. onOpenAutoFocus={(e) => e.preventDefault()} onFocusOutside={(e) => e.preventDefault()} className="w-auto max-w-[92vw] p-2" diff --git a/components/edit/surfaces/slide/use-tracked-rect.ts b/components/edit/surfaces/slide/use-tracked-rect.ts index 4427af3e1e..40c75316a8 100644 --- a/components/edit/surfaces/slide/use-tracked-rect.ts +++ b/components/edit/surfaces/slide/use-tracked-rect.ts @@ -15,12 +15,20 @@ function sameRect(a: TrackedRect | null, b: TrackedRect | null): boolean { } /** - * Tracks the on-screen rect of the rendered element `#editable-element-{id}`. + * 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 `.editable-element-text` child, + * 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, rotation and page - * scroll, so the anchored bar follows the element through every gesture - * (drag, resize, zoom) without separate store subscriptions or listeners. - * Returns null while `elementId` is "" or the node is not mounted. + * 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); @@ -33,7 +41,8 @@ export function useTrackedRect(elementId: string): TrackedRect | null { let raf = 0; let current: TrackedRect | null = null; const measure = () => { - const node = document.getElementById(`editable-element-${elementId}`); + const wrapper = document.getElementById(`editable-element-${elementId}`); + const node = wrapper?.querySelector('.editable-element-text') ?? null; let next: TrackedRect | null = null; if (node) { const r = node.getBoundingClientRect(); From 116f29ca9342c08ef73eccb8a028b1b3ccc94068 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 22 May 2026 01:48:34 -0400 Subject: [PATCH 12/40] feat(maic-editor): modernize the text format bar UI Replace the native / 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 +78,84 @@ 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; 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) => ( - - ))} - -
+ + + + + {FONTS.map((f) => ( + + {f.value === '' ? t('edit.text.fontDefault') : 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} + + {fontSize} + run({ command: 'fontsize', value: stepFontSize(attrs.fontsize, 2) })} - className="px-2 text-sm" + className={STEP_BUTTON} > - + +
-
+ + + + + {/* Text color — a swatch reflecting the current color; the native color + input is visually hidden but still owns the picker interaction. */} -
+ + + Date: Fri, 22 May 2026 02:05:25 -0400 Subject: [PATCH 13/40] fix(maic-editor): curate the font picker to fonts the app actually loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configs/font.ts listed 29 fonts but the app only ever loads Inter (via next/font); the other 28 had no @font-face or bundled file, so picking them silently fell back with no visible effect — and nothing but the format bar even imports the registry. Trim it to what genuinely renders; the file's comment records how to restore the rest (wire up font loading first). Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/text-format-bar.tsx | 4 +- configs/font.ts | 37 +++++-------------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 89435d9aca..ce56d11d6d 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -111,8 +111,8 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { // w-max keeps the row at its natural width so the popover (w-auto) sizes to // it — one clean line, nothing squished.
- {/* Font — design-system Select. Fonts come from OpenMAIC's canonical - FONTS registry (configs/font.ts): the web fonts the renderer loads. */} + {/* Font — design-system Select; options come from the FONTS registry + (configs/font.ts), scoped to fonts the app actually loads. */} ` that mirrors `attrs.fontsize` locally, commits on Enter / blur (clamped to [8, 96]; non-numeric reverts), and reverts on Escape. Adds the `edit.text.fontSize` aria-label key in all 6 locales. Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/text-format-bar.tsx | 40 +++++++++++++++++-- lib/i18n/locales/ar-SA.json | 1 + lib/i18n/locales/en-US.json | 1 + lib/i18n/locales/ja-JP.json | 1 + lib/i18n/locales/ru-RU.json | 1 + lib/i18n/locales/zh-CN.json | 1 + lib/i18n/locales/zh-TW.json | 1 + 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 5521cc5bbb..3805786482 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Bold, Italic, @@ -106,6 +106,24 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { [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]); return ( // w-max keeps the row at its natural width so the popover (w-auto) sizes to @@ -146,9 +164,23 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { > - - {fontSize} - + 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) })} diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index bd733173fb..d686f8bb73 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -174,6 +174,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 7ba647389e..cb414a7171 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -174,6 +174,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 a4a767330b..920750d884 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -174,6 +174,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 758ec71a87..0a9648d06e 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -174,6 +174,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 74cecdac3d..4b4cfb4f99 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -174,6 +174,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 1971409276..678f56f8cb 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -174,6 +174,7 @@ "fontDefault": "預設", "sizeUp": "放大字級", "sizeDown": "縮小字級", + "fontSize": "字級", "bold": "粗體", "italic": "斜體", "underline": "底線", From 1ed5cb8240a3295a978e078d8d0a425eaf378188 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 01:44:51 -0400 Subject: [PATCH 30/40] fix(maic-editor): force list markers visible (defeat preflight specificity) The earlier list CSS didn't survive Tailwind's preflight (which also resets `padding: 0` on `
    `/`
      `, so with `list-style-position: outside` the markers had no room to render). Add `!important` on `list-style` and `padding-inline-start`, and broaden to also match `.prosemirror-editor ul`/ `ol`/`li` in case the markup ever nests differently than expected. Co-Authored-By: Claude Opus 4.7 --- app/globals.css | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/globals.css b/app/globals.css index 9bea408211..8cd5d7e211 100644 --- a/app/globals.css +++ b/app/globals.css @@ -154,23 +154,27 @@ outline: none; } -/* Tailwind's preflight resets `list-style` to none. The text element's - `bulletList` / `orderedList` commands genuinely wrap content in - `
      • ` / `
        1. `, but without markers the list button looked - inert. Scope to the slide text element so we don't leak list bullets - into the rest of the app. Covers both edit (`.prosemirror-editor` - inside `.editable-element-text`) and playback (`BaseTextElement` - renders the same HTML inside `.editable-element-text`). */ -.editable-element-text ul { - list-style: disc; - padding-inline-start: 1.25rem; -} -.editable-element-text ol { - list-style: decimal; - padding-inline-start: 1.25rem; -} -.editable-element-text li { - display: list-item; +/* Tailwind's preflight resets `list-style: none` and `padding: 0` on + `
            `/`
              `. 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, +.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; } .prosemirror-editor.format-painter { From 25b3053cb57def13fc543894cefddaeaf99984d5 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 01:44:51 -0400 Subject: [PATCH 31/40] fix(maic-editor): reset richTextAttrs when the editing element changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `richTextAttrs` is a single shared store updated by whichever ProseMirror was last focused. Switching from one text element to another visibly carried the previous element's toggle states (bold / italic / alignment / list) on the format bar for a moment — until the new element's ProseMirror took focus and repopulated the attrs. `useSyncEditingElementId` now resets the attrs to defaults whenever the editing id changes, so the bar shows a neutral state during the transition instead of stale. Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/use-slide-surface.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index ea40ee67e5..a54467aa32 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -8,6 +8,7 @@ import type { InsertPaletteItem, SurfaceState } from '@/lib/edit/scene-editor-su import { useI18n } from '@/lib/hooks/use-i18n'; import { createElementId } from '@/lib/edit/element-id'; 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'; @@ -234,8 +235,17 @@ export function useSelectedNonTextElementId(): string { */ export function useSyncEditingElementId(editingElementId: string): void { const setEditingElementId = useCanvasStore.use.setEditingElementId(); + const setRichTextAttrs = useCanvasStore.use.setRichtextAttrs(); useLayoutEffect(() => { setEditingElementId(editingElementId); + // Also reset `richTextAttrs` to defaults: it's a single shared store + // updated by whichever ProseMirror was last focused. Without this, the + // format bar visibly carries the previous element's toggle states (B, I, + // alignment, …) for a moment when the selection jumps to a different + // text element — the new element's ProseMirror only repopulates the + // attrs once it takes focus. Resetting on every editing-id change makes + // the bar show neutral defaults during the transition instead of stale. + setRichTextAttrs(defaultRichTextAttrs); return () => setEditingElementId(''); - }, [editingElementId, setEditingElementId]); + }, [editingElementId, setEditingElementId, setRichTextAttrs]); } From 9fe11a96652dcba3c2fb6009613a3cc61922171a Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 02:00:10 -0400 Subject: [PATCH 32/40] feat(maic-editor): replace OS color dialog with a curated palette popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the text-color swatch opened the browser's native `` dialog — off-brand and inconsistent across platforms. Swap it for a `ColorPicker` popover: a 12-swatch grid covering the common slide-text needs (4 neutrals + warm + cool) plus a hex input for anything else. Closes on pick. Selected swatch gets the violet outline; hex input commits on Enter / blur (reverts if invalid). Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/ColorPicker.tsx | 97 +++++++++++++++++++ .../edit/surfaces/slide/text-format-bar.tsx | 53 ++++++---- 2 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 components/edit/surfaces/slide/ColorPicker.tsx diff --git a/components/edit/surfaces/slide/ColorPicker.tsx b/components/edit/surfaces/slide/ColorPicker.tsx new file mode 100644 index 0000000000..743f766e5c --- /dev/null +++ b/components/edit/surfaces/slide/ColorPicker.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Curated text-color palette + hex input. Replaces ``'s OS + * dialog — which looked off-brand and varied per platform. The palette covers + * the common slide-text needs (neutrals + a few saturated accents); the hex + * field is the escape hatch for anything else. + */ +const PALETTE: readonly string[] = [ + '#000000', + '#525252', + '#a3a3a3', + '#ffffff', + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#06b6d4', + '#3b82f6', + '#8b5cf6', + '#ec4899', +]; + +const HEX_RE = /^#?[0-9a-fA-F]{6}$/; + +export function ColorPicker({ + value, + onPick, +}: { + readonly value: string; + readonly onPick: (color: string) => void; +}) { + const [hex, setHex] = useState(value); + // Re-sync from the outside when a swatch click changes value, or the bar + // remounts on a new element. Doesn't clobber mid-type because `value` + // doesn't change until we commit. + useEffect(() => { + setHex(value); + }, [value]); + + const commitHex = () => { + if (!HEX_RE.test(hex)) { + setHex(value); + return; + } + const normalized = (hex.startsWith('#') ? hex : `#${hex}`).toLowerCase(); + onPick(normalized); + }; + + const currentLower = value.toLowerCase(); + + return ( +
                  +
                  + {PALETTE.map((c) => { + const isSelected = c === currentLower; + return ( +
                  +
                  + Hex + setHex(e.target.value)} + onBlur={commitHex} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + else if (e.key === 'Escape') { + setHex(value); + e.currentTarget.blur(); + } + }} + spellCheck={false} + placeholder="#000000" + className="w-24 rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs tabular-nums text-zinc-700 outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:focus:border-violet-500 dark:focus:ring-violet-900" + /> +
                  +
                  + ); +} diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 3805786482..80ab7a989b 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -18,6 +18,7 @@ import { runActiveTextCommand, type TextCommandPayload, } from '@/lib/prosemirror/active-editor-registry'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, @@ -27,6 +28,7 @@ import { } 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; @@ -124,6 +126,7 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { if (clamped !== fontSize) run({ command: 'fontsize', value: `${clamped}px` }); setSizeInput(String(clamped)); }, [sizeInput, fontSize, run]); + const [colorOpen, setColorOpen] = useState(false); return ( // w-max keeps the row at its natural width so the popover (w-auto) sizes to @@ -217,23 +220,39 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { - {/* Text color — a swatch reflecting the current color; the native color - input is visually hidden but still owns the picker interaction. */} - + {/* Text color — curated palette + hex input in a popover, replacing the + OS color dialog. preventDefault on mousedown so opening the popover + doesn't steal focus from ProseMirror. */} + + + + + e.preventDefault()} + > + { + run({ command: 'forecolor', value: c }); + setColorOpen(false); + }} + /> + + From e89db51d30dba5933c4329a0ca0fe2bf2c791743 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 09:18:50 -0400 Subject: [PATCH 33/40] feat(maic-editor): replace flat swatch popover with a real color picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous popover was a chunky 12-swatch grid plus a hex input nobody types into. Rebuild on `react-colorful` (3KB, well-tested): - SV pad + hue slider for free-form picking, with scoped CSS overrides to keep the picker tight (128px pad height) and rounded — not stock. - OS eyedropper via the EyeDropper API, feature-detected (Chrome / Edge; hidden on Safari / Firefox). - Row of 10 small (18px) common colors at the foot for one-click reach. - Current-color preview + read-only hex display. - Hex input dropped entirely — picking is meant to be tactile. Live preview while dragging; the popover closes on a swatch / eyedropper commit (not on drag). Co-Authored-By: Claude Opus 4.7 --- app/globals.css | 23 +++ .../edit/surfaces/slide/ColorPicker.tsx | 155 ++++++++++-------- .../edit/surfaces/slide/text-format-bar.tsx | 3 +- package.json | 1 + pnpm-lock.yaml | 14 ++ 5 files changed, 126 insertions(+), 70 deletions(-) diff --git a/app/globals.css b/app/globals.css index 8cd5d7e211..48f43c0bdc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -177,6 +177,29 @@ 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+') diff --git a/components/edit/surfaces/slide/ColorPicker.tsx b/components/edit/surfaces/slide/ColorPicker.tsx index 743f766e5c..249c346326 100644 --- a/components/edit/surfaces/slide/ColorPicker.tsx +++ b/components/edit/surfaces/slide/ColorPicker.tsx @@ -1,14 +1,12 @@ 'use client'; import { useEffect, useState } from 'react'; +import { HexColorPicker } from 'react-colorful'; +import { Pipette } from 'lucide-react'; -/** - * Curated text-color palette + hex input. Replaces ``'s OS - * dialog — which looked off-brand and varied per platform. The palette covers - * the common slide-text needs (neutrals + a few saturated accents); the hex - * field is the escape hatch for anything else. - */ -const PALETTE: readonly string[] = [ +// Common slide-text colors — single tight row at the foot of the picker so they +// stay one-click reachable without dominating the popover. +const COMMON: readonly string[] = [ '#000000', '#525252', '#a3a3a3', @@ -17,80 +15,99 @@ const PALETTE: readonly string[] = [ '#f97316', '#eab308', '#22c55e', - '#06b6d4', '#3b82f6', '#8b5cf6', - '#ec4899', ]; -const HEX_RE = /^#?[0-9a-fA-F]{6}$/; +// EyeDropper API (not in `lib.dom` yet under our TS config). Feature-detected +// at render so the button hides on browsers without it (Safari / Firefox). +interface EyeDropperInstance { + open(): Promise<{ sRGBHex: string }>; +} +interface EyeDropperCtor { + new (): EyeDropperInstance; +} -export function ColorPicker({ - value, - onPick, -}: { +interface ColorPickerProps { readonly value: string; - readonly onPick: (color: string) => void; -}) { - const [hex, setHex] = useState(value); - // Re-sync from the outside when a swatch click changes value, or the bar - // remounts on a new element. Doesn't clobber mid-type because `value` - // doesn't change until we commit. - useEffect(() => { - setHex(value); - }, [value]); + /** Live color update — fires on every gradient/slider drag tick. */ + readonly onChange: (color: string) => void; + /** Discrete commit (swatch click / eyedropper). Caller closes the popover. */ + readonly onCommit: (color: string) => void; +} - const commitHex = () => { - if (!HEX_RE.test(hex)) { - setHex(value); - return; - } - const normalized = (hex.startsWith('#') ? hex : `#${hex}`).toLowerCase(); - onPick(normalized); +/** + * Editor text-color picker. Saturation/value pad + hue slider (react-colorful) + * for free-form colors, the OS eye-dropper for sampling the screen, and a + * tight row of common colors at the bottom. No hex text input — picking is + * meant to be tactile. + */ +export function ColorPicker({ value, onChange, onCommit }: ColorPickerProps) { + // Local mirror so the picker UI stays responsive while dragging without + // round-tripping through ProseMirror + store on every tick. Re-sync when + // `value` changes externally (swatch click, eyedropper, parent reset); + // suppressing the cascade-render lint because the cascade *is* the intent. + const [color, setColor] = useState(value); + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => setColor(value), [value]); + + const handleChange = (c: string) => { + setColor(c); + onChange(c); + }; + const handleCommit = (c: string) => { + setColor(c); + onCommit(c); }; - const currentLower = value.toLowerCase(); + const EyeDropper = (globalThis as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper; + const sampleScreen = async () => { + if (!EyeDropper) return; + try { + const result = await new EyeDropper().open(); + handleCommit(result.sRGBHex); + } catch { + // User dismissed the OS picker — nothing to do. + } + }; return ( -
                  -
                  - {PALETTE.map((c) => { - const isSelected = c === currentLower; - return ( - + )}
                  -
                  - Hex - setHex(e.target.value)} - onBlur={commitHex} - onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur(); - else if (e.key === 'Escape') { - setHex(value); - e.currentTarget.blur(); - } - }} - spellCheck={false} - placeholder="#000000" - className="w-24 rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs tabular-nums text-zinc-700 outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:focus:border-violet-500 dark:focus:ring-violet-900" - /> +
                  + {COMMON.map((c) => ( +
                  ); diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 80ab7a989b..6b515e7730 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -246,7 +246,8 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { > { + onChange={(c) => run({ command: 'forecolor', value: c })} + onCommit={(c) => { run({ command: 'forecolor', value: c }); setColorOpen(false); }} diff --git a/package.json b/package.json index 2f0114ae85..72ff6a9741 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,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 ad762566c8..cfedc3d18d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,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) @@ -8053,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: @@ -18009,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 From aceb5193d8ffd671c9b2b9537dc2c382e63028d0 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 10:51:50 -0400 Subject: [PATCH 34/40] fix(maic-editor): keep the color popover open while dragging the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each SV-pad / hue-slider drag tick fires onChange → dispatches the color command → `editorView.focus()` pulls focus out of the popover into ProseMirror. Radix's default onFocusOutside path was treating that as a dismiss, so the popover closed the instant a drag started — clicking anywhere on the picker shut it. preventDefault on `onFocusOutside` (mirrors the AnchoredBar hardening) keeps it open; the popover still closes on swatch / eyedropper commits and on outside-click / Esc. Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/text-format-bar.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/edit/surfaces/slide/text-format-bar.tsx b/components/edit/surfaces/slide/text-format-bar.tsx index 6b515e7730..eaad3cbde4 100644 --- a/components/edit/surfaces/slide/text-format-bar.tsx +++ b/components/edit/surfaces/slide/text-format-bar.tsx @@ -242,7 +242,13 @@ export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { align="center" sideOffset={8} className="w-auto p-3" + // Dragging on the SV pad / hue slider fires onChange every tick, + // each tick dispatches the color command which calls + // editorView.focus() — pulling focus out of this popover. Without + // preventing onFocusOutside, that focus shift triggers Radix's + // dismiss path and the picker closes the instant the drag starts. onOpenAutoFocus={(e) => e.preventDefault()} + onFocusOutside={(e) => e.preventDefault()} > Date: Sat, 23 May 2026 11:22:31 -0400 Subject: [PATCH 35/40] fix(maic-editor): scope body padding override + gate ColorPicker mid-drag sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from a self-CR on the branch: - `body { padding-right: 0 !important }` was global, overriding Radix's `react-remove-scroll` compensation for every Dialog / Sheet / Select / Popover across the app. Scope it to a `body[data-maic-editor='true']` selector; `SlideCanvas` sets the attribute while mounted. Non-editor pages get Radix's default behavior back. - `ColorPicker`'s `useEffect(() => setColor(value), [value])` mirror could race a stale `value` against the user's current pointer position mid-drag — a single late round-trip would snap the picker back. Gate the re-sync on `isDragging.current` (cleared on `pointerup`); external commits (swatch / eyedropper) still sync immediately because they fire while no drag is in flight. Co-Authored-By: Claude Opus 4.7 --- app/globals.css | 14 ++++++---- .../edit/surfaces/slide/ColorPicker.tsx | 26 ++++++++++++++----- .../edit/surfaces/slide/SlideCanvas.tsx | 11 ++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/app/globals.css b/app/globals.css index 48f43c0bdc..520a5c699f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -131,11 +131,15 @@ } body { @apply bg-background text-foreground; - /* Radix Select / Popover wrap with `react-remove-scroll`, which adds a - compensation `padding-right` to when they open. Our - already reserves the scrollbar gutter (above), so that compensation - creates a visible layout shift on every dropdown open — pin body's - padding-right so opening a Select doesn't move the page. */ + } + /* Radix Select / Popover wrap with `react-remove-scroll`, which adds a + compensation `padding-right` to when they open. Our + 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; } } diff --git a/components/edit/surfaces/slide/ColorPicker.tsx b/components/edit/surfaces/slide/ColorPicker.tsx index 249c346326..e78bd95470 100644 --- a/components/edit/surfaces/slide/ColorPicker.tsx +++ b/components/edit/surfaces/slide/ColorPicker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { HexColorPicker } from 'react-colorful'; import { Pipette } from 'lucide-react'; @@ -44,14 +44,28 @@ interface ColorPickerProps { */ export function ColorPicker({ value, onChange, onCommit }: ColorPickerProps) { // Local mirror so the picker UI stays responsive while dragging without - // round-tripping through ProseMirror + store on every tick. Re-sync when - // `value` changes externally (swatch click, eyedropper, parent reset); - // suppressing the cascade-render lint because the cascade *is* the intent. + // round-tripping through ProseMirror + store on every tick. const [color, setColor] = useState(value); - // eslint-disable-next-line react-hooks/set-state-in-effect - useEffect(() => setColor(value), [value]); + // Don't snap the picker back mid-drag: a stale `value` arriving from a + // ProseMirror dispatch a few ticks behind would otherwise overwrite the + // user's current pointer position. Gate the re-sync on the pointer being + // up. External commits (swatch / eyedropper) sync immediately because + // they fire while no drag is in flight. + const isDragging = useRef(false); + useEffect(() => { + const onUp = () => { + isDragging.current = false; + }; + window.addEventListener('pointerup', onUp); + return () => window.removeEventListener('pointerup', onUp); + }, []); + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!isDragging.current) setColor(value); + }, [value]); const handleChange = (c: string) => { + isDragging.current = true; setColor(c); onChange(c); }; diff --git a/components/edit/surfaces/slide/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx index 64028ffda2..aacdcb1d60 100644 --- a/components/edit/surfaces/slide/SlideCanvas.tsx +++ b/components/edit/surfaces/slide/SlideCanvas.tsx @@ -44,6 +44,17 @@ 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 From f53e7a63c33ec50b961778474296270f3bb4baa0 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sat, 23 May 2026 11:22:31 -0400 Subject: [PATCH 36/40] chore(maic-editor): polish from self-CR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate the `richTextAttrs` reset in `useSyncEditingElementId` to only fire on element-to-element transitions (track previous editing id via a ref). The unconditional reset on the first selection briefly flashed neutral defaults (color #000, fontsize 16px) before the focusing ProseMirror repopulated the real values. - Doc-comment the text-insertion add-element asymmetry: text uses the renderer's `addElement` (because the rect math lives there and we get auto-select for free), image uses surface-side `applyOp` (its source is the ImagePicker, not a canvas gesture). Both commit through the same store, but the text lane doesn't show as a typed `element.add` op in the session history — acceptable, now explicit. Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/use-slide-surface.ts | 23 ++++++++++++------- .../hooks/useInsertFromCreateSelection.ts | 10 ++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index a54467aa32..258f6c4dbc 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -236,16 +236,23 @@ export function useSelectedNonTextElementId(): string { 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); - // Also reset `richTextAttrs` to defaults: it's a single shared store - // updated by whichever ProseMirror was last focused. Without this, the - // format bar visibly carries the previous element's toggle states (B, I, - // alignment, …) for a moment when the selection jumps to a different - // text element — the new element's ProseMirror only repopulates the - // attrs once it takes focus. Resetting on every editing-id change makes - // the bar show neutral defaults during the transition instead of stale. - setRichTextAttrs(defaultRichTextAttrs); + 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/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts index b9e21feea6..c10c2df352 100644 --- a/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts +++ b/components/slide-renderer/Editor/Canvas/hooks/useInsertFromCreateSelection.ts @@ -90,6 +90,16 @@ export function useInsertFromCreateSelection(viewportRef: RefObject Date: Sat, 23 May 2026 11:29:17 -0400 Subject: [PATCH 37/40] chore(maic-editor): listen to every gesture-end channel in ColorPicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR round-2 residual nit: the single `pointerup` listener that clears the drag-gate would silently keep the gate stuck on any browser / emulator that only emits the older mouse/touch families. Listen on all four (`mouseup`, `touchend`, `pointerup`, `pointercancel`) — belt-and-suspenders, no behavior change on the common path. Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/ColorPicker.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/edit/surfaces/slide/ColorPicker.tsx b/components/edit/surfaces/slide/ColorPicker.tsx index e78bd95470..6b78454406 100644 --- a/components/edit/surfaces/slide/ColorPicker.tsx +++ b/components/edit/surfaces/slide/ColorPicker.tsx @@ -56,8 +56,13 @@ export function ColorPicker({ value, onChange, onCommit }: ColorPickerProps) { const onUp = () => { isDragging.current = false; }; - window.addEventListener('pointerup', onUp); - return () => window.removeEventListener('pointerup', onUp); + // react-colorful dispatches `mouseup` / `touchend` directly (not + // synthetic pointer events), so we listen on every gesture-end channel + // to catch any browser / emulator that only emits one family. + // `pointercancel` handles the OS yanking the gesture mid-drag. + const channels = ['mouseup', 'touchend', 'pointerup', 'pointercancel'] as const; + channels.forEach((ev) => window.addEventListener(ev, onUp)); + return () => channels.forEach((ev) => window.removeEventListener(ev, onUp)); }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect From 975e405ab61417cf1f72025a654e965379e13d2e Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 24 May 2026 01:39:29 -0400 Subject: [PATCH 38/40] fix(maic-editor): preserve image aspect ratio on insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createDefaultImageElement` hardcoded the new image's box to 360×220, so anything not ~1.6:1 (which is almost everything users upload — photos, screenshots, logos) ended up squashed or stretched the moment it landed on the slide. Wrap the factory in `insertImageElement` that measures the source via `new Image()`, then dispatches `element.add` with dimensions scaled to fit MAX 600×400 while preserving the natural ratio. Load failure falls back to the factory default so insertion always succeeds. Co-Authored-By: Claude Opus 4.7 --- .../edit/surfaces/slide/use-slide-surface.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 258f6c4dbc..6cc42a3b94 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -52,13 +52,54 @@ export function buildInsertItems( 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, }), }, ]; } +// 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; + } + 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 }); From 55a9a712de934e27135af77584d74fef29258098 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 24 May 2026 01:40:03 -0400 Subject: [PATCH 39/40] chore(maic-editor): drop the now-dead addElement helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addElement` was only ever used by the inline image-insert which became `insertImageElement`; text uses `armText` (toggle). PPTElement-typed parameter was already unused after the text refactor — removing the dead helper resolves the lint warning. Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/use-slide-surface.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index 6cc42a3b94..da9dee5461 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -29,8 +29,6 @@ export function buildInsertItems( // branch in useInsertFromCreateSelection adds the element at that rect. creatingType?: string, ): InsertPaletteItem[] { - const addElement = (element: PPTElement) => - useSlideEditSession.getState().applyOp({ type: 'element.add', element }); const armText = () => { const cs = useCanvasStore.getState(); cs.setCreatingElement(creatingType === 'text' ? null : { type: 'text' }); From 9b6bf001121c5610c6767a7a4084fb29884dff34 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 24 May 2026 01:42:03 -0400 Subject: [PATCH 40/40] chore(maic-editor): drop now-unused PPTElement import in use-slide-surface After `addElement` was dropped (55a9a71), the `PPTElement` type import has no remaining consumers in this file. Co-Authored-By: Claude Opus 4.7 --- components/edit/surfaces/slide/use-slide-surface.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/components/edit/surfaces/slide/use-slide-surface.ts b/components/edit/surfaces/slide/use-slide-surface.ts index da9dee5461..2254d97bf8 100644 --- a/components/edit/surfaces/slide/use-slide-surface.ts +++ b/components/edit/surfaces/slide/use-slide-surface.ts @@ -11,7 +11,6 @@ import { createDefaultImageElement, createDefaultSlide } from '@/lib/edit/slide- 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';