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:
@@ -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 (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 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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user