From 110f0971e4fb00bde72622ae9dc8e9d9c93b93b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 19 Oct 2026 03:48:35 -0300 Subject: [PATCH 1/2] Rework table controls around row and column scopes --- CHANGELOG.md | 1 + docs/help/markdown-reference.md | 2 + docs/reference.md | 2 +- docs/specification.md | 4 +- e2e/desktop/specs/table-controls.spec.ts | 20 ++ .../components/editor-context-popup.test.tsx | 163 ++++++++++++++-- .../components/editor-context-popup.tsx | 184 +++++++++++++++--- .../editor/components/milkdown-editor.css | 26 ++- .../editor/components/table-command-icons.ts | 40 ---- .../editor/plugins/contextPopup.test.ts | 67 ++++++- src/features/editor/plugins/contextPopup.ts | 94 ++++++++- .../editor/plugins/tableControls.test.ts | 14 +- src/features/editor/plugins/tableControls.ts | 27 ++- src/locales/en.json | 6 + 14 files changed, 541 insertions(+), 109 deletions(-) delete mode 100644 src/features/editor/components/table-command-icons.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f54a10..490b93d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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. +- Group table commands in a compact bar with row and column selectors, five icon-only actions for the selected scope, and a separate Delete table button. The selectors name the current row or column, or selected range, and every action remains reachable from the keyboard. Table grips are slim rails that use the accent color while hovered or selected. - 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/help/markdown-reference.md b/docs/help/markdown-reference.md index 1fc78940..1b7b15ea 100644 --- a/docs/help/markdown-reference.md +++ b/docs/help/markdown-reference.md @@ -129,6 +129,8 @@ Use pipes between cells and a separator row after the header. Colons in that sep **Try it:** **Insert > Table** creates a table. **Format > Table** adds, moves, or deletes rows and columns. In the document, table controls provide those actions beside the table too. +Right-click in a table, or press `Shift+F10`, to open its commands. Choose the row or column selector above the action icons to select that scope and show its actions. The selectors name the current row or column, or the selected range; the header is named **Header row**, and body rows start at one. Hover an action to read its name. **Delete table** stays beside the selectors, after a divider. The slim grips on the table's edges select or drag rows and columns, and the `+` buttons insert at a boundary. + ## Code blocks Wrap code in three backticks or tildes. An optional language name enables highlighting when Leafdown knows it. Four leading spaces also make an indented code block, without a language badge. diff --git a/docs/reference.md b/docs/reference.md index 273884be..5811ad7b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -326,7 +326,7 @@ Outside tables, rows with one or two actions show text: `Up` and `Down` for bloc The construct holding the whole caret or selection narrows its context: - **Code block or frontmatter:** Leaves out Strong, Emphasis, Inline code, and Link. -- **Table:** Leaves out Blockquote, Ordered list, Unordered list, Task list, and Block type. Adds three rows before Insert, or last where Insert is left out: Add row above, Add row below, Move row up, Move row down, Delete row; Add column before, Add column after, Move column left, Move column right, Delete column; Delete table. +- **Table:** Leaves out Blockquote, Ordered list, Unordered list, Task list, and Block type. Adds a two-row table bar before Insert, or last where Insert is left out. The first row holds row and column scope selectors and Delete table. The second holds Add row above, Add row below, Move row up, Move row down, and Delete row in row scope, or Add column before, Add column after, Move column left, Move column right, and Delete column in column scope. These actions use icon buttons with accessible names and tooltips. An object's actions follow the first row, each shown only while it is available: diff --git a/docs/specification.md b/docs/specification.md index 5aa6d7ca..ad2d7167 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -431,7 +431,9 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e ### Table Controls -- Hovering a table cell shows a grip on the table's left border beside the cell's row and one on its top border above the cell's column. The header row has no row grip. While the table is scrolled sideways, the row grip stays at the table's visible left edge and the column grip scrolls with its column, showing only while its column is in view. +- The context popup groups table commands in a two-row bar. Its first row selects the row or column scope and offers a separate `Delete table` button, with a spaced divider before it. The second row shows that scope's five icon-only actions: insert before or after, move in either direction, and delete. Action names remain available to assistive technology and in tooltips, and unavailable actions stay visible but disabled. The selectors keep the popup's normal arrow-key traversal; `Enter` or `Space` changes scope. +- Scope selectors name the current row or column, or selected range. Body rows and columns are numbered from one; the protected first row is named `Header row`. Choosing a scope selects its whole rows or columns without changing content or taking focus out of the popup. Switching between scopes retains the original targets; changing the selection or editing the table establishes new ones. A grip opens its matching scope; a caret or ordinary cell selection opens row actions. +- Hovering a table cell shows a slim rail grip on the table's left border beside the cell's row and one on its top border above the cell's column. A grip uses the accent color while hovered or while its row or column is selected. The header row has no row grip. While the table is scrolled sideways, the row grip stays at the table's visible left edge and the column grip scrolls with its column, showing only while its column is in view. - Clicking a grip selects its whole row or column as a cell selection, keeps focus in the editor, and opens the context popup with the table's commands on release, as a block handle does for its block. `Shift`-click extends or reverses the selected row or column range. Clicking a grip within a selected row or column range collapses it to that row or column on release. - Dragging a grip moves its row or column, or the selected range it belongs to, to the row or column boundary nearest the pointer, shown by a line at that boundary. Nothing lands above the header row, and a drag that would leave the range where it started shows no line and changes nothing. The move is one undoable change, leaves the moved rows or columns selected, and does not open the popup. A selected row range that includes the header row is not dragged as a range. - One insertion button on the table's left border follows the pointer to the nearest row boundary below the header row, and one on its top border to the nearest column boundary in view; both include the table's ends. Hovering a button shows a faint line at its boundary, and clicking it inserts an empty row or column there, as one undoable change, with the caret in the new row's first cell or the new column's header cell. diff --git a/e2e/desktop/specs/table-controls.spec.ts b/e2e/desktop/specs/table-controls.spec.ts index 778bd8f7..99dee38e 100644 --- a/e2e/desktop/specs/table-controls.spec.ts +++ b/e2e/desktop/specs/table-controls.spec.ts @@ -125,6 +125,26 @@ describe("desktop table controls", () => { await expect( $("[data-testid='editor-context-popup'] [aria-label='Delete row']"), ).toBeDisplayed(); + const popup = $("[data-testid='editor-context-popup']"); + await expect(popup.$("button=Row 1")).toHaveAttribute("aria-pressed", "true"); + await expect(popup.$("[aria-label='Add row below']")).toHaveText(""); + const dividerGap = await browser.execute(() => { + const button = document.querySelector( + "[data-testid='editor-context-popup'] [aria-label='Delete table']", + ); + const divider = + button?.parentElement?.parentElement?.querySelector("[data-slot='separator']"); + if (!button || !divider) + throw new Error("The table delete control or its divider was not found."); + return button.getBoundingClientRect().left - divider.getBoundingClientRect().right; + }); + expect(dividerGap).toBeGreaterThanOrEqual(8); + await popup.$("button=Columns 1–2").click(); + await expect(popup.$("[aria-label='Delete column']")).toBeDisplayed(); + await expect(popup.$("[aria-label='Delete row']")).not.toExist(); + await popup.$("button=Row 1").click(); + await expect(popup.$("[aria-label='Delete row']")).toBeDisplayed(); + expect(await tableRect()).toEqual(before); expect( await browser.execute( (selector) => document.querySelector(selector)?.querySelectorAll(".selectedCell").length, diff --git a/src/features/editor/components/editor-context-popup.test.tsx b/src/features/editor/components/editor-context-popup.test.tsx index 172d3977..f95f0d7a 100644 --- a/src/features/editor/components/editor-context-popup.test.tsx +++ b/src/features/editor/components/editor-context-popup.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { CellSelection } from "@milkdown/kit/prose/tables"; import { StrictMode, useState } from "react"; import { describe, expect, it, vi, type Mock } from "vitest"; @@ -7,7 +8,11 @@ import { createActiveEditorCommandState, createEditorCommandState } from "@/test import { EXTENDED_TABLE_MARKDOWN } from "@/test/fixtures/editorMarkdown"; import { dispatchDOMEvent } from "@/test/utils/events"; import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; -import { selectTableCellRange, setSelectionInTableCell } from "@/test/utils/prosemirror"; +import { + runKeyDownHandlers, + selectTableCellRange, + setSelectionInTableCell, +} from "@/test/utils/prosemirror"; import { act, render, renderWithUser, screen, waitFor, within } from "@/test/utils/react"; import { getEditorCommandState, type EditorCommandId, type EditorCommandState } from "../commands"; @@ -137,6 +142,7 @@ const TABLE_COLUMN_LABELS = [ "Delete column", ]; const TABLE_LABELS = [...TABLE_ROW_LABELS, ...TABLE_COLUMN_LABELS, "Delete table"]; +const TABLE_BAR_LABELS = ["Row 1", "Column 1", "Delete table", ...TABLE_ROW_LABELS]; const mountEditor = setupMilkdownEditorMount(); @@ -209,12 +215,12 @@ describe("editor-context-popup", () => { [ "a caret in a table", { container: "table", selectionKind: "caret" }, - ["Paste", ...TABLE_LABELS, "Insert"], + ["Paste", ...TABLE_BAR_LABELS, "Insert"], ], [ "a text or cell selection in a table", { container: "table", selectionKind: "text" }, - [...QUICK_ACTION_LABELS, ...INLINE_ACTION_LABELS, ...TABLE_LABELS], + [...QUICK_ACTION_LABELS, ...INLINE_ACTION_LABELS, ...TABLE_BAR_LABELS], ], ])("offers %s its own actions", (_label, context, labels) => { render( @@ -331,6 +337,49 @@ describe("editor-context-popup", () => { }); describe("table commands", () => { + it("keeps keyboard focus in the live popup while changing its table selection", async () => { + let request: ContextPopupRequest | null = null; + let refresh = noop; + const mounted = await mountEditor(EXTENDED_TABLE_MARKDOWN, { + getContextPopupOpen: () => request !== null, + onContextPopupRequested: (next) => { + request = next; + refresh(); + }, + }); + vi.spyOn(mounted.view, "coordsAtPos").mockReturnValue({ + top: 60, + bottom: 80, + left: 40, + right: 50, + }); + setSelectionInTableCell(mounted, 2, 1); + runKeyDownHandlers(mounted.view, "ContextMenu"); + const popup = () => ( + { + request = null; + refresh(); + }} + onExecute={noop} + onReturnFocus={() => mounted.view.focus()} + /> + ); + const host = renderWithUser(popup()); + refresh = () => host.rerender(popup()); + await waitFor(() => expect(screen.getByLabelText("Paste")).toHaveFocus()); + await host.user.keyboard("{ArrowDown}{ArrowRight}{Enter}"); + expect(screen.getByRole("button", { name: "Column 2" })).toHaveFocus(); + expect((mounted.view.state.selection as CellSelection).isColSelection()).toBe(true); + await host.user.keyboard("{ArrowLeft}{Enter}"); + expect(screen.getByRole("button", { name: "Row 2" })).toHaveFocus(); + expect((mounted.view.state.selection as CellSelection).isRowSelection()).toBe(true); + await host.user.keyboard("{Escape}"); + await waitFor(() => expect(mounted.view.dom).toHaveFocus()); + }); + const renderForEditor = async ( selectionKind: ContextPopupRequest["selectionKind"], select: (mounted: Awaited>) => void, @@ -352,8 +401,75 @@ describe("editor-context-popup", () => { return onExecute; }; - const enabledTableLabels = () => - TABLE_LABELS.filter((label) => !screen.getByLabelText(label).disabled); + const enabledTableLabels = () => { + const enabled = (labels: string[]) => + labels.filter((label) => !screen.getByLabelText(label).disabled); + const rows = enabled(TABLE_ROW_LABELS); + act(() => screen.getByRole("button", { name: "Column 1" }).click()); + const columns = enabled(TABLE_COLUMN_LABELS); + act(() => screen.getByRole("button", { name: "Row 1" }).click()); + return [...rows, ...columns, ...enabled(["Delete table"])]; + }; + + it("shows the selected axis and switches actions without adding visible command captions", async () => { + const selectAxis = vi.fn(); + const request: ContextPopupRequest = { + ...POINTER_REQUEST, + container: "table", + table: { + axis: "column", + row: { from: 2, to: 3 }, + column: { from: 1, to: 2 }, + selectAxis, + }, + }; + const { user } = renderWithUser( + , + ); + expect(screen.getByRole("button", { name: "Column 2" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByLabelText("Add column before")).toHaveTextContent(""); + expect(screen.queryByLabelText("Add row above")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Row 2" })); + expect(selectAxis).toHaveBeenCalledWith("row"); + expect(screen.getByRole("button", { name: "Row 2" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByLabelText("Add row above")).toHaveTextContent(""); + expect(screen.queryByLabelText("Add column before")).not.toBeInTheDocument(); + }); + + it.each([ + [{ from: 0, to: 1 }, { from: 0, to: 1 }, "Header row", "Column 1"], + [{ from: 0, to: 3 }, { from: 1, to: 3 }, "Header to row 2", "Columns 2–3"], + [{ from: 1, to: 3 }, { from: 0, to: 2 }, "Rows 1–2", "Columns 1–2"], + ] as const)("names the table's row and column ranges", (row, column, rowLabel, columnLabel) => { + render( + , + ); + expect(screen.getByRole("button", { name: rowLabel })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: columnLabel })).toBeInTheDocument(); + }); it("keeps the header row's protected row commands visible but disabled for a caret", async () => { await renderForEditor("caret", (mounted) => { @@ -1113,15 +1229,21 @@ describe("editor-context-popup", () => { expect(screen.getByLabelText("Blockquote")).toHaveFocus(); }); - it("walks the table rows by column and clamps onto Delete table", async () => { + it("reaches every table command by switching the axis from the keyboard", async () => { const { user } = renderToolbar( { ...KEYBOARD_REQUEST, container: "table", selectionKind: "caret" }, { commandState: withEnabledCommands( "format.table.addRowAbove", + "format.table.addRowBelow", + "format.table.moveRowUp", "format.table.moveRowDown", + "format.table.deleteRow", "format.table.addColumnBefore", + "format.table.addColumnAfter", + "format.table.moveColumnLeft", "format.table.moveColumnRight", + "format.table.deleteColumn", "format.table.delete", ), }, @@ -1132,22 +1254,33 @@ describe("editor-context-popup", () => { }); await user.keyboard("{ArrowDown}"); - expect(screen.getByLabelText("Add row above")).toHaveFocus(); - - await user.keyboard("{ArrowRight}"); - expect(screen.getByLabelText("Move row down")).toHaveFocus(); + expect(screen.getByRole("button", { name: "Row 1" })).toHaveFocus(); await user.keyboard("{ArrowDown}"); - expect(screen.getByLabelText("Move column right")).toHaveFocus(); + expect(screen.getByLabelText("Add row above")).toHaveFocus(); - await user.keyboard("{ArrowDown}"); + for (const label of TABLE_ROW_LABELS.slice(1)) { + await user.keyboard("{ArrowRight}"); + expect(screen.getByLabelText(label)).toHaveFocus(); + } + + await user.keyboard("{ArrowUp}"); expect(screen.getByLabelText("Delete table")).toHaveFocus(); - await user.keyboard("{ArrowDown}"); - expect(screen.getByRole("button", { name: "Insert" })).toHaveFocus(); + await user.keyboard("{ArrowLeft}{Enter}"); + expect(screen.getByRole("button", { name: "Column 1" })).toHaveFocus(); + expect(screen.getByRole("button", { name: "Column 1" })).toHaveAttribute( + "aria-pressed", + "true", + ); - await user.keyboard("{ArrowUp}{ArrowUp}"); + await user.keyboard("{ArrowLeft}{ArrowDown}"); expect(screen.getByLabelText("Add column before")).toHaveFocus(); + + for (const label of TABLE_COLUMN_LABELS.slice(1)) { + await user.keyboard("{ArrowRight}"); + expect(screen.getByLabelText(label)).toHaveFocus(); + } }); it("moves through an object's row to the selection's own rows", async () => { diff --git a/src/features/editor/components/editor-context-popup.tsx b/src/features/editor/components/editor-context-popup.tsx index 3fa9cc95..c89c0030 100644 --- a/src/features/editor/components/editor-context-popup.tsx +++ b/src/features/editor/components/editor-context-popup.tsx @@ -1,9 +1,13 @@ import { ArrowDownIcon, + ArrowDownFromLineIcon, ArrowDownToLineIcon, ArrowLeftIcon, + ArrowLeftFromLineIcon, ArrowRightIcon, + ArrowRightFromLineIcon, ArrowUpIcon, + ArrowUpFromLineIcon, BoldIcon, CheckIcon, ChevronRightIcon, @@ -12,7 +16,7 @@ import { CopyIcon, CornerDownRightIcon, FileCodeIcon, - Grid2x2XIcon, + Columns3Icon, Heading1Icon, Heading2Icon, Heading3Icon, @@ -32,6 +36,8 @@ import { PilcrowIcon, PlusIcon, ScissorsIcon, + Rows3Icon, + Table2Icon, TableIcon, TextQuoteIcon, Trash2Icon, @@ -44,6 +50,7 @@ import { useLayoutEffect, useMemo, useRef, + useState, type KeyboardEvent, type ReactNode, } from "react"; @@ -72,21 +79,15 @@ import { cn } from "@/lib/utils"; import type { EditorCommandId, EditorCommandState } from "../commands"; import type { CalloutTypeChoice, CalloutTypeChoices } from "../commands/formatting/callouts"; import { getEditorCommandLabelId } from "../commands/metadata"; -import type { ContextPopupRequest } from "../plugins/contextPopup"; +import type { ContextPopupRequest, ContextPopupTable } from "../plugins/contextPopup"; import { isDismissedFromOutside } from "../utils/popoverDismissal"; +import type { TableAxis } from "../utils/tables"; import { CalloutTypeIcon } from "./callout-type-icon"; -import { - AddColumnAfterIcon, - AddColumnBeforeIcon, - AddRowAboveIcon, - AddRowBelowIcon, - DeleteColumnIcon, - DeleteRowIcon, -} from "./table-command-icons"; interface ContextButtonCommand { commandId: EditorCommandId; icon: LucideIcon; + destructive?: boolean; } interface ContextSubmenuCommand { @@ -143,23 +144,23 @@ const CALLOUT_COMMANDS = [ ] satisfies readonly ContextButtonCommand[]; const TABLE_ROW_COMMANDS = [ - { commandId: "format.table.addRowAbove", icon: AddRowAboveIcon }, - { commandId: "format.table.addRowBelow", icon: AddRowBelowIcon }, + { commandId: "format.table.addRowAbove", icon: ArrowUpFromLineIcon }, + { commandId: "format.table.addRowBelow", icon: ArrowDownFromLineIcon }, { commandId: "format.table.moveRowUp", icon: ArrowUpIcon }, { commandId: "format.table.moveRowDown", icon: ArrowDownIcon }, - { commandId: "format.table.deleteRow", icon: DeleteRowIcon }, + { commandId: "format.table.deleteRow", icon: Trash2Icon, destructive: true }, ] satisfies readonly ContextButtonCommand[]; const TABLE_COLUMN_COMMANDS = [ - { commandId: "format.table.addColumnBefore", icon: AddColumnBeforeIcon }, - { commandId: "format.table.addColumnAfter", icon: AddColumnAfterIcon }, + { commandId: "format.table.addColumnBefore", icon: ArrowLeftFromLineIcon }, + { commandId: "format.table.addColumnAfter", icon: ArrowRightFromLineIcon }, { commandId: "format.table.moveColumnLeft", icon: ArrowLeftIcon }, { commandId: "format.table.moveColumnRight", icon: ArrowRightIcon }, - { commandId: "format.table.deleteColumn", icon: DeleteColumnIcon }, + { commandId: "format.table.deleteColumn", icon: Trash2Icon, destructive: true }, ] satisfies readonly ContextButtonCommand[]; const TABLE_COMMANDS = [ - { commandId: "format.table.delete", icon: Grid2x2XIcon }, + { commandId: "format.table.delete", icon: Table2Icon, destructive: true }, ] satisfies readonly ContextButtonCommand[]; const BLOCK_FORMATTING_COMMANDS = [ @@ -216,7 +217,8 @@ const FITTED_COMMAND_LABELS: Partial> = { type ContextRow = | { id: string; kind: "buttons"; commands: readonly ContextButtonCommand[] } | { id: string; kind: "submenu"; commands: readonly ContextSubmenuEntry[]; labelId: MessageId } - | { id: string; kind: "calloutTypes" }; + | { id: string; kind: "calloutTypes" } + | { id: string; kind: "tableScope" | "tableActions" }; const buttonRow = (id: string, commands: readonly ContextButtonCommand[]): ContextRow => ({ commands, @@ -272,9 +274,8 @@ const getContextRows = ( if (container === "table") { rows.push( - buttonRow("tableRows", TABLE_ROW_COMMANDS), - buttonRow("tableColumns", TABLE_COLUMN_COMMANDS), - buttonRow("table", TABLE_COMMANDS), + { id: "tableScope", kind: "tableScope" }, + { id: "tableActions", kind: "tableActions" }, ); } @@ -379,6 +380,14 @@ export function EditorContextPopup({ const isOpen = request !== null; const source = request?.source; const contentRef = useRef(null); + const [tableChoice, setTableChoice] = useState<{ + request: ContextPopupRequest; + axis: TableAxis; + } | null>(null); + const tableAxis = + tableChoice && tableChoice.request === request + ? tableChoice.axis + : (request?.table?.axis ?? "row"); // Sticky for one open popup, so that focus moving into a portalled submenu does not clear it. const hasHeldFocusRef = useRef(false); const pinnedRectRef = useRef(null); @@ -595,6 +604,34 @@ export function EditorContextPopup({ (contextRow.kind === "buttons" || rows[row - 1]?.kind === "buttons") && ( )} + {contextRow.kind === "tableScope" && ( + { + setTableChoice({ request, axis }); + request.table?.selectAxis(axis); + }} + onExecute={onExecute} + canExecute={canExecute} + isChecked={isChecked} + row={row} + /> + )} + {contextRow.kind === "tableActions" && ( +
+ + +
+ )} {contextRow.kind === "buttons" && ( boolean; isChecked: (commandId: EditorCommandId) => boolean | undefined; row: number; + columnOffset?: number; + spread?: boolean; } function ContextCommandRow({ @@ -648,17 +687,20 @@ function ContextCommandRow({ canExecute, isChecked, row, + columnOffset = 0, + spread = false, }: ContextCommandRowProps) { const { t } = useLocalization(); const showLabels = fitted && commands.length < 4; return (
- {commands.map(({ commandId, icon: Icon }, column) => { + {commands.map(({ commandId, icon: Icon, destructive }, column) => { const label = t(getEditorCommandLabelId(commandId)); const enabled = canExecute(commandId); const checked = isChecked(commandId); @@ -689,12 +731,13 @@ function ContextCommandRow({ : button} - {...toolbarPosition(row, column)} + {...toolbarPosition(row, column + columnOffset)} > {showLabels && ( @@ -726,6 +769,89 @@ function ContextCommandRow({ ); } +interface ContextTableScopeProps { + axis: TableAxis; + table: ContextPopupTable | undefined; + onSelectAxis: (axis: TableAxis) => void; + onExecute: ContextCommandRowProps["onExecute"]; + canExecute: ContextCommandRowProps["canExecute"]; + isChecked: ContextCommandRowProps["isChecked"]; + row: number; +} + +function ContextTableScope({ + axis, + table, + onSelectAxis, + onExecute, + canExecute, + isChecked, + row, +}: ContextTableScopeProps) { + const { t } = useLocalization(); + const rows = table?.row ?? { from: 1, to: 2 }; + const columns = table?.column ?? { from: 0, to: 1 }; + const rowLabel = + rows.from === 0 + ? rows.to === 1 + ? t("editor.contextPopup.table.header") + : t("editor.contextPopup.table.headerRows", { row: rows.to - 1 }) + : rows.to - rows.from === 1 + ? t("editor.contextPopup.table.row", { row: rows.from }) + : t("editor.contextPopup.table.rows", { from: rows.from, to: rows.to - 1 }); + const columnLabel = + columns.to - columns.from === 1 + ? t("editor.contextPopup.table.column", { column: columns.from + 1 }) + : t("editor.contextPopup.table.columns", { from: columns.from + 1, to: columns.to }); + const choices = [ + { axis: "row", icon: Rows3Icon, label: rowLabel }, + { axis: "column", icon: Columns3Icon, label: columnLabel }, + ] as const; + + return ( +
+ {choices.map(({ axis: choice, icon: Icon, label }, column) => ( + + onSelectAxis(choice)} + size="sm" + type="button" + variant="ghost" + /> + } + /> + } + {...toolbarPosition(row, column)} + > + + {label} + + + {label} + + + ))} + + +
+ ); +} + interface ContextSubmenuProps { children: ReactNode; icon?: LucideIcon; diff --git a/src/features/editor/components/milkdown-editor.css b/src/features/editor/components/milkdown-editor.css index 5d38f6a4..2ad29695 100644 --- a/src/features/editor/components/milkdown-editor.css +++ b/src/features/editor/components/milkdown-editor.css @@ -1441,18 +1441,32 @@ } .leafdown-table-grip { - @apply cursor-grab rounded-sm text-muted-foreground hover:text-foreground active:cursor-grabbing; + @apply cursor-grab border-0 bg-transparent p-0 shadow-none hover:bg-transparent active:cursor-grabbing; + + &::before { + @apply rounded-full bg-muted-foreground/30; + content: ""; + } + + &:hover::before, + &[data-selected]::before { + @apply bg-primary; + } &[data-axis="row"] { - @apply h-5 w-3.5; + @apply h-8 w-3.5; + + &::before { + @apply h-full w-1.5; + } } &[data-axis="column"] { - @apply h-3.5 w-5; - } + @apply h-3.5 w-12; - svg { - @apply size-3.5 fill-current; + &::before { + @apply h-1.5 w-full; + } } } diff --git a/src/features/editor/components/table-command-icons.ts b/src/features/editor/components/table-command-icons.ts deleted file mode 100644 index 5ef58ebf..00000000 --- a/src/features/editor/components/table-command-icons.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createLucideIcon } from "lucide-react"; - -// Lucide draws no icon for adding or deleting a single table row or column, so these are drawn on -// its grid and stroke to sit beside the icons it does draw. - -export const AddRowAboveIcon = createLucideIcon("add-row-above", [ - ["rect", { height: "8", key: "row", rx: "2", width: "18", x: "3", y: "13" }], - ["path", { d: "M12 3v6", key: "plus-vertical" }], - ["path", { d: "M9 6h6", key: "plus-horizontal" }], -]); - -export const AddRowBelowIcon = createLucideIcon("add-row-below", [ - ["rect", { height: "8", key: "row", rx: "2", width: "18", x: "3", y: "3" }], - ["path", { d: "M12 15v6", key: "plus-vertical" }], - ["path", { d: "M9 18h6", key: "plus-horizontal" }], -]); - -export const AddColumnBeforeIcon = createLucideIcon("add-column-before", [ - ["rect", { height: "18", key: "column", rx: "2", width: "8", x: "13", y: "3" }], - ["path", { d: "M3 12h6", key: "plus-horizontal" }], - ["path", { d: "M6 9v6", key: "plus-vertical" }], -]); - -export const AddColumnAfterIcon = createLucideIcon("add-column-after", [ - ["rect", { height: "18", key: "column", rx: "2", width: "8", x: "3", y: "3" }], - ["path", { d: "M15 12h6", key: "plus-horizontal" }], - ["path", { d: "M18 9v6", key: "plus-vertical" }], -]); - -export const DeleteRowIcon = createLucideIcon("delete-row", [ - ["rect", { height: "8", key: "row", rx: "2", width: "18", x: "3", y: "3" }], - ["path", { d: "m9.5 15.5 5 5", key: "cross-down" }], - ["path", { d: "m14.5 15.5-5 5", key: "cross-up" }], -]); - -export const DeleteColumnIcon = createLucideIcon("delete-column", [ - ["rect", { height: "18", key: "column", rx: "2", width: "8", x: "3", y: "3" }], - ["path", { d: "m15.5 9.5 5 5", key: "cross-down" }], - ["path", { d: "m20.5 9.5-5 5", key: "cross-up" }], -]); diff --git a/src/features/editor/plugins/contextPopup.test.ts b/src/features/editor/plugins/contextPopup.test.ts index 927eab83..667da064 100644 --- a/src/features/editor/plugins/contextPopup.test.ts +++ b/src/features/editor/plugins/contextPopup.test.ts @@ -1,9 +1,14 @@ // @vitest-environment happy-dom +import { CellSelection } from "@milkdown/kit/prose/tables"; import { describe, expect, it, vi } from "vitest"; import type { ContextPopupRequest, ContextPopupSource } from "@/features/editor"; -import { BASIC_TABLE_MARKDOWN, HELLO_WORLD_TEXT } from "@/test/fixtures/editorMarkdown"; +import { + BASIC_TABLE_MARKDOWN, + EXTENDED_TABLE_MARKDOWN, + HELLO_WORLD_TEXT, +} from "@/test/fixtures/editorMarkdown"; import { dispatchContextMenu, dispatchMouseDown, @@ -25,6 +30,7 @@ import { } from "@/test/utils/prosemirror"; import { waitFor } from "@/test/utils/react"; +import { getSelectedTableRect } from "../utils/tables"; import { createHierarchicalBlockSelection, getSelectableBlockTargets } from "./blockSelection"; const mountEditor = setupMilkdownEditorMount(); @@ -622,6 +628,65 @@ describe("context popup plugin", () => { ).toEqual({ container: "table", selectionKind: "text" }); }); + it("switches table axes while retaining the original targets and keyboard source", async () => { + const { onContextPopupRequested, lastRequest } = collectRequests(); + const mounted = await mountEditor(EXTENDED_TABLE_MARKDOWN, { + onContextPopupRequested, + getContextPopupOpen: () => true, + }); + mockCoordinates(mounted); + setSelectionInTableCell(mounted, 2, 1); + runKeyDownHandlers(mounted.view, "ContextMenu"); + const doc = mounted.view.state.doc; + expect(lastRequest().table).toMatchObject({ + axis: "row", + row: { from: 2, to: 3 }, + column: { from: 1, to: 2 }, + }); + + lastRequest().table!.selectAxis("row"); + expect((mounted.view.state.selection as CellSelection).isRowSelection()).toBe(true); + lastRequest().table!.selectAxis("column"); + expect((mounted.view.state.selection as CellSelection).isColSelection()).toBe(true); + expect(getSelectedTableRect(mounted.view.state)).toMatchObject({ left: 1, right: 2 }); + expect(lastRequest().table).toMatchObject({ + axis: "column", + row: { from: 2, to: 3 }, + column: { from: 1, to: 2 }, + }); + lastRequest().table!.selectAxis("row"); + expect(getSelectedTableRect(mounted.view.state)).toMatchObject({ top: 2, bottom: 3 }); + expect(lastRequest().source).toBe("keyboard"); + expect(mounted.view.state.doc).toBe(doc); + + const stale = lastRequest().table!; + setSelectionInTableCell(mounted, 1, 0); + const selection = mounted.view.state.selection; + stale.selectAxis("column"); + expect(mounted.view.state.selection).toBe(selection); + runKeyDownHandlers(mounted.view, "ContextMenu"); + expect(lastRequest().table).toMatchObject({ + row: { from: 1, to: 2 }, + column: { from: 0, to: 1 }, + }); + }); + + it("preserves multiple row and column targets when switching a cell range", async () => { + const { onContextPopupRequested, lastRequest } = collectRequests(); + const mounted = await mountEditor(EXTENDED_TABLE_MARKDOWN, { + onContextPopupRequested, + getContextPopupOpen: () => true, + }); + mockCoordinates(mounted); + selectTableCellRange(mounted, { row: 2, col: 1 }, { row: 1, col: 0 }); + runKeyDownHandlers(mounted.view, "ContextMenu"); + lastRequest().table!.selectAxis("row"); + lastRequest().table!.selectAxis("column"); + expect(getSelectedTableRect(mounted.view.state)).toMatchObject({ left: 0, right: 2 }); + lastRequest().table!.selectAxis("row"); + expect(getSelectedTableRect(mounted.view.state)).toMatchObject({ top: 1, bottom: 3 }); + }); + it("reports no container for a selection reaching out of one", async () => { expect( await requestContext(`Before\n\n${FENCED_CODE_MARKDOWN}`, (mounted) => { diff --git a/src/features/editor/plugins/contextPopup.ts b/src/features/editor/plugins/contextPopup.ts index 83783d34..edc90de3 100644 --- a/src/features/editor/plugins/contextPopup.ts +++ b/src/features/editor/plugins/contextPopup.ts @@ -7,6 +7,14 @@ import { createContextPopupAnchor, type ContextPopupAnchor, } from "../utils/contextPopupAnchor"; +import { + getSelectedTableAxis, + getSelectedTableRect, + getTablePosition, + setTableAxisSelection, + type TableAxis, + type TableAxisRange, +} from "../utils/tables"; import { isStructuralBlockSelection } from "./blockSelectionKeyboard"; import { TABLE_DRAGGING_ATTRIBUTE, TABLE_GRIP_SELECTOR } from "./tableControls"; @@ -18,11 +26,19 @@ export type ContextPopupSelectionKind = "block" | "caret" | "text"; export type ContextPopupContainer = "code" | "table"; +export interface ContextPopupTable { + axis: TableAxis; + row: TableAxisRange; + column: TableAxisRange; + selectAxis: (axis: TableAxis) => void; +} + export interface ContextPopupRequest { anchor: ContextPopupAnchor; container: ContextPopupContainer | null; selectionKind: ContextPopupSelectionKind; source: ContextPopupSource; + table?: ContextPopupTable; } export interface LeafdownContextPopupPluginOptions { @@ -85,6 +101,77 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl // The pointer path opens on release, so the selection a drag builds must not open the popup. let pointerSelecting = false; let suppressPointerRelease = false; + let pressedTableAxis: TableAxis | null = null; + let tableScope: + | (Omit & { + table: EditorState["doc"]; + position: number; + selection: EditorState["selection"]; + }) + | null = null; + + const readTable = (view: EditorView): ContextPopupTable | undefined => { + const rect = getSelectedTableRect(view.state); + + if (!rect) return undefined; + + const position = getTablePosition(rect); + if ( + !tableScope || + tableScope.table !== rect.table || + tableScope.position !== position || + !tableScope.selection.eq(view.state.selection) + ) { + tableScope = { + axis: + pressedTableAxis ?? + (getSelectedTableAxis(view.state, position, "column") && + !getSelectedTableAxis(view.state, position, "row") + ? "column" + : "row"), + row: { from: rect.top, to: rect.bottom }, + column: { from: rect.left, to: rect.right }, + position, + selection: view.state.selection, + table: rect.table, + }; + } + + if (pressedTableAxis) tableScope = { ...tableScope, axis: pressedTableAxis }; + pressedTableAxis = null; + const scope = tableScope; + return { + axis: scope.axis, + row: scope.row, + column: scope.column, + selectAxis: (axis) => { + if (view.isDestroyed) return; + const current = getSelectedTableRect(view.state); + if ( + !current || + current.table !== scope.table || + getTablePosition(current) !== position || + !scope.selection.eq(view.state.selection) + ) + return; + + const range = scope[axis]; + const tr = view.state.tr; + setTableAxisSelection( + tr, + current.tableStart, + current.table, + axis, + range.from, + range.to - 1, + ); + const changed = !tr.selection.eq(view.state.selection); + tableScope = { ...scope, axis, selection: tr.selection }; + view.dispatch(tr); + if (!changed) requestSelectionPopup(view, openSource); + }, + }; + }; const requestSelectionPopup = (view: EditorView, source: ContextPopupSource) => { if (!canMeasureSelection(view)) { @@ -94,11 +181,14 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl anchor ??= createContextPopupAnchor(view); openSource = source; dismissed = false; + const container = getContainer(view.state); + if (container !== "table") tableScope = null; options.onRequest?.({ anchor, - container: getContainer(view.state), + container, selectionKind: getSelectionKind(view.state), source, + ...(container === "table" ? { table: readTable(view) } : {}), }); return true; @@ -148,6 +238,8 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl ) { pointerSelecting = true; suppressPointerRelease = false; + const axis = event.target.closest(TABLE_GRIP_SELECTOR)?.getAttribute("data-axis"); + pressedTableAxis = axis === "row" || axis === "column" ? axis : null; } }; diff --git a/src/features/editor/plugins/tableControls.test.ts b/src/features/editor/plugins/tableControls.test.ts index 5b77fedd..9cc76f4e 100644 --- a/src/features/editor/plugins/tableControls.test.ts +++ b/src/features/editor/plugins/tableControls.test.ts @@ -188,8 +188,13 @@ describe("table controls", () => { dispatchMouseEvent(rowGrip, "mouseup", { button: 0 }); await waitFor(() => { expect(onContextPopupRequested).toHaveBeenCalledWith( - expect.objectContaining({ container: "table", source: "pointer" }), + expect.objectContaining({ + container: "table", + source: "pointer", + table: expect.objectContaining({ axis: "row" }), + }), ); + expect(rowGrip).toHaveAttribute("data-selected"); }); hover(cellCenter(1, 0)); @@ -199,6 +204,13 @@ describe("table controls", () => { selection = mounted.view.state.selection; expect((selection as CellSelection).isColSelection()).toBe(true); expect(selectedRect(mounted.view.state)).toMatchObject({ left: 0, right: 1, top: 0 }); + await waitFor(() => { + expect(columnGrip).toHaveAttribute("data-selected"); + expect(rowGrip).not.toHaveAttribute("data-selected"); + expect(onContextPopupRequested).toHaveBeenLastCalledWith( + expect.objectContaining({ table: expect.objectContaining({ axis: "column" }) }), + ); + }); }); it("drags a row to the boundary under the pointer as one undoable change", async () => { diff --git a/src/features/editor/plugins/tableControls.ts b/src/features/editor/plugins/tableControls.ts index 0e14ec60..ff15347c 100644 --- a/src/features/editor/plugins/tableControls.ts +++ b/src/features/editor/plugins/tableControls.ts @@ -107,18 +107,6 @@ const createIcon = (doc: Document, draw: (svg: SVGSVGElement) => void) => { return svg; }; -const drawGripDots = (axis: TableAxis) => (svg: SVGSVGElement) => { - for (const along of [5, 12, 19]) { - for (const across of [9, 15]) { - const dot = svg.ownerDocument.createElementNS(SVG_NAMESPACE, "circle"); - dot.setAttribute("cx", String(axis === "row" ? across : along)); - dot.setAttribute("cy", String(axis === "row" ? along : across)); - dot.setAttribute("r", "1.5"); - svg.append(dot); - } - } -}; - const drawPlus = (svg: SVGSVGElement) => { const path = svg.ownerDocument.createElementNS(SVG_NAMESPACE, "path"); path.setAttribute("d", "M12 5v14m-7-7h14"); @@ -200,7 +188,6 @@ class TableControlsView { for (const grip of [this.rowGrip, this.columnGrip]) { grip.dataset.leafdownTableGrip = ""; - grip.append(createIcon(doc, drawGripDots(grip.dataset.axis as TableAxis))); grip.addEventListener("mousedown", this.handleGripMouseDown); grip.addEventListener("mouseup", this.handleGripMouseUp); } @@ -236,7 +223,7 @@ class TableControlsView { update(view: EditorView, previousState: EditorView["state"]) { this.view = view; - if (view.state.doc !== previousState.doc) { + if (view.state.doc !== previousState.doc || !view.state.selection.eq(previousState.selection)) { this.scheduleRefresh(); } } @@ -445,6 +432,18 @@ class TableControlsView { const row = geometry.rows.at(hovered.row); const column = geometry.columns.at(hovered.col); const columnCenter = column ? column.left + column.width / 2 : NaN; + const tablePos = getTablePos(this.view, hovered.table); + for (const [axis, grip, index] of [ + ["row", this.rowGrip, hovered.row], + ["column", this.columnGrip, hovered.col], + ] as const) { + const range = + tablePos === null ? null : getSelectedTableAxis(this.view.state, tablePos, axis); + grip.toggleAttribute( + "data-selected", + range !== null && index >= range.from && index < range.to, + ); + } this.place( this.rowGrip, diff --git a/src/locales/en.json b/src/locales/en.json index 0f52049e..eae7bf56 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -461,6 +461,12 @@ "editor.contextPopup.goToFootnote": "Go to", "editor.contextPopup.codeBlockLanguage": "Language...", "editor.contextPopup.calloutExpanded": "Opens expanded", + "editor.contextPopup.table.header": "Header row", + "editor.contextPopup.table.headerRows": "Header to row {row, number}", + "editor.contextPopup.table.row": "Row {row, number}", + "editor.contextPopup.table.rows": "Rows {from, number}–{to, number}", + "editor.contextPopup.table.column": "Column {column, number}", + "editor.contextPopup.table.columns": "Columns {from, number}–{to, number}", "session.reopenWithEncoding": "Reopen with encoding", "session.documentOpened": "Document opened.", "session.openFolderIndexFailed": "Could not open folder index file.", From 83319bafd0376ebdfbca3761aaab87a8a775d161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 19 Oct 2026 04:08:57 -0300 Subject: [PATCH 2/2] Select the source window before closing its named-folder dialog --- e2e/desktop/specs/named-folders-switch.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/desktop/specs/named-folders-switch.spec.ts b/e2e/desktop/specs/named-folders-switch.spec.ts index 7e89c6bb..e37b18d2 100644 --- a/e2e/desktop/specs/named-folders-switch.spec.ts +++ b/e2e/desktop/specs/named-folders-switch.spec.ts @@ -67,8 +67,8 @@ describe("desktop named folder switching", () => { const secondWindow = (await browser.getWindowHandles()).find( (handle) => !before.includes(handle), )!; - await closeNamedFolders(); await browser.switchToWindow(firstWindow); + await closeNamedFolders(); await openRecentPath(callouts.path); await browser.switchToWindow(secondWindow); await expect($(ACTIVE_EDITOR)).toHaveText(