chore(editor): toggleable formatting toolbar

Add a localStorage-backed insert-menu toggle (default off) that surfaces the formatting toolbar outside focus mode, and redesign it as a lean inline row: ghost icon buttons grouped by thin dividers, a level-reflecting heading glyph (pilcrow / H1-H3), and the active command as the only filled control.

Also keep the editor focused when using the toolbar: command buttons preventDefault on mousedown, and the dropdown menus return focus to the editor on close.
pull/6065/head
boojack 6 days ago
parent 76aee4e177
commit ba609c5cb8

@ -8,7 +8,14 @@ import type { EditorToolbarProps } from "../types";
import InsertMenu from "./InsertMenu";
import VisibilitySelector from "./VisibilitySelector";
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => {
export const EditorToolbar: FC<EditorToolbarProps> = ({
onSave,
onCancel,
memoName,
onAudioRecorderClick,
isFormattingToolbarVisible,
onToggleFormattingToolbar,
}) => {
const t = useTranslate();
const { actions, dispatch } = useEditorContext();
// Subscribe to narrow/derived slices so typing (which only changes content)
@ -42,6 +49,8 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
onToggleFocusMode={handleToggleFocusMode}
memoName={memoName}
onAudioRecorderClick={onAudioRecorderClick}
isFormattingToolbarVisible={isFormattingToolbarVisible}
onToggleFormattingToolbar={onToggleFormattingToolbar}
/>
<VisibilitySelector value={visibility} onChange={handleVisibilityChange} />
</div>

@ -1,5 +1,5 @@
import { HeadingIcon, type LucideIcon, Minimize2Icon, MoreHorizontalIcon } from "lucide-react";
import { type RefObject, useRef } from "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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -10,21 +10,31 @@ import {
} 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 } from "../formatting/commands";
import {
EDITOR_COMMANDS,
EDITOR_COMMANDS_BY_ID,
type EditorCommand,
type EditorCommandId,
isCommandActive,
type ToolbarHeadingLevel,
} from "../formatting/commands";
import { isCompactWidth, useEditorActiveState, useElementWidth } from "../hooks";
import type { EditorController } from "../types";
interface FormattingToolbarProps {
controllerRef: RefObject<EditorController | null>;
onExit: () => void;
/** Extra classes for the host to frame the toolbar (e.g. the focus-mode header band). */
/** Called by the exit button; when omitted (normal-mode toolbar) the button is hidden. */
onExit?: () => void;
/** Extra classes for the host to frame the toolbar row. */
className?: string;
}
const MARK_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "mark");
const LIST_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "list");
// Paragraph + headings render as a single label-only dropdown (a closed set).
// Paragraph + headings render as a single icon dropdown (a closed set); the
// trigger glyph reflects the current block level.
const HEADING_COMMANDS = EDITOR_COMMANDS.filter((command) => command.group === "heading");
const HEADING_LEVEL_ICONS: Record<ToolbarHeadingLevel, LucideIcon> = { 1: Heading1Icon, 2: Heading2Icon, 3: Heading3Icon };
interface ToolbarButton {
Icon?: LucideIcon;
@ -33,12 +43,29 @@ interface ToolbarButton {
onClick: () => void;
}
// Button styling: quiet ghost controls that sit directly on the editor surface
// (no filled track or border — that container read as a heavy slab). The active
// verb is the only filled element, so the toolbar recedes and the current state
// carries the weight. Kept as raw buttons (not the Button kit) because the idle
// hover + active treatment don't map to a single kit variant, and per policy a
// custom look is raw HTML rather than className overrides on the kit.
const SEGMENT_BASE =
"inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded-md text-sm transition-colors outline-none touch-manipulation focus-visible:ring-2 focus-visible:ring-ring";
const SEGMENT_IDLE = "text-muted-foreground hover:text-foreground hover:bg-foreground/5";
const SEGMENT_ACTIVE = "bg-accent text-accent-foreground";
// Command buttons must not take focus on mousedown — that blurs the editor and
// drops the selection the command targets. The click still fires and applies the
// format to the live selection.
const preventFocusSteal: MouseEventHandler<HTMLButtonElement> = (event) => event.preventDefault();
/**
* Focus-mode header: a rich-text formatting toolbar driven entirely through the
* editor controller's formatting capability. Every button is derived from the
* shared command catalog (formatting/commands.ts), so adding a verb there
* surfaces it here automatically. Responsive: below COMPACT_TOOLBAR_WIDTH the
* list and link controls fold into a "more" menu while marks stay inline.
* Formatting toolbar: a lean inline row of the heading picker plus mark/list/link
* 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.
*/
export function FormattingToolbar({ controllerRef, onExit, className }: FormattingToolbarProps) {
const t = useTranslate();
@ -49,6 +76,13 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
const run = (id: EditorCommandId) => controllerRef.current?.formatting?.run(id);
// Menus grab focus while open and hand it back to their trigger on close; send
// it to the editor instead so the user can keep typing after a pick.
const returnFocusToEditor = (event: Event) => {
event.preventDefault();
controllerRef.current?.focus();
};
const handleLink = () => {
const formatting = controllerRef.current?.formatting;
if (!formatting) {
@ -56,18 +90,19 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
}
if (formatting.getActiveFormats().link) {
formatting.run("link");
return;
}
const url = window.prompt(t("editor.format.link-prompt"));
if (!url) {
return;
}
const selected = formatting.getSelectedText();
if (selected) {
formatting.run("link", { url });
} else {
controllerRef.current?.insertMarkdown(`[${url}](${url})`);
// window.prompt blurs the editor, so refocus below regardless of outcome.
const url = window.prompt(t("editor.format.link-prompt"));
if (url) {
const selected = formatting.getSelectedText();
if (selected) {
formatting.run("link", { url });
} else {
controllerRef.current?.insertMarkdown(`[${url}](${url})`);
}
}
}
controllerRef.current?.focus();
};
// Map a catalog command to a toolbar button. `link` keeps its bespoke
@ -79,7 +114,9 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
onClick: command.id === "link" ? handleLink : () => run(command.id),
});
const headingLabel = active.headingLevel === null ? t("editor.format.paragraph") : t(`editor.format.heading-${active.headingLevel}`);
// Pilcrow for paragraph, else the matching Hn glyph. Deeper levels (H4H6)
// aren't toolbar-addressable and report as null, i.e. the pilcrow.
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);
@ -87,18 +124,15 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
return (
<div
ref={rootRef}
className={cn("w-full flex flex-row items-center gap-1 flex-nowrap", className)}
className={cn("w-full flex flex-row items-center gap-0.5", className)}
role="toolbar"
aria-label={t("editor.format.heading")}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label={t("editor.format.heading")}>
<HeadingIcon className="w-4 h-4" />
{!compact && <span className="ml-1 text-xs">{headingLabel}</span>}
</Button>
<SegmentButton Icon={HeadingGlyph} label={t("editor.format.heading")} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent align="start" onCloseAutoFocus={returnFocusToEditor}>
{HEADING_COMMANDS.map((command) => (
<DropdownMenuItem key={command.id} onClick={() => run(command.id)}>
{t(command.labelKey)}
@ -110,17 +144,17 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
<Divider />
{markButtons.map((button) => (
<FmtButton key={button.label} {...button} />
<SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />
))}
<Divider />
{compact ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t("editor.format.more")}>
<MoreHorizontalIcon className="w-4 h-4" />
</Button>
<SegmentButton Icon={MoreHorizontalIcon} label={t("editor.format.more")} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent align="start" onCloseAutoFocus={returnFocusToEditor}>
{listButtons.map((button) => (
<DropdownMenuItem key={button.label} onClick={button.onClick}>
{button.label}
@ -132,34 +166,52 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti
</DropdownMenu>
) : (
<>
<Divider />
{listButtons.map((button) => (
<FmtButton key={button.label} {...button} />
<SegmentButton key={button.label} {...button} onMouseDown={preventFocusSteal} />
))}
<Divider />
<FmtButton {...linkButton} />
<SegmentButton {...linkButton} onMouseDown={preventFocusSteal} />
</>
)}
<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>
{onExit && (
<>
<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>
</>
)}
</div>
);
}
// Thin vertical rule between command groups (heading · marks · lists · link).
function Divider() {
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1 shrink-0" />;
return <span aria-hidden="true" className="w-px h-5 bg-border mx-1.5 shrink-0" />;
}
function FmtButton({ Icon, active, label, onClick }: ToolbarButton) {
// Active state shown by switching the kit variant (prop-only, per the shadcn
// kit usage policy) rather than overriding className.
return (
<Button variant={active ? "secondary" : "ghost"} size="icon" aria-label={label} aria-pressed={active} title={label} onClick={onClick}>
{Icon && <Icon className="w-4 h-4" />}
</Button>
);
interface SegmentButtonProps extends ComponentPropsWithoutRef<"button"> {
Icon?: LucideIcon;
label: string;
/** Toggle state; when set the segment gets aria-pressed and the active fill. */
active?: boolean;
}
// The one segment element, shared by command toggles and dropdown triggers.
// Forwards ref + rest props so it also works as a Radix `asChild` trigger
// (which injects its own onClick/aria attributes).
const SegmentButton = forwardRef<HTMLButtonElement, SegmentButtonProps>(({ Icon, label, active, className, ...rest }, ref) => (
<button
ref={ref}
type="button"
aria-label={label}
aria-pressed={active}
title={label}
className={cn(SEGMENT_BASE, active ? SEGMENT_ACTIVE : SEGMENT_IDLE, className)}
{...rest}
>
{Icon && <Icon className="w-4 h-4" />}
</button>
));
SegmentButton.displayName = "SegmentButton";

@ -10,6 +10,7 @@ import {
MicIcon,
MoreHorizontalIcon,
PlusIcon,
TypeIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
@ -18,6 +19,7 @@ import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
@ -39,7 +41,14 @@ const InsertMenu = (props: InsertMenuProps) => {
const t = useTranslate();
const { actions, dispatch } = useEditorContext();
const relations = useEditorSelector((s) => s.metadata.relations);
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
const {
location: initialLocation,
onLocationChange,
onToggleFocusMode,
onToggleFormattingToolbar,
isFormattingToolbarVisible,
isUploading: isUploadingProp,
} = props;
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
@ -133,6 +142,11 @@ const InsertMenu = (props: InsertMenuProps) => {
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const handleToggleFormattingToolbar = useCallback(() => {
onToggleFormattingToolbar?.();
setMoreSubmenuOpen(false);
}, [onToggleFormattingToolbar]);
const handleMediaUploadClick = useCallback(() => {
handleUploadClick("image/*,video/*");
}, [handleUploadClick]);
@ -201,7 +215,7 @@ const InsertMenu = (props: InsertMenuProps) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* View submenu with Focus Mode + editor display mode */}
{/* 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" />
@ -212,6 +226,10 @@ const InsertMenu = (props: InsertMenuProps) => {
<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>
</DropdownMenuContent>

@ -6,14 +6,13 @@ export const FOCUS_MODE_STYLES = {
},
transition: "transition-all duration-300 ease-in-out",
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
// Full-bleed formatting-toolbar header band. The negative margins + width
// counteract the card's px-4 pt-3 padding (1rem each side) so the band sits
// flush to the inner borders; w-[calc(100%+2rem)] overrides the toolbar's own
// w-full (twMerge keeps this later width). Keep in sync with the card padding.
formattingHeader: "-mx-4 -mt-3 mb-1 w-[calc(100%+2rem)] px-3 py-2 bg-muted/50 border-b border-border rounded-t-lg",
} as const;
export const EDITOR_HEIGHT = {
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
normal: "max-h-[50vh]",
} as const;
// localStorage key for the user's preference to show the formatting toolbar in
// normal (non-focus) mode. Defaults to off.
export const FORMATTING_TOOLBAR_STORAGE_KEY = "memos-editor-formatting-toolbar";

@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useLocalStorage } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
@ -13,7 +14,7 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { errorService, memoService, transcriptionService, validationService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
@ -54,6 +55,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const { markNewMemo } = useNewMemo();
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
// Persisted preference: also show the formatting toolbar in normal mode. Focus
// mode always shows it regardless; this only governs the non-focus layout.
const [isFormattingToolbarVisible, setFormattingToolbarVisible] = useLocalStorage(FORMATTING_TOOLBAR_STORAGE_KEY, false);
const memoName = memo?.name;
const canTranscribe = useMemo(() => {
@ -188,6 +192,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.toggleFocusMode());
};
const handleToggleFormattingToolbar = useCallback(() => {
setFormattingToolbarVisible((visible) => !visible);
}, [setFormattingToolbarVisible]);
const handleStartAudioRecording = async () => {
setIsAudioRecorderOpen(true);
await audioRecorder.startRecording();
@ -314,9 +322,11 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className,
)}
>
{/* Focus-mode header: the formatting toolbar (exit lives in it). */}
{isFocusMode && (
<FormattingToolbar controllerRef={editorRef} onExit={handleToggleFocusMode} className={FOCUS_MODE_STYLES.formattingHeader} />
{/* Formatting toolbar. Always shown in focus mode (with an exit button);
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} />
)}
{(memoName || (!memo && hasTimestamp)) && (
@ -343,7 +353,14 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2">
<EditorMetadata memoName={memoName} />
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onAudioRecorderClick={handleAudioRecorderClick} />
<EditorToolbar
onSave={handleSave}
onCancel={onCancel}
memoName={memoName}
onAudioRecorderClick={handleAudioRecorderClick}
isFormattingToolbarVisible={isFormattingToolbarVisible}
onToggleFormattingToolbar={handleToggleFormattingToolbar}
/>
</div>
</div>
</>

@ -30,6 +30,9 @@ export interface EditorToolbarProps {
onCancel?: () => void;
memoName?: string;
onAudioRecorderClick: () => void;
/** Whether the formatting toolbar is shown in normal mode (persisted preference). */
isFormattingToolbarVisible: boolean;
onToggleFormattingToolbar: () => void;
}
export interface EditorMetadataProps {
@ -65,6 +68,9 @@ export interface InsertMenuProps {
onToggleFocusMode?: () => void;
memoName?: string;
onAudioRecorderClick?: () => void;
/** Persisted toggle for the normal-mode formatting toolbar. */
isFormattingToolbarVisible?: boolean;
onToggleFormattingToolbar?: () => void;
}
export interface VisibilitySelectorProps {

@ -164,6 +164,7 @@
"any-thoughts": "Any thoughts...",
"exit-focus-mode": "Exit Focus Mode",
"focus-mode": "Focus Mode",
"formatting-toolbar": "Formatting toolbar",
"format": {
"heading": "Heading",
"paragraph": "Paragraph",

Loading…
Cancel
Save