From ba609c5cb807960b700a5fa0b814559b752f7335 Mon Sep 17 00:00:00 2001 From: boojack Date: Thu, 2 Jul 2026 09:19:34 +0800 Subject: [PATCH] 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. --- .../MemoEditor/Toolbar/EditorToolbar.tsx | 11 +- .../MemoEditor/Toolbar/FormattingToolbar.tsx | 152 ++++++++++++------ .../MemoEditor/Toolbar/InsertMenu.tsx | 22 ++- web/src/components/MemoEditor/constants.ts | 9 +- web/src/components/MemoEditor/index.tsx | 27 +++- .../components/MemoEditor/types/components.ts | 6 + web/src/locales/en.json | 1 + 7 files changed, 165 insertions(+), 63 deletions(-) diff --git a/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx b/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx index a216df934..ee9145fd2 100644 --- a/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/Toolbar/EditorToolbar.tsx @@ -8,7 +8,14 @@ import type { EditorToolbarProps } from "../types"; import InsertMenu from "./InsertMenu"; import VisibilitySelector from "./VisibilitySelector"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => { +export const EditorToolbar: FC = ({ + 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 = ({ onSave, onCancel, memoNa onToggleFocusMode={handleToggleFocusMode} memoName={memoName} onAudioRecorderClick={onAudioRecorderClick} + isFormattingToolbarVisible={isFormattingToolbarVisible} + onToggleFormattingToolbar={onToggleFormattingToolbar} /> diff --git a/web/src/components/MemoEditor/Toolbar/FormattingToolbar.tsx b/web/src/components/MemoEditor/Toolbar/FormattingToolbar.tsx index 185a059e6..3db5951d5 100644 --- a/web/src/components/MemoEditor/Toolbar/FormattingToolbar.tsx +++ b/web/src/components/MemoEditor/Toolbar/FormattingToolbar.tsx @@ -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; - 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 = { 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 = (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 (H4–H6) + // 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 (
- + - + {HEADING_COMMANDS.map((command) => ( run(command.id)}> {t(command.labelKey)} @@ -110,17 +144,17 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti {markButtons.map((button) => ( - + ))} + + {compact ? ( - + - + {listButtons.map((button) => ( {button.label} @@ -132,34 +166,52 @@ export function FormattingToolbar({ controllerRef, onExit, className }: Formatti ) : ( <> - {listButtons.map((button) => ( - + ))} - + )} -
- - + {onExit && ( + <> +
+ + + )}
); } +// Thin vertical rule between command groups (heading · marks · lists · link). function Divider() { - return
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 914e828c5..acc41d737 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -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 { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 7e454f8ab..2ee2d893e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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",