Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/help/markdown-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 3 additions & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion e2e/desktop/specs/named-folders-switch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions e2e/desktop/specs/table-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
163 changes: 148 additions & 15 deletions src/features/editor/components/editor-context-popup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// @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";

import { createActiveEditorCommandState, createEditorCommandState } from "@/test/factories/editor";
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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = () => (
<EditorContextPopup
request={request}
commandState={getEditorCommandState(mounted.view)}
onClose={() => {
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<ReturnType<typeof mountEditor>>) => void,
Expand All @@ -352,8 +401,75 @@ describe("editor-context-popup", () => {
return onExecute;
};

const enabledTableLabels = () =>
TABLE_LABELS.filter((label) => !screen.getByLabelText<HTMLButtonElement>(label).disabled);
const enabledTableLabels = () => {
const enabled = (labels: string[]) =>
labels.filter((label) => !screen.getByLabelText<HTMLButtonElement>(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(
<EditorContextPopup
request={request}
commandState={withEnabledCommands(
"format.table.addColumnBefore",
"format.table.addRowAbove",
)}
onClose={noop}
onExecute={noop}
onReturnFocus={noop}
/>,
);
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(
<EditorContextPopup
request={{
...POINTER_REQUEST,
container: "table",
table: { axis: "row", row, column, selectAxis: noop },
}}
commandState={enabledPopupCommandState}
onClose={noop}
onExecute={noop}
onReturnFocus={noop}
/>,
);
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) => {
Expand Down Expand Up @@ -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",
),
},
Expand All @@ -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 () => {
Expand Down
Loading