diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ae9b61..26f54a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Changed +- Group the editor's context actions in a compact popup outside tables. Icon controls fill their rows, separators distinguish command groups, and short labels name block movement, footnote actions, image replacement, the code language, and a callout's authored disclosure. - Rewrite the bundled help guides with numbered window illustrations, task-by-task instructions, menu paths and shortcuts, and Markdown examples pairing source with its rendered result. The guides and illustrations work offline and follow the current appearance. - Redesign the help pages, keyboard shortcuts, What's new, and Changelog as one family. Each opens larger, taking most of the window's height and as much width as its text needs, with its title in a header and its text at 15 px. A help page with several sections, and the Changelog, list them beside the text in a wide window to jump between. What's new alternates its highlights' drawings from side to side, starts each change with its first sentence in bold, and folds a release's fixes behind a count you can open. The Changelog lays out every release the same way, with its fixes listed, and the shortcuts sit in cards side by side. - Show shortcuts and key hints as raised keycaps across menus, the command palette, the welcome screen, the shortcuts reference, help, and release notes. Keycaps name the platform's keys: Ctrl on Windows and Linux, and modifier symbols on macOS, with full names for screen readers. diff --git a/docs/reference.md b/docs/reference.md index e494cc83..273884be 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -315,6 +315,8 @@ The context popup is a contextual menu triggered by a pointer or keyboard select The popup offers the actions of the context it opens in, one row or submenu per group, in this order. A command unavailable within its context stays visible but disabled. +Outside tables, rows with one or two actions show text: `Up` and `Down` for block movement, `Rename` and `Go to` for footnotes, `Language...` for a code block's language, and `Opens expanded` for a callout's authored disclosure. Paste and Replace image keep their command labels. The full command name remains the accessible name. + - **Caret:** Paste; Blockquote, Ordered list, Unordered list, Task list; Block type; Insert. - **Text selection:** Cut, Copy, Paste, Delete; Strong, Emphasis, Inline code, Link; Block type. - **Structural block selection:** Cut, Copy, Paste, Delete; Move block up, Move block down; Blockquote, Ordered list, Unordered list, Task list; Block type; Insert. diff --git a/docs/specification.md b/docs/specification.md index 6026169a..5aa6d7ca 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -174,6 +174,8 @@ A named folder is one ordinary root folder with an editable display name, ordere - **Open from right-click:** commands act on the editor selection established by the right-click. - **Open from keyboard:** commands act on the caret or selection the request was made from, and the popup holds focus. +Outside tables, the popup is compact and separates command groups with horizontal rules. Icon controls share each row's full width. Rows with one or two actions show short labels, wrapping longer translations within the popup. Accessible names retain the full command name, and a tooltip supplies it where the visible label is shortened. Submenu rows show their icon and label together. + ## Editor Model The editor is a unified hybrid Markdown surface. Behavior is governed by rendering rules, contextual marker visibility, command transformations, and serialization. diff --git a/e2e/desktop/specs/block-selection.spec.ts b/e2e/desktop/specs/block-selection.spec.ts index 6c43ed34..018eeae4 100644 --- a/e2e/desktop/specs/block-selection.spec.ts +++ b/e2e/desktop/specs/block-selection.spec.ts @@ -755,6 +755,33 @@ describe("desktop block selection", () => { await dispatchHandleGesture(first); await expect($("[data-testid='editor-context-popup']")).toBeDisplayed(); + const popupRows = await browser.execute(() => { + const popup = document.querySelector("[data-testid='editor-context-popup']")!; + const bounds = popup.getBoundingClientRect(); + const groups = new Map(); + + for (const control of popup.querySelectorAll("[data-toolbar-row]")) { + const key = control.dataset.toolbarRow!; + const row = groups.get(key) ?? []; + row.push(control.getBoundingClientRect()); + groups.set(key, row); + } + + return [...groups.values()].map((row) => ({ + count: row.length, + leadingSpace: row[0].left - bounds.left, + trailingSpace: bounds.right - row.at(-1)!.right, + widths: row.map((control) => control.width), + })); + }); + expect(popupRows.some(({ count }) => count === 2)).toBe(true); + for (const row of popupRows) { + expect(row.leadingSpace).toBeGreaterThanOrEqual(0); + expect(row.trailingSpace).toBeGreaterThanOrEqual(0); + expect(row.leadingSpace).toBeLessThanOrEqual(8); + expect(row.trailingSpace).toBeLessThanOrEqual(8); + expect(Math.max(...row.widths) - Math.min(...row.widths)).toBeLessThan(1); + } const selectedFirstGeometry = await getGeometryForText("Nested first"); expect(Math.abs(selectedFirstGeometry.blockLeft - firstGeometry.blockLeft)).toBeLessThan(0.5); await expect($("aria/Bold")).not.toExist(); diff --git a/src/features/editor/components/editor-context-popup.test.tsx b/src/features/editor/components/editor-context-popup.test.tsx index 37f04c0d..172d3977 100644 --- a/src/features/editor/components/editor-context-popup.test.tsx +++ b/src/features/editor/components/editor-context-popup.test.tsx @@ -230,6 +230,71 @@ describe("editor-context-popup", () => { expect(controlLabels()).toEqual(labels); }); + it("labels sparse rows while keeping the full command names and actions", async () => { + const onExecute = vi.fn(); + const { rerender, user } = renderWithUser( + , + ); + + expect(screen.getByRole("button", { name: "Paste" })).toHaveTextContent("Paste"); + expect(screen.getByRole("button", { name: "Rename footnote" })).toHaveTextContent("Rename"); + expect(screen.getByRole("button", { name: "Jump to footnote definition" })).toHaveTextContent( + "Go to", + ); + await user.click(screen.getByRole("button", { name: "Jump to footnote definition" })); + expect(onExecute).toHaveBeenCalledWith("edit.jumpToFootnoteDefinition"); + + rerender( + , + ); + + expect(screen.getByRole("button", { name: "Move block up" })).toHaveTextContent("Up"); + expect(screen.getByRole("button", { name: "Move block down" })).toHaveTextContent("Down"); + expect(screen.getByRole("button", { name: "Code block language..." })).toHaveTextContent( + "Language...", + ); + expect(screen.getByRole("button", { name: "Copy" }).textContent).toBe(""); + await user.click(screen.getByRole("button", { name: "Move block down" })); + expect(onExecute).toHaveBeenCalledWith("edit.moveBlockDown"); + }); + + it("keeps sparse table-context rows as icon controls", () => { + render( + , + ); + + for (const name of [ + "Paste", + "Rename footnote", + "Jump to footnote definition", + "Delete table", + ]) { + expect(screen.getByRole("button", { name }).textContent).toBe(""); + } + }); + it("places an object's actions directly under the quick actions", () => { const { rerender } = render( { expect(controlLabels().slice(0, 2)).toEqual(["Paste", "Callout opens expanded"]); expect(screen.getByLabelText("Callout opens expanded")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByLabelText("Callout opens expanded")).toHaveTextContent("Opens expanded"); await user.click(screen.getByLabelText("Callout opens expanded")); diff --git a/src/features/editor/components/editor-context-popup.tsx b/src/features/editor/components/editor-context-popup.tsx index 18254867..3fa9cc95 100644 --- a/src/features/editor/components/editor-context-popup.tsx +++ b/src/features/editor/components/editor-context-popup.tsx @@ -5,10 +5,12 @@ import { ArrowRightIcon, ArrowUpIcon, BoldIcon, + CheckIcon, ChevronRightIcon, ClipboardPasteIcon, Code2Icon, CopyIcon, + CornerDownRightIcon, FileCodeIcon, Grid2x2XIcon, Heading1Icon, @@ -21,12 +23,14 @@ import { InfoIcon, ItalicIcon, Link2Icon, + LightbulbIcon, ListIcon, ListOrderedIcon, ListTodoIcon, MinusIcon, PencilLineIcon, PilcrowIcon, + PlusIcon, ScissorsIcon, TableIcon, TextQuoteIcon, @@ -35,6 +39,7 @@ import { type LucideIcon, } from "lucide-react"; import { + Fragment, useEffect, useLayoutEffect, useMemo, @@ -58,9 +63,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; import { Toolbar, ToolbarButton } from "@/components/ui/toolbar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useLocalization, type MessageId } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; import type { EditorCommandId, EditorCommandState } from "../commands"; import type { CalloutTypeChoice, CalloutTypeChoices } from "../commands/formatting/callouts"; @@ -197,6 +204,15 @@ const OBJECT_COMMAND_ROWS = [ { id: "callout", commands: CALLOUT_COMMANDS }, ] as const; +const FITTED_COMMAND_LABELS: Partial> = { + "edit.moveBlockUp": "editor.contextPopup.moveUp", + "edit.moveBlockDown": "editor.contextPopup.moveDown", + "edit.renameFootnote": "editor.contextPopup.renameFootnote", + "edit.jumpToFootnoteDefinition": "editor.contextPopup.goToFootnote", + "format.codeBlockLanguage": "editor.contextPopup.codeBlockLanguage", + "format.calloutExpanded": "editor.contextPopup.calloutExpanded", +}; + type ContextRow = | { id: string; kind: "buttons"; commands: readonly ContextButtonCommand[] } | { id: string; kind: "submenu"; commands: readonly ContextSubmenuEntry[]; labelId: MessageId } @@ -518,6 +534,9 @@ export function EditorContextPopup({ return null; } + const fitted = request.container !== "table"; + const rows = getContextRows(request, canExecute); + return ( { if (hasHeldFocusRef.current) { @@ -566,43 +588,45 @@ export function EditorContextPopup({ side="bottom" sideOffset={8} > - {getContextRows(request, canExecute).map((contextRow, row) => { - switch (contextRow.kind) { - case "buttons": - return ( - - ); - case "submenu": - return ( - - ); - case "calloutTypes": - return ( - - ); - } - })} + {rows.map((contextRow, row) => ( + + {fitted && + row > 0 && + (contextRow.kind === "buttons" || rows[row - 1]?.kind === "buttons") && ( + + )} + {contextRow.kind === "buttons" && ( + + )} + {contextRow.kind === "submenu" && ( + + )} + {contextRow.kind === "calloutTypes" && ( + + )} + + ))} ); @@ -610,6 +634,7 @@ export function EditorContextPopup({ interface ContextCommandRowProps { commands: readonly ContextButtonCommand[]; + fitted: boolean; onExecute: (commandId: EditorCommandId) => void; canExecute: (commandId: EditorCommandId) => boolean; isChecked: (commandId: EditorCommandId) => boolean | undefined; @@ -618,47 +643,83 @@ interface ContextCommandRowProps { function ContextCommandRow({ commands, + fitted, onExecute, canExecute, isChecked, row, }: ContextCommandRowProps) { const { t } = useLocalization(); + const showLabels = fitted && commands.length < 4; return ( -
+
{commands.map(({ commandId, icon: Icon }, column) => { const label = t(getEditorCommandLabelId(commandId)); const enabled = canExecute(commandId); + const checked = isChecked(commandId); + const shortLabelId = showLabels ? FITTED_COMMAND_LABELS[commandId] : undefined; + const showTooltip = !showLabels || shortLabelId !== undefined; + const CommandIcon = + fitted && commandId === "edit.jumpToFootnoteDefinition" ? CornerDownRightIcon : Icon; + const button = ( +
@@ -667,11 +728,12 @@ function ContextCommandRow({ interface ContextSubmenuProps { children: ReactNode; + icon?: LucideIcon; label: string; row: number; } -function ContextSubmenu({ children, label, row }: ContextSubmenuProps) { +function ContextSubmenu({ children, icon: Icon, label, row }: ContextSubmenuProps) { return ( @@ -688,7 +753,12 @@ function ContextSubmenu({ children, label, row }: ContextSubmenuProps) { } {...toolbarPosition(row, 0)} > - {label} + {Icon && } + + {label} + @@ -704,6 +774,7 @@ interface CalloutTypeChoiceProps { } interface ContextCommandSubmenuProps extends CalloutTypeChoiceProps { + fitted: boolean; labelId: MessageId; commands: readonly ContextSubmenuEntry[]; onExecute: (commandId: EditorCommandId) => void; @@ -712,6 +783,7 @@ interface ContextCommandSubmenuProps extends CalloutTypeChoiceProps { } function ContextCommandSubmenu({ + fitted, labelId, commands, onChooseCalloutType, @@ -724,7 +796,13 @@ function ContextCommandSubmenu({ const label = t(labelId); return ( - + {commands.map((entry) => { if ("calloutTypes" in entry) { @@ -844,10 +922,12 @@ function InsertCalloutItems({ onChooseCalloutType, readCalloutTypes }: CalloutTy } interface ContextCalloutTypeSubmenuProps extends CalloutTypeChoiceProps { + fitted: boolean; row: number; } function ContextCalloutTypeSubmenu({ + fitted, onChooseCalloutType, readCalloutTypes, row, @@ -856,7 +936,7 @@ function ContextCalloutTypeSubmenu({ const label = t("editor.contextPopup.calloutType"); return ( - +