chore(editor): flatten insert menu

- Insert menu: replace the nested "More" submenu with a flat dropdown —
  insert actions plus inline Focus Mode and Formatting-toolbar toggles.
- Formatting toolbar: remove the link button and its window.prompt flow;
  the link command stays in the shared catalog for future surfaces.
This commit is contained in:
boojack 2026-07-05 20:43:02 +08:00
parent b787bfa75f
commit 1794d0dc51
3 changed files with 30 additions and 156 deletions

View file

@ -1,18 +1,11 @@
import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, PilcrowIcon } from "lucide-react"; import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, PilcrowIcon } from "lucide-react";
import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react"; import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { import {
EDITOR_COMMANDS, EDITOR_COMMANDS,
EDITOR_COMMANDS_BY_ID,
type EditorCommand, type EditorCommand,
type EditorCommandId, type EditorCommandId,
isCommandActive, isCommandActive,
@ -60,12 +53,12 @@ const SEGMENT_ACTIVE = "bg-accent text-accent-foreground";
const preventFocusSteal: MouseEventHandler<HTMLButtonElement> = (event) => event.preventDefault(); const preventFocusSteal: MouseEventHandler<HTMLButtonElement> = (event) => event.preventDefault();
/** /**
* Formatting toolbar: a lean inline row of the heading picker plus mark/list/link * Formatting toolbar: a lean inline row of the heading picker plus mark/list
* controls, every one derived from the shared command catalog * controls, every one derived from the shared command catalog
* (formatting/commands.ts), so adding a verb there surfaces it here automatically. * (formatting/commands.ts), so adding a verb there surfaces it here automatically.
* Groups are separated by thin vertical dividers. Responsive: below * Groups are separated by thin vertical dividers. Responsive: below
* COMPACT_TOOLBAR_WIDTH the list and link controls fold into a "more" menu while * COMPACT_TOOLBAR_WIDTH the list controls fold into a "more" menu while marks stay
* marks stay inline. In focus mode an exit button is pushed to the far edge. * inline. In focus mode an exit button is pushed to the far edge.
*/ */
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) { export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
const t = useTranslate(); const t = useTranslate();
@ -83,28 +76,12 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
controllerRef.current?.focus(); controllerRef.current?.focus();
}; };
const handleLink = () => { // Map a catalog command to a toolbar button.
const formatting = controllerRef.current?.formatting;
if (!formatting) {
return;
}
if (formatting.getActiveFormats().link) {
formatting.run("link");
} else {
// window.prompt blurs the editor, so refocus below regardless of outcome.
const url = window.prompt(t("editor.format.link-prompt"));
if (url) formatting.run("link", { url });
}
controllerRef.current?.focus();
};
// Map a catalog command to a toolbar button. `link` keeps its bespoke
// prompt-then-apply flow; everything else just runs its command.
const toButton = (command: EditorCommand): ToolbarButton => ({ const toButton = (command: EditorCommand): ToolbarButton => ({
Icon: command.icon, Icon: command.icon,
label: t(command.labelKey), label: t(command.labelKey),
active: isCommandActive(active, command.id), active: isCommandActive(active, command.id),
onClick: command.id === "link" ? handleLink : () => run(command.id), onClick: () => run(command.id),
}); });
// Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4–H6) // Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4–H6)
@ -112,7 +89,6 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
const HeadingGlyph = active.headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS[active.headingLevel]; const HeadingGlyph = active.headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS[active.headingLevel];
const markButtons = MARK_COMMANDS.map(toButton); const markButtons = MARK_COMMANDS.map(toButton);
const listButtons = LIST_COMMANDS.map(toButton); const listButtons = LIST_COMMANDS.map(toButton);
const linkButton = toButton(EDITOR_COMMANDS_BY_ID.link);
return ( return (
<div <div
@ -153,18 +129,10 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
{button.label} {button.label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={linkButton.onClick}>{linkButton.label}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<> listButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
{listButtons.map((button) => (
<SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />
))}
<Divider />
<SegmentButton {...linkButton} onMouseDown={preventFocusSteal} />
</>
)} )}
{onExit && ( {onExit && (
@ -179,7 +147,7 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
); );
} }
// Thin vertical rule between command groups (heading · marks · lists · link). // Thin vertical rule between command groups (heading · marks · lists).
function Divider() { function Divider() {
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />; return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />;
} }

View file

@ -1,33 +1,16 @@
import { uniqBy } from "lodash-es"; import { uniqBy } from "lodash-es";
import { import { CheckIcon, FileIcon, ImageIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, PlusIcon, TypeIcon } from "lucide-react";
FileIcon, import { useCallback, useEffect, useState } from "react";
ImageIcon,
LinkIcon,
LoaderIcon,
type LucideIcon,
MapPinIcon,
Maximize2Icon,
MicIcon,
MoreHorizontalIcon,
PlusIcon,
TypeIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata"; import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import type { MapPoint } from "@/components/map/types"; import type { MapPoint } from "@/components/map/types";
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding"; import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useDebouncedEffect } from "@/hooks"; import { useDebouncedEffect } from "@/hooks";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
@ -52,12 +35,6 @@ const InsertMenu = (props: InsertMenuProps) => {
const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
150,
setMoreSubmenuOpen,
);
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => { const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
newFiles.forEach((file) => dispatch(actions.addLocalFile(file))); newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
@ -137,16 +114,6 @@ const InsertMenu = (props: InsertMenuProps) => {
setLocationDialogOpen(false); setLocationDialogOpen(false);
}, [locationReset]); }, [locationReset]);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const handleToggleFormattingToolbar = useCallback(() => {
onToggleFormattingToolbar?.();
setMoreSubmenuOpen(false);
}, [onToggleFormattingToolbar]);
const handleMediaUploadClick = useCallback(() => { const handleMediaUploadClick = useCallback(() => {
handleUploadClick("image/*,video/*"); handleUploadClick("image/*,video/*");
}, [handleUploadClick]); }, [handleUploadClick]);
@ -155,83 +122,41 @@ const InsertMenu = (props: InsertMenuProps) => {
handleUploadClick(); handleUploadClick();
}, [handleUploadClick]); }, [handleUploadClick]);
const menuItems = useMemo( // Insert actions (add content).
() => const insertItems = [
[ { key: "media", label: t("attachment-library.tabs.media"), icon: ImageIcon, onClick: handleMediaUploadClick },
{ { key: "audio", label: t("editor.audio-recorder.trigger"), icon: MicIcon, onClick: props.onAudioRecorderClick },
key: "upload-media", { key: "file", label: t("common.file"), icon: FileIcon, onClick: handleFileUploadClick },
label: t("attachment-library.tabs.media"), { key: "link", label: t("editor.insert-menu.link-memo"), icon: LinkIcon, onClick: handleOpenLinkDialog },
icon: ImageIcon, { key: "location", label: t("editor.insert-menu.add-location"), icon: MapPinIcon, onClick: handleLocationClick },
onClick: handleMediaUploadClick, ];
},
{
key: "record-audio",
label: t("editor.audio-recorder.trigger"),
icon: MicIcon,
onClick: () => props.onAudioRecorderClick?.(),
},
{
key: "upload-file",
label: t("common.file"),
icon: FileIcon,
onClick: handleFileUploadClick,
},
{
key: "link",
label: t("editor.insert-menu.link-memo"),
icon: LinkIcon,
onClick: handleOpenLinkDialog,
},
{
key: "location",
label: t("editor.insert-menu.add-location"),
icon: MapPinIcon,
onClick: handleLocationClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t],
);
return ( return (
<> <>
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" disabled={isUploading}> <Button variant="secondary" size="icon" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />} {isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
{menuItems.slice(0, 3).map((item) => ( {insertItems.map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}> <DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" /> <item.icon className="w-4 h-4" />
{item.label} {item.label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{menuItems.slice(3).map((item) => ( {/* View toggles: focus mode + formatting-toolbar visibility. */}
<DropdownMenuItem key={item.key} onClick={item.onClick}> <DropdownMenuItem onClick={onToggleFocusMode}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* View submenu: focus mode + normal-mode formatting toolbar toggle */}
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
<MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
<DropdownMenuItem onClick={handleToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" /> <Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")} {t("editor.focus-mode")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuCheckboxItem checked={Boolean(isFormattingToolbarVisible)} onCheckedChange={handleToggleFormattingToolbar}> <DropdownMenuItem onClick={onToggleFormattingToolbar}>
<TypeIcon className="w-4 h-4" /> <TypeIcon className="w-4 h-4" />
{t("editor.formatting-toolbar")} {t("editor.formatting-toolbar")}
</DropdownMenuCheckboxItem> {isFormattingToolbarVisible && <CheckIcon className="w-4 h-4 ml-auto" />}
</DropdownMenuSubContent> </DropdownMenuItem>
</DropdownMenuSub>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -70,25 +70,6 @@ describe("FormattingToolbar", () => {
expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false");
}); });
it("prompts for a URL and links the selection when adding a link", () => {
const { controller, run } = makeController({ getSelectedText: () => "memos" });
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://usememos.com");
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
expect(run).toHaveBeenCalledWith("link", { url: "https://usememos.com" });
promptSpy.mockRestore();
});
it("removes an active link without prompting", () => {
const { controller, run } = makeController({ active: { link: true } });
const promptSpy = vi.spyOn(window, "prompt");
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
expect(promptSpy).not.toHaveBeenCalled();
expect(run).toHaveBeenCalledWith("link");
promptSpy.mockRestore();
});
it("calls onExit when the exit button is clicked", () => { it("calls onExit when the exit button is clicked", () => {
const { controller } = makeController(); const { controller } = makeController();
const { onExit } = renderToolbar(controller); const { onExit } = renderToolbar(controller);