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:
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 { 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" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue