diff --git a/CHANGELOG.md b/CHANGELOG.md index 700bd7f1..e47304b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - 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. - Write an inserted horizontal rule with the run the document's breaks agree on, including its length and internal spacing. Where the breaks disagree or there are none, use `***`. Typed markers keep their spelling, and a run that would be read as another construct still uses a safe fallback. -- Choose a callout's type from a submenu. `Insert > Callout` and the context popup's Insert submenu list the types a new callout can take, and `Format > Callout` and a new `Callout type` row in the context popup list the types of the callout you are in, with its current type checked. The context popup shows each type's icon. `Callout...` and `Callout type...` in the command palette, and a callout's type icon, still open the searchable type list. +- Choose a callout's type and dialect from a submenu. `Insert > Callout` and the context popup's Insert submenu list the inferred dialect's types first, then submenus for the other dialects: GitHub alert, MkDocs, Docusaurus, and VitePress. Choosing one inserts an empty callout authored in that dialect; adding a second dialect makes later insertions list GitHub alert types first. `Format > Callout` and a new `Callout type` row in the context popup list only the types of the callout's own dialect, with its current type checked. The context popup shows each type's icon. `Callout...` and `Callout type...` in the command palette, and a callout's type icon, still open the searchable type list; the new-callout picker keeps the inferred dialect alone. - Redraw the article navigator's file and folder icons. Folders are filled and drawn open while expanded, articles show a darker page of lines, and other files show a page marked with their type, such as an image, code, a spreadsheet, an archive, or a PDF. The welcome screen's Open file and Open folder buttons and the drop overlay use the same icons, and recent files and folders are listed without one. - Enlarge the text of the article navigator, Find in folder, document tabs, and status bar from 12 px to 13 px, with taller rows and larger file, folder, and chevron icons to match. Document tabs grow too: a taller strip with more space between tabs, a larger close mark and new-document button, and room for longer names before they are cut short. Zoom still scales the whole interface, and Text size still sets document text only. - Show only the folder's name in the article navigator's header, without the article count beside it. Find in folder still reports how many articles it searched. @@ -25,6 +25,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed - Keep the marker typed to start a list: `-`, `+`, or `*` for a bullet list, and `.` or `)` for an ordered one. Typing `1) ` now starts an ordered list. Lists created by commands still use the document's prevailing marker or the default, and adjacent lists still use different markers where saving them with the same one would join them. +- Keep the context popup closed when Escape dismisses it before the pointer release finishes opening it. - Show the whole name of a preview tab. Its name is slanted, and the tab cut off the part of the last letter that leaned past the end of the name. - Fade out a toast as it closes. A toast that timed out or was closed slid up at full opacity and stopped with its lower part still showing at the top of the window, cut off at the border, until it disappeared. Closing and swiped-away toasts now fade as they move away and leave nothing behind. - Reopen each restored tab once. A launch could reopen every kept tab twice, and a document with unsaved changes then appeared in two tabs that each held its text. A window now never restores a document that one of its tabs already holds, and a session already kept with a document twice reopens it in one tab. diff --git a/docs/decisions.md b/docs/decisions.md index 6c484275..656fb4af 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -76,7 +76,7 @@ ### Keep callout dialects in one editor container -**Decision:** GitHub alerts, Material for MkDocs admonitions, Docusaurus admonitions, and VitePress custom containers share one editable callout node. Each node retains its source dialect, type token, title spelling, opening marker, and colon fence length. Saving writes that node in its authored dialect. Opening and closing blank lines are retained when the container can own them. The view's temporary disclosure state does not rewrite an authored `???` or `???+` marker. A new callout uses the dialect every callout in the document shares, and a GitHub alert when the document has no callouts or more than one dialect. +**Decision:** GitHub alerts, Material for MkDocs admonitions, Docusaurus admonitions, and VitePress custom containers share one editable callout node. Each node retains its source dialect, type token, title spelling, opening marker, and colon fence length. Saving writes that node in its authored dialect. Opening and closing blank lines are retained when the container can own them. The view's temporary disclosure state does not rewrite an authored `???` or `???+` marker. Unless another dialect is chosen from `Insert > Callout`, a new callout uses the dialect every callout in the document shares, and a GitHub alert when the document has no callouts or more than one dialect. **Rationale:** These syntaxes carry similar editable block content but disagree about markers, type sets, titles, and collapse state. Converting them to a single source dialect would rewrite documents used by other renderers and can change their meaning. @@ -85,7 +85,7 @@ - A callout body uses the ordinary Markdown block model, so links, lists, code, editing history, and save/reopen use the same editor behavior as surrounding content. - Unrecognized forms remain ordinary Markdown; support for one dialect does not claim a similar spelling from another. - The typed marker starts a new callout while editing, and `Insert > Callout` inserts one of a chosen type. There is no syntax-conversion command; a callout's type changes only within its dialect's type set, and only an MkDocs admonition's `???` or `???+` disclosure is an authored choice a command switches. -- A document that agrees on a dialect gets more of it, as lists and headings follow the form a document agrees on; one naming two dialects is left to the default rather than having one chosen for its author. The default is a GitHub alert because GitHub renders it and a renderer without callout support still shows it as a blockquote with its text intact. A new callout has no title, an MkDocs one uses `!!!`, and a colon one uses a three-colon fence. Settled in [issue #727](https://github.com/Azganoth/leafdown/issues/727). +- A document that agrees on a dialect gets more of it, as lists and headings follow the form a document agrees on; one naming two dialects is left to the default rather than having one chosen for its author. The default is a GitHub alert because GitHub renders it and a renderer without callout support still shows it as a blockquote with its text intact. `Insert > Callout` in the menu bar and context popup lists that dialect's types first, then a separator and submenus for each other dialect, labeled GitHub alert, MkDocs, Docusaurus, and VitePress. The dialect listed first has no submenu of its own. Choosing another dialect authors the new callout in it; later insertions infer the dialect from the document including that callout. The command palette and gutter type picker keep the inferred dialect alone. A new callout has no title, an MkDocs one uses `!!!`, and a colon one uses a three-colon fence. The inference rule was settled in [issue #727](https://github.com/Azganoth/leafdown/issues/727), and the explicit insertion choice in [issue #773](https://github.com/Azganoth/leafdown/issues/773). - The parser reads callouts only among the document's own blocks and inside a colon fence longer than theirs, so a new callout always goes between the document's own blocks, after the one holding the selection. ### No vault or workspace model diff --git a/docs/help/markdown-reference.md b/docs/help/markdown-reference.md index 0a00259c..ac87fb33 100644 --- a/docs/help/markdown-reference.md +++ b/docs/help/markdown-reference.md @@ -89,7 +89,7 @@ flowchart LR ``` ```` -**Callouts** support GitHub alert blocks, Material for MkDocs admonitions, and Docusaurus or VitePress colon fences. Supported types and titles depend on the dialect. **Insert > Callout** adds one of the type you choose, in the dialect your document's callouts already use, or as a GitHub alert. Choose a type from **Format > Callout**, or press the icon at the start of a callout's header, to change its type, and use **Format > Callout > Callout opens expanded** to choose whether a collapsible MkDocs admonition opens expanded. For example: +**Callouts** support GitHub alert blocks, Material for MkDocs admonitions, and Docusaurus or VitePress colon fences. Supported types and titles depend on the dialect. **Insert > Callout**, also in the context popup, lists the types of the dialect your document's callouts share, or GitHub alert types when there are none or more than one dialect. Below a separator, **GitHub alert**, **MkDocs**, **Docusaurus**, and **VitePress** submenus offer the other dialects; the dialect listed first has no submenu of its own. Choose a type to insert an empty callout in that dialect, with no title. MkDocs starts with `!!!`, and Docusaurus and VitePress use a three-colon fence. Adding a second dialect makes later insertions list GitHub alert types first. The command palette's **Callout...** keeps offering only the inferred dialect. Choose a type from **Format > Callout**, or press the icon at the start of a callout's header, to change its type within its own dialect, and use **Format > Callout > Callout opens expanded** to choose whether a collapsible MkDocs admonition opens expanded. For example: ```text > [!NOTE] diff --git a/docs/reference.md b/docs/reference.md index a170769d..9a2895eb 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -190,7 +190,7 @@ In the folder search view, `Enter` in the query field searches at once and `Arro - **Unordered list** - **Task list** - **Blockquote** -- **Callout**: Lists the types of the dialect a new callout is written in. Choosing one inserts an empty callout of that type after the document block holding the selection. +- **Callout**: Lists the types of the dialect the document's callouts share, or GitHub alert types when there are none or more than one dialect. After a separator, submenus labeled **GitHub alert**, **MkDocs**, **Docusaurus**, and **VitePress** offer the other dialects' types; the dialect listed first has no submenu of its own. Choosing a type inserts an empty callout in that dialect after the document block holding the selection, with no title, `!!!` for MkDocs, and a three-colon fence for Docusaurus and VitePress. - **Code block** - **Table** - **Horizontal rule** @@ -318,7 +318,7 @@ The popup offers the actions of the context it opens in, one row or submenu per - **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. -`Block type` holds Paragraph and Heading 1 through Heading 6. `Insert` holds Paragraph, Heading 1 through Heading 6, Blockquote, Callout, Ordered list, Unordered list, Task list, Code block, Table, and Horizontal rule. Its `Callout` is a submenu of the types `Insert > Callout` lists, each with the icon a callout of that type shows. +`Block type` holds Paragraph and Heading 1 through Heading 6. `Insert` holds Paragraph, Heading 1 through Heading 6, Blockquote, Callout, Ordered list, Unordered list, Task list, Code block, Table, and Horizontal rule. Its `Callout` has the same structure as the menu bar's `Insert > Callout`: the inferred dialect's types, a separator, and submenus for the other dialects, each type with the icon a callout of that type shows. The construct holding the whole caret or selection narrows its context: diff --git a/docs/specification.md b/docs/specification.md index 3efbcd1b..0c1cee74 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -175,7 +175,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - Material for MkDocs admonitions use `!!!`, `???`, or `???+`, a type, an optional quoted title, and a four-space-indented body. Supported types are `note`, `abstract`, `info`, `tip`, `success`, `question`, `warning`, `failure`, `danger`, `bug`, `example`, and `quote`. `???` opens collapsed and `???+` opens expanded. The disclosure control changes only the current view; saving retains the authored marker. `Format > Callout > Callout opens expanded` is checked for a `???+` admonition and switches the authored marker between `???` and `???+` as one Undo step, and the view then shows the admonition as the new marker opens it. - Docusaurus callouts use a colon fence followed directly by `note`, `tip`, `info`, `warning`, or `danger`, with an optional `[title]`. VitePress containers put a space after the colon fence and accept `info`, `tip`, `warning`, `danger`, or `details`, with an optional plain title after the type. `details` opens collapsed. A closing colon fence must be at least as long as its opening fence. Longer outer fences can hold nested colon callouts. - A callout's type icon names its type as written in its accessible name. Pressing the icon, or running `Callout type...` from the command palette with the caret or selection in a callout, opens a searchable list of that callout's dialect types with the current one checked, its search focused. `Format > Callout` and the context popup's `Callout type` submenu list the same types and mark the current one. Choosing a type from any of them changes only the type token, keeping the dialect, title spelling, marker or fence, and body, as one Undo step; choosing the current type, Escape, Tab, or a press elsewhere changes nothing. The icon is chrome outside the body and pressing it leaves the selection where it was. A callout nested in another changes on its own. -- `Insert > Callout`, in the menu bar and in the context popup's Insert submenu, lists the types a new callout takes. `Callout...`, from the command palette or a gutter insertion slot between the document's own blocks, opens the searchable list for a new callout. Choosing a type from either inserts an empty callout after the document block holding the end of the selection, or at the slot, with the caret in its body, as one Undo step; closing the list without choosing leaves the document unchanged. A callout nested in a list, a blockquote, or another callout would reopen as different Markdown, so a new callout always goes between the document's own blocks. It uses the dialect every callout already in the document shares, and a GitHub alert in a document with no callouts or with more than one dialect. It is written with no title, with `!!!` for an MkDocs admonition and a three-colon fence for Docusaurus and VitePress. +- `Insert > Callout`, in the menu bar and in the context popup's Insert submenu, lists the types of the dialect every callout already in the document shares, or GitHub alert types in a document with no callouts or with more than one dialect. A separator follows, then a submenu for each other dialect, labeled `GitHub alert`, `MkDocs`, `Docusaurus`, and `VitePress`; the dialect listed first has no submenu of its own. The context popup shows each type's icon. Choosing a type inserts an empty callout in that dialect after the document block holding the end of the selection, with the caret in its body, as one Undo step. Its authored dialect participates in the inference for later insertions, so adding a second dialect makes GitHub alert types appear first. `Callout...`, from the command palette or a gutter insertion slot between the document's own blocks, opens the searchable list of the inferred dialect's types alone; choosing inserts after the selection's document block, or at the slot, and closing without choosing leaves the document unchanged. A callout nested in a list, a blockquote, or another callout would reopen as different Markdown, so a new callout always goes between the document's own blocks. It is written with no title, with `!!!` for an MkDocs admonition and a three-colon fence for Docusaurus and VitePress. - Typing a supported opening marker and pressing Enter creates a callout body; pasting a supported Markdown form creates the same container. Unsupported directives, unknown type markers, and ordinary fenced code keep their existing Markdown interpretation. Directive attributes, MkDocs inline modifiers and custom types, and VitePress container attributes are outside this initial support. - Prose blocks wrap to the editor viewport width; horizontal scrolling is restricted to code blocks, tables, and display math blocks. - Soft wrap is visual only and never modifies saved Markdown. @@ -397,7 +397,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - `Ordered list` and `Unordered list` convert a list of the other kind on that rule. The marker and the numbers belonged to the list that is gone, so the one they make is written in the default form; each item keeps the padding and the opening line it holds itself, and the list keeps its tightness, which the conversion does not ask about. - A code block's language is content the block carries rather than a format a command asks for, so `Code block` removes a block whatever its info string. Only `Code block language...` sets, changes, or clears it. - `Clear block formatting` converts the selected blocks, or the current block, to paragraphs when applicable. -- Insert commands add new content after the current block, or after the last selected block when the selection spans multiple blocks. A callout from `Insert > Callout` goes after the document block holding that block, as [Rendering](#rendering) describes. +- Insert commands add new content after the current block, or after the last selected block when the selection spans multiple blocks. A callout from `Insert > Callout` goes after the document block holding that block. Its types are listed in the inferred dialect first, followed by submenus offering the other dialects, as [Rendering](#rendering) describes. This choice inserts a new callout; changing an existing callout's type still offers only its own dialect's types. - The `Image` insert command inserts `![]()` and places the caret inside the parentheses. - The `Table` insert command creates a default 2-by-2 table. - The `Paragraph` insert command creates a new empty paragraph. diff --git a/e2e/desktop/specs/callouts.spec.ts b/e2e/desktop/specs/callouts.spec.ts index d732b1e9..be4ce60f 100644 --- a/e2e/desktop/specs/callouts.spec.ts +++ b/e2e/desktop/specs/callouts.spec.ts @@ -1,9 +1,16 @@ import { $, browser, expect } from "@wdio/globals"; import { readFile } from "node:fs/promises"; import path from "node:path"; +import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { getSaveMenuItem, openRecentPath, selectFileMenuItem } from "../support/ui.js"; +import { + findMenuItem, + getSaveMenuItem, + openMenu, + openRecentPath, + selectFileMenuItem, +} from "../support/ui.js"; describe("desktop callouts", () => { it("renders, edits, folds, saves, and reopens each supported form", async () => { @@ -149,4 +156,76 @@ describe("desktop callouts", () => { $('.leafdown-callout[data-dialect="mkdocs"] .leafdown-callout-body'), ).not.toBeDisplayed(); }); + + for (const [dialect, label, type] of [ + ["github", null, "Caution"], + ["docusaurus", "Docusaurus", "Danger"], + ["vitepress", "VitePress", "Danger"], + ["mkdocs", "MkDocs", "Bug"], + ] as const) { + it(`inserts a ${label ?? "GitHub alert"} callout through the Insert menu`, async () => { + const active = "[data-testid=active-document-host]"; + await selectFileMenuItem("New"); + await openMenu("Insert"); + await (await findMenuItem((text) => text === "Callout")).click(); + + if (label) { + await (await findMenuItem((text) => text === label)).click(); + } + + await (await findMenuItem((text) => text === type)).click(); + await expect($(`${active} .leafdown-callout`)).toHaveAttribute("data-dialect", dialect); + await browser.waitUntil( + () => + browser.execute(() => { + const body = document.querySelector( + "[data-testid=active-document-host] .leafdown-callout-body", + )!; + return ( + body.contains(window.getSelection()?.anchorNode ?? null) && + document.activeElement?.classList.contains("ProseMirror") + ); + }), + { timeoutMsg: "Choosing a callout dialect did not return focus to its body." }, + ); + }); + } + + it("inserts another dialect from the context popup and lists GitHub types first afterward", async () => { + const active = "[data-testid=active-document-host]"; + + await selectFileMenuItem("New"); + await openMenu("Insert"); + await (await findMenuItem((text) => text === "Callout")).click(); + await (await findMenuItem((text) => text === "MkDocs")).click(); + await (await findMenuItem((text) => text === "Bug")).click(); + + // The embedded driver drops Shift from F10, so raise the chord on the focused editor. + await $(`${active} .ProseMirror`).execute((editor) => { + editor.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "F10", + shiftKey: true, + }), + ); + }); + const popup = $("[data-testid=editor-context-popup]"); + await expect(popup).toBeDisplayed(); + await popup.$("aria/Insert").click(); + await (await findMenuItem((text) => text === "Callout")).click(); + await (await findMenuItem((text) => text === "VitePress")).click(); + const details = await findMenuItem((text) => text === "Details"); + await expect(details.$("[data-slot=callout-type-icon]")).toBeDisplayed(); + await details.click(); + + await expect($(`${active} .leafdown-callout[data-dialect=mkdocs]`)).toBeDisplayed(); + await expect($(`${active} .leafdown-callout[data-dialect=vitepress]`)).toBeDisplayed(); + await browser.keys(Key.Escape); + await openMenu("Insert"); + await (await findMenuItem((text) => text === "Callout")).click(); + await expect(await findMenuItem((text) => text === "Caution")).toBeDisplayed(); + await browser.keys([Key.Escape, Key.Escape]); + }); }); diff --git a/src/components/layout/command-menubar.test.tsx b/src/components/layout/command-menubar.test.tsx index 9d85b895..de1a45dc 100644 --- a/src/components/layout/command-menubar.test.tsx +++ b/src/components/layout/command-menubar.test.tsx @@ -8,11 +8,11 @@ import { type CommandState, type ReopenWithEncodingControl, } from "@/commands"; -import type { CalloutTypeChoice } from "@/features/editor"; +import type { CalloutTypeChoice, CalloutTypeChoices } from "@/features/editor"; import type { RecentItem } from "@/features/preferences"; import { TEST_MARKDOWN_FILE_PATH, TEST_NOTES_FOLDER_PATH } from "@/test/fixtures/paths"; import { withMacUserAgent, withWindowsUserAgent } from "@/test/utils/platform"; -import { renderWithUser, screen, within } from "@/test/utils/react"; +import { renderWithUser, screen, waitFor, within } from "@/test/utils/react"; import { CommandMenubar } from "./command-menubar"; @@ -535,7 +535,7 @@ describe("CommandMenubar", () => { describe("callout types", () => { const createCalloutTypes = () => ({ - read: vi.fn((choice: CalloutTypeChoice) => + read: vi.fn((choice: CalloutTypeChoice): CalloutTypeChoices => choice === "change" ? { current: "warning", @@ -551,6 +551,11 @@ describe("CommandMenubar", () => { { label: "Note", token: "NOTE" }, { label: "Tip", token: "TIP" }, ], + otherDialects: [ + { dialect: "mkdocs", options: [{ label: "Bug", token: "bug" }] }, + { dialect: "docusaurus", options: [{ label: "Danger", token: "danger" }] }, + { dialect: "vitepress", options: [{ label: "Details", token: "details" }] }, + ], }, ), choose: vi.fn(), @@ -582,6 +587,53 @@ describe("CommandMenubar", () => { expect(insertTrigger).toHaveAttribute("aria-expanded", "false"); }); + it.each([ + ["MkDocs", "mkdocs", "Bug", "bug"], + ["Docusaurus", "docusaurus", "Danger", "danger"], + ["VitePress", "vitepress", "Details", "details"], + ["GitHub alert", "github", "Caution", "CAUTION"], + ] as const)("inserts from the %s dialect submenu", async (label, dialect, typeLabel, token) => { + const editor = appendEditor(); + const calloutTypes = { ...createCalloutTypes(), choose: vi.fn(() => editor.focus()) }; + + if (dialect === "github") { + calloutTypes.read.mockReturnValue({ + current: null, + options: [{ label: "Bug", token: "bug" }], + otherDialects: [{ dialect, options: [{ label: typeLabel, token }] }], + }); + } + + const { user } = renderCommandMenuBar({ calloutTypes }); + + await user.click(screen.getByRole("menuitem", { name: "Insert" })); + await user.hover(menuItem(/^Callout$/u)); + await user.keyboard("{ArrowRight}"); + + const trigger = await screen.findByRole("menuitem", { name: label }); + const menu = trigger.closest("[role='menu']")!; + + expect(within(menu).getByRole("separator")).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: dialect === "github" ? "MkDocs" : "GitHub alert" }), + ).not.toBeInTheDocument(); + await user.keyboard(label[0]); + await waitFor(() => expect(trigger).toHaveFocus()); + await user.keyboard("{ArrowRight}"); + const item = await screen.findByRole("menuitem", { name: typeLabel }); + + expect(item.querySelector("svg, [data-slot='callout-type-icon']")).toBeNull(); + await user.keyboard("{Enter}"); + await nextFrame(); + + expect(calloutTypes.choose).toHaveBeenCalledWith({ type: "insert", dialect }, token); + expect(editor).toHaveFocus(); + expect(screen.getByRole("menuitem", { name: "Insert" })).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + it("disables Insert's Callout submenu where no callout can be inserted", async () => { const calloutTypes = createCalloutTypes(); const { user } = renderCommandMenuBar({ diff --git a/src/components/layout/command-menubar.tsx b/src/components/layout/command-menubar.tsx index 2444325c..e253c361 100644 --- a/src/components/layout/command-menubar.tsx +++ b/src/components/layout/command-menubar.tsx @@ -577,13 +577,49 @@ function InsertCalloutSubmenu() { } function InsertCalloutItems() { + const { t } = useLocalization(); const { calloutTypes } = useCommandMenu(); + const choices = calloutTypes.read("insert"); - return calloutTypes.read("insert")?.options.map(({ label, token }) => ( - calloutTypes.choose("insert", token)}> - {label} - - )); + if (!choices) { + return null; + } + + return ( + <> + + {choices.options.map(({ label, token }) => ( + calloutTypes.choose("insert", token)}> + {label} + + ))} + + {choices.otherDialects && ( + <> + + + {choices.otherDialects.map(({ dialect, options }) => ( + + {t(`editor.calloutDialect.${dialect}`)} + + + {options.map(({ label, token }) => ( + calloutTypes.choose({ type: "insert", dialect }, token)} + > + {label} + + ))} + + + + ))} + + + )} + + ); } // The submenu keeps its indicator columns out of the Format menu itself. diff --git a/src/features/editor/commands/formatting/callouts.test.ts b/src/features/editor/commands/formatting/callouts.test.ts index 98b19cd2..4d496de8 100644 --- a/src/features/editor/commands/formatting/callouts.test.ts +++ b/src/features/editor/commands/formatting/callouts.test.ts @@ -398,6 +398,70 @@ describe("callout insertion", () => { }); describe("callout types chosen from a submenu", () => { + const forms = [ + { dialect: "github", token: "WARNING", old: "> [!NOTE]\n> Old", body: "> [!WARNING]\n> Body" }, + { + dialect: "mkdocs", + token: "warning", + old: "???+ tip\n Old", + body: "!!! warning\n Body", + }, + { + dialect: "docusaurus", + token: "warning", + old: ":::tip\nOld\n:::", + body: ":::warning\nBody\n:::", + }, + { + dialect: "vitepress", + token: "warning", + old: "::: tip\nOld\n:::", + body: "::: warning\nBody\n:::", + }, + ] as const; + + describe.each(forms)("explicit $dialect insertion", ({ dialect, token, body }) => { + it.each([{ dialect: null, old: "" }, ...forms])( + "inserts beside $dialect callouts and updates the inferred types", + async (existing) => { + const source = existing.old ? withProse(existing.old) : "Intro\n\nOutro\n"; + const { mounted, requests } = await mountWithRequests(source); + + placeCaret(mounted, "Intro"); + + expect(chooseCalloutType(mounted.view, { type: "insert", dialect }, token)).toBe(true); + expect(requests).toEqual([]); + expect(mounted.view.state.selection.$from.node(1).attrs).toMatchObject({ + dialect, + token, + title: null, + }); + const inferred = + existing.dialect === null || existing.dialect === dialect ? dialect : "github"; + const choices = readCalloutTypeChoices(mounted.view.state, "insert"); + + expect(choices?.options.map((option) => option.token)).toEqual(CALLOUT_TYPES[inferred]); + expect(choices?.otherDialects?.map((group) => group.dialect)).toEqual( + forms.map((form) => form.dialect).filter((value) => value !== inferred), + ); + typeText(mounted.view, "Body"); + await expectRoundTrip(mounted, source.replace("Intro\n\n", `Intro\n\n${body}\n\n`)); + }, + ); + + it("inserts an empty callout as one Undo step", async () => { + const source = "Intro\n\nOutro\n"; + const mounted = await mountEditor(source); + + placeCaret(mounted, "Intro"); + expect(chooseCalloutType(mounted.view, { type: "insert", dialect }, token)).toBe(true); + expect(mounted.view.state.selection.$from.node(1).textContent).toBe(""); + expect(undo(mounted.view)).toBe(true); + expect(mounted.getMarkdown()).toBe(source); + expect(canUndo(mounted.view.state)).toBe(false); + }); + }); + it("lists a GitHub alert's types by name, marking the type it has", async () => { const mounted = await mountEditor(withProse("> [!WARNING]\n> Body text")); @@ -491,6 +555,20 @@ describe("callout types chosen from a submenu", () => { expect(chooseCalloutType(mounted.view, "insert", "warning")).toBe(false); expect(mounted.view.state.doc).toBe(before); }); + + it("validates types against an explicitly chosen insertion dialect", async () => { + const mounted = await mountEditor("Intro\n\nOutro\n"); + placeCaret(mounted, "Intro"); + const before = mounted.view.state.doc; + const choice = { type: "insert", dialect: "docusaurus" } as const; + + expect( + readCalloutTypeChoices(mounted.view.state, choice)?.options.map(({ token }) => token), + ).toEqual(CALLOUT_TYPES.docusaurus); + expect(chooseCalloutType(mounted.view, choice, "details")).toBe(false); + expect(mounted.view.state.doc).toBe(before); + expect(canUndo(mounted.view.state)).toBe(false); + }); }); describe("authored callout disclosure", () => { diff --git a/src/features/editor/commands/formatting/callouts.ts b/src/features/editor/commands/formatting/callouts.ts index 2b9b3d48..346d9e58 100644 --- a/src/features/editor/commands/formatting/callouts.ts +++ b/src/features/editor/commands/formatting/callouts.ts @@ -181,7 +181,7 @@ export const applyCalloutType = (view: EditorView, request: CalloutTypeRequest, }; /** Whether a type is chosen for the callout in the selection or for one to insert after it. */ -export type CalloutTypeChoice = "change" | "insert"; +export type CalloutTypeChoice = "change" | "insert" | { type: "insert"; dialect: CalloutDialect }; export interface CalloutTypeOption { label: string; @@ -192,8 +192,14 @@ export interface CalloutTypeChoices { /** The callout's type as written, or `null` for a callout still to be inserted. */ current: string | null; options: readonly CalloutTypeOption[]; + otherDialects?: readonly { + dialect: CalloutDialect; + options: readonly CalloutTypeOption[]; + }[]; } +const CALLOUT_DIALECTS = ["github", "mkdocs", "docusaurus", "vitepress"] as const; + const listCalloutTypes = (dialect: CalloutDialect): CalloutTypeOption[] => CALLOUT_TYPES[dialect].map((token) => ({ label: formatCalloutTypeLabel(token), token })); @@ -209,10 +215,21 @@ export const readCalloutTypeChoices = ( state: EditorState, choice: CalloutTypeChoice, ): CalloutTypeChoices | null => { - if (choice === "insert") { - return findCalloutInsertionBoundary(state) === null - ? null - : { current: null, options: listCalloutTypes(findNewCalloutDialect(state.doc)) }; + if (choice !== "change") { + if (findCalloutInsertionBoundary(state) === null) { + return null; + } + + const dialect = choice === "insert" ? findNewCalloutDialect(state.doc) : choice.dialect; + + return { + current: null, + options: listCalloutTypes(dialect), + otherDialects: CALLOUT_DIALECTS.filter((other) => other !== dialect).map((other) => ({ + dialect: other, + options: listCalloutTypes(other), + })), + }; } const callout = findSelectedCallout(state); @@ -227,9 +244,9 @@ export const readCalloutTypeChoices = ( /** Inserts a callout of the type, or changes the selected callout to it, with no picker between. */ export const chooseCalloutType = (view: EditorView, choice: CalloutTypeChoice, token: string) => { - if (choice === "insert") { + if (choice !== "change") { const boundary = findCalloutInsertionBoundary(view.state); - const dialect = findNewCalloutDialect(view.state.doc); + const dialect = choice === "insert" ? findNewCalloutDialect(view.state.doc) : choice.dialect; return ( boundary !== null && diff --git a/src/features/editor/components/editor-context-popup.test.tsx b/src/features/editor/components/editor-context-popup.test.tsx index fd07535a..37f04c0d 100644 --- a/src/features/editor/components/editor-context-popup.test.tsx +++ b/src/features/editor/components/editor-context-popup.test.tsx @@ -513,6 +513,11 @@ describe("editor-context-popup", () => { { label: "Note", token: "NOTE" }, { label: "Tip", token: "TIP" }, ], + otherDialects: [ + { dialect: "mkdocs", options: [{ label: "Bug", token: "bug" }] }, + { dialect: "docusaurus", options: [{ label: "Danger", token: "danger" }] }, + { dialect: "vitepress", options: [{ label: "Details", token: "details" }] }, + ], }; const renderCalloutPopup = (commandState: EditorCommandState) => { @@ -606,7 +611,15 @@ describe("editor-context-popup", () => { const items = within(group).getAllByRole("menuitem"); expect(readCalloutTypes).toHaveBeenCalledWith("insert"); - expect(items.map((item) => item.textContent)).toEqual(["Note", "Tip"]); + expect(items.map((item) => item.textContent)).toEqual([ + "Note", + "Tip", + "MkDocs", + "Docusaurus", + "VitePress", + ]); + expect(within(group).getByRole("separator")).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "GitHub alert" })).not.toBeInTheDocument(); expect(iconOf(items[1])?.style.maskImage).toBe('url("/callout-tip.svg")'); await user.keyboard("{ArrowDown}{Enter}"); @@ -614,6 +627,45 @@ describe("editor-context-popup", () => { expect(onChooseCalloutType).toHaveBeenCalledWith("insert", "TIP"); }); + it.each([ + ["MkDocs", "mkdocs", "Bug", "bug"], + ["Docusaurus", "docusaurus", "Danger", "danger"], + ["VitePress", "vitepress", "Details", "details"], + ["GitHub alert", "github", "Caution", "CAUTION"], + ] as const)( + "inserts from %s with the type's icon", + async (label, dialect, typeLabel, token) => { + const { onChooseCalloutType, readCalloutTypes, user } = renderCalloutPopup( + withEnabledCommands("insert.callout"), + ); + + if (dialect === "github") { + readCalloutTypes.mockReturnValue({ + current: null, + options: [{ label: "Bug", token: "bug" }], + otherDialects: [{ dialect, options: [{ label: typeLabel, token }] }], + }); + } + + await user.click(screen.getByRole("button", { name: "Insert" })); + await user.hover(await screen.findByRole("menuitem", { name: "Callout" })); + await user.keyboard("{ArrowRight}"); + const trigger = await screen.findByRole("menuitem", { name: label }); + await user.keyboard(label[0]); + await waitFor(() => expect(trigger).toHaveFocus()); + await user.keyboard("{ArrowRight}"); + + const group = await screen.findByRole("group", { name: label }); + const item = within(group).getByRole("menuitem", { name: typeLabel }); + + expect(iconOf(item)).toHaveAttribute("aria-hidden", "true"); + expect(iconOf(item)?.style.maskImage).toBe(`url("/callout-${token.toLowerCase()}.svg")`); + await user.keyboard("{Enter}"); + + expect(onChooseCalloutType).toHaveBeenCalledWith({ type: "insert", dialect }, token); + }, + ); + it("disables the Insert submenu's Callout submenu where no callout can be inserted", async () => { const { readCalloutTypes, user } = renderCalloutPopup(enabledPopupCommandState); diff --git a/src/features/editor/components/editor-context-popup.tsx b/src/features/editor/components/editor-context-popup.tsx index ae8c90ee..18254867 100644 --- a/src/features/editor/components/editor-context-popup.tsx +++ b/src/features/editor/components/editor-context-popup.tsx @@ -51,6 +51,7 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -793,16 +794,53 @@ function InsertCalloutSubmenu({ } function InsertCalloutItems({ onChooseCalloutType, readCalloutTypes }: CalloutTypeChoiceProps) { - return readCalloutTypes("insert")?.options.map(({ label, token }) => ( - onChooseCalloutType("insert", token)} - > - - {label} - - )); + const { t } = useLocalization(); + const choices = readCalloutTypes("insert"); + + if (!choices) { + return null; + } + + return ( + <> + {choices.options.map(({ label, token }) => ( + onChooseCalloutType("insert", token)} + > + + {label} + + ))} + {choices.otherDialects && ( + <> + + {choices.otherDialects.map(({ dialect, options }) => ( + + + {t(`editor.calloutDialect.${dialect}`)} + + + + {options.map(({ label, token }) => ( + onChooseCalloutType({ type: "insert", dialect }, token)} + > + + {label} + + ))} + + + + ))} + + )} + + ); } interface ContextCalloutTypeSubmenuProps extends CalloutTypeChoiceProps { diff --git a/src/features/editor/plugins/contextPopup.test.ts b/src/features/editor/plugins/contextPopup.test.ts index 1e6ea60b..927eab83 100644 --- a/src/features/editor/plugins/contextPopup.test.ts +++ b/src/features/editor/plugins/contextPopup.test.ts @@ -385,6 +385,32 @@ describe("context popup plugin", () => { expect(popupState.onContextPopupRequested).not.toHaveBeenCalled(); }); + it("keeps Escape's dismissal when a pointer release is still queued", async () => { + const popupState = trackPopupOpenState(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, popupState); + + mockCoordinates(mounted); + setTextSelection(mounted.view, 1, 6); + dispatchMouseDown(mounted.view.dom, { button: 0 }); + setTextSelection(mounted.view, 1, 7); + dispatchMouseUp(mounted.view.dom, { button: 0 }); + runKeyDownHandlers(mounted.view, "Escape"); + popupState.onContextPopupRequested.mockClear(); + + await settleAnimationFrame(); + + expect(popupState.getContextPopupOpen()).toBe(false); + expect(popupState.onContextPopupRequested).not.toHaveBeenCalled(); + + dispatchMouseDown(mounted.view.dom, { button: 0 }); + setTextSelection(mounted.view, 1, 8); + dispatchMouseUp(mounted.view.dom, { button: 0 }); + + await settleAnimationFrame(); + + expect(popupState.getContextPopupOpen()).toBe(true); + }); + it("stays dismissed after Select all until the selection collapses", async () => { const popupState = trackPopupOpenState(); const mounted = await mountEditor(HELLO_WORLD_TEXT, popupState); diff --git a/src/features/editor/plugins/contextPopup.ts b/src/features/editor/plugins/contextPopup.ts index 4207e861..83783d34 100644 --- a/src/features/editor/plugins/contextPopup.ts +++ b/src/features/editor/plugins/contextPopup.ts @@ -235,6 +235,7 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl mousedown: (_view, event) => { if (event instanceof MouseEvent && event.button === 0) { pointerSelecting = true; + suppressPointerRelease = false; } return false; @@ -266,6 +267,7 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl } dismissed = true; + suppressPointerRelease = true; event.preventDefault(); return true; diff --git a/src/features/session/services/documentEditorBridge.test.ts b/src/features/session/services/documentEditorBridge.test.ts index c3a7bd06..20cbc110 100644 --- a/src/features/session/services/documentEditorBridge.test.ts +++ b/src/features/session/services/documentEditorBridge.test.ts @@ -213,9 +213,12 @@ describe("document editor bridge", () => { expect(readCalloutTypes).toHaveBeenCalledWith("change"); expect(documentEditorBridge.chooseCalloutType("doc:test", "insert", "TIP")).toBe(true); expect(chooseCalloutType).toHaveBeenCalledWith("insert", "TIP"); + const choice = { type: "insert", dialect: "mkdocs" } as const; + expect(documentEditorBridge.chooseCalloutType("doc:test", choice, "bug")).toBe(true); + expect(chooseCalloutType).toHaveBeenCalledWith(choice, "bug"); expect(documentEditorBridge.readCalloutTypes("doc:other", "change")).toBeNull(); expect(documentEditorBridge.chooseCalloutType("doc:other", "insert", "TIP")).toBe(false); - expect(chooseCalloutType).toHaveBeenCalledTimes(1); + expect(chooseCalloutType).toHaveBeenCalledTimes(2); }); it("notifies a stable listener snapshot", () => { diff --git a/src/locales/en.json b/src/locales/en.json index b83ff844..bc7012bc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -356,6 +356,10 @@ "menu.heading": "Heading", "menu.insert.frontmatter": "Frontmatter", "menu.callout": "Callout", + "editor.calloutDialect.github": "GitHub alert", + "editor.calloutDialect.mkdocs": "MkDocs", + "editor.calloutDialect.docusaurus": "Docusaurus", + "editor.calloutDialect.vitepress": "VitePress", "menu.format.table": "Table", "menu.view.appearance": "Appearance", "menu.view.sortArticlesBy": "Sort articles by",