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
@@ -1,18 +1,11 @@
import { Heading1Icon, Heading2Icon, Heading3Icon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon, PilcrowIcon } from "lucide-react";
import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, type RefObject, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import {
EDITOR_COMMANDS,
EDITOR_COMMANDS_BY_ID,
type EditorCommand,
type EditorCommandId,
isCommandActive,
@@ -60,12 +53,12 @@ const SEGMENT_ACTIVE = "bg-accent text-accent-foreground";
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
* (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 list and link controls fold into a "more" menu while
* marks stay inline. In focus mode an exit button is pushed to the far edge.
* COMPACT_TOOLBAR_WIDTH the list controls fold into a "more" menu while marks stay
* inline. In focus mode an exit button is pushed to the far edge.
*/
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
const t = useTranslate();
@@ -83,28 +76,12 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
controllerRef.current?.focus();
};
const handleLink = () => {
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.
// Map a catalog command to a toolbar button.
const toButton = (command: EditorCommand): ToolbarButton => ({
Icon: command.icon,
label: t(command.labelKey),
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 (H4H6)
@@ -112,7 +89,6 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
const HeadingGlyph = active.headingLevel === null ? PilcrowIcon : HEADING_LEVEL_ICONS[active.headingLevel];
const markButtons = MARK_COMMANDS.map(toButton);
const listButtons = LIST_COMMANDS.map(toButton);
const linkButton = toButton(EDITOR_COMMANDS_BY_ID.link);
return (
<div
@@ -153,18 +129,10 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
{button.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={linkButton.onClick}>{linkButton.label}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{listButtons.map((button) => (
<SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />
))}
<Divider />
<SegmentButton {...linkButton} onMouseDown={preventFocusSteal} />
</>
listButtons.map((button) => <SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />)
)}
{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() {
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />;
}
@@ -1,33 +1,16 @@
import { uniqBy } from "lodash-es";
import {
FileIcon,
ImageIcon,
LinkIcon,
LoaderIcon,
type LucideIcon,
MapPinIcon,
Maximize2Icon,
MicIcon,
MoreHorizontalIcon,
PlusIcon,
TypeIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckIcon, FileIcon, ImageIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, PlusIcon, TypeIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import type { MapPoint } from "@/components/map/types";
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu";
import { useDebouncedEffect } from "@/hooks";
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 [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[]) => {
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
@@ -137,16 +114,6 @@ const InsertMenu = (props: InsertMenuProps) => {
setLocationDialogOpen(false);
}, [locationReset]);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const handleToggleFormattingToolbar = useCallback(() => {
onToggleFormattingToolbar?.();
setMoreSubmenuOpen(false);
}, [onToggleFormattingToolbar]);
const handleMediaUploadClick = useCallback(() => {
handleUploadClick("image/*,video/*");
}, [handleUploadClick]);
@@ -155,83 +122,41 @@ const InsertMenu = (props: InsertMenuProps) => {
handleUploadClick();
}, [handleUploadClick]);
const menuItems = useMemo(
() =>
[
{
key: "upload-media",
label: t("attachment-library.tabs.media"),
icon: ImageIcon,
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],
);
// 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: "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 },
];
return (
<>
<DropdownMenu modal={false}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{menuItems.slice(0, 3).map((item) => (
{insertItems.map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{menuItems.slice(3).map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<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" />
{t("editor.focus-mode")}
</DropdownMenuItem>
<DropdownMenuCheckboxItem checked={Boolean(isFormattingToolbarVisible)} onCheckedChange={handleToggleFormattingToolbar}>
<TypeIcon className="w-4 h-4" />
{t("editor.formatting-toolbar")}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* 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>
</DropdownMenuContent>
</DropdownMenu>
-19
View File
@@ -70,25 +70,6 @@ describe("FormattingToolbar", () => {
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", () => {
const { controller } = makeController();
const { onExit } = renderToolbar(controller);