fix(editor): scope view toggles to inline presentations (#6227)

This commit is contained in:
amblued 2026-08-23 16:26:17 +08:00 committed by GitHub
parent 76a7629243
commit fc6cbab7d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 136 additions and 66 deletions

View file

@ -103,6 +103,15 @@ A reducer (`state/reducer.ts`) drives an **external store**, not a `useReducer`
Pure TypeScript functions containing business logic. No React hooks, easy to test.
### Presentation: inline vs hosted
Every instance is one of two things, and `onFocusModeExit` is the switch:
- **Inline** (prop omitted) — the editor sits in page flow (Home composer, memo edit, comments) and owns its presentation. The + menu offers the view toggles: focus mode expands the editor over the page and the formatting toolbar's trailing button minimizes it back in place, while the formatting-toolbar preference governs the normal-mode layout.
- **Hosted** (prop supplied) — a host presents the editor full-screen and owns that frame; `contexts/GlobalMemoEditorContext.tsx` is the one today. The editor mounts straight into focus mode and exits by calling back to dismiss the host, so the formatting toolbar's trailing button reads as Close rather than minimize. The + menu's view toggles are absent: focus mode is not the editor's to leave, and it already forces the formatting toolbar on.
Those toggles travel as a single optional `viewToggles` object (`types/components.ts`) down `EditorToolbar` → `InsertMenu`, so they can only appear or disappear together.
### Lifecycle hooks
Cross-cutting React workflows stay outside the editor shell. `useMemoSave`

View file

@ -14,8 +14,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
onCancel,
memoName,
onAudioRecorderClick,
isFormattingToolbarVisible,
onToggleFormattingToolbar,
viewToggles,
onInsertImages,
}) => {
const t = useTranslate();
@ -40,10 +39,6 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
dispatch(actions.setMetadata({ location: next }));
};
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
const handleVisibilityChange = (next: Visibility) => {
dispatch(actions.setMetadata({ visibility: next }));
};
@ -56,11 +51,9 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
isSaving={isSaving}
location={location}
onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode}
memoName={memoName}
onAudioRecorderClick={onAudioRecorderClick}
isFormattingToolbarVisible={isFormattingToolbarVisible}
onToggleFormattingToolbar={onToggleFormattingToolbar}
viewToggles={viewToggles}
onInsertImages={onInsertImages}
/>
<VisibilitySelector value={visibility} onChange={handleVisibilityChange} />

View file

@ -1,4 +1,13 @@
import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, TypeIcon } from "lucide-react";
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
type LucideIcon,
Minimize2Icon,
MoreHorizontalIcon,
TypeIcon,
XIcon,
} from "lucide-react";
import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@ -16,8 +25,12 @@ import type { EditorController } from "../types";
interface FormattingToolbarProps {
controllerRef: RefObject<EditorController | null>;
/** Called by the exit button; when omitted (normal-mode toolbar) the button is hidden. */
onExit?: () => void;
/**
* Trailing dismiss button for the frame the editor sits in: "minimize" collapses
* focus mode back into the page, "close" dismisses a host-owned frame. Omitted on
* the inline normal-mode toolbar, which has no frame to leave.
*/
exit?: { action: "minimize" | "close"; onExit: () => void };
/** Extra classes for the host to frame the toolbar row. */
className?: string;
}
@ -58,9 +71,10 @@ const preventFocusSteal: MouseEventHandler<HTMLButtonElement> = (event) => event
* (formatting/commands.ts), so adding a verb there surfaces it here automatically.
* Groups are separated by thin vertical dividers. Responsive: below
* COMPACT_TOOLBAR_WIDTH the block controls fold into a "more" menu while marks
* stay inline. In focus mode an exit button is pushed to the far edge.
* stay inline. When the editor sits in a frame, the button that dismisses it is
* pushed to the far edge.
*/
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
export function FormattingToolbar({ controllerRef, exit, className }: FormattingToolbarProps) {
const t = useTranslate();
const rootRef = useRef<HTMLDivElement>(null);
const width = useElementWidth(rootRef);
@ -87,6 +101,8 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
// Type glyph for paragraph, else the matching Hn glyph. Deeper levels (H4–H6)
// aren't toolbar-addressable and report as null, i.e. the Type glyph.
const HeadingGlyph = active.headingLevel === null ? TypeIcon : HEADING_LEVEL_ICONS[active.headingLevel];
const ExitIcon = exit?.action === "close" ? XIcon : Minimize2Icon;
const exitLabel = exit && t(exit.action === "close" ? "common.close" : "editor.exit-focus-mode");
const markButtons = MARK_COMMANDS.map(toButton);
const blockButtons = BLOCK_COMMANDS.map(toButton);
@ -131,11 +147,11 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
blockButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
)}
{onExit && (
{exit && (
<>
<div className="flex-1" />
<Button variant="ghost" size="icon" aria-label={t("editor.exit-focus-mode")} title={t("editor.exit-focus-mode")} onClick={onExit}>
<Minimize2Icon className="w-4 h-4" />
<Button variant="ghost" size="icon" aria-label={exitLabel} title={exitLabel} onClick={exit.onExit}>
<ExitIcon className="w-4 h-4" />
</Button>
</>
)}

View file

@ -35,14 +35,7 @@ const InsertMenu = (props: InsertMenuProps) => {
const t = useTranslate();
const { actions, dispatch, getState } = useEditorContext();
const relations = useEditorSelector((s) => s.metadata.relations);
const {
location: initialLocation,
onLocationChange,
onToggleFocusMode,
onToggleFormattingToolbar,
isFormattingToolbarVisible,
isUploading: isUploadingProp,
} = props;
const { location: initialLocation, onLocationChange, viewToggles, isUploading: isUploadingProp } = props;
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
@ -169,17 +162,22 @@ const InsertMenu = (props: InsertMenuProps) => {
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* View toggles: focus mode + formatting-toolbar visibility. */}
<DropdownMenuItem onClick={onToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleFormattingToolbar}>
<TypeIcon className="w-4 h-4" />
{t("editor.formatting-toolbar")}
{isFormattingToolbarVisible && <CheckIcon className="w-4 h-4 ml-auto" />}
</DropdownMenuItem>
{/* View toggles: focus mode + formatting-toolbar visibility. Absent
when a host owns the editor's presentation — neither applies there. */}
{viewToggles && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={viewToggles.onToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
</DropdownMenuItem>
<DropdownMenuItem onClick={viewToggles.onToggleFormattingToolbar}>
<TypeIcon className="w-4 h-4" />
{t("editor.formatting-toolbar")}
{viewToggles.isFormattingToolbarVisible && <CheckIcon className="w-4 h-4 ml-auto" />}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>

View file

@ -24,12 +24,14 @@ import {
import { cacheService, errorService, transcriptionService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
import type { MemoEditorProps } from "./types";
import type { EditorViewToggles, MemoEditorProps } from "./types";
import type { LocalFile } from "./types/attachment";
import type { EditorController } from "./types/editorController";
// A host that presents the editor full-screen supplies `onFocusModeExit`; its
// presence is what makes an instance hosted, so focus mode starts on and stays on.
const MemoEditor = (props: MemoEditorProps) => (
<EditorProvider initialFocusMode={props.initialFocusMode}>
<EditorProvider initialFocusMode={Boolean(props.onFocusModeExit)}>
<MemoEditorImpl {...props} />
</EditorProvider>
);
@ -208,8 +210,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
}
}, [editorCacheKey]);
// Hosted: focus mode is the host's frame, so leaving it dismisses the host.
// Inline: focus mode is a view this editor owns and toggles in place.
const handleToggleFocusMode = () => {
if (isFocusMode && onFocusModeExit) {
if (onFocusModeExit) {
rememberCursor();
onFocusModeExit();
return;
@ -268,6 +272,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
}
};
// The + menu's view toggles only describe how an inline editor presents
// itself, so a hosted editor offers neither: its host owns the frame, and
// focus mode already forces the formatting toolbar on.
const viewToggles: EditorViewToggles | undefined = onFocusModeExit
? undefined
: {
onToggleFocusMode: handleToggleFocusMode,
isFormattingToolbarVisible,
onToggleFormattingToolbar: handleToggleFormattingToolbar,
};
const handleSave = useMemoSave({
memoName,
parentMemoName,
@ -301,11 +316,15 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
!isFocusMode && className,
)}
>
{/* Formatting toolbar. Always shown in focus mode (with an exit button);
{/* Formatting toolbar. Always shown in focus mode (trailing the button
that leaves the current frame — minimize inline, close when hosted);
in normal mode it appears only when the user toggled it on via the
insert menu. */}
{(isFocusMode || isFormattingToolbarVisible) && (
<FormattingToolbar controllerRef={editorRef} onExit={isFocusMode ? handleToggleFocusMode : undefined} />
<FormattingToolbar
controllerRef={editorRef}
exit={isFocusMode ? { action: onFocusModeExit ? "close" : "minimize", onExit: handleToggleFocusMode } : undefined}
/>
)}
{(memoName || (!memo && hasTimestamp)) && (
@ -342,8 +361,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
onCancel={onCancel ? handleCancel : undefined}
memoName={memoName}
onAudioRecorderClick={handleAudioRecorderClick}
isFormattingToolbarVisible={isFormattingToolbarVisible}
onToggleFormattingToolbar={handleToggleFormattingToolbar}
viewToggles={viewToggles}
onInsertImages={handleInsertImages}
/>
</div>

View file

@ -11,9 +11,12 @@ export interface MemoEditorProps {
memo?: Memo;
parentMemoName?: string;
autoFocus?: boolean;
/** Opens this editor instance directly in the existing focus-mode presentation. */
initialFocusMode?: boolean;
/** Closes an externally mounted editor when the user exits focus mode. */
/**
* Marks the instance as *hosted*: a host (the global composer dialog) presents
* the editor in the focus-mode layout and owns that frame. The editor mounts
* straight into focus mode, drops the view toggles that only make sense inline,
* and exits by calling this to dismiss the host rather than collapsing in place.
*/
onFocusModeExit?: () => void;
/**
* Default `createTime` for a *new* memo (create mode only). When set, the
@ -36,14 +39,24 @@ export interface EditorContentProps {
onFiles: (files: File[], position: number) => void;
}
/**
* The + menu's view toggles. They change how the editor presents itself
* inline, so a hosted editor omits the whole group and both items disappear
* together — there is no way to offer one without the other.
*/
export interface EditorViewToggles {
onToggleFocusMode: () => void;
/** Whether the formatting toolbar is shown in normal mode (persisted preference). */
isFormattingToolbarVisible: boolean;
onToggleFormattingToolbar: () => void;
}
export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
onAudioRecorderClick: () => void;
/** Whether the formatting toolbar is shown in normal mode (persisted preference). */
isFormattingToolbarVisible: boolean;
onToggleFormattingToolbar: () => void;
viewToggles?: EditorViewToggles;
onInsertImages: (files: File[]) => void;
}
@ -81,12 +94,9 @@ export interface InsertMenuProps {
isSaving?: boolean;
location?: Location;
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
memoName?: string;
onAudioRecorderClick?: () => void;
/** Persisted toggle for the normal-mode formatting toolbar. */
isFormattingToolbarVisible?: boolean;
onToggleFormattingToolbar?: () => void;
viewToggles?: EditorViewToggles;
onInsertImages: (files: File[]) => void;
}

View file

@ -5,6 +5,7 @@ export type {
EditorContentProps,
EditorMetadataProps,
EditorToolbarProps,
EditorViewToggles,
FocusModeExitButtonProps,
FocusModeOverlayProps,
InsertMenuProps,

View file

@ -136,11 +136,12 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
</VisuallyHidden>
<EditorComponent
autoFocus
initialFocusMode
cacheKey="global-memo-editor"
placeholder={t("editor.any-thoughts")}
onConfirm={closeEditor}
onCancel={closeEditor}
// Hosts the editor's focus-mode presentation: it mounts in focus
// mode and dismisses this dialog instead of collapsing inline.
onFocusModeExit={requestCloseEditor}
onSavingChange={reportSaving}
/>

View file

@ -44,10 +44,11 @@ function makeController(opts: { active?: Partial<ActiveFormatState> } = {}) {
return { controller, run };
}
function renderToolbar(controller: EditorController, onExit = vi.fn()) {
function renderToolbar(controller: EditorController, action: "minimize" | "close" = "minimize") {
const ref = createRef<EditorController>();
ref.current = controller;
render(<FormattingToolbar controllerRef={ref} onExit={onExit} />);
const onExit = vi.fn();
render(<FormattingToolbar controllerRef={ref} exit={{ action, onExit }} />);
return { onExit };
}
@ -80,4 +81,13 @@ describe("FormattingToolbar", () => {
fireEvent.click(screen.getByRole("button", { name: "editor.exit-focus-mode" }));
expect(onExit).toHaveBeenCalledTimes(1);
});
it("labels the exit button as close when a host owns the frame", () => {
const { controller } = makeController();
const { onExit } = renderToolbar(controller, "close");
expect(screen.queryByRole("button", { name: "editor.exit-focus-mode" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
expect(onExit).toHaveBeenCalledTimes(1);
});
});

View file

@ -120,10 +120,12 @@ describe("GlobalMemoEditorProvider", () => {
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
expect(mocks.setQuickFindOpen).toHaveBeenCalledWith(false);
// `onFocusModeExit` is the hosted marker: the editor mounts in focus mode and
// exits by dismissing this dialog rather than collapsing inline.
expect(mocks.editorProps).toMatchObject({
autoFocus: true,
initialFocusMode: true,
cacheKey: "global-memo-editor",
onFocusModeExit: expect.any(Function),
});
await waitFor(() => expect(dialog).toContainElement(document.activeElement as HTMLElement | null));

View file

@ -19,7 +19,14 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn();
});
const renderMenu = (onInsertImages = vi.fn(), isSaving = false) =>
const viewToggles = {
onToggleFocusMode: vi.fn(),
isFormattingToolbarVisible: false,
onToggleFormattingToolbar: vi.fn(),
};
/** `hosted` mirrors an editor whose presentation belongs to a host (the global composer). */
const renderMenu = (onInsertImages = vi.fn(), isSaving = false, hosted = false) =>
render(
<EditorProvider>
<InsertMenu
@ -27,7 +34,7 @@ const renderMenu = (onInsertImages = vi.fn(), isSaving = false) =>
onLocationChange={vi.fn()}
onInsertImages={onInsertImages}
onAudioRecorderClick={vi.fn()}
isFormattingToolbarVisible={false}
viewToggles={hosted ? undefined : viewToggles}
/>
</EditorProvider>,
);
@ -51,6 +58,17 @@ describe("InsertMenu", () => {
]);
});
test("drops the view toggles when a host owns the editor's presentation", () => {
renderMenu(vi.fn(), false, true);
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
const labels = screen.getAllByRole("menuitem").map((item) => item.textContent);
expect(labels).not.toContain("editor.focus-mode");
expect(labels).not.toContain("editor.formatting-toolbar");
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
});
test("uses separate unrestricted and multi-image file inputs", () => {
const onInsertImages = vi.fn();
const { container } = renderMenu(onInsertImages);
@ -84,13 +102,7 @@ describe("InsertMenu", () => {
render(
<EditorProvider initialEditorState={state}>
<EditorToolbar
onSave={vi.fn()}
onAudioRecorderClick={vi.fn()}
isFormattingToolbarVisible={false}
onToggleFormattingToolbar={vi.fn()}
onInsertImages={vi.fn()}
/>
<EditorToolbar onSave={vi.fn()} onAudioRecorderClick={vi.fn()} viewToggles={viewToggles} onInsertImages={vi.fn()} />
</EditorProvider>,
);