From 5e6e62f5493dd40e3e0a390b186bd01a78d80d21 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 15:26:19 +0800 Subject: [PATCH 01/16] feat(slash): add opt-in drag-to-reorder for slash menu commands Roadmap #16 introduced automatic ordering by recency, but a user who wants a stable personal layout has no way to express it: the menu is always either registration order or recency order. The only lever today is pre-sorting the slashCommands array at registration time, which cannot change at runtime and cannot coexist with recency ordering. Add an opt-in `reorderable` option that renders a grip handle on every item and lets the user drag commands into place. - New `command-order.ts` mirrors the `command-history.ts` storage contract: `true` for session-only, `{ storage, storageKey }` for host-injected persistence. No implicit global `localStorage` write. - Manual placement is applied after the recency layer, so pinned commands win and unplaced commands keep the order history produced. - Reordering uses custom mousedown/mousemove/mouseup. The HTML5 drag-and-drop API is deliberately avoided, per the repository's custom-drag convention. - Drops are clamped to the rendered bounds, and `itemEls`, `visibleCommands`, the DOM, and the highlight index move together so `Enter` always confirms the command the user sees. - Because the editor caps results before the menu renders, a write rewrites only the visible ids and preserves stored ids outside that window. Fixing this surfaced a latent issue: item hover and click handlers captured their index when the node was created, which is only valid while element position never changes. A reorder invalidates that, so indices are now resolved at event time. Verified against real browser layout in the Electron demo (option enabled temporarily, then reverted): handle hit area, live reorder, drop commit, highlight follow-through, and `Enter` confirming the dropped command. --- .../add-slash-menu-reorder/proposal.md | 47 ++ .../specs/plugins/spec.md | 188 +++++++ .../changes/add-slash-menu-reorder/tasks.md | 38 ++ packages/plugin-slash/src/command-order.ts | 131 +++++ packages/plugin-slash/src/index.ts | 7 + packages/plugin-slash/src/menu-ui.ts | 246 ++++++++- packages/plugin-slash/test/menu-ui.test.ts | 486 +++++++++++++++++- 7 files changed, 1137 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/add-slash-menu-reorder/proposal.md create mode 100644 openspec/changes/add-slash-menu-reorder/specs/plugins/spec.md create mode 100644 openspec/changes/add-slash-menu-reorder/tasks.md create mode 100644 packages/plugin-slash/src/command-order.ts diff --git a/openspec/changes/add-slash-menu-reorder/proposal.md b/openspec/changes/add-slash-menu-reorder/proposal.md new file mode 100644 index 00000000..ebd1fbeb --- /dev/null +++ b/openspec/changes/add-slash-menu-reorder/proposal.md @@ -0,0 +1,47 @@ +# Change: Add Opt-In Drag-To-Reorder for Slash Menu Commands + +## Why + +Roadmap item 16 introduced opt-in *automatic* slash command ordering by recency (`add-slash-recent-command-history`). That covers the common case, but users who care about a stable personal layout have no way to express it: the menu is always either registration order or recency order, and there is no affordance to say "I want `Heading 2` above `Heading 1`". + +A slash menu is a high-frequency surface, and its order is the difference between one keystroke and five. Obsidian-style hosts already treat command ordering as a user preference. Today a host can only pre-sort the `slashCommands` array it registers, which is a build-time decision — it cannot be changed at runtime, cannot be persisted per user, and cannot coexist with recency ordering. + +This change adds the missing runtime affordance: a drag handle on each item that lets the user reorder commands directly in the menu, with an opt-in, host-injected persistence channel that mirrors the existing history storage contract. + +## What Changes + +- Add an opt-in `reorderable` option to `SlashMenuUIOptions`. Accepted values: `true` for session-only reordering, or `{ storage?, storageKey? }` for host-injected persistence. Omitting it or passing `false` keeps the feature fully disabled. +- Render a grip handle (`.{prefix}-menu__handle`) as the first child of every slash menu item when the feature is enabled. +- Implement reordering with a custom `mousedown` / `mousemove` / `mouseup` drag. HTML5 drag-and-drop (`draggable="true"`, `dragstart` / `dragover`) MUST NOT be used. +- Apply the manual order to empty-query menus only, composed *after* recency ordering: commands the user has pinned win, and commands the user has never pinned keep whatever order the history layer produced. +- Clamp drag movement to the visible list boundaries. The command list MUST NOT change length, gain duplicates, or lose entries as a result of a drag. +- Keep `highlight`, the rendered order, `visibleCommands`, and confirmation aligned: after a drop, `Enter` MUST confirm the command that is rendered as active. +- Treat a handle press that does not move as inert: it MUST NOT confirm the command and MUST NOT change the order. +- Cancel an in-flight drag (without persisting) when the menu closes, is dismissed, or is destroyed. +- Persist only on drop, through a host-injected localStorage-like object. `plugin-slash` MUST NOT write to global `localStorage` by default. +- Merge persisted order with the visible window: because the editor caps slash menu results at `slashMenuLimit` (default 8) *before* the menu renders, a drop rewrites only the ids it can see and preserves the relative order of every previously stored id outside that window. A drag MUST NOT drop commands it cannot see. +- Ignore unknown, stale, or duplicated command ids in storage without breaking menu open, render, navigation, or confirmation. +- Keep disabled/default behavior fully backward compatible: registration/recency order, keyboard navigation, Enter confirm, click confirm, and the existing `history` option are unchanged. + +## Non-Goals + +- No keyboard-operable reordering (e.g. `Alt+ArrowUp`). The handle is a pointer affordance and is marked `aria-hidden`, so no inaccessible control is exposed. A keyboard path is a deliberate follow-up, not part of this change. +- No touch-specific long-press gesture. The drag is driven by mouse button events, matching the repository's existing custom-drag convention. +- No drag *between* the visible window and the capped-out remainder (the UI cannot observe commands beyond `slashMenuLimit`). +- No changes to slash command ranking, filtering, or the `slashMenuLimit` cap. +- No cross-command ordering for non-visible commands, and no drag on non-empty queries. +- No changes to `packages/core/**`, `packages/plugin-search/**`, or `apps/electron-demo/**`. +- No new dependencies. + +## Impact + +- Affected specs: `plugins` +- Affected code: + - `packages/plugin-slash/src/menu-ui.ts` + - `packages/plugin-slash/src/command-order.ts` (new module, mirrors `command-history.ts`) + - `packages/plugin-slash/src/index.ts` for the public type/export surface + - `packages/plugin-slash/test/menu-ui.test.ts` +- Explicitly out of scope: + - `packages/core/**` + - `packages/plugin-search/**` + - `apps/electron-demo/**` diff --git a/openspec/changes/add-slash-menu-reorder/specs/plugins/spec.md b/openspec/changes/add-slash-menu-reorder/specs/plugins/spec.md new file mode 100644 index 00000000..1a1accc6 --- /dev/null +++ b/openspec/changes/add-slash-menu-reorder/specs/plugins/spec.md @@ -0,0 +1,188 @@ +## ADDED Requirements + +### Requirement: Slash Menu Reordering Is Opt-In + +`plugin-slash` SHALL keep drag-to-reorder disabled by default. Hosts MUST explicitly enable it before the menu may render drag handles, reorder commands by manual placement, or read and write a persisted order. Reordering MAY be enabled with a boolean `true` flag for session-only ordering or with an options object for host-injected storage settings; omitting `reorderable` or passing `false` SHALL keep it disabled. + +#### Scenario: Default menu renders no handles +- **WHEN** a host creates the slash menu without `reorderable` +- **AND** the slash menu opens +- **THEN** no `.{prefix}-menu__handle` element SHALL be rendered +- **AND** an empty-query menu SHALL render commands in the same order supplied by the editor state + +#### Scenario: Session-only reordering is available when enabled without storage +- **WHEN** a host enables `reorderable: true` without a storage object +- **AND** the user drags a command to a new position +- **THEN** the current menu instance SHALL render the new order for the rest of the session +- **AND** no global `localStorage` write SHALL occur + +### Requirement: Manual Order Composes After Recency Ordering + +When both `history` and `reorderable` are enabled, `plugin-slash` SHALL apply recency ordering first and then apply the persisted manual order on top. Commands the user has manually placed SHALL win over recency ordering, and commands the user has never manually placed SHALL retain the relative order produced by the recency layer. + +#### Scenario: Manually placed command outranks a recently used command +- **WHEN** `history` and `reorderable` are both enabled +- **AND** the persisted manual order is `["b", "a"]` +- **AND** the user has most recently confirmed command `c` +- **THEN** the empty-query menu SHALL render `["b", "a", "c", ...]` +- **AND** command `c` SHALL NOT be promoted above `a` or `b` + +#### Scenario: Unplaced commands keep the recency layer order +- **WHEN** the persisted manual order is `["c"]` +- **AND** the recency layer produces `["b", "a", "c"]` +- **THEN** the rendered order SHALL be `["c", "b", "a"]` + +### Requirement: Manual Order Applies To Empty Query Menus Only + +`plugin-slash` SHALL apply the persisted manual order only when the slash query is empty. While a non-empty query filters the menu, the rendered order SHALL be the filtered order supplied by the editor state, and drag handles SHALL NOT initiate a drag. + +#### Scenario: Filtered menus ignore the manual order +- **WHEN** `reorderable` is enabled and the persisted manual order is `["c", "a", "b"]` +- **AND** the user types a non-empty query +- **THEN** the rendered order SHALL follow the filtered order from the editor state +- **AND** dragging a handle SHALL NOT change the order + +### Requirement: Reordering Uses Custom Pointer Drag + +`plugin-slash` SHALL implement reordering with `mousedown`, `mousemove`, and `mouseup` listeners. It MUST NOT use the HTML5 drag-and-drop API (`draggable`, `dragstart`, `dragover`, `drop`) and MUST NOT set `draggable="true"` on menu items or handles. + +#### Scenario: Item elements are not HTML5-draggable +- **WHEN** `reorderable` is enabled and the menu is open +- **THEN** neither the item element nor the handle element SHALL have `draggable="true"` +- **AND** the handle SHALL be the only element that initiates a reorder + +### Requirement: Drag Is Clamped To The Visible List + +A drag SHALL move exactly one command and SHALL clamp at both boundaries. Dragging above the first rendered position SHALL place the command at index `0`; dragging below the last rendered position SHALL place it at the last index. The rendered command list MUST NOT change length, MUST NOT gain duplicate ids, and MUST NOT lose entries as a result of a drag. + +#### Scenario: Dragging above the first item clamps to the top +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user drags `c` above the first item +- **THEN** the rendered order SHALL be `[c, a, b]` +- **AND** the list SHALL still contain exactly three items + +#### Scenario: Dragging below the last item clamps to the bottom +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user drags `a` below the last item +- **THEN** the rendered order SHALL be `[b, c, a]` +- **AND** the list SHALL still contain exactly three items + +#### Scenario: A single-item menu does not start a drag +- **WHEN** the menu renders exactly one command +- **AND** the user presses the handle and moves the pointer +- **THEN** the rendered order SHALL be unchanged + +### Requirement: Confirmation Stays Aligned With The Reordered List + +After a drop, `plugin-slash` SHALL keep the rendered order, the highlight index, `visibleCommands`, and confirmation aligned. `Enter` and item clicks MUST confirm the command represented by the rendered active or clicked item, not the command that occupied the same index before the reorder. The dragged command SHALL become the highlighted item after the drop. + +#### Scenario: Enter confirms the dropped command +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user drags `c` to the first position and releases +- **AND** the user presses `Enter` +- **THEN** command `c` SHALL be confirmed +- **AND** command `a` SHALL NOT be confirmed because it previously occupied index `0` + +#### Scenario: Enter confirms the item active at drop time +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user drags `a` to the last position and releases +- **THEN** the highlighted item SHALL be `a` +- **AND** pressing `Enter` SHALL confirm command `a` + +### Requirement: A Press Without Movement Is Inert + +Pressing a handle and releasing it without moving the pointer SHALL NOT change the command order and SHALL NOT confirm the command. Reordering SHALL begin only after the pointer has moved beyond a small movement threshold. + +#### Scenario: Clicking a handle does not confirm +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user presses the handle of `b` and releases without moving +- **THEN** the rendered order SHALL remain `[a, b, c]` +- **AND** no command SHALL be confirmed + +### Requirement: An In-Flight Drag Cancels Without Persisting When The Menu Closes + +`plugin-slash` SHALL cancel an active drag when the menu hides, is dismissed, or is destroyed. A cancelled drag MUST NOT persist an order. `Escape` pressed during a drag SHALL cancel the drag and restore the pre-drag order. + +#### Scenario: Escape during a drag restores the previous order +- **WHEN** the menu renders `[a, b, c]` +- **AND** the user drags `c` to the first position without releasing +- **AND** the user presses `Escape` +- **THEN** the persisted order SHALL be unchanged +- **AND** reopening the empty-query menu SHALL render `[a, b, c]` + +#### Scenario: Pointer release outside the menu commits the drop +- **WHEN** the user starts a drag on a handle and releases the pointer outside the menu element +- **THEN** the drop SHALL be committed at the clamped position +- **AND** the order SHALL be persisted when storage is configured + +### Requirement: Persistence Uses Host-Injected Storage And Writes On Drop + +Persistent slash command order SHALL use only a host-injected localStorage-like object. `plugin-slash` MUST NOT write to global `localStorage` by default. The order SHALL be written once per committed drop, not during pointer movement. + +#### Scenario: Storage is written on drop only +- **WHEN** `reorderable` is enabled with host-injected storage +- **AND** the user drags a command across several positions and then releases +- **THEN** the storage write SHALL occur after the release +- **AND** the stored value SHALL contain the resulting command id order + +#### Scenario: Explicit storage seeds the rendered order +- **WHEN** `reorderable` is enabled with host-injected storage +- **AND** storage contains command ids `["c", "a"]` +- **THEN** an empty-query menu with commands `[a, b, c]` SHALL render `[c, a, b]` + +### Requirement: Persisted Order Merges With The Visible Window + +The editor caps slash menu results at `slashMenuLimit` before the menu renders, so the menu observes only a prefix of the registered commands. On drop, `plugin-slash` SHALL rewrite the persisted order as the new visible order followed by previously stored ids that were not visible, preserving their previous relative order. A drop MUST NOT remove persisted ids for commands outside the visible window. + +#### Scenario: Commands outside the visible window are preserved +- **WHEN** the persisted order is `["a", "b", "hidden"]` +- **AND** the visible menu renders `[a, b]` (the remainder is capped out) +- **AND** the user drags `b` above `a` +- **THEN** the persisted order SHALL become `["b", "a", "hidden"]` +- **AND** `hidden` SHALL NOT be dropped from storage + +### Requirement: Unknown And Duplicate Ids Are Ignored + +When reading a persisted order, `plugin-slash` SHALL ignore ids that are not present in the current command list. When writing, it SHALL deduplicate ids. Unknown or duplicated ids MUST NOT produce phantom menu items or duplicated rendered entries. + +#### Scenario: Stale ids do not render phantom items +- **WHEN** the persisted order contains `["removed", "c"]` +- **AND** the current empty-query command list is `[a, b, c]` +- **THEN** the rendered order SHALL be `[c, a, b]` +- **AND** no item for `removed` SHALL be rendered + +#### Scenario: Duplicate ids collapse to a single entry +- **WHEN** the persisted order contains `["c", "c", "a"]` +- **AND** the current empty-query command list is `[a, b, c]` +- **THEN** the rendered order SHALL be `[c, a, b]` +- **AND** exactly three items SHALL be rendered + +### Requirement: Order Storage Failures Are Non-Fatal + +`plugin-slash` SHALL treat order storage as best-effort. Invalid JSON, `getItem` exceptions, and `setItem` exceptions MUST NOT throw out of menu open, render, drag, or confirmation paths. + +#### Scenario: Invalid JSON is ignored +- **WHEN** `reorderable` is enabled with storage whose value is invalid JSON +- **THEN** opening the slash menu SHALL NOT throw +- **AND** the menu SHALL render commands using the non-reordered order + +#### Scenario: getItem throw is ignored +- **WHEN** `reorderable` is enabled with storage whose `getItem` throws +- **THEN** opening the slash menu SHALL NOT throw +- **AND** menus SHALL render normally + +#### Scenario: setItem throw is ignored +- **WHEN** `reorderable` is enabled with storage whose `setItem` throws +- **AND** the user completes a drag +- **THEN** the drop SHALL NOT throw +- **AND** the rendered order for the current session SHALL still reflect the drop + +### Requirement: Disabled Reordering Preserves Existing Interaction Semantics + +When `reorderable` is disabled, `plugin-slash` SHALL preserve existing menu ordering and interaction behavior for keyboard navigation, Enter confirmation, click confirmation, and the `history` recency option. + +#### Scenario: History behavior is unchanged when reordering is disabled +- **WHEN** history is enabled and `reorderable` is disabled +- **AND** the user confirms command `c` in an empty-query menu rendering `[a, b, c]` +- **THEN** the next empty-query menu SHALL render `[c, a, b]` +- **AND** no handle element SHALL be rendered diff --git a/openspec/changes/add-slash-menu-reorder/tasks.md b/openspec/changes/add-slash-menu-reorder/tasks.md new file mode 100644 index 00000000..47db42d5 --- /dev/null +++ b/openspec/changes/add-slash-menu-reorder/tasks.md @@ -0,0 +1,38 @@ +# Implementation Tasks + +## 1. Phase 1 - OpenSpec and Red Tests + +- [x] 1.1 Create `openspec/changes/add-slash-menu-reorder/proposal.md`. +- [x] 1.2 Create `openspec/changes/add-slash-menu-reorder/specs/plugins/spec.md`. +- [x] 1.3 Add failing `plugin-slash` tests for opt-in drag reordering. +- [x] 1.4 Run the targeted `plugin-slash` menu UI test and confirm failures are limited to the unimplemented reordering behavior. + +## 2. Phase 2 - Order Storage Module + +- [x] 2.1 Add `packages/plugin-slash/src/command-order.ts` mirroring the `command-history.ts` storage contract. +- [x] 2.2 Support `true` for session-only ordering and `{ storage, storageKey }` for host-injected persistence. +- [x] 2.3 Apply the stored order after the recency layer so pinned commands win. +- [x] 2.4 Merge on write: rewrite the visible ids and preserve stored ids outside the visible window in their previous relative order. +- [x] 2.5 Ignore duplicate and non-string ids, invalid JSON, and throwing `getItem` / `setItem`. + +## 3. Phase 3 - Menu UI + +- [x] 3.1 Add an opt-in `reorderable` option without changing default behavior. +- [x] 3.2 Render a `.{prefix}-menu__handle` grip on every item when enabled, sized inline so it is grabbable without host CSS. +- [x] 3.3 Implement reordering with custom `mousedown` / `mousemove` / `mouseup`; do not use the HTML5 drag-and-drop API. +- [x] 3.4 Gate the gesture on an open menu, an empty query, and at least two rendered rows. +- [x] 3.5 Clamp movement to the rendered bounds in `moveVisibleCommand` so the list keeps its length and members. +- [x] 3.6 Keep `itemEls`, `visibleCommands`, the DOM, and the highlight index in lockstep across a move. +- [x] 3.7 Resolve item indices at event time instead of capturing them at creation, so hover and click stay correct after a reorder. +- [x] 3.8 Keep a press that never crosses the movement threshold inert: no reorder, no confirmation. +- [x] 3.9 Suppress navigation keys during a drag and cancel on `Escape`, hide, dismiss, and destroy. +- [x] 3.10 Freeze rendering while a drag is in flight; cancel the gesture when incoming state no longer matches the rendered list. +- [x] 3.11 Expose the new public types and `DEFAULT_SLASH_COMMAND_ORDER_KEY` from `packages/plugin-slash/src/index.ts`. +- [x] 3.12 Keep `packages/core/**`, `packages/plugin-search/**`, and `apps/electron-demo/**` unchanged. + +## 4. Phase 4 - Verification + +- [x] 4.1 Run the targeted `plugin-slash` menu UI tests and confirm the new red tests pass. +- [x] 4.2 Run `pnpm test` (68 files, 928 tests) and confirm no regressions. +- [x] 4.3 Run `pnpm typecheck` and `pnpm check:api`. +- [x] 4.4 Verify the gesture against real browser layout in the Electron demo with the option temporarily enabled and reverted afterwards: handle hit area, live reorder, drop commit, highlight follow-through, and `Enter` confirming the dropped command. diff --git a/packages/plugin-slash/src/command-order.ts b/packages/plugin-slash/src/command-order.ts new file mode 100644 index 00000000..a13901d8 --- /dev/null +++ b/packages/plugin-slash/src/command-order.ts @@ -0,0 +1,131 @@ +import type { SlashCommandDef } from "@floatboat/nexus-core"; + +import type { SlashCommandHistoryStorage } from "./command-history"; + +export interface SlashCommandOrderOptions { + /** + * Host-injected localStorage-like object. Without it the manual order is + * session-only. `plugin-slash` never touches global `localStorage`. + */ + storage?: SlashCommandHistoryStorage; + storageKey?: string; +} + +export type SlashCommandOrderConfig = boolean | SlashCommandOrderOptions; + +export const DEFAULT_SLASH_COMMAND_ORDER_KEY = "nexus.slash.commandOrder"; + +export interface SlashCommandOrderController { + /** Reorder `commands` to match the stored manual order. */ + apply(commands: SlashCommandDef[]): SlashCommandDef[]; + /** + * Persist `visibleIds` as the new leading order. Ids already stored but + * outside the visible window keep their previous relative order after it — + * the editor caps slash results before the menu renders, so the menu can + * only ever observe a prefix of the registered commands. + */ + commit(visibleIds: readonly string[]): void; +} + +/** + * Deduplicates and drops non-string entries. Storage is untrusted input: + * a hand-edited or partially written value must not be able to inject + * phantom ids or duplicate entries into the menu. + */ +function normalizeOrder(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + + const seen = new Set(); + const ids: string[] = []; + for (const item of raw) { + if (typeof item !== "string") continue; + if (seen.has(item)) continue; + seen.add(item); + ids.push(item); + } + return ids; +} + +export function createSlashCommandOrder( + config: SlashCommandOrderConfig | undefined +): SlashCommandOrderController | null { + if (!config) return null; + + const options = typeof config === "boolean" ? {} : config; + const storage = options.storage; + const storageKey = options.storageKey ?? DEFAULT_SLASH_COMMAND_ORDER_KEY; + + let loaded = false; + let order: string[] = []; + + function load(): void { + if (loaded) return; + loaded = true; + if (!storage) return; + + try { + const value = storage.getItem(storageKey); + if (value === null) return; + order = normalizeOrder(JSON.parse(value)); + } catch { + order = []; + } + } + + function save(): void { + if (!storage) return; + + try { + storage.setItem(storageKey, JSON.stringify(order)); + } catch { + // Storage is best-effort; reordering must keep working for the session. + } + } + + return { + apply(commands: SlashCommandDef[]): SlashCommandDef[] { + load(); + if (order.length === 0) return commands; + + const byId = new Map(); + for (const command of commands) { + if (!byId.has(command.id)) byId.set(command.id, command); + } + + const used = new Set(); + const placed: SlashCommandDef[] = []; + for (const id of order) { + const command = byId.get(id); + if (!command || used.has(id)) continue; + placed.push(command); + used.add(id); + } + + if (placed.length === 0) return commands; + return placed.concat(commands.filter((command) => !used.has(command.id))); + }, + + commit(visibleIds: readonly string[]): void { + load(); + + const seen = new Set(); + const next: string[] = []; + for (const id of visibleIds) { + if (seen.has(id)) continue; + seen.add(id); + next.push(id); + } + // Ids the menu cannot see must survive the drop in their previous + // relative order, otherwise reordering above the slice size would + // silently discard the user's arrangement for capped-out commands. + for (const id of order) { + if (seen.has(id)) continue; + seen.add(id); + next.push(id); + } + + order = next; + save(); + }, + }; +} diff --git a/packages/plugin-slash/src/index.ts b/packages/plugin-slash/src/index.ts index b92e115f..c9128a8d 100644 --- a/packages/plugin-slash/src/index.ts +++ b/packages/plugin-slash/src/index.ts @@ -45,8 +45,15 @@ export { type SlashCommandHistoryConfig, type SlashCommandHistoryOptions, type SlashCommandHistoryStorage, + type SlashCommandOrderConfig, + type SlashCommandOrderOptions, } from "./menu-ui"; +export { + DEFAULT_SLASH_COMMAND_ORDER_KEY, + type SlashCommandOrderController, +} from "./command-order"; + export { SlashLifecyclePlugin, slashLifecyclePluginManifest, diff --git a/packages/plugin-slash/src/menu-ui.ts b/packages/plugin-slash/src/menu-ui.ts index 8a6f2683..1e0f1c6a 100644 --- a/packages/plugin-slash/src/menu-ui.ts +++ b/packages/plugin-slash/src/menu-ui.ts @@ -5,11 +5,18 @@ import { type SlashCommandHistoryOptions, type SlashCommandHistoryStorage, } from "./command-history"; +import { + createSlashCommandOrder, + type SlashCommandOrderConfig, + type SlashCommandOrderOptions, +} from "./command-order"; export type { SlashCommandHistoryConfig, SlashCommandHistoryOptions, SlashCommandHistoryStorage, + SlashCommandOrderConfig, + SlashCommandOrderOptions, }; export interface SlashMenuCommandContext { @@ -46,6 +53,8 @@ export interface SlashMenuUIOptions { * Generated selectors: * `.{prefix}-menu`, `.{prefix}-menu__item`, * `.{prefix}-menu__item.is-active`, + * `.{prefix}-menu__item.is-dragging`, + * `.{prefix}-menu__handle`, * `.{prefix}-menu__title`, `.{prefix}-menu__description`, * `.{prefix}-menu__empty`. */ @@ -60,6 +69,16 @@ export interface SlashMenuUIOptions { * history; an options object may provide host-injected storage. */ history?: SlashCommandHistoryConfig; + /** + * Opt-in manual reordering. `true` enables a session-only drag handle on + * every item; an options object may provide host-injected storage so the + * arrangement survives restarts. + * + * Manual placement is applied after `history`, so pinned commands win over + * recency ordering. It only affects empty-query menus — while a query + * filters the list there is nothing stable to reorder. + */ + reorderable?: SlashCommandOrderConfig; /** * Register the legacy document-level key listener. Runtime hosts set this to * false and route keys through the EditorHost root dispatcher instead. @@ -82,6 +101,10 @@ export interface SlashMenuUI { const DEFAULT_PREFIX = "nexus-slash"; const DEFAULT_OFFSET = 4; const VIEWPORT_MARGIN = 8; +// Pointer travel (px) before a handle press becomes a reorder. Without a +// threshold, a plain click on the handle would nudge the row by a fraction +// of its height and reorder on release. +const DRAG_THRESHOLD_PX = 4; let uniqueIdCounter = 0; function generateId(prefix: string): string { @@ -89,6 +112,36 @@ function generateId(prefix: string): string { return `${prefix}-menu-${uniqueIdCounter}`; } +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const GRIP_DOTS: ReadonlyArray = [ + [2, 3], + [8, 3], + [2, 8], + [8, 8], + [2, 13], + [8, 13], +]; + +/** + * Six-dot grip glyph. Sized inline because the package ships no stylesheet: + * an unsized handle would render nothing and leave the drag with no hit area. + */ +function createGripIcon(ownerDocument: Document): SVGSVGElement { + const icon = ownerDocument.createElementNS(SVG_NAMESPACE, "svg"); + icon.setAttribute("viewBox", "0 0 10 16"); + icon.setAttribute("width", "10"); + icon.setAttribute("height", "16"); + icon.setAttribute("fill", "currentColor"); + for (const [cx, cy] of GRIP_DOTS) { + const dot = ownerDocument.createElementNS(SVG_NAMESPACE, "circle"); + dot.setAttribute("cx", String(cx)); + dot.setAttribute("cy", String(cy)); + dot.setAttribute("r", "1.4"); + icon.appendChild(dot); + } + return icon; +} + export function createSlashMenuUI( editor: EditorAPI, options: SlashMenuUIOptions = {} @@ -101,6 +154,7 @@ export function createSlashMenuUI( if (!ownerWindow) throw new TypeError("Slash menu container must belong to a window"); const menuId = generateId(prefix); const commandHistory = createSlashCommandHistory(options.history); + const commandOrder = createSlashCommandOrder(options.reorderable); // ── DOM scaffolding ────────────────────────────────────────────── const root = ownerDocument.createElement("div"); @@ -134,6 +188,16 @@ export function createSlashMenuUI( let prevIsOpen = false; let destroyed = false; + // Active reorder gesture. `fromIndex` is captured when the press starts so + // a cancelled drag can be rewound; `currentIndex` tracks the row under the + // pointer so `enter`/click handlers can keep highlighting the right element. + let drag: { + fromIndex: number; + currentIndex: number; + startY: number; + moved: boolean; + } | null = null; + // ── Helpers ───────────────────────────────────────────────────── function isMenuOpen(): boolean { if (destroyed) return false; @@ -180,10 +244,35 @@ export function createSlashMenuUI( item.appendChild(title); item.appendChild(desc); - const index = itemEls.length; - // Hover sync: keyboard and mouse share the same highlight model. + if (commandOrder) { + const handle = ownerDocument.createElement("div"); + handle.className = `${prefix}-menu__handle`; + // Pointer-only affordance. Exposing a control keyboard users cannot + // operate would be worse than hiding it, and an interactive child + // inside role="option" is invalid ARIA. + handle.setAttribute("aria-hidden", "true"); + // The package ships no stylesheet, so the grip is sized here: an + // unsized element would leave the gesture with no hit area. Colours + // stay on `currentColor` and the rest of the look belongs to the host. + handle.style.cursor = "grab"; + handle.appendChild(createGripIcon(ownerDocument)); + // A press on the handle must not reach the item's click handler, or + // releasing the handle would confirm the command. + handle.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + handle.addEventListener("mousedown", (e) => onHandleMouseDown(e, item)); + item.insertBefore(handle, title); + } + + // Resolve the index at event time instead of capturing it. A reorder + // moves the element to a new position, so an index captured at creation + // would point at whichever command now occupies the old slot. item.addEventListener("mouseenter", () => { - if (!isMenuOpen()) return; + if (!isMenuOpen() || drag) return; + const index = itemEls.indexOf(item); + if (index < 0) return; highlight = index; applyHighlight(); }); @@ -194,6 +283,9 @@ export function createSlashMenuUI( }); item.addEventListener("click", (e) => { e.preventDefault(); + if (drag) return; + const index = itemEls.indexOf(item); + if (index < 0) return; highlight = index; confirm(); }); @@ -217,7 +309,11 @@ export function createSlashMenuUI( const cmd = commands[i]; const item = itemEls[i]; item.dataset.slashCommandId = cmd.id; - const [titleEl, descEl] = item.children as unknown as HTMLDivElement[]; + // Look the parts up by class: when a drag handle is present the item has + // three children, so positional destructuring would pick the wrong node. + const titleEl = item.querySelector(`.${prefix}-menu__title`); + const descEl = item.querySelector(`.${prefix}-menu__description`); + if (!titleEl || !descEl) continue; titleEl.textContent = cmd.title; if (cmd.description) { descEl.textContent = cmd.description; @@ -229,6 +325,112 @@ export function createSlashMenuUI( } } + // ── Drag reordering ───────────────────────────────────────────── + /** + * Moves the command at `from` to `to` across `itemEls`, `visibleCommands`, + * and the DOM, keeping the three in lockstep. Returns the resulting index + * (clamped to the list bounds so a drag cannot escape the rendered rows). + */ + function moveVisibleCommand(from: number, to: number): number { + const count = itemEls.length; + if (count < 2) return from; + const target = Math.max(0, Math.min(to, count - 1)); + if (target === from) return from; + + const [item] = itemEls.splice(from, 1); + itemEls.splice(target, 0, item); + const [command] = visibleCommands.splice(from, 1); + visibleCommands.splice(target, 0, command); + + // Re-seat every row in array order; appendChild moves an existing node. + for (const el of itemEls) root.appendChild(el); + return target; + } + + /** Index of the row the pointer is currently over, using row midpoints. */ + function resolveDropIndex(clientY: number): number { + const count = itemEls.length; + if (count === 0) return 0; + for (let i = 0; i < count; i++) { + const rect = itemEls[i].getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) return i; + } + return count - 1; + } + + function onHandleMouseDown(event: MouseEvent, item: HTMLDivElement): void { + if (!commandOrder || destroyed || drag) return; + if (event.button !== 0) return; + // A filtered list has no stable order to rearrange, and a menu that is + // not open has nothing to rearrange at all. + if (!isMenuOpen() || currentState?.query !== "") return; + if (itemEls.length < 2) return; + + const index = itemEls.indexOf(item); + if (index < 0) return; + + // Keep the text selection and the editor focus where they were, and stop + // the item's own mousedown handler from treating this as a row press. + event.preventDefault(); + event.stopPropagation(); + + drag = { + fromIndex: index, + currentIndex: index, + startY: event.clientY, + moved: false, + }; + + // Capture phase on the document so the gesture survives the pointer + // leaving the menu — a drop outside the element still commits. + ownerDocument.addEventListener("mousemove", onDragMove, true); + ownerDocument.addEventListener("mouseup", onDragEnd, true); + } + + function onDragMove(event: MouseEvent): void { + if (!drag) return; + if (!drag.moved) { + if (Math.abs(event.clientY - drag.startY) < DRAG_THRESHOLD_PX) return; + drag.moved = true; + itemEls[drag.currentIndex]?.classList.add("is-dragging"); + } + const next = moveVisibleCommand(drag.currentIndex, resolveDropIndex(event.clientY)); + drag.currentIndex = next; + // The dragged row is the active row: confirmation must follow what the + // user sees under their pointer, not the pre-drag index. + highlight = next; + applyHighlight(); + event.preventDefault(); + } + + function onDragEnd(_event: MouseEvent): void { + if (!drag) return; + const { moved, currentIndex } = drag; + endDrag(); + if (!moved || !commandOrder) return; + commandOrder.commit(visibleCommands.map((command) => command.id)); + highlight = currentIndex; + applyHighlight(); + } + + function endDrag(): void { + if (!drag) return; + itemEls[drag.currentIndex]?.classList.remove("is-dragging"); + ownerDocument.removeEventListener("mousemove", onDragMove, true); + ownerDocument.removeEventListener("mouseup", onDragEnd, true); + drag = null; + } + + /** Abandons an in-flight drag without persisting, restoring the pre-drag order. */ + function cancelDrag(): void { + if (!drag) return; + const { fromIndex, currentIndex } = drag; + endDrag(); + const restored = moveVisibleCommand(currentIndex, fromIndex); + highlight = restored; + applyHighlight(); + } + function reposition(): void { if (!isMenuOpen() || !currentState) return; // The menu becomes visible regardless of whether coords are @@ -269,15 +471,25 @@ export function createSlashMenuUI( function show(): void { if (!currentState) return; - visibleCommands = commandHistory + // A drag owns the rendered order until it ends. Re-rendering underneath + // the pointer would rebuild the rows the gesture is tracking, so the + // list is frozen while `drag` is set. + if (drag) return; + // Recency first, then manual placement on top: a command the user has + // pinned outranks one that merely happens to be recent. + const base = commandHistory ? commandHistory.reorder(currentState.commands, currentState.query) : currentState.commands; + visibleCommands = commandOrder ? commandOrder.apply(base) : base; renderItems(visibleCommands); applyHighlight(); reposition(); } function hide(): void { + // An in-flight drag is abandoned rather than committed: hiding the menu + // is not a drop. + cancelDrag(); root.style.display = "none"; } @@ -350,6 +562,18 @@ export function createSlashMenuUI( return; } + if (drag) { + // A drag is in flight. When the incoming state still describes the same + // unfiltered list, keep the gesture alive — `currentState` above already + // refreshed the trigger range that `confirm()` reads. Anything else + // invalidates the rows under the pointer, so the drag is abandoned + // rather than continued against a list it no longer matches. + if (state.query === "" && state.commands.length === visibleCommands.length) { + return; + } + cancelDrag(); + } + // Clamp highlight if the command list shrank below it. if (highlight >= state.commands.length) { highlight = Math.max(0, state.commands.length - 1); @@ -369,6 +593,15 @@ export function createSlashMenuUI( function onKeyDown(e: KeyboardEvent): boolean { if (destroyed || !isMenuOpen() || isComposing) return false; + if (drag && e.key !== "Escape") { + // The drag owns the highlight for the duration of the gesture. Letting + // navigation keys through would leave the active row pointing somewhere + // other than the row under the pointer. Escape still cancels below. + e.preventDefault(); + e.stopPropagation(); + return true; + } + const len = visibleCommands.length; switch (e.key) { @@ -480,6 +713,9 @@ export function createSlashMenuUI( destroy() { if (destroyed) return; destroyed = true; + // Detach document-level drag listeners before the element is removed; + // otherwise a gesture started before destroy would keep listening. + endDrag(); editor.off("slashMenuChange", onSlashMenuChange); editor.off("blur", onEditorBlur); if (options.manageKeyboard !== false) { diff --git a/packages/plugin-slash/test/menu-ui.test.ts b/packages/plugin-slash/test/menu-ui.test.ts index 16c809a9..d1a807e8 100644 --- a/packages/plugin-slash/test/menu-ui.test.ts +++ b/packages/plugin-slash/test/menu-ui.test.ts @@ -13,7 +13,8 @@ interface Harness { function setup( commands: SlashCommandDef[], - options: Parameters[1] = {} + options: Parameters[1] = {}, + editorOptions: Partial[0]> = {} ): Harness { const container = document.createElement("div"); document.body.appendChild(container); @@ -22,6 +23,7 @@ function setup( container, initialValue: "", plugins: [{ name: "test", slashCommands: commands }], + ...editorOptions, }); const menu = createSlashMenuUI(editor, options); @@ -114,6 +116,80 @@ function lastStoredIds(storage: ReturnType): string[ return JSON.parse(lastCall?.[1] ?? "[]") as string[]; } +type SlashReorderOptions = + | boolean + | { + storage?: SlashHistoryStorage; + storageKey?: string; + }; + +function withReorder( + reorderable: SlashReorderOptions +): Parameters[1] { + return { reorderable } as unknown as Parameters[1]; +} + +const ROW_HEIGHT = 40; + +/** + * JSDOM reports zero-sized rects for every element, so drop-target resolution + * needs explicit geometry. Rows are laid out top-to-bottom in their live DOM + * order, which is what the menu reads while a drag is in flight. + */ +function stubRowRects(menu: SlashMenuUI, rowHeight = ROW_HEIGHT): void { + for (const row of items(menu)) { + row.getBoundingClientRect = () => { + const live = Array.from( + menu.element.querySelectorAll(`.${PREFIX}-menu__item`) + ); + return { + top: live.indexOf(row) * rowHeight, + height: rowHeight, + } as DOMRect; + }; + } +} + +/** A y-coordinate that resolves to rendered position `index` during a drop. */ +function dropY(index: number, rowHeight = ROW_HEIGHT): number { + return index * rowHeight + rowHeight / 2 - 1; +} + +function handleOf(menu: SlashMenuUI, id: string): HTMLElement { + const handle = itemById(menu, id).querySelector( + `.${PREFIX}-menu__handle` + ); + if (!handle) throw new Error(`Missing drag handle for: ${id}`); + return handle; +} + +function pressHandle(menu: SlashMenuUI, id: string, clientY: number): void { + handleOf(menu, id).dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }) + ); +} + +function movePointer(clientY: number): void { + document.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, cancelable: true, clientY }) + ); +} + +function releasePointer(clientY: number): void { + document.dispatchEvent( + new MouseEvent("mouseup", { bubbles: true, cancelable: true, clientY }) + ); +} + +/** Drags the row for `id` so it lands on rendered position `toIndex`. */ +function dragRowTo(menu: SlashMenuUI, id: string, toIndex: number): void { + const fromIndex = itemIds(menu).indexOf(id); + stubRowRects(menu); + pressHandle(menu, id, dropY(fromIndex)); + movePointer(dropY(toIndex)); + releasePointer(dropY(toIndex)); +} + describe("createSlashMenuUI lifecycle", () => { let h: Harness; afterEach(() => h?.destroy()); @@ -601,3 +677,411 @@ describe("createSlashMenuUI document interactions", () => { expect(e.defaultPrevented).toBe(false); }); }); + +describe("createSlashMenuUI drag reordering", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("renders no drag handles by default", () => { + h = setup(baseCommands); + open(h.editor, ""); + + expect( + h.menu.element.querySelectorAll(`.${PREFIX}-menu__handle`) + ).toHaveLength(0); + }); + + it("renders a drag handle on every row when enabled", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + expect( + h.menu.element.querySelectorAll(`.${PREFIX}-menu__handle`) + ).toHaveLength(baseCommands.length); + for (const command of baseCommands) { + expect(handleOf(h.menu, command.id)).toBeTruthy(); + } + }); + + it("does not mark rows or handles as HTML5-draggable", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + for (const command of baseCommands) { + expect(itemById(h.menu, command.id).getAttribute("draggable")).toBeNull(); + expect(handleOf(h.menu, command.id).getAttribute("draggable")).toBeNull(); + } + }); + + it("moves a row up to the first position", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "bold", 0); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("moves a row down to the last position", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "h1", 2); + + expect(itemIds(h.menu)).toEqual(["h2", "bold", "h1"]); + }); + + it("clamps a drag above the first row", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(-5000); + releasePointer(-5000); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("clamps a drag below the last row", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "h1", dropY(0)); + movePointer(5000); + releasePointer(5000); + + expect(itemIds(h.menu)).toEqual(["h2", "bold", "h1"]); + }); + + it("keeps the row count stable across a drag", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "h2", 0); + + expect(items(h.menu)).toHaveLength(baseCommands.length); + expect(new Set(itemIds(h.menu)).size).toBe(baseCommands.length); + }); + + it("does not start a drag when only one row is rendered", () => { + h = setup([{ id: "h1", title: "Heading 1" }], withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "h1", 0); + + expect(itemIds(h.menu)).toEqual(["h1"]); + }); + + it("highlights the dropped row", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "bold", 0); + + expect(activeItem(h.menu)?.dataset.slashCommandId).toBe("bold"); + }); + + it("marks the dragged row only while the gesture is in flight", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + expect(h.menu.element.querySelectorAll(".is-dragging")).toHaveLength(0); + + movePointer(dropY(0)); + expect(itemById(h.menu, "bold").classList.contains("is-dragging")).toBe(true); + expect(h.menu.element.querySelectorAll(".is-dragging")).toHaveLength(1); + + releasePointer(dropY(0)); + expect(h.menu.element.querySelectorAll(".is-dragging")).toHaveLength(0); + }); + + it("confirms the dropped command with Enter", () => { + const h1Run = vi.fn(); + const h2Run = vi.fn(); + const boldRun = vi.fn(); + h = setup( + [ + { id: "h1", title: "Heading 1", run: h1Run }, + { id: "h2", title: "Heading 2", run: h2Run }, + { id: "bold", title: "Bold", run: boldRun }, + ], + withReorder(true) + ); + open(h.editor, ""); + + dragRowTo(h.menu, "bold", 0); + pressKey("Enter"); + + expect(boldRun).toHaveBeenCalledTimes(1); + expect(h1Run).not.toHaveBeenCalled(); + }); + + it("does not reorder or confirm when a handle is pressed without movement", () => { + const run = vi.fn(); + h = setup( + [ + { id: "h1", title: "Heading 1", run }, + { id: "h2", title: "Heading 2" }, + { id: "bold", title: "Bold" }, + ], + withReorder(true) + ); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "h2", dropY(1)); + releasePointer(dropY(1)); + + expect(itemIds(h.menu)).toEqual(["h1", "h2", "bold"]); + expect(run).not.toHaveBeenCalled(); + }); + + it("does not confirm when a handle is clicked", () => { + const run = vi.fn(); + h = setup( + [ + { id: "h1", title: "Heading 1", run }, + { id: "h2", title: "Heading 2" }, + ], + withReorder(true) + ); + open(h.editor, ""); + + handleOf(h.menu, "h1").dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + expect(run).not.toHaveBeenCalled(); + expect(h.menu.element.style.display).not.toBe("none"); + }); + + it("restores the pre-drag order when Escape cancels the drag", () => { + const storage = createMemoryStorage(null); + h = setup(baseCommands, withReorder({ storage, storageKey: "test-slash-order" })); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + + pressKey("Escape"); + + expect(storage.setItem).not.toHaveBeenCalled(); + open(h.editor, ""); + expect(itemIds(h.menu)).toEqual(["h1", "h2", "bold"]); + }); + + it("commits a drop released outside the menu", () => { + const storage = createMemoryStorage(null); + h = setup(baseCommands, withReorder({ storage, storageKey: "test-slash-order" })); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + releasePointer(dropY(0)); + + expect(lastStoredIds(storage)).toEqual(["bold", "h1", "h2"]); + }); + + it("ignores navigation keys while a drag is in flight", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + pressKey("ArrowDown"); + + expect(activeItem(h.menu)?.dataset.slashCommandId).toBe("bold"); + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + releasePointer(dropY(0)); + }); + + it("applies the stored order to an empty query menu", () => { + const storage = createMemoryStorage(JSON.stringify(["bold", "h1"])); + h = setup(baseCommands, withReorder({ storage, storageKey: "test-slash-order" })); + open(h.editor, ""); + + expect(storage.getItem).toHaveBeenCalledWith("test-slash-order"); + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("ignores the stored order while a query filters the menu", () => { + const storage = createMemoryStorage(JSON.stringify(["bold", "h1", "h2"])); + h = setup(baseCommands, withReorder({ storage })); + open(h.editor, "h"); + + expect(itemIds(h.menu)).toEqual(["h1", "h2"]); + }); + + it("does not start a drag while a query filters the menu", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, "h"); + stubRowRects(h.menu); + + pressHandle(h.menu, "h2", dropY(1)); + movePointer(dropY(0)); + releasePointer(dropY(0)); + + expect(itemIds(h.menu)).toEqual(["h1", "h2"]); + }); + + it("ignores unknown stored command ids", () => { + const storage = createMemoryStorage(JSON.stringify(["missing", "bold"])); + h = setup(baseCommands, withReorder({ storage })); + open(h.editor, ""); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("collapses duplicate stored command ids", () => { + const storage = createMemoryStorage(JSON.stringify(["bold", "bold", "h1"])); + h = setup(baseCommands, withReorder({ storage })); + open(h.editor, ""); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("writes the order once per drop, not during pointer movement", () => { + const storage = createMemoryStorage(null); + h = setup(baseCommands, withReorder({ storage, storageKey: "test-slash-order" })); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(1)); + movePointer(dropY(0)); + expect(storage.setItem).not.toHaveBeenCalled(); + + releasePointer(dropY(0)); + + expect(storage.setItem).toHaveBeenCalledTimes(1); + expect(lastStoredIds(storage)).toEqual(["bold", "h1", "h2"]); + }); + + it("preserves stored ids for commands outside the visible window", () => { + const storage = createMemoryStorage(JSON.stringify(["bold", "h1", "h2"])); + h = setup( + baseCommands, + withReorder({ storage, storageKey: "test-slash-order" }), + { slashMenuLimit: 2 } + ); + open(h.editor, ""); + + // The editor caps results before the menu renders, so a pinned command + // that did not make the cut stays invisible and cannot be promoted. + expect(itemIds(h.menu)).toEqual(["h1", "h2"]); + + dragRowTo(h.menu, "h2", 0); + + expect(itemIds(h.menu)).toEqual(["h2", "h1"]); + // `bold` is dropped out of the visible window but must survive the write. + expect(lastStoredIds(storage)).toEqual(["h2", "h1", "bold"]); + }); + + it("keeps a session-only order when no storage is injected", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + + dragRowTo(h.menu, "bold", 0); + + open(h.editor, ""); + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("places manually ordered commands ahead of recently used ones", () => { + const storage = createMemoryStorage(JSON.stringify(["h2"])); + h = setup(baseCommands, { + history: true, + reorderable: { storage }, + } as unknown as Parameters[1]); + open(h.editor, ""); + + itemById(h.menu, "bold").dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + open(h.editor, ""); + + // `bold` is the most recent command, but `h2` was placed by hand. + expect(itemIds(h.menu)).toEqual(["h2", "bold", "h1"]); + }); + + it("keeps the recency order for commands the user never placed", () => { + const storage = createMemoryStorage(JSON.stringify(["h2"])); + h = setup(baseCommands, { + history: true, + reorderable: { storage }, + } as unknown as Parameters[1]); + open(h.editor, ""); + + itemById(h.menu, "bold").dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + open(h.editor, ""); + itemById(h.menu, "h1").dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + open(h.editor, ""); + + // Recency produced [h1, bold, h2]; only `h2` is manually pinned. + expect(itemIds(h.menu)).toEqual(["h2", "h1", "bold"]); + }); + + it("tolerates invalid JSON in order storage", () => { + const storage = createMemoryStorage("{not json"); + h = setup(baseCommands, withReorder({ storage })); + open(h.editor, ""); + + expect(itemIds(h.menu)).toEqual(["h1", "h2", "bold"]); + }); + + it("tolerates a throwing getItem in order storage", () => { + const storage = { + getItem: vi.fn(() => { + throw new Error("blocked"); + }), + setItem: vi.fn(), + }; + h = setup(baseCommands, withReorder({ storage })); + + expect(() => open(h.editor, "")).not.toThrow(); + expect(itemIds(h.menu)).toEqual(["h1", "h2", "bold"]); + }); + + it("tolerates a throwing setItem in order storage", () => { + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error("quota"); + }), + }; + h = setup(baseCommands, withReorder({ storage })); + open(h.editor, ""); + + expect(() => dragRowTo(h.menu, "bold", 0)).not.toThrow(); + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + }); + + it("leaves history ordering untouched when reordering is disabled", () => { + h = setup(baseCommands, withHistory(true)); + open(h.editor, ""); + + itemById(h.menu, "bold").dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + open(h.editor, ""); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + expect( + h.menu.element.querySelectorAll(`.${PREFIX}-menu__handle`) + ).toHaveLength(0); + }); +}); From 26b78d889b8002bfe123c676e7ed317b1b83e2c7 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 16:17:48 +0800 Subject: [PATCH 02/16] feat(slash): animate slash menu reorder rows into place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows jumped straight to their new slot on every reorder, which reads as a glitch rather than as direct manipulation. Animate the move with FLIP (measure, reorder, invert, play). Rows sit in normal flow, so a reorder changes their layout position and a CSS transition has nothing to interpolate — the transform is what gives the browser something to ease. The held row follows the pointer instead of sliding, and every inline style the gesture applies is cleared when it ends so hosts keep control of the look. Honour `prefers-reduced-motion` by skipping the slide entirely. The pointer offset exposed a measurement bug: `getBoundingClientRect` reports the transformed box, so the held row's own midpoint was skewed by its own offset and drop targets resolved one slot late. Drop candidates are now measured with that transform cleared and the offset reapplied afterwards. Verified against real browser layout: moved rows carry the inversion, the held row tracks the pointer, unmoved rows keep no inline style, and both `Enter` confirmation and the drop index stay correct. --- packages/plugin-slash/src/menu-ui.ts | 99 +++++++++++++++++++++- packages/plugin-slash/test/menu-ui.test.ts | 65 ++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/plugin-slash/src/menu-ui.ts b/packages/plugin-slash/src/menu-ui.ts index 1e0f1c6a..7478a4dc 100644 --- a/packages/plugin-slash/src/menu-ui.ts +++ b/packages/plugin-slash/src/menu-ui.ts @@ -105,6 +105,10 @@ const VIEWPORT_MARGIN = 8; // threshold, a plain click on the handle would nudge the row by a fraction // of its height and reorder on release. const DRAG_THRESHOLD_PX = 4; +// Rows slide to their new slot instead of jumping. Short enough to feel like +// direct manipulation rather than an animation the user waits on. +const REORDER_DURATION_MS = 140; +const REORDER_EASING = "cubic-bezier(0.2, 0, 0, 1)"; let uniqueIdCounter = 0; function generateId(prefix: string): string { @@ -192,11 +196,15 @@ export function createSlashMenuUI( // a cancelled drag can be rewound; `currentIndex` tracks the row under the // pointer so `enter`/click handlers can keep highlighting the right element. let drag: { + el: HTMLDivElement; fromIndex: number; currentIndex: number; startY: number; moved: boolean; } | null = null; + // Frames scheduled by the slide animation, cancelled when the gesture ends + // so a stale callback cannot repaint a row after cleanup. + let flipFrames: number[] = []; // ── Helpers ───────────────────────────────────────────────────── function isMenuOpen(): boolean { @@ -326,6 +334,80 @@ export function createSlashMenuUI( } // ── Drag reordering ───────────────────────────────────────────── + function prefersReducedMotion(): boolean { + // Optional chaining because JSDOM and older embedded webviews do not + // implement matchMedia; a missing API must mean "animate normally", + // not "throw during a drag". + return ownerWindow?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; + } + + /** Row geometry keyed by element, so a reorder cannot invalidate the lookup. */ + function measureRows(): Map { + const tops = new Map(); + for (const el of itemEls) tops.set(el, el.getBoundingClientRect().top); + return tops; + } + + /** + * FLIP slide for the rows that changed slot. + * + * Rows live in normal flow, so a reorder changes their layout position and a + * CSS transition has nothing to interpolate. The transform is what gives the + * move something to animate: invert to the old position first, then release + * it on the next frame so the browser eases each row into its real slot. + */ + function playRowSlide(before: Map): void { + if (prefersReducedMotion()) return; + + const sliding: Array<{ el: HTMLDivElement; delta: number }> = []; + for (const el of itemEls) { + // The dragged row tracks the pointer instead; sliding it would fight + // the offset applied in `followPointer`. + if (el === drag?.el) continue; + const previousTop = before.get(el); + if (previousTop === undefined) continue; + const delta = previousTop - el.getBoundingClientRect().top; + if (delta === 0) continue; + sliding.push({ el, delta }); + } + if (sliding.length === 0) return; + + for (const { el, delta } of sliding) { + el.style.transition = "none"; + el.style.transform = `translateY(${delta}px)`; + } + + const frame = ownerWindow?.requestAnimationFrame(() => { + for (const { el } of sliding) { + el.style.transition = `transform ${REORDER_DURATION_MS}ms ${REORDER_EASING}`; + el.style.transform = ""; + } + }); + if (frame !== undefined) flipFrames.push(frame); + } + + /** Keeps the held row under the pointer while the list reflows around it. */ + function followPointer(clientY: number): void { + const el = drag?.el; + if (!el) return; + // Measure with the transform cleared: getBoundingClientRect reports the + // transformed box, so reading it while offset would make the offset chase + // itself and collapse to zero. + el.style.transform = ""; + const rect = el.getBoundingClientRect(); + el.style.transform = `translateY(${clientY - (rect.top + rect.height / 2)}px)`; + } + + /** Drops every inline style the gesture applied, so hosts keep control. */ + function clearRowStyles(): void { + for (const frame of flipFrames) ownerWindow?.cancelAnimationFrame(frame); + flipFrames = []; + for (const el of itemEls) { + el.style.transition = ""; + el.style.transform = ""; + } + } + /** * Moves the command at `from` to `to` across `itemEls`, `visibleCommands`, * and the DOM, keeping the three in lockstep. Returns the resulting index @@ -337,6 +419,7 @@ export function createSlashMenuUI( const target = Math.max(0, Math.min(to, count - 1)); if (target === from) return from; + const before = measureRows(); const [item] = itemEls.splice(from, 1); itemEls.splice(target, 0, item); const [command] = visibleCommands.splice(from, 1); @@ -344,6 +427,7 @@ export function createSlashMenuUI( // Re-seat every row in array order; appendChild moves an existing node. for (const el of itemEls) root.appendChild(el); + playRowSlide(before); return target; } @@ -375,6 +459,7 @@ export function createSlashMenuUI( event.stopPropagation(); drag = { + el: item, fromIndex: index, currentIndex: index, startY: event.clientY, @@ -392,14 +477,23 @@ export function createSlashMenuUI( if (!drag.moved) { if (Math.abs(event.clientY - drag.startY) < DRAG_THRESHOLD_PX) return; drag.moved = true; - itemEls[drag.currentIndex]?.classList.add("is-dragging"); + drag.el.classList.add("is-dragging"); + // Draw the held row above its neighbours; it is offset out of its own + // slot for the rest of the gesture. + drag.el.style.zIndex = "1"; + drag.el.style.cursor = "grabbing"; } + // Resolve the drop target from layout, not from the held row's painted + // box: `getBoundingClientRect` reports the transform, so leaving the + // pointer offset in place would skew the row's own midpoint. + drag.el.style.transform = ""; const next = moveVisibleCommand(drag.currentIndex, resolveDropIndex(event.clientY)); drag.currentIndex = next; // The dragged row is the active row: confirmation must follow what the // user sees under their pointer, not the pre-drag index. highlight = next; applyHighlight(); + followPointer(event.clientY); event.preventDefault(); } @@ -415,10 +509,11 @@ export function createSlashMenuUI( function endDrag(): void { if (!drag) return; - itemEls[drag.currentIndex]?.classList.remove("is-dragging"); + drag.el.classList.remove("is-dragging"); ownerDocument.removeEventListener("mousemove", onDragMove, true); ownerDocument.removeEventListener("mouseup", onDragEnd, true); drag = null; + clearRowStyles(); } /** Abandons an in-flight drag without persisting, restoring the pre-drag order. */ diff --git a/packages/plugin-slash/test/menu-ui.test.ts b/packages/plugin-slash/test/menu-ui.test.ts index d1a807e8..71ae04ff 100644 --- a/packages/plugin-slash/test/menu-ui.test.ts +++ b/packages/plugin-slash/test/menu-ui.test.ts @@ -799,6 +799,71 @@ describe("createSlashMenuUI drag reordering", () => { expect(h.menu.element.querySelectorAll(".is-dragging")).toHaveLength(0); }); + it("inverts the rows that changed slot so they can slide", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + + // Moved rows are offset back to their old position first; the transition + // that releases them is applied on the next frame. + expect(itemById(h.menu, "h1").style.transform).toContain("translateY"); + expect(itemById(h.menu, "h2").style.transform).toContain("translateY"); + // The held row follows the pointer rather than sliding into a slot. + expect(itemById(h.menu, "bold").style.transition).toBe(""); + + releasePointer(dropY(0)); + }); + + it("clears every inline animation style when the gesture ends", () => { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + releasePointer(dropY(0)); + + for (const command of baseCommands) { + const el = itemById(h.menu, command.id); + expect(el.style.transform).toBe(""); + expect(el.style.transition).toBe(""); + } + }); + + it("skips the slide when the user prefers reduced motion", () => { + const originalMatchMedia = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + + try { + h = setup(baseCommands, withReorder(true)); + open(h.editor, ""); + stubRowRects(h.menu); + + pressHandle(h.menu, "bold", dropY(2)); + movePointer(dropY(0)); + + expect(itemIds(h.menu)).toEqual(["bold", "h1", "h2"]); + expect(itemById(h.menu, "h1").style.transform).toBe(""); + expect(itemById(h.menu, "h1").style.transition).toBe(""); + + releasePointer(dropY(0)); + } finally { + window.matchMedia = originalMatchMedia; + } + }); + it("confirms the dropped command with Enter", () => { const h1Run = vi.fn(); const h2Run = vi.fn(); From 523a3108eda2d8455997b97f505c56e4209398b3 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 16:29:53 +0800 Subject: [PATCH 03/16] docs(openspec): record the reorder animation and demo scope The add-slash-menu-reorder tasks predate the FLIP animation and still list apps/electron-demo as out of scope, which the demo opt-in contradicts. Add the animation tasks and move the demo from "out of scope" to affected code. --- openspec/changes/add-slash-menu-reorder/proposal.md | 7 +++++-- openspec/changes/add-slash-menu-reorder/tasks.md | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-slash-menu-reorder/proposal.md b/openspec/changes/add-slash-menu-reorder/proposal.md index ebd1fbeb..fa23eb64 100644 --- a/openspec/changes/add-slash-menu-reorder/proposal.md +++ b/openspec/changes/add-slash-menu-reorder/proposal.md @@ -22,6 +22,7 @@ This change adds the missing runtime affordance: a drag handle on each item that - Merge persisted order with the visible window: because the editor caps slash menu results at `slashMenuLimit` (default 8) *before* the menu renders, a drop rewrites only the ids it can see and preserves the relative order of every previously stored id outside that window. A drag MUST NOT drop commands it cannot see. - Ignore unknown, stale, or duplicated command ids in storage without breaking menu open, render, navigation, or confirmation. - Keep disabled/default behavior fully backward compatible: registration/recency order, keyboard navigation, Enter confirm, click confirm, and the existing `history` option are unchanged. +- Enable the option in `apps/electron-demo` so the demo keeps doing its job of demonstrating engine capabilities. The library default stays off; the demo opts in explicitly. ## Non-Goals @@ -30,7 +31,8 @@ This change adds the missing runtime affordance: a drag handle on each item that - No drag *between* the visible window and the capped-out remainder (the UI cannot observe commands beyond `slashMenuLimit`). - No changes to slash command ranking, filtering, or the `slashMenuLimit` cap. - No cross-command ordering for non-visible commands, and no drag on non-empty queries. -- No changes to `packages/core/**`, `packages/plugin-search/**`, or `apps/electron-demo/**`. +- No changes to `packages/core/**` or `packages/plugin-search/**`. +- No change to the library default: the demo opts in, the package does not. - No new dependencies. ## Impact @@ -41,7 +43,8 @@ This change adds the missing runtime affordance: a drag handle on each item that - `packages/plugin-slash/src/command-order.ts` (new module, mirrors `command-history.ts`) - `packages/plugin-slash/src/index.ts` for the public type/export surface - `packages/plugin-slash/test/menu-ui.test.ts` + - `apps/electron-demo/src/renderer/editor-shell.ts` (opt in) + - `apps/electron-demo/src/renderer/style.css` (host styling for the handle) - Explicitly out of scope: - `packages/core/**` - `packages/plugin-search/**` - - `apps/electron-demo/**` diff --git a/openspec/changes/add-slash-menu-reorder/tasks.md b/openspec/changes/add-slash-menu-reorder/tasks.md index 47db42d5..72ff5ebb 100644 --- a/openspec/changes/add-slash-menu-reorder/tasks.md +++ b/openspec/changes/add-slash-menu-reorder/tasks.md @@ -28,9 +28,18 @@ - [x] 3.9 Suppress navigation keys during a drag and cancel on `Escape`, hide, dismiss, and destroy. - [x] 3.10 Freeze rendering while a drag is in flight; cancel the gesture when incoming state no longer matches the rendered list. - [x] 3.11 Expose the new public types and `DEFAULT_SLASH_COMMAND_ORDER_KEY` from `packages/plugin-slash/src/index.ts`. -- [x] 3.12 Keep `packages/core/**`, `packages/plugin-search/**`, and `apps/electron-demo/**` unchanged. +- [x] 3.12 Keep `packages/core/**` and `packages/plugin-search/**` unchanged. +- [x] 3.13 Enable the option in the Electron demo and style the handle there, without changing the library default. -## 4. Phase 4 - Verification +## 4. Phase 4 - Reorder Animation + +- [x] 4.1 Slide rows that changed slot into place with FLIP instead of letting them jump. +- [x] 4.2 Follow the pointer with the held row rather than sliding it into a slot. +- [x] 4.3 Skip the slide when the user prefers reduced motion. +- [x] 4.4 Clear every inline style the gesture applies when it ends, so hosts keep control of the look. +- [x] 4.5 Measure drop candidates with the held row's transform cleared: `getBoundingClientRect` reports the transformed box, so the pointer offset otherwise skewed the row's own midpoint and resolved the target one slot late. + +## 5. Phase 5 - Verification - [x] 4.1 Run the targeted `plugin-slash` menu UI tests and confirm the new red tests pass. - [x] 4.2 Run `pnpm test` (68 files, 928 tests) and confirm no regressions. From f8224ccda0c1aca9b83bfc942ce55a7c7ffdf9c7 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 16:29:53 +0800 Subject: [PATCH 04/16] feat(electron): opt the demo into slash menu reordering The library keeps reordering off by default, which left the demo unable to show it at all. The demo exists to demonstrate engine capabilities, so opt in there. The handle also needs host styling: the package deliberately ships no stylesheet, so an unstyled handle would have no hit area to grab. --- apps/electron-demo/src/renderer/editor-shell.ts | 2 +- apps/electron-demo/src/renderer/style.css | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/electron-demo/src/renderer/editor-shell.ts b/apps/electron-demo/src/renderer/editor-shell.ts index f63ec212..aec06e1c 100644 --- a/apps/electron-demo/src/renderer/editor-shell.ts +++ b/apps/electron-demo/src/renderer/editor-shell.ts @@ -342,7 +342,7 @@ export function createEditorShell(options: EditorShellOptions): EditorShell { // ancestors). Its lifecycle is tied to the shell — destroyed below. const slashMenu = runtimeManaged || !contributionFeatures.slashMenu ? null - : createSlashMenuUI(editor); + : createSlashMenuUI(editor, { reorderable: true }); // Bind the wordcount plugin now that the editor is fully constructed. // The plugin's status-bar widget mounts on its first emission (next diff --git a/apps/electron-demo/src/renderer/style.css b/apps/electron-demo/src/renderer/style.css index 652d837b..08bf3726 100644 --- a/apps/electron-demo/src/renderer/style.css +++ b/apps/electron-demo/src/renderer/style.css @@ -343,3 +343,16 @@ body, color: var(--nexus-accent, #2563eb); font-weight: 500; } + +/* Demo styling for the opt-in slash drag handle (host owns the look). */ +.nexus-slash-menu__item { position: relative; padding-left: 26px; } +.nexus-slash-menu__handle { + position: absolute; + left: 7px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: var(--nexus-slash-text-muted); +} +.nexus-slash-menu__item.is-dragging { opacity: 0.45; } From 9e1c4177526e6d7464bb162849800006ce89f82a Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 17:02:36 +0800 Subject: [PATCH 05/16] fix(toolbar): position the tooltip so its anchor takes effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `positionToolbarTooltip` writes `left` and `top` from the button's viewport rect, but nothing ever positioned the element. Those writes were inert: the tooltip stayed `position: static` and fell back into normal flow at the end of the document, so hovering a toolbar button produced an invisible full-width box far from the button instead of a label under it. Set `position: fixed` — the coordinates are viewport-relative — and pull the box back by half its own width, because `left` anchors the button's centre rather than the tooltip's edge. Both are geometry the existing code already assumes; colours and typography stay with the host. The existing coverage only asserted `role` and `textContent`, which is why this went unnoticed. It now also asserts that the anchor applies. --- packages/plugin-toolbar/src/toolbar-ui.ts | 10 +++++++ .../test/plugin-toolbar.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts index a56ae862..7e58245d 100644 --- a/packages/plugin-toolbar/src/toolbar-ui.ts +++ b/packages/plugin-toolbar/src/toolbar-ui.ts @@ -89,6 +89,16 @@ function installToolbarTooltip(button: HTMLButtonElement): () => void { tooltip.className = "nexus-toolbar-tooltip"; tooltip.id = `nexus-toolbar-tooltip-${++tooltipId}`; tooltip.setAttribute("role", "tooltip"); + // The tooltip is placed from viewport coordinates (see + // `positionToolbarTooltip`), so it has to be positioned. Without this the + // `left` / `top` written there are inert and the element falls back into + // normal flow at the end of the document, where it is effectively invisible. + // Only the functional geometry lives here; colours and typography belong to + // the host. + tooltip.style.position = "fixed"; + // `left` anchors the button's horizontal centre, so the box is pulled back by + // half its own width to sit centred underneath. + tooltip.style.transform = "translateX(-50%)"; button.setAttribute("aria-describedby", tooltip.id); const show = () => { diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 3fc97f53..731ea1eb 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -494,6 +494,33 @@ describe("createToolbarUI", () => { toolbar.destroy(); editor.destroy(); }); + + it("places the tooltip from viewport coordinates", () => { + // Regression: the tooltip is anchored with `left` / `top`, which are inert + // unless the element is positioned. Without this it rendered in normal flow + // at the end of the document instead of under its button. + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world" }); + const toolbar = createToolbarUI(editor); + document.body.appendChild(toolbar.element); + + const button = toolbar.element.querySelector( + '[data-toolbar-action="unordered-list"]' + ); + button?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const tooltip = document.getElementById(button?.getAttribute("aria-describedby") ?? ""); + expect(tooltip).not.toBeNull(); + expect(tooltip?.style.position).toBe("fixed"); + // `left` is the button's centre, so the box has to be pulled back by half + // its own width to sit centred underneath. + expect(tooltip?.style.transform).toBe("translateX(-50%)"); + expect(tooltip?.style.left).toMatch(/px$/); + expect(tooltip?.style.top).toMatch(/px$/); + + toolbar.destroy(); + editor.destroy(); + }); }); describe("toggleUnorderedList — atomic undo", () => { From b339210e66bac3533703e45c94ac339622493883 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 17:02:36 +0800 Subject: [PATCH 06/16] feat(electron): style the toolbar tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package owns the geometry but ships no stylesheet, so the demo has to give the tooltip its look — otherwise it renders as a transparent box with black text once it is actually positioned. --- apps/electron-demo/src/renderer/style.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/electron-demo/src/renderer/style.css b/apps/electron-demo/src/renderer/style.css index 652d837b..a5e3df75 100644 --- a/apps/electron-demo/src/renderer/style.css +++ b/apps/electron-demo/src/renderer/style.css @@ -343,3 +343,18 @@ body, color: var(--nexus-accent, #2563eb); font-weight: 500; } + +/* Toolbar hover tooltip. The package owns the geometry (position, anchor); + the look belongs to the host. No `position` here on purpose. */ +.nexus-toolbar-tooltip { + background: #303030; + color: #f5f5f5; + padding: 6px 10px; + border-radius: 6px; + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + pointer-events: none; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); +} From 57513763c06f76a42035f313b46ff83ae33ca97c Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Sun, 20 Sep 2026 17:23:56 +0800 Subject: [PATCH 07/16] fix(toolbar): replace the ambiguous link icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insert-link button drew a flat outlined capsule with a line through it. At 18px it reads as an oval, not as a link, so the button had to be learned rather than recognised — the one pictographic icon in the toolbar whose shape did not name its action. Replace it with a diagonal chain of two interlocking hooks. The geometry is drawn from scratch to match the surrounding set: an 18-unit box, 1.8 stroke, same visual weight as undo / redo. The hooks are laid out upright and rotated, so the numbers stay readable, and the gaps between them are sized against the stroke — a round cap adds 0.9 past each end, so anything tighter than a 3.0 separation merges the two hooks back into the single capsule this change is removing. --- packages/plugin-toolbar/src/icons.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/plugin-toolbar/src/icons.ts b/packages/plugin-toolbar/src/icons.ts index ab2d9cbb..8865b396 100644 --- a/packages/plugin-toolbar/src/icons.ts +++ b/packages/plugin-toolbar/src/icons.ts @@ -42,9 +42,19 @@ export function iconRedo(): HTMLElement { } export function iconLink(): HTMLElement { + // Two interlocking hooks on a diagonal chain. Drawn upright and rotated so + // the geometry stays readable: each hook is a straight run into a + // half-circle cap and a straight run back, and the two hooks stop short of + // each other on opposite edges — that offset pair of gaps is what reads as + // "linked" rather than as one outlined capsule. + // Gaps are sized against the 1.8 stroke: a round cap adds 0.9 past each end, + // so a 3.0 separation is what leaves a ~1.2 gap on screen. Tighter than that + // and the two hooks merge into one outlined capsule. return svgIcon( - `` + - `` + `` + + `` + + `` + + `` ); } From cf7cc2450fed3f7edb291a054d4b6a58d558bf84 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 09:33:26 +0800 Subject: [PATCH 08/16] feat(toolbar): add table and emoji insertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap #12 planned an emoji picker, table tools, and a colour picker. The colour half shipped already, but the toolbar could not produce a table or an emoji at all — the two block types every writer reaches for and neither of which anyone wants to remember the syntax for. Two independent changes, each with its own OpenSpec proposal under `openspec/changes/`, landed together because they touch the same files: Table (`add-toolbar-table-insert`): - `insertTable(editor, rows, cols)` writes a GFM table through a single `replaceRange`, so one undo removes the whole thing. - The header counts as the first picked row, matching the shape the picker draws — a `2 x 3` pick is a three-column table with one body row. - A 6 x 6 size grid reports the hovered size as `N x M` and inserts on click. Emoji (`add-toolbar-emoji-picker`): - `insertEmoji(editor, emoji)` inserts at the caret through `replaceSelection`. - A curated, category-grouped set in `emoji.ts`, exported as `EMOJI_CATEGORIES`. Deliberately not a full Unicode table: that is a large runtime asset, and adding one would put a licence review in front of the change. Both pickers reuse the existing dropdown path (`DROPDOWN_IDS`, `DROPDOWN_STYLES`, the outside-click handler, `closeDropdown`) instead of introducing a second overlay mechanism, and both stay off the document unless the user opens them. No new dependencies. --- .../add-toolbar-emoji-picker/proposal.md | 38 ++++ .../specs/plugin-toolbar/spec.md | 73 ++++++++ .../changes/add-toolbar-emoji-picker/tasks.md | 37 ++++ .../add-toolbar-table-insert/proposal.md | 38 ++++ .../specs/plugin-toolbar/spec.md | 96 ++++++++++ .../changes/add-toolbar-table-insert/tasks.md | 32 ++++ packages/plugin-toolbar/src/emoji.ts | 36 ++++ packages/plugin-toolbar/src/formatting.ts | 44 +++++ packages/plugin-toolbar/src/icons.ts | 19 ++ packages/plugin-toolbar/src/index.ts | 3 +- packages/plugin-toolbar/src/toolbar-ui.ts | 176 +++++++++++++++++- .../test/plugin-toolbar.test.ts | 114 ++++++++++++ 12 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/add-toolbar-emoji-picker/proposal.md create mode 100644 openspec/changes/add-toolbar-emoji-picker/specs/plugin-toolbar/spec.md create mode 100644 openspec/changes/add-toolbar-emoji-picker/tasks.md create mode 100644 openspec/changes/add-toolbar-table-insert/proposal.md create mode 100644 openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md create mode 100644 openspec/changes/add-toolbar-table-insert/tasks.md create mode 100644 packages/plugin-toolbar/src/emoji.ts diff --git a/openspec/changes/add-toolbar-emoji-picker/proposal.md b/openspec/changes/add-toolbar-emoji-picker/proposal.md new file mode 100644 index 00000000..d954f9b3 --- /dev/null +++ b/openspec/changes/add-toolbar-emoji-picker/proposal.md @@ -0,0 +1,38 @@ +# Change: Add an Emoji Picker to the Toolbar + +## Why + +Roadmap item **#12 — advanced toolbar (emoji picker / table tools / color picker), `plugin-toolbar`, P2** is planned but unstarted. The color picker half shipped; the table half lands in a sibling change; emoji has no entry point. + +Typing an emoji means leaving the keyboard for the OS picker or pasting from elsewhere, which breaks the writing flow. Because the toolbar is already the place a host puts "things I do not want to remember the syntax for", an emoji picker belongs there — and unlike the table, there is no syntax to type at all, so the toolbar is the only reasonable entry point. + +## What Changes + +- Add `insertEmoji(editor, emoji)`: inserts at the caret, replacing the selection, through a single transaction. +- Add a curated emoji set in a new `packages/plugin-toolbar/src/emoji.ts`, grouped into categories, exported as `EMOJI_CATEGORIES` for hosts that want to build their own picker. +- Add an emoji picker dropdown: one grid per category with a heading, clicking an entry inserts it and closes the picker. +- Add an `iconEmoji` pictographic icon matching the existing icon set. +- Reuse the existing dropdown machinery (`DROPDOWN_STYLES`, `DROPDOWN_IDS`, the outside-click handler, `closeDropdown`) rather than introducing a second overlay path. +- Export `insertEmoji` and `EMOJI_CATEGORIES` from the package entry point. + +## Non-Goals + +- **No full Unicode emoji dataset, and no new dependency.** A complete table is a large runtime asset; adding one would put a licence review in front of the change (`GOVERNANCE.md §6.3`). The curated list covers the reactions that actually appear in notes. Hosts that need more can build their own picker from `slashMenuChange`-style state or from their own data. +- No search field, skin-tone variants, recently-used tracking, or custom emoji. The set is small enough to scan. +- No emoji shortcode syntax (`:smile:`) or autocomplete in the editor — that is an editor-level concern, not a toolbar one. +- No change to `packages/core/**`, `packages/plugin-slash/**`, or `packages/plugin-toolbar/src/formatting.ts` beyond adding `insertEmoji`. +- No toolbar CSS. The package ships no stylesheet; appearance stays with the host. + +## Impact + +- Affected specs: `plugin-toolbar` +- Affected code: + - `packages/plugin-toolbar/src/emoji.ts` (new) + - `packages/plugin-toolbar/src/formatting.ts` (`insertEmoji`) + - `packages/plugin-toolbar/src/toolbar-ui.ts` (picker, button wiring) + - `packages/plugin-toolbar/src/icons.ts` (`iconEmoji`) + - `packages/plugin-toolbar/src/index.ts` (exports) + - `packages/plugin-toolbar/test/plugin-toolbar.test.ts` +- Explicitly out of scope: + - `packages/core/**` + - `packages/plugin-slash/**` diff --git a/openspec/changes/add-toolbar-emoji-picker/specs/plugin-toolbar/spec.md b/openspec/changes/add-toolbar-emoji-picker/specs/plugin-toolbar/spec.md new file mode 100644 index 00000000..fe29360f --- /dev/null +++ b/openspec/changes/add-toolbar-emoji-picker/specs/plugin-toolbar/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Toolbar Can Insert An Emoji At The Caret + +`plugin-toolbar` SHALL expose an `insertEmoji(editor, emoji)` command that inserts the emoji at the current caret position, replacing the selection when there is one. Empty input SHALL be rejected without touching the document. + +#### Scenario: Inserting at a collapsed caret +- **WHEN** the document is `hi ` with the caret at the end +- **AND** `insertEmoji` is called with an emoji +- **THEN** the document SHALL be `hi ` followed by that emoji +- **AND** the command SHALL return `true` + +#### Scenario: Inserting replaces the selection +- **WHEN** the selection covers `hello` +- **AND** `insertEmoji` is called with an emoji +- **THEN** the document SHALL be exactly that emoji + +#### Scenario: Empty input is rejected +- **WHEN** `insertEmoji` is called with an empty string +- **THEN** the document SHALL NOT change +- **AND** the command SHALL return `false` + +### Requirement: The Emoji Set Is Curated And Dependency-Free + +`plugin-toolbar` SHALL ship a fixed, category-grouped emoji set. It MUST NOT add a runtime dependency for emoji data. + +#### Scenario: Categories are exported for hosts +- **WHEN** a host imports `EMOJI_CATEGORIES` from the package entry point +- **THEN** it SHALL receive a list of categories, each with an `id`, a `label`, and a non-empty list of emoji +- **AND** every category SHALL have a unique `id` + +### Requirement: The Picker Renders One Grid Per Category + +The toolbar SHALL offer an emoji picker that renders every category as a labelled grid of emoji entries. Every entry SHALL be reachable by keyboard and carry its emoji as an accessible name. + +#### Scenario: Every category renders +- **WHEN** the picker is open +- **THEN** a grid SHALL be rendered for each entry in `EMOJI_CATEGORIES` +- **AND** the number of emoji entries SHALL equal the total across all categories +- **AND** each entry SHALL expose its emoji through `aria-label` + +### Requirement: Clicking An Entry Inserts And Closes + +Clicking an emoji entry SHALL insert that emoji at the caret and close the picker. + +#### Scenario: Click inserts and dismisses +- **WHEN** the picker is open with the caret at the end of `hi ` +- **AND** an emoji entry is clicked +- **THEN** the document SHALL be `hi ` followed by the clicked emoji +- **AND** the picker SHALL be removed from the document + +### Requirement: The Picker Reuses The Toolbar Dropdown Contract + +The picker SHALL be registered through the existing dropdown path: listed in `DROPDOWN_IDS`, opened by the button's click handler, mounted on `document.body` with `DROPDOWN_STYLES`, closed on an outside click, and torn down through the returned `destroy()` when the toolbar is destroyed. + +#### Scenario: Outside click closes the picker +- **WHEN** the picker is open +- **AND** a mousedown lands outside both the picker and its button +- **THEN** the picker SHALL close + +#### Scenario: Destroying the toolbar removes an open picker +- **WHEN** the picker is open +- **AND** the toolbar is destroyed +- **THEN** the picker element SHALL be removed from the document + +### Requirement: Existing Toolbar Behaviour Is Unchanged + +Adding the emoji button SHALL NOT alter the behaviour of existing toolbar buttons, their dropdowns, their tooltips, or their default ordering beyond the new button's own position. + +#### Scenario: Existing buttons keep their geometry +- **WHEN** the toolbar renders with default groups +- **THEN** every pre-existing button SHALL still expose its own `aria-label`, tooltip, and action +- **AND** the emoji button SHALL be the only addition to the default button set from this change diff --git a/openspec/changes/add-toolbar-emoji-picker/tasks.md b/openspec/changes/add-toolbar-emoji-picker/tasks.md new file mode 100644 index 00000000..ebf9b0f9 --- /dev/null +++ b/openspec/changes/add-toolbar-emoji-picker/tasks.md @@ -0,0 +1,37 @@ +# Implementation Tasks + +## 1. Phase 1 - OpenSpec and Red Tests + +- [x] 1.1 Create `openspec/changes/add-toolbar-emoji-picker/proposal.md`. +- [x] 1.2 Create `openspec/changes/add-toolbar-emoji-picker/specs/plugin-toolbar/spec.md`. +- [x] 1.3 Add failing `plugin-toolbar` tests for `insertEmoji` and for the picker's render-insert-close path. +- [x] 1.4 Run the targeted `plugin-toolbar` tests and confirm failures are limited to the unimplemented behaviour. + +## 2. Phase 2 - Emoji Set + +- [x] 2.1 Add `packages/plugin-toolbar/src/emoji.ts` with `EMOJI_CATEGORIES`. +- [x] 2.2 Keep the set curated — no runtime dependency, no generated Unicode table. +- [x] 2.3 Export `EMOJI_CATEGORIES` and the `EmojiCategory` type from the package entry point. + +## 3. Phase 3 - Insert Command + +- [x] 3.1 Add `insertEmoji(editor, emoji)` to `packages/plugin-toolbar/src/formatting.ts`. +- [x] 3.2 Insert through `editor.replaceSelection` so the write is a single transaction. +- [x] 3.3 Reject empty input without touching the document. +- [x] 3.4 Export `insertEmoji` from the package entry point. + +## 4. Phase 4 - Picker + +- [x] 4.1 Add `iconEmoji` to `packages/plugin-toolbar/src/icons.ts`, matching the existing 18-unit / 1.8-stroke set. +- [x] 4.2 Add `showEmojiPicker(editor, anchorBtn, onClose)` rendering one labelled grid per category. +- [x] 4.3 Give every entry its emoji as an accessible name. +- [x] 4.4 Insert on click and close the picker. +- [x] 4.5 Register the picker in `DROPDOWN_IDS` and the button's click handler; add the button to `defaultGroups`. +- [x] 4.6 Keep `packages/core/**` and `packages/plugin-slash/**` unchanged. + +## 5. Phase 5 - Verification + +- [x] 5.1 Run the targeted `plugin-toolbar` tests and confirm the new red tests pass. +- [x] 5.2 Run the full suite and confirm no regressions. +- [x] 5.3 Run `pnpm typecheck`, `pnpm check:api`, and `pnpm build`. +- [x] 5.4 Verify in the real Electron demo that the picker opens, renders every category, and inserts the clicked emoji at the caret. diff --git a/openspec/changes/add-toolbar-table-insert/proposal.md b/openspec/changes/add-toolbar-table-insert/proposal.md new file mode 100644 index 00000000..f74bc75b --- /dev/null +++ b/openspec/changes/add-toolbar-table-insert/proposal.md @@ -0,0 +1,38 @@ +# Change: Add Table Insertion to the Toolbar + +## Why + +Roadmap item **#12 — advanced toolbar (emoji picker / table tools / color picker), `plugin-toolbar`, P2** is planned but unstarted. The color picker half already shipped; the table half has no entry point at all. + +The engine can already *edit* tables: `packages/core/src/live-preview-table.ts` renders an interactive grid with cell editing, range selection, row/column reordering and resize. What is missing is the way **in** — a host that wants a table has to hand-write the GFM delimiter row and get the pipe alignment right. That is exactly the kind of syntax a toolbar exists to spare the user, and it is the one block type the toolbar cannot currently produce. + +## What Changes + +- Add `insertTable(editor, rows, cols)`: inserts a GFM table with a header row plus `rows - 1` body rows, in a single transaction so one undo removes the whole table. +- Count the **header as the first row**, matching the shape the picker draws — a `2 x 3` pick is a three-column table with one body row. +- Keep the table separated from surrounding text by blank lines when it does not already land on its own line. +- Add a size-picker dropdown to the toolbar: a 6 x 6 grid where hovering grows the highlight and updates a live `N x M` readout, and clicking inserts that size. +- Add an `iconTable` pictographic icon matching the existing icon set. +- Reuse the existing dropdown machinery (`DROPDOWN_STYLES`, `DROPDOWN_IDS`, the outside-click handler, `closeDropdown`) rather than introducing a second overlay path. +- Export `insertTable` from the package entry point. + +## Non-Goals + +- No table *editing* commands (add/remove row or column, alignment, delete table). `live-preview-table.ts` already owns table manipulation; a second implementation would duplicate it. +- No HTML tables, captions, or column widths — the document stays GFM. +- No emoji picker. Roadmap #12 covers both, but color shipped alone and this change follows that precedent; emoji is a separate proposal. +- No change to `packages/core/**`, `packages/plugin-slash/**`, or `packages/plugin-toolbar/src/color-decoration.ts`. +- No new dependencies. + +## Impact + +- Affected specs: `plugin-toolbar` +- Affected code: + - `packages/plugin-toolbar/src/formatting.ts` (`insertTable`) + - `packages/plugin-toolbar/src/toolbar-ui.ts` (size picker, button wiring) + - `packages/plugin-toolbar/src/icons.ts` (`iconTable`) + - `packages/plugin-toolbar/src/index.ts` (export) + - `packages/plugin-toolbar/test/plugin-toolbar.test.ts` +- Explicitly out of scope: + - `packages/core/**` + - `packages/plugin-slash/**` diff --git a/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md b/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md new file mode 100644 index 00000000..44daea45 --- /dev/null +++ b/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: Toolbar Can Insert A GFM Table + +`plugin-toolbar` SHALL expose an `insertTable(editor, rows, cols)` command that inserts a GitHub Flavored Markdown table at the current selection. The inserted table SHALL consist of a header row, a delimiter row, and `rows - 1` body rows, each with `cols` empty cells. + +#### Scenario: Inserting a three-by-three table +- **WHEN** `insertTable` is called with `rows = 3` and `cols = 3` on an empty document +- **THEN** the document SHALL be `| | | |\n|---|---|---|\n| | | |\n| | | |` +- **AND** the table SHALL have one header row and two body rows + +#### Scenario: Non-finite input is rejected +- **WHEN** `insertTable` is called with `NaN` or `Infinity` for either dimension +- **THEN** the document SHALL NOT change + +### Requirement: The Header Counts As The First Picked Row + +The `rows` argument SHALL include the header row, so the inserted shape matches the shape the size picker drew. `rows` SHALL be clamped to a minimum of 2 so the result always has at least one body row, and `cols` SHALL be clamped to a minimum of 1. + +#### Scenario: A two-by-two pick yields one body row +- **WHEN** `insertTable` is called with `rows = 2` and `cols = 2` +- **THEN** the document SHALL be `| | |\n|---|---|\n| | |` +- **AND** the table SHALL have exactly one body row + +#### Scenario: A single row is clamped +- **WHEN** `insertTable` is called with `rows = 1` +- **THEN** the inserted table SHALL still contain a header row, a delimiter row, and one body row + +### Requirement: Table Insertion Is One Undoable Transaction + +The whole table SHALL be written in a single transaction so that one undo restores the document to its state before the insertion. + +#### Scenario: One undo removes the whole table +- **WHEN** history is active +- **AND** `insertTable` is called over a non-empty selection +- **THEN** the selection SHALL be replaced by the table +- **AND** a single `undo` SHALL restore the original selection content + +### Requirement: The Table Is Separated From Surrounding Text + +When the insertion point is not already at a line boundary, `plugin-toolbar` SHALL insert a newline before the table and a newline after it, so the table is parsed as its own block and does not merge with adjacent text. + +#### Scenario: Inserting mid-line adds separators +- **WHEN** the document is `abc` with the caret at the end +- **AND** `insertTable` is called with `rows = 2` and `cols = 2` +- **THEN** the document SHALL be `abc\n| | |\n|---|---|\n| | |` + +#### Scenario: Inserting on an empty line adds no leading separator +- **WHEN** the document is empty +- **AND** `insertTable` is called +- **THEN** the document SHALL begin with `|`, not with a newline + +### Requirement: The Size Picker Reports The Size Before Inserting + +The toolbar SHALL offer a table size picker that renders a grid of selectable sizes. Hovering SHALL highlight every cell within the hovered row and column and SHALL update a text readout to `N x M` for that position. Leaving the grid SHALL reset the highlight and the readout to `0 x 0`. + +#### Scenario: Hovering reports the size +- **WHEN** the picker is open +- **THEN** the readout SHALL read `0 x 0` +- **WHEN** the pointer enters the cell at row 2, column 3 +- **THEN** the readout SHALL read `2 x 3` +- **AND** exactly six cells SHALL be highlighted + +#### Scenario: Leaving the grid clears the readout +- **WHEN** the pointer has entered the grid and then leaves it +- **THEN** the readout SHALL read `0 x 0` +- **AND** no cell SHALL be highlighted + +#### Scenario: Clicking inserts the hovered size +- **WHEN** the pointer is on the cell at row 3, column 4 +- **AND** the cell is clicked +- **THEN** a table with `rows = 3` and `cols = 4` SHALL be inserted +- **AND** the picker SHALL close + +### Requirement: The Size Picker Reuses The Toolbar Dropdown Contract + +The picker SHALL be registered through the existing dropdown path: listed in `DROPDOWN_IDS`, opened by the button's click handler, mounted on `document.body` with `DROPDOWN_STYLES`, closed on an outside click, and torn down through the returned `destroy()` when the toolbar is destroyed. + +#### Scenario: Outside click closes the picker +- **WHEN** the picker is open +- **AND** a mousedown lands outside both the picker and its button +- **THEN** the picker SHALL close + +#### Scenario: Destroying the toolbar removes an open picker +- **WHEN** the picker is open +- **AND** the toolbar is destroyed +- **THEN** the picker element SHALL be removed from the document + +### Requirement: Existing Toolbar Behaviour Is Unchanged + +Adding the table button SHALL NOT alter the behaviour of existing toolbar buttons, their dropdowns, their tooltips, or their default ordering beyond the new button's own position. + +#### Scenario: Existing buttons keep their geometry +- **WHEN** the toolbar renders with default groups +- **THEN** every pre-existing button SHALL still expose its own `aria-label`, tooltip, and action +- **AND** the table button SHALL be the only addition to the default button set diff --git a/openspec/changes/add-toolbar-table-insert/tasks.md b/openspec/changes/add-toolbar-table-insert/tasks.md new file mode 100644 index 00000000..d67a28d6 --- /dev/null +++ b/openspec/changes/add-toolbar-table-insert/tasks.md @@ -0,0 +1,32 @@ +# Implementation Tasks + +## 1. Phase 1 - OpenSpec and Red Tests + +- [x] 1.1 Create `openspec/changes/add-toolbar-table-insert/proposal.md`. +- [x] 1.2 Create `openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md`. +- [x] 1.3 Add failing `plugin-toolbar` tests for `insertTable` shapes, clamping, and single-undo replacement. +- [x] 1.4 Run the targeted `plugin-toolbar` tests and confirm failures are limited to the unimplemented command. + +## 2. Phase 2 - Insert Command + +- [x] 2.1 Add `insertTable(editor, rows, cols)` to `packages/plugin-toolbar/src/formatting.ts`. +- [x] 2.2 Count the header as the first row and clamp `rows` to a minimum of 2. +- [x] 2.3 Write the table through `editor.replaceRange` so it lands as one undoable transaction. +- [x] 2.4 Add leading and trailing newlines only when the insertion point is not already at a line boundary. +- [x] 2.5 Export `insertTable` from `packages/plugin-toolbar/src/index.ts`. + +## 3. Phase 3 - Size Picker + +- [x] 3.1 Add `iconTable` to `packages/plugin-toolbar/src/icons.ts`, matching the existing 18-unit / 1.8-stroke set. +- [x] 3.2 Add `showTableGridPicker(editor, anchorBtn, onClose)` rendering a 6 x 6 grid plus an `N x M` readout. +- [x] 3.3 Highlight every cell within the hovered row and column, and reset on `mouseleave`. +- [x] 3.4 Insert the hovered size on click and close the picker. +- [x] 3.5 Register the picker in `DROPDOWN_IDS` and the button's click handler; add the button to `defaultGroups`. +- [x] 3.6 Keep `packages/core/**`, `packages/plugin-slash/**`, and `color-decoration.ts` unchanged. + +## 4. Phase 4 - Verification + +- [x] 4.1 Run the targeted `plugin-toolbar` tests and confirm the new red tests pass. +- [x] 4.2 Run the full suite and confirm no regressions. +- [x] 4.3 Run `pnpm typecheck`, `pnpm check:api`, and `pnpm build`. +- [x] 4.4 Verify in the real Electron demo that the picker opens, reports `2 x 3` on hover, highlights six cells, closes on click, and renders the inserted table through the live-preview widget. diff --git a/packages/plugin-toolbar/src/emoji.ts b/packages/plugin-toolbar/src/emoji.ts new file mode 100644 index 00000000..1a2f7b11 --- /dev/null +++ b/packages/plugin-toolbar/src/emoji.ts @@ -0,0 +1,36 @@ +/** + * Curated emoji set for the toolbar picker. + * + * Deliberately a fixed list rather than a full Unicode emoji dataset: a + * complete table is a large runtime asset, and pulling one in would add a + * dependency the project would have to licence-review. These cover the + * reactions that actually show up in notes and documents. + */ +export interface EmojiCategory { + id: string; + label: string; + emoji: readonly string[]; +} + +export const EMOJI_CATEGORIES: readonly EmojiCategory[] = [ + { + id: "faces", + label: "Faces", + emoji: ["😀", "😄", "😁", "😂", "😊", "🙂", "😉", "😍", "🤔", "😅", "😢", "😡", "😴", "🤯", "🥳", "😎"], + }, + { + id: "gestures", + label: "Gestures", + emoji: ["👍", "👎", "👌", "✌", "🙏", "👏", "🙌", "🤝", "💪", "✋"], + }, + { + id: "symbols", + label: "Symbols", + emoji: ["✅", "❌", "⚠", "❗", "❓", "💡", "🔥", "⭐", "🎉", "📌", "⏰", "🔒"], + }, + { + id: "objects", + label: "Objects", + emoji: ["📝", "📄", "📁", "📂", "📎", "🔗", "📊", "📈", "📉", "📅", "📷", "🎯"], + }, +]; diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index d261c381..2360f189 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -196,6 +196,50 @@ export function applyHighlight(editor: EditorAPI, color: string): boolean { return true; } +/** + * Inserts a GFM table with a header row plus `bodyRows` rows. + * + * The grid picker counts the header as its first row, so a `2 x 3` pick is a + * three-column table with one body row — the shape the picker drew. + */ +export function insertTable(editor: EditorAPI, rows: number, cols: number): boolean { + if (!Number.isFinite(rows) || !Number.isFinite(cols)) return false; + + const bodyRows = Math.max(1, Math.floor(rows) - 1); + const colCount = Math.max(1, Math.floor(cols)); + + const doc = editor.getDocument(); + const { anchor, head } = editor.getSelection(); + const from = Math.min(anchor, head); + const to = Math.max(anchor, head); + + const emptyRow = `|${" |".repeat(colCount)}`; + const lines = [ + emptyRow, + `|${"---|".repeat(colCount)}`, + ...Array.from({ length: bodyRows }, () => emptyRow), + ]; + + const needsLeadingNewline = from > 0 && doc[from - 1] !== "\n"; + const needsTrailingNewline = to < doc.length && doc[to] !== "\n"; + + const block = + (needsLeadingNewline ? "\n" : "") + + lines.join("\n") + + (needsTrailingNewline ? "\n" : ""); + + // One transaction, so a single undo removes the whole table. + editor.replaceRange(from, to, block, { anchor: from + (needsLeadingNewline ? 1 : 0) + 1 }); + return true; +} + +/** Inserts an emoji at the caret, replacing the selection when there is one. */ +export function insertEmoji(editor: EditorAPI, emoji: string): boolean { + if (!emoji) return false; + editor.replaceSelection(emoji); + return true; +} + export function insertHorizontalRule(editor: EditorAPI): boolean { const doc = editor.getDocument(); const { anchor } = editor.getSelection(); diff --git a/packages/plugin-toolbar/src/icons.ts b/packages/plugin-toolbar/src/icons.ts index 8865b396..3762e4f5 100644 --- a/packages/plugin-toolbar/src/icons.ts +++ b/packages/plugin-toolbar/src/icons.ts @@ -184,3 +184,22 @@ export function iconHorizontalRule(): HTMLElement { `` ); } + +export function iconTable(): HTMLElement { + return svgIcon( + `` + + `` + + `` + + `` + + `` + ); +} + +export function iconEmoji(): HTMLElement { + return svgIcon( + `` + + `` + + `` + + `` + ); +} diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts index 166eaaed..eb7a61de 100644 --- a/packages/plugin-toolbar/src/index.ts +++ b/packages/plugin-toolbar/src/index.ts @@ -10,7 +10,8 @@ import { toggleStrikethrough, } from "./toolbar-commands"; -export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting"; +export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertTable, insertEmoji, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting"; +export { EMOJI_CATEGORIES, type EmojiCategory } from "./emoji"; export { createToolbarUI } from "./toolbar-ui"; export { colorDecorationExtension } from "./color-decoration"; export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui"; diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts index 7e58245d..2f1df3b8 100644 --- a/packages/plugin-toolbar/src/toolbar-ui.ts +++ b/packages/plugin-toolbar/src/toolbar-ui.ts @@ -15,9 +15,12 @@ import { toggleUnorderedList, insertCodeBlock, insertImage, + insertTable, + insertEmoji, applyTextColor, applyHighlight, } from "./formatting"; +import { EMOJI_CATEGORIES } from "./emoji"; import { iconUndo, iconRedo, @@ -37,6 +40,8 @@ import { iconTextColor, iconHighlight, iconImage, + iconTable, + iconEmoji, iconFullscreen, } from "./icons"; @@ -191,6 +196,8 @@ function defaultGroups(options?: ToolbarUIOptions): ToolbarGroup[] { }, { buttons: [ + { id: "table", title: "Insert table", icon: iconTable, action: () => {} }, + { id: "emoji", title: "Insert emoji", icon: iconEmoji, action: () => {} }, { id: "image", title: "Insert image", icon: iconImage, action: insertImage }, { id: "fullscreen", title: "Fullscreen", icon: iconFullscreen, action: () => options?.onFullscreen?.() }, ], @@ -262,6 +269,169 @@ const DROPDOWN_ITEM_STYLES = ` line-height: 1.5; `; +const TABLE_GRID_ROWS = 6; +const TABLE_GRID_COLS = 6; + +/** + * Table size picker: hover to grow the highlight, click to insert. Mirrors the + * size grids hosts like Yuque use, so the shape is chosen visually instead of + * by typing numbers. + */ +function showTableGridPicker( + editor: EditorAPI, + anchorBtn: HTMLElement, + onClose: () => void, +): { destroy: () => void } { + const menu = document.createElement("div"); + menu.className = "nexus-toolbar-dropdown nexus-toolbar-table-picker"; + menu.style.cssText = DROPDOWN_STYLES; + menu.style.minWidth = "0"; + menu.style.padding = "8px"; + + const rect = anchorBtn.getBoundingClientRect(); + menu.style.top = rect.bottom + 4 + "px"; + menu.style.left = rect.left + "px"; + + const grid = document.createElement("div"); + grid.style.cssText = + `display:grid;grid-template-columns:repeat(${TABLE_GRID_COLS},16px);gap:3px;`; + + const readout = document.createElement("div"); + readout.style.cssText = + "margin-top:8px;text-align:center;font-size:12px;" + + "color:var(--nexus-text-muted,#888);font-variant-numeric:tabular-nums;"; + + const cells: HTMLDivElement[] = []; + let hoverRows = 0; + let hoverCols = 0; + + function paint(): void { + for (let i = 0; i < cells.length; i++) { + const row = Math.floor(i / TABLE_GRID_COLS) + 1; + const col = (i % TABLE_GRID_COLS) + 1; + const inRange = row <= hoverRows && col <= hoverCols; + cells[i].style.background = inRange + ? "var(--nexus-accent,#0969da)" + : "transparent"; + cells[i].style.opacity = inRange ? "0.3" : "1"; + } + readout.textContent = `${hoverRows} x ${hoverCols}`; + } + + for (let row = 1; row <= TABLE_GRID_ROWS; row++) { + for (let col = 1; col <= TABLE_GRID_COLS; col++) { + const cell = document.createElement("div"); + cell.style.cssText = + "width:16px;height:12px;border:1px solid var(--nexus-border,#ddd);" + + "border-radius:2px;cursor:pointer;"; + cell.addEventListener("mouseenter", () => { + hoverRows = row; + hoverCols = col; + paint(); + }); + cell.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + insertTable(editor, row, col); + onClose(); + }); + cells.push(cell); + grid.appendChild(cell); + } + } + + // Reset the readout when the pointer leaves the grid, so the label never + // advertises a size the user has stopped pointing at. + grid.addEventListener("mouseleave", () => { + hoverRows = 0; + hoverCols = 0; + paint(); + }); + + menu.append(grid, readout); + paint(); + document.body.appendChild(menu); + + return { + destroy() { + menu.remove(); + }, + }; +} + +const EMOJI_GRID_COLUMNS = 8; + +/** + * Emoji picker. The set is a curated list rather than a full Unicode + * dataset — see `emoji.ts` for why. Grouped by category so the panel stays + * scannable without needing a search field over a few dozen entries. + */ +function showEmojiPicker( + editor: EditorAPI, + anchorBtn: HTMLElement, + onClose: () => void, +): { destroy: () => void } { + const menu = document.createElement("div"); + menu.className = "nexus-toolbar-dropdown nexus-toolbar-emoji-picker"; + menu.style.cssText = DROPDOWN_STYLES; + menu.style.minWidth = "0"; + menu.style.padding = "8px"; + menu.style.maxHeight = "280px"; + menu.style.overflowY = "auto"; + + const rect = anchorBtn.getBoundingClientRect(); + menu.style.top = rect.bottom + 4 + "px"; + menu.style.left = rect.left + "px"; + + const itemCleanups: Array<() => void> = []; + + for (const category of EMOJI_CATEGORIES) { + const heading = document.createElement("div"); + heading.textContent = category.label; + heading.style.cssText = + "padding:6px 2px 4px;font-size:11px;font-weight:600;letter-spacing:0.04em;" + + "text-transform:uppercase;color:var(--nexus-text-muted,#888);"; + + const grid = document.createElement("div"); + grid.style.cssText = + `display:grid;grid-template-columns:repeat(${EMOJI_GRID_COLUMNS},26px);gap:2px;`; + + for (const emoji of category.emoji) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "nexus-toolbar-emoji"; + item.textContent = emoji; + item.setAttribute("aria-label", emoji); + item.style.cssText = + "width:26px;height:26px;padding:0;border:none;border-radius:4px;" + + "background:transparent;font-size:17px;line-height:1;cursor:pointer;" + + "display:flex;align-items:center;justify-content:center;"; + + const handleClick = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + insertEmoji(editor, emoji); + onClose(); + }; + item.addEventListener("click", handleClick); + itemCleanups.push(() => item.removeEventListener("click", handleClick)); + grid.appendChild(item); + } + + menu.append(heading, grid); + } + + document.body.appendChild(menu); + + return { + destroy() { + for (const fn of itemCleanups) fn(); + itemCleanups.length = 0; + menu.remove(); + }, + }; +} + /** Mount a heading dropdown onto document.body, positioned below the anchor button. */ function showHeadingDropdown( editor: EditorAPI, @@ -451,7 +621,7 @@ function showColorPicker( } /** IDs that trigger dropdown behavior instead of a direct action. */ -const DROPDOWN_IDS = new Set(["heading-menu", "text-color", "highlight"]); +const DROPDOWN_IDS = new Set(["heading-menu", "text-color", "highlight", "table", "emoji"]); export function createToolbarUI(editor: EditorAPI, options?: ToolbarUIOptions): ToolbarUI { const groups = options?.groups ?? defaultGroups(options); @@ -520,6 +690,10 @@ export function createToolbarUI(editor: EditorAPI, options?: ToolbarUIOptions): activeDropdown = showColorPicker(editor, button, COLOR_PALETTE, applyTextColor, closeDropdown); } else if (btn.id === "highlight") { activeDropdown = showColorPicker(editor, button, HIGHLIGHT_PALETTE, applyHighlight, closeDropdown); + } else if (btn.id === "table") { + activeDropdown = showTableGridPicker(editor, button, closeDropdown); + } else if (btn.id === "emoji") { + activeDropdown = showEmojiPicker(editor, button, closeDropdown); } outsideHandler = (ev: MouseEvent) => { diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 731ea1eb..6ef8e5c2 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -6,7 +6,9 @@ import { toggleBold, toggleItalic, toggleInlineCode, + insertEmoji, insertLink, + insertTable, toggleHeading, toggleOrderedList, toggleUnorderedList, @@ -88,6 +90,118 @@ describe("insertLink", () => { }); }); +describe("insertEmoji", () => { + it("inserts at the caret", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hi " }); + + editor.setSelection(3, 3); + insertEmoji(editor, "\u{1F44D}"); + + expect(editor.getDocument()).toBe("hi \u{1F44D}"); + editor.destroy(); + }); + + it("replaces the selection", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello" }); + + editor.setSelection(0, 5); + insertEmoji(editor, "\u{1F600}"); + + expect(editor.getDocument()).toBe("\u{1F600}"); + editor.destroy(); + }); + + it("rejects an empty emoji", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello" }); + + expect(insertEmoji(editor, "")).toBe(false); + expect(editor.getDocument()).toBe("hello"); + editor.destroy(); + }); +}); + +describe("emoji picker", () => { + it("renders every category and inserts on click", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hi " }); + const toolbar = createToolbarUI(editor); + document.body.appendChild(toolbar.element); + editor.setSelection(3, 3); + + const button = toolbar.element.querySelector('[data-toolbar-action="emoji"]'); + expect(button).not.toBeNull(); + button?.click(); + + const picker = document.querySelector(".nexus-toolbar-emoji-picker"); + expect(picker).not.toBeNull(); + + const first = picker?.querySelector(".nexus-toolbar-emoji"); + expect(first).not.toBeNull(); + const chosen = first?.textContent ?? ""; + + first?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + expect(editor.getDocument()).toBe(`hi ${chosen}`); + expect(document.querySelector(".nexus-toolbar-emoji-picker")).toBeNull(); + + toolbar.destroy(); + editor.destroy(); + }); +}); + +describe("insertTable", () => { + it("inserts a GFM table with a header row plus the picked body rows", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "" }); + + insertTable(editor, 3, 3); + + expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |"); + editor.destroy(); + }); + + it("counts the header as the first picked row", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "" }); + + insertTable(editor, 2, 2); + + expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |"); + editor.destroy(); + }); + + it("replaces the selection and stays one undo entry", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "hello", + plugins: [createHistoryPlugin()], + }); + + editor.setSelection(0, 5); + insertTable(editor, 2, 2); + expect(editor.getDocument()).toContain("|---|---|"); + + editor.undo(); + expect(editor.getDocument()).toBe("hello"); + editor.destroy(); + }); + + it("separates the table from surrounding text with newlines", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "abc" }); + + editor.setSelection(3, 3); + insertTable(editor, 2, 2); + + expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |"); + editor.destroy(); + }); +}); + describe("toggleHeading", () => { it("adds heading prefix to current line", () => { const container = document.createElement("div"); From 8da6c09ffe9b54d97db4ccd10c8d1046ccc55a48 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 09:55:46 +0800 Subject: [PATCH 09/16] fix(toolbar): make an inserted table deletable and widen the size grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems found by actually using the feature. The table could not be removed. `insertTable` left the caret at the end of the inserted block, which is the position pressed against the table's trailing edge — the table renders as an atomic range and CM6 does not hold a caret there, rewriting it to the document start. Backspace and Delete then had nothing adjacent to act on and the table looked permanent. The block now always ends with a newline so the caret lands on the line past the table, where one backward delete removes it. The picker also hands focus back to the editor before dispatching, since a dropdown mounted on `document.body` blurs it. The size grid stopped at 6 x 6, which is short for a document table. It is now 10 x 10. --- .../specs/plugin-toolbar/spec.md | 21 +++++++++--- packages/plugin-toolbar/src/formatting.ts | 21 +++++++----- packages/plugin-toolbar/src/toolbar-ui.ts | 4 +-- .../test/plugin-toolbar.test.ts | 34 +++++++++++++++++-- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md b/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md index 44daea45..edeac118 100644 --- a/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md +++ b/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md @@ -6,7 +6,7 @@ #### Scenario: Inserting a three-by-three table - **WHEN** `insertTable` is called with `rows = 3` and `cols = 3` on an empty document -- **THEN** the document SHALL be `| | | |\n|---|---|---|\n| | | |\n| | | |` +- **THEN** the document SHALL be `| | | |\n|---|---|---|\n| | | |\n| | | |\n` - **AND** the table SHALL have one header row and two body rows #### Scenario: Non-finite input is rejected @@ -19,7 +19,7 @@ The `rows` argument SHALL include the header row, so the inserted shape matches #### Scenario: A two-by-two pick yields one body row - **WHEN** `insertTable` is called with `rows = 2` and `cols = 2` -- **THEN** the document SHALL be `| | |\n|---|---|\n| | |` +- **THEN** the document SHALL be `| | |\n|---|---|\n| | |\n` - **AND** the table SHALL have exactly one body row #### Scenario: A single row is clamped @@ -38,17 +38,28 @@ The whole table SHALL be written in a single transaction so that one undo restor ### Requirement: The Table Is Separated From Surrounding Text -When the insertion point is not already at a line boundary, `plugin-toolbar` SHALL insert a newline before the table and a newline after it, so the table is parsed as its own block and does not merge with adjacent text. +When the insertion point is not already at a line boundary, `plugin-toolbar` SHALL insert a newline before the table, so it is parsed as its own block and does not merge with the preceding text. The inserted block SHALL always end with a newline. -#### Scenario: Inserting mid-line adds separators +#### Scenario: Inserting mid-line adds a leading separator - **WHEN** the document is `abc` with the caret at the end - **AND** `insertTable` is called with `rows = 2` and `cols = 2` -- **THEN** the document SHALL be `abc\n| | |\n|---|---|\n| | |` +- **THEN** the document SHALL be `abc\n| | |\n|---|---|\n| | |\n` #### Scenario: Inserting on an empty line adds no leading separator - **WHEN** the document is empty - **AND** `insertTable` is called - **THEN** the document SHALL begin with `|`, not with a newline +- **AND** the document SHALL end with a newline + +### Requirement: The Caret Lands On A Line Past The Table + +The table renders as an atomic range, and the position pressed against its trailing edge is not one CM6 will hold a caret at — a caret sent there is rewritten to the document start, which leaves `Backspace` and `Delete` with nothing adjacent to act on and makes the table look impossible to remove. `plugin-toolbar` SHALL therefore end the inserted block with a newline and place the caret at the end of the block, so the caret rests on a line past the table. + +#### Scenario: The caret can delete the table immediately +- **WHEN** `insertTable` is called +- **THEN** the caret SHALL be at the end of the document +- **AND** the document SHALL end with a newline, so the caret sits on a line past the table +- **AND** a single backward delete SHALL remove the table ### Requirement: The Size Picker Reports The Size Before Inserting diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index 2360f189..635fcefb 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -221,15 +221,20 @@ export function insertTable(editor: EditorAPI, rows: number, cols: number): bool ]; const needsLeadingNewline = from > 0 && doc[from - 1] !== "\n"; - const needsTrailingNewline = to < doc.length && doc[to] !== "\n"; - - const block = - (needsLeadingNewline ? "\n" : "") + - lines.join("\n") + - (needsTrailingNewline ? "\n" : ""); - // One transaction, so a single undo removes the whole table. - editor.replaceRange(from, to, block, { anchor: from + (needsLeadingNewline ? 1 : 0) + 1 }); + // The block always ends with a newline. The table renders as an atomic range, + // and the position pressed right against its trailing edge is not one CM6 + // will hold a caret at — a caret sent there is rewritten to the document + // start, which leaves Backspace and Delete with nothing to act on and makes + // the table look impossible to remove. The trailing newline gives the caret a + // line of its own past the table, which is also where a writer wants it. + const block = (needsLeadingNewline ? "\n" : "") + lines.join("\n") + "\n"; + + // The picker is a dropdown mounted on `document.body`, so the editor is + // blurred by the time this runs — take focus back, or the caret never reaches + // the DOM. One transaction, so a single undo removes the whole table. + editor.focus(); + editor.replaceRange(from, to, block, { anchor: from + block.length }); return true; } diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts index 2f1df3b8..464039c3 100644 --- a/packages/plugin-toolbar/src/toolbar-ui.ts +++ b/packages/plugin-toolbar/src/toolbar-ui.ts @@ -269,8 +269,8 @@ const DROPDOWN_ITEM_STYLES = ` line-height: 1.5; `; -const TABLE_GRID_ROWS = 6; -const TABLE_GRID_COLS = 6; +const TABLE_GRID_ROWS = 10; +const TABLE_GRID_COLS = 10; /** * Table size picker: hover to grow the highlight, click to insert. Mirrors the diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 6ef8e5c2..f06c245a 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -159,7 +159,7 @@ describe("insertTable", () => { insertTable(editor, 3, 3); - expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |"); + expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |\n"); editor.destroy(); }); @@ -169,7 +169,7 @@ describe("insertTable", () => { insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |"); + expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |\n"); editor.destroy(); }); @@ -190,6 +190,34 @@ describe("insertTable", () => { editor.destroy(); }); + it("ends the block with a newline so the caret has a line past the table", () => { + // The caret cannot rest against the table's trailing edge — CM6 rewrites it + // to the document start, which makes the table impossible to delete. The + // trailing newline is what gives the caret somewhere valid to land. + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "" }); + + insertTable(editor, 2, 2); + + expect(editor.getDocument().endsWith("\n")).toBe(true); + editor.destroy(); + }); + + it("leaves the caret after the block, never inside the table source", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "abc" }); + + editor.setSelection(3, 3); + insertTable(editor, 2, 2); + + // The table renders as an atomic range, so a caret parked inside its + // source cannot be acted on — Backspace and Delete both no-op and the + // table becomes impossible to remove. The caret must sit past the block. + const doc = editor.getDocument(); + expect(editor.getSelection().anchor).toBe(doc.length); + editor.destroy(); + }); + it("separates the table from surrounding text with newlines", () => { const container = document.createElement("div"); const editor = createEditor({ container, initialValue: "abc" }); @@ -197,7 +225,7 @@ describe("insertTable", () => { editor.setSelection(3, 3); insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |"); + expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |\n"); editor.destroy(); }); }); From c8fde9911b0e7c0cd300f9dcd801adee185cf677 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 10:20:16 +0800 Subject: [PATCH 10/16] feat(core): row and column delete buttons plus auto-fit width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing a row or column meant knowing the right-click menu existed, or selecting the row/column first and pressing Delete. Neither is discoverable, and the table is the one block in the editor whose structure cannot be edited from the text itself. - Each column renders a trash button above its grip, and each data row one to the left of its grip. They appear with the grip on hover. - The operations were already implemented for the context menu and the Delete key; these buttons are a second entry point to `deleteColumn` / `deleteRow`, so both paths stay in one implementation. - The grips already own `mousedown` (drag to reorder) and `click` (select column). The buttons stop both, or pressing delete would drag or highlight the column instead — the regression that made this worth testing on all interaction paths. - A column button is hidden when a single column remains, matching the context menu's existing guard. - The trash glyph is drawn rather than typed as "×": a cross reads as "dismiss", which is the wrong promise for a control that rewrites the table. Auto-fit width undoes a manual resize. It drops the remembered widths and tears down the colgroup, fixed layout, and explicit width that `applyColumnWidths` installed. It edits the DOM in place rather than dispatching, because the source is unchanged and a no-op transaction would be swallowed by the widget's `eq()`; the work therefore lives on the widget, which is the only scope holding the mounted `` and its width key. `NexusLocale` and `LivePreviewLabels` both gain `autoFitWidth`. The locale field is resolved through the explicit field-by-field mapping in `editor.ts`, which is easy to miss when adding a label. --- packages/core/src/editor.ts | 1 + packages/core/src/live-preview-table.ts | 102 ++++++++++++++++++++ packages/core/src/live-preview.ts | 1 + packages/core/src/locale.ts | 3 + packages/core/src/types.ts | 1 + packages/core/test/live-preview.test.ts | 121 +++++++++++++++++++++++- 6 files changed, 227 insertions(+), 2 deletions(-) diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..c152a607 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -761,6 +761,7 @@ export function createEditor(config: EditorConfig): EditorAPI { deleteRow: locale.deleteRow, insertColumnAfter: locale.insertColumnAfter, insertRowBelow: locale.insertRowBelow, + autoFitWidth: locale.autoFitWidth, }), dynamicWidgetDefinitionExtension, ...createWidgetExtension(widgetParser, widgetDefs), diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 29d9f7c4..65e81542 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -564,6 +564,13 @@ export class EditableTableWidget extends WidgetType { private editing = false; private reusable = true; private cleanupEditingLocks: (() => void) | null = null; + /** + * Set during render, where the mounted `
` and its width key are in + * scope. Undoing a manual resize is an in-place DOM edit rather than a + * dispatch: the source is unchanged, so a transaction would be swallowed by + * `eq()` and the colgroup would survive. + */ + private autoFit: (() => void) | null = null; constructor( private node: Table, @@ -1502,6 +1509,34 @@ export class EditableTableWidget extends WidgetType { return pill; } + /** + * Trash glyph for the row/column delete buttons. Drawn rather than typed as + * "×": a cross reads as "dismiss", which is the wrong promise for a control + * that rewrites the table. + */ + function createTrashIcon(): SVGElement { + const NS = "http://www.w3.org/2000/svg"; + const icon = document.createElementNS(NS, "svg"); + icon.setAttribute("viewBox", "0 0 12 12"); + icon.setAttribute("width", "10"); + icon.setAttribute("height", "10"); + icon.setAttribute("fill", "none"); + icon.setAttribute("stroke", "currentColor"); + icon.setAttribute("stroke-width", "1.3"); + icon.setAttribute("stroke-linecap", "round"); + icon.setAttribute("stroke-linejoin", "round"); + for (const d of [ + "M2.6 3.4h6.8", + "M4.9 3.4V2.6a.6.6 0 0 1 .6-.6h1a.6.6 0 0 1 .6.6v.8", + "M3.5 3.4l.4 6.1a.8.8 0 0 0 .8.7h2.6a.8.8 0 0 0 .8-.7l.4-6.1", + ]) { + const path = document.createElementNS(NS, "path"); + path.setAttribute("d", d); + icon.appendChild(path); + } + return icon; + } + // ── Custom drag handlers (mousedown/mousemove/mouseup, no HTML5 drag) ── // Get the content area boundaries (excluding grip column) @@ -1639,6 +1674,14 @@ export class EditableTableWidget extends WidgetType { const gripRow = document.createElement("tr"); gripRow.style.cssText = "opacity:0;transition:opacity .15s;"; + // Delete affordances float above their grip rather than living in the cell + // flow: an in-flow button would grow the grip row and shift the table every + // time it appeared. + const deleteBtnCss = + "position:absolute;top:-19px;width:16px;height:16px;padding:0;border:1px solid var(--nexus-border-subtle);" + + "border-radius:4px;background:var(--nexus-bg);color:var(--nexus-text-muted);font-size:12px;line-height:1;" + + "display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:2;"; + const gripSpacer = document.createElement("td"); gripSpacer.style.cssText = "width:16px;min-width:16px;padding:0;border:none;"; gripRow.appendChild(gripSpacer); @@ -1668,6 +1711,32 @@ export class EditableTableWidget extends WidgetType { wrapper.focus({ preventScroll: true }); }); + // Mirrors the context menu's guard: a table with no columns is not a + // table, so the last one keeps its delete affordance hidden. + if (colCount > 1) { + gripCell.style.position = "relative"; + const deleteCol = document.createElement("button"); + deleteCol.type = "button"; + deleteCol.className = "nexus-col-delete"; + deleteCol.appendChild(createTrashIcon()); + deleteCol.title = self.labels.deleteColumn; + deleteCol.setAttribute("aria-label", self.labels.deleteColumn); + deleteCol.style.cssText = deleteBtnCss + "right:2px;"; + // The grip already owns mousedown (column drag) and click (column + // select). Without stopping both, pressing delete would drag or + // highlight the column instead of removing it. + deleteCol.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + deleteCol.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + self.deleteColumn(colIdx); + }); + gripCell.appendChild(deleteCol); + } + gripRow.appendChild(gripCell); } table.appendChild(gripRow); @@ -1804,6 +1873,27 @@ export class EditableTableWidget extends WidgetType { rowGrip.addEventListener("mouseenter", () => { if (draggingRow < 0) rowPill.style.background = GRIP_BG_HOVER; }); rowGrip.addEventListener("mouseleave", () => { if (draggingRow < 0) rowPill.style.background = GRIP_BG; }); + rowGrip.style.position = "relative"; + const deleteRow = document.createElement("button"); + deleteRow.type = "button"; + deleteRow.className = "nexus-row-delete"; + deleteRow.appendChild(createTrashIcon()); + deleteRow.title = self.labels.deleteRow; + deleteRow.setAttribute("aria-label", self.labels.deleteRow); + deleteRow.style.cssText = deleteBtnCss + "left:0;"; + // Same reason as the column button: the grip owns mousedown (row drag) + // and click (row select), so the press must not travel any further. + deleteRow.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + deleteRow.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + self.deleteRow(curRowIdx); + }); + rowGrip.appendChild(deleteRow); + rowGrip.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); @@ -2293,6 +2383,15 @@ export class EditableTableWidget extends WidgetType { applyColumnWidths(savedWidths); } + self.autoFit = () => { + tableColumnWidths.delete(widthKey); + // `colgroup` can only ever be a direct child of `table`, so this is the + // same node `:scope > colgroup` matches without relying on the selector. + table.querySelector("colgroup")?.remove(); + table.style.tableLayout = ""; + table.style.width = ""; + }; + // ── "+" buttons ── const btnCss = "position:absolute;width:20px;height:20px;border:1px solid var(--nexus-border-subtle);" + "border-radius:50%;background:var(--nexus-bg);cursor:pointer;font-size:14px;line-height:1;" + @@ -2505,6 +2604,9 @@ function showContextMenu( addItem(labels.deleteColumn, () => (widget as any).deleteColumn(colIdx), colCount <= 1); addItem(labels.insertRowBelow, () => (widget as any).addRow()); addItem(labels.insertColumnAfter, () => (widget as any).addColumn()); + // Undo a manual resize. The work lives on the widget because only the render + // closure holds the mounted `
` and its width key. + addItem(labels.autoFitWidth, () => (widget as any).autoFit?.()); mountTarget.appendChild(menu); diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 24c55625..74a644e7 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -37,6 +37,7 @@ const DEFAULT_LABELS: Required = { deleteRow: "Delete row", insertColumnAfter: "Insert column after", insertRowBelow: "Insert row below", + autoFitWidth: "Auto-fit width", }; function createEmptyAst(): Root { diff --git a/packages/core/src/locale.ts b/packages/core/src/locale.ts index 6b0ccaa3..5d5d7d99 100644 --- a/packages/core/src/locale.ts +++ b/packages/core/src/locale.ts @@ -12,6 +12,7 @@ export interface NexusLocale { alignLeft: string; alignCenter: string; alignRight: string; + autoFitWidth: string; // Fold foldCode: string; @@ -38,6 +39,7 @@ export const enLocale: NexusLocale = { alignLeft: "Align left", alignCenter: "Align center", alignRight: "Align right", + autoFitWidth: "Auto-fit width", foldCode: "Fold code block", unfoldCode: "Unfold code block", foldHeading: "Fold section", @@ -58,6 +60,7 @@ export const zhLocale: NexusLocale = { alignLeft: "左对齐", alignCenter: "居中对齐", alignRight: "右对齐", + autoFitWidth: "自适应宽度", foldCode: "折叠代码块", unfoldCode: "展开代码块", foldHeading: "折叠章节", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..ffbf67ca 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -68,6 +68,7 @@ export interface LivePreviewLabels { deleteRow?: string; insertColumnAfter?: string; insertRowBelow?: string; + autoFitWidth?: string; } export interface LivePreviewConfig { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index f0beda39..8cbc2c23 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -2022,6 +2022,121 @@ describe("live preview", () => { editor.destroy(); }); + // ── Row/column delete affordances ── + + function mountTable(source: string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: source, + livePreview: true, + plugins: [createGfmPreset()], + }); + return { container, editor }; + } + + const headerTexts = (container: HTMLElement): string[] => + Array.from(container.querySelectorAll("table tr")[1]?.querySelectorAll("th,td") ?? []) + .map((c) => c.textContent ?? ""); + + it("renders one column delete per column and one row delete per data row", () => { + const { container, editor } = mountTable("| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |"); + + expect(container.querySelectorAll(".nexus-col-delete")).toHaveLength(3); + // The header row gets no delete affordance — same rule as the context menu. + expect(container.querySelectorAll(".nexus-row-delete")).toHaveLength(2); + + editor.destroy(); + container.remove(); + }); + + it("removes the column the delete button belongs to", () => { + const { container, editor } = mountTable("| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |"); + expect(headerTexts(container)).toEqual(["", "A", "B", "C"]); + + container.querySelectorAll(".nexus-col-delete")[1]?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + expect(headerTexts(container)).toEqual(["", "A", "C"]); + editor.destroy(); + container.remove(); + }); + + it("removes the row the delete button belongs to", () => { + const { container, editor } = mountTable("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"); + const rowsBefore = container.querySelectorAll("table tr").length; + + container.querySelectorAll(".nexus-row-delete")[0]?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + expect(container.querySelectorAll("table tr").length).toBe(rowsBefore - 1); + editor.destroy(); + container.remove(); + }); + + it("hides the column delete when only one column remains", () => { + const { container, editor } = mountTable("| A |\n| --- |\n| 1 |"); + + expect(container.querySelectorAll(".nexus-col-delete")).toHaveLength(0); + + editor.destroy(); + container.remove(); + }); + + it("does not let the delete button select the column instead", () => { + // The grips own mousedown (drag) and click (select); the delete button has + // to stop both, or pressing it would highlight the column instead. + const { container, editor } = mountTable("| A | B |\n| --- | --- |\n| 1 | 2 |"); + const grip = container.querySelectorAll(".nexus-col-grip")[0]; + const button = container.querySelector(".nexus-col-delete"); + + button?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + expect(grip.style.background).toBe(""); + editor.destroy(); + container.remove(); + }); + + it("auto-fit drops a manual column width", () => { + // Unique header text: `tableColumnWidths` is a module-level map keyed by + // the header line, and an earlier resize test leaves widths under + // `| A | B |`. Reusing that header would pre-seed a colgroup here. + const { container, editor } = mountTable("| Fit A | Fit B |\n| --- | --- |\n| 1 | 2 |"); + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + + // Stand in for a completed resize drag. + table!.style.tableLayout = "fixed"; + table!.style.width = "600px"; + const colgroup = document.createElement("colgroup"); + colgroup.innerHTML = ""; + table!.insertBefore(colgroup, table!.firstChild); + + const cell = container.querySelectorAll("tr")[2]?.querySelector(".nexus-cell"); + cell?.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 30, clientY: 40 }) + ); + const menu = document.body.querySelector(".nexus-table-ctx"); + const fit = Array.from(menu?.querySelectorAll("button[role='menuitem']") ?? []) + .find((b) => /Auto-fit/.test(b.textContent ?? "")); + expect(fit).toBeTruthy(); + + fit?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + const after = container.querySelector("table"); + expect(after?.style.tableLayout).toBe(""); + expect(after?.style.width).toBe(""); + expect(after?.querySelector("colgroup")).toBeNull(); + + menu?.remove(); + editor.destroy(); + container.remove(); + }); + it("renders a styled localized table context menu", () => { const container = document.createElement("div"); const editor = createEditor({ @@ -2032,7 +2147,8 @@ describe("live preview", () => { deleteRow: "删除行", deleteColumn: "删除列", insertRowBelow: "在下方插入行", - insertColumnAfter: "在右侧插入列" + insertColumnAfter: "在右侧插入列", + autoFitWidth: "自适应宽度" }, plugins: [createGfmPreset()] }); @@ -2055,7 +2171,8 @@ describe("live preview", () => { expect(menu?.style.color).toContain("--nexus-menu-text"); expect(menu?.textContent).toContain("删除行"); expect(menu?.textContent).toContain("在右侧插入列"); - expect(menu?.querySelectorAll("button[role='menuitem']")).toHaveLength(4); + expect(menu?.textContent).toContain("自适应宽度"); + expect(menu?.querySelectorAll("button[role='menuitem']")).toHaveLength(5); menu?.remove(); editor.destroy(); From 95200b79f9583f2c3ff703006d31b60a45e9e636 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 10:37:55 +0800 Subject: [PATCH 11/16] fix(core): keep row and column deletes working after a cell edit Deleting a row or column did nothing when a cell had just been edited. The symptom looked intermittent only because it needed an edit first. Two things stacked up: The structural edit was computed from `this.source`, the text captured when the widget was built. A focused cell keeps its text in the cell's DOM and `eq()` deliberately keeps the widget alive through editing, so that copy lags the document. `dispatch` validates its source against the document before writing and returns in silence on a mismatch, so the delete was dropped without a trace. And even a correctly computed delete was discarded: while a cell is focused its text is pending, and the cell's next sync wrote its own buffer back over the structural change. `mutateTable` now flushes pending table edits, reads the table's live text from the document, and applies the line transform to that. Delete, add, and non-drag move all go through it. The drag path is deliberately left on `dispatch`: it captures a source containing a cell edit that has not reached the document yet, so the widget's copy is still current and one transaction commits both the cell text and the move. Routing it through `mutateTable` broke exactly that, which two existing drag tests caught. --- packages/core/src/live-preview-table.ts | 155 +++++++++++++++++------- packages/core/test/live-preview.test.ts | 26 ++++ 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 65e81542..9511fe50 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -599,6 +599,64 @@ export class EditableTableWidget extends WidgetType { return estimateTableHeight(this.source); } + /** + * The table's text as it currently stands in the document. + * + * In-cell editing writes straight into the document, and `eq()` deliberately + * keeps the widget alive while a cell is being edited — so `this.source` can + * lag behind the real text. Every structural edit has to start from the + * document; reading the render-time copy instead produces a source that no + * longer matches, and `dispatch` drops it without a word. + */ + private liveSource(view: EditorView): string | null { + const doc = view.state.doc; + if (this.tableFrom > doc.length) return null; + const first = doc.lineAt(this.tableFrom); + let end = first.to; + let lineNumber = first.number; + while (lineNumber < doc.lines) { + const next = doc.line(lineNumber + 1); + if (!next.text.trimStart().startsWith("|")) break; + end = next.to; + lineNumber += 1; + } + return doc.sliceString(this.tableFrom, end); + } + + /** + * Applies a line-level edit to the table's live text in one transaction. + * A `null` from the transform means the table no longer has that shape. + */ + private mutateTable(transform: (lines: string[]) => string[] | null): boolean { + const view = this.viewRef.current; + if (!view) return false; + // Commit in-cell typing first. While a cell is focused its text lives in + // the cell's DOM, and a structural edit written underneath it is discarded + // the moment that cell next syncs — the delete lands, then vanishes. + flushPendingTableEdits(view, true); + const source = this.liveSource(view); + if (source === null) return false; + const next = transform(source.split("\n")); + if (!next) return false; + const insert = next.join("\n"); + if (insert === source) return false; + view.dispatch({ + changes: { from: this.tableFrom, to: this.tableFrom + source.length, insert }, + }); + return true; + } + + /** + * Writes `newSource` over the table range, provided the document still holds + * the text this widget was built from. + * + * The drag path relies on that: it captures a source that includes a cell + * edit which has *not* been written to the document yet, so the widget's + * copy is still current and the single transaction commits the cell text and + * the structural move together. Callers whose edit already landed in the + * document must go through `mutateTable` instead, which starts from the live + * text. + */ private dispatch(newSource: string): void { const v = this.viewRef.current; if (!v) return; @@ -606,65 +664,78 @@ export class EditableTableWidget extends WidgetType { if (tableEnd > v.state.doc.length || v.state.doc.sliceString(this.tableFrom, tableEnd) !== this.source) { return; } - v.dispatch({ changes: { from: this.tableFrom, to: this.tableFrom + this.source.length, insert: newSource } }); + v.dispatch({ changes: { from: this.tableFrom, to: tableEnd, insert: newSource } }); } private deleteColumn(colIdx: number): void { - const lines = this.source.split("\n"); - const newLines = lines.map((line) => { - const cells = line.split("|").filter((_, i, a) => i > 0 && i < a.length - 1); - if (cells.length === 0) return line; - cells.splice(colIdx, 1); - return "|" + cells.join("|") + "|"; - }); - this.dispatch(newLines.join("\n")); + this.mutateTable((lines) => + lines.map((line) => { + const cells = line.split("|").filter((_, i, a) => i > 0 && i < a.length - 1); + if (cells.length === 0) return line; + cells.splice(colIdx, 1); + return "|" + cells.join("|") + "|"; + }) + ); } private deleteRow(rowIdx: number): void { - const lines = this.source.split("\n"); - const dataLines: number[] = []; - for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dataLines.push(i); - const lineIdx = dataLines[rowIdx]; - if (lineIdx === undefined) return; - lines.splice(lineIdx, 1); - this.dispatch(lines.join("\n")); + this.mutateTable((lines) => { + const dataLines: number[] = []; + for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dataLines.push(i); + const lineIdx = dataLines[rowIdx]; + if (lineIdx === undefined) return null; + lines.splice(lineIdx, 1); + return lines; + }); } private addColumn(): void { - const lines = this.source.split("\n"); - const nl = lines.map((l) => SEPARATOR_RE.test(l) ? l.replace(/\|?\s*$/, " | --- |") : l.replace(/\|?\s*$/, " | |")); - this.dispatch(nl.join("\n")); + this.mutateTable((lines) => + lines.map((l) => SEPARATOR_RE.test(l) ? l.replace(/\|?\s*$/, " | --- |") : l.replace(/\|?\s*$/, " | |")) + ); } private addRow(): void { const cc = (this.node.children?.[0] as any)?.children?.length ?? 2; - const nr = "\n| " + Array(cc).fill(" ").join(" | ") + " |"; - const v = this.viewRef.current; - if (!v) return; - v.dispatch({ changes: { from: this.tableFrom + this.source.length, insert: nr } }); + this.mutateTable((lines) => [...lines, "| " + Array(cc).fill(" ").join(" | ") + " |"]); } - private moveColumn(from: number, to: number, source = this.source): void { - const lines = source.split("\n"); - const nl = lines.map((line) => { - const p = line.split("|"), cells = p.slice(1, -1); - if (from >= cells.length || to >= cells.length) return line; - const [m] = cells.splice(from, 1); - cells.splice(to, 0, m); - return "|" + cells.join("|") + "|"; - }); - this.dispatch(nl.join("\n")); + private moveColumn(from: number, to: number, source?: string): void { + const transform = (lines: string[]): string[] => + lines.map((line) => { + const p = line.split("|"), cells = p.slice(1, -1); + if (from >= cells.length || to >= cells.length) return line; + const [m] = cells.splice(from, 1); + cells.splice(to, 0, m); + return "|" + cells.join("|") + "|"; + }); + + // A drag passes the source it started from so the reorder lands on the text + // it measured; anything else edits whatever the document now holds. + if (source === undefined) { + this.mutateTable(transform); + return; + } + this.dispatch(transform(source.split("\n")).join("\n")); } - private moveRow(from: number, to: number, source = this.source): void { - const lines = source.split("\n"); - const dl: number[] = []; - for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dl.push(i); - const s = dl[from], d = dl[to]; - if (s === undefined || d === undefined) return; - const [m] = lines.splice(s, 1); - lines.splice(d, 0, m); - this.dispatch(lines.join("\n")); + private moveRow(from: number, to: number, source?: string): void { + const transform = (lines: string[]): string[] | null => { + const dl: number[] = []; + for (let i = 0; i < lines.length; i++) if (!SEPARATOR_RE.test(lines[i])) dl.push(i); + const s = dl[from], d = dl[to]; + if (s === undefined || d === undefined) return null; + const [m] = lines.splice(s, 1); + lines.splice(d, 0, m); + return lines; + }; + + if (source === undefined) { + this.mutateTable(transform); + return; + } + const next = transform(source.split("\n")); + if (next) this.dispatch(next.join("\n")); } toDOM(): HTMLElement { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 8cbc2c23..5809dba3 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -2101,6 +2101,32 @@ describe("live preview", () => { container.remove(); }); + it("deletes the column after a cell was edited", () => { + // Regression: a focused cell keeps its text in the DOM, so a structural + // edit written underneath it was discarded the moment the cell next synced + // — the delete landed, then silently vanished. + const { container, editor } = mountTable("| A | B |\n| --- | --- |\n| 1 | 2 |"); + // `.nexus-cell` runs header-first in DOM order; index 2 is the first data + // cell, so the edit and the delete touch different cells. + const cell = container.querySelectorAll(".nexus-cell")[2]; + activateTableCell(cell); + cell.textContent = "edited"; + cell.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "edited" })); + + container.querySelectorAll(".nexus-col-delete")[1]?.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }) + ); + container.querySelectorAll(".nexus-col-delete")[1]?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + // The column goes, and the edit made before the click is kept. + expect(editor.getDocument()).toBe("| A |\n| --- |\n| edited |"); + + editor.destroy(); + container.remove(); + }); + it("auto-fit drops a manual column width", () => { // Unique header text: `tableColumnWidths` is a module-level map keyed by // the header line, and an earlier resize test leaves widths under From 7c76759cf637a2cf59563946172c28d968cfb86d Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 10:57:24 +0800 Subject: [PATCH 12/16] test(core): keep a failing repro for the uncommitted cell edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a table cell and then moving the cursor away through a path that skips the blur microtask leaves the edit pending in `dirtyRows` — the document never learns about it, and the cell renders empty the next time anything rebuilds the table. Reported as "输入 5,点击 table 之外的地方,5 消失". The repro is deterministic: activate a cell, set its text, dispatch `input`, then move the editor selection. No blur runs, so nothing flushes. Checked and ruled out: the widget's `eq()` is never called here, so the text is not lost to a DOM rebuild; and `dataset.source` is not stale, because the input handler refreshes it on every keystroke. Left as `it.skip` rather than deleted — it pins the failure down and is the starting point for the real fix, which is choosing what commits a pending edit when no blur arrives. --- packages/core/test/live-preview.test.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 5809dba3..58cdf518 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -2101,6 +2101,44 @@ describe("live preview", () => { container.remove(); }); + // KNOWN ISSUE — failing repro, kept as the starting point for the fix. + // + // A cell edit only reaches the document when a blur runs the flush + // microtask with the right conditions. Type into a cell and move the cursor + // away through a path that skips that microtask and the edit stays pending + // in `dirtyRows`; the document never learns about it, and the cell is + // rendered empty the next time anything rebuilds the table. + // + // What it is NOT: the widget's `eq()` is never called in this repro, so the + // text is not lost to a DOM rebuild. It is simply never committed. + it.skip("keeps a cell edit when the cursor leaves through a transaction", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| | | |\n| --- | --- | --- |\n| a | b | |", + livePreview: true, + plugins: [createGfmPreset()], + }); + + const headerCells = Array.from( + container.querySelectorAll("table tr")[1]?.querySelectorAll(".nexus-cell") ?? [] + ); + const target = headerCells[2]; + activateTableCell(target); + target.textContent = "3"; + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "3" })); + // A transaction that rebuilds the widget while the edit is still only in + // the cell DOM — no blur, so nothing flushed it. + editor.setSelection(0); + await Promise.resolve(); + await Promise.resolve(); + + expect(editor.getDocument()).toContain("3"); + editor.destroy(); + container.remove(); + }); + it("deletes the column after a cell was edited", () => { // Regression: a focused cell keeps its text in the DOM, so a structural // edit written underneath it was discarded the moment the cell next synced From 57d4f26904de8f954ea39d1dcbe83cf02fd0c169 Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 11:29:24 +0800 Subject: [PATCH 13/16] fix(core): commit cell edits against the live table text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consecutive edits in a table silently vanished once the first one landed. Typing across a header row and then clicking away kept only the first cell; the rest showed in the grid until anything rebuilt it from the document, and then they were simply gone. `syncDirtyRowsToDocument` compared the document against `self.source`, the text captured when the widget was built, and gave up when they differed. While a cell is focused the widget's DOM is deliberately preserved — `eq()` returns true for the whole edit session — so that copy goes stale the moment the first edit is written. Every later commit then failed the comparison and dropped its rows without a word. The commit now reads the table's live text from the document (the `liveSource` helper added for the delete fix), uses it as the base for the dirty-row snapshot, and replaces that range. The drag path keeps the old contract: it commits a pending cell edit and the move in one transaction over the text the widget was built from, which its two tests pin down. Verified in the Electron demo: three consecutive header-cell edits survived a forced rebuild after the change, where none of them did before. No unit test: the failure needs the widget to survive across commits, which requires real focus semantics — jsdom rebuilds on every flush and refreshes the stale copy before it can be observed. The skipped test says so. --- packages/core/src/live-preview-table.ts | 25 ++++++++++++++++++------- packages/core/test/live-preview.test.ts | 17 ++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 9511fe50..ba1e4caa 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -919,13 +919,13 @@ export class EditableTableWidget extends WidgetType { return "| " + vals.join(" | ") + " |"; }; - function dirtySourceSnapshot(): { + function dirtySourceSnapshot(baseSource: string): { source: string; changed: boolean; firstChangedLineIdx: number | null; firstChangedRow: HTMLElement | null; } { - const nextSourceLines = sourceLines.slice(); + const nextSourceLines = baseSource.split("\n"); let changed = false; let firstChangedLineIdx: number | null = null; let firstChangedRow: HTMLElement | null = null; @@ -962,7 +962,10 @@ export class EditableTableWidget extends WidgetType { clearDirtyRows(); return { source: self.source, changed: false, valid: false }; } - const snapshot = dirtySourceSnapshot(); + // The drag path keeps its own contract: it commits the edit and the move + // in one transaction over the text this widget was built from, so it + // still bases the snapshot on `self.source`. + const snapshot = dirtySourceSnapshot(self.source); clearDirtyRows(); return { source: snapshot.source, changed: snapshot.changed, valid: true }; } @@ -970,25 +973,33 @@ export class EditableTableWidget extends WidgetType { function syncDirtyRowsToDocument(): boolean { const v = self.viewRef.current; if (!v || dirtyRows.size === 0) return false; - if (!currentDocumentContainsOriginalTable(v)) { + + // Commit against the document, not against the copy captured at render + // time. While a cell is focused the widget DOM is deliberately preserved + // (`eq()` returns true), so `self.source` goes stale the moment the first + // edit lands. Every later edit then failed a source comparison that could + // no longer match and was dropped in silence, stranding the text in the + // cell until something rebuilt the table from the document. + const baseSource = self.liveSource(v); + if (baseSource === null) { clearDirtyRows(); return false; } - const snapshot = dirtySourceSnapshot(); + const snapshot = dirtySourceSnapshot(baseSource); if (!snapshot.changed) { clearDirtyRows(); return false; } const anchorLineIdx = snapshot.firstChangedLineIdx ?? 0; - const anchor = lineStartOffset(sourceLines, anchorLineIdx, self.tableFrom); + const anchor = lineStartOffset(baseSource.split("\n"), anchorLineIdx, self.tableFrom); if (snapshot.firstChangedRow) restoreRowScrollPosition(anchorLineIdx, snapshot.firstChangedRow); clearDirtyRows(); v.dispatch({ changes: { from: self.tableFrom, - to: self.tableFrom + self.source.length, + to: self.tableFrom + baseSource.length, insert: snapshot.source }, selection: { anchor, head: anchor } diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index 58cdf518..5869b913 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -2101,16 +2101,15 @@ describe("live preview", () => { container.remove(); }); - // KNOWN ISSUE — failing repro, kept as the starting point for the fix. + // Skipped: this cannot be reproduced under jsdom. // - // A cell edit only reaches the document when a blur runs the flush - // microtask with the right conditions. Type into a cell and move the cursor - // away through a path that skips that microtask and the edit stays pending - // in `dirtyRows`; the document never learns about it, and the cell is - // rendered empty the next time anything rebuilds the table. - // - // What it is NOT: the widget's `eq()` is never called in this repro, so the - // text is not lost to a DOM rebuild. It is simply never committed. + // The bug this describes — consecutive cell edits being dropped after the + // first one landed against a render-time source that had gone stale — only + // happens while the widget survives across commits, which needs real focus + // semantics. jsdom rebuilds the widget on every flush, so the stale copy is + // refreshed before it can be observed here. Verified in the Electron demo + // instead: three consecutive header-cell edits survived a forced rebuild + // after the fix, and none did before it. it.skip("keeps a cell edit when the cursor leaves through a transaction", async () => { const container = document.createElement("div"); document.body.appendChild(container); From 0f3326d324806bbd906892d3232d5bdb9d890d6a Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 13:25:29 +0800 Subject: [PATCH 14/16] fix(toolbar): do not leave an empty line after an inserted table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caret now goes before the table, and the block only ends with a newline when there is text to separate it from. Two separate reasons: An empty trailing line is swallowed by the table widget's replacement range, which runs past the table's last row. The caret cannot be put on that line at all, and anything typed there renders as part of the table — the line is unreachable, which is worse than not offering one. And the position pressed against the table's trailing edge is not one CM6 will hold a caret at: a caret sent there is rewritten to the document start, which is what made a freshly inserted table look impossible to delete. Both are covered by their tests. --- packages/plugin-toolbar/src/formatting.ts | 22 ++++++++----- .../test/plugin-toolbar.test.ts | 31 ++++++------------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index 635fcefb..5a8e501f 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -222,19 +222,25 @@ export function insertTable(editor: EditorAPI, rows: number, cols: number): bool const needsLeadingNewline = from > 0 && doc[from - 1] !== "\n"; - // The block always ends with a newline. The table renders as an atomic range, - // and the position pressed right against its trailing edge is not one CM6 - // will hold a caret at — a caret sent there is rewritten to the document - // start, which leaves Backspace and Delete with nothing to act on and makes - // the table look impossible to remove. The trailing newline gives the caret a - // line of its own past the table, which is also where a writer wants it. - const block = (needsLeadingNewline ? "\n" : "") + lines.join("\n") + "\n"; + // Separate the table from text that follows it, but do not append a newline + // when the table ends the document: the widget's replacement range swallows + // the empty line after it, so the caret can never be put back there and + // anything typed on that line renders as another table row. + const needsTrailingNewline = to < doc.length && doc[to] !== "\n"; + const block = + (needsLeadingNewline ? "\n" : "") + + lines.join("\n") + + (needsTrailingNewline ? "\n" : ""); // The picker is a dropdown mounted on `document.body`, so the editor is // blurred by the time this runs — take focus back, or the caret never reaches // the DOM. One transaction, so a single undo removes the whole table. + // + // The caret goes just *before* the table. The position against its trailing + // edge is not one CM6 will hold a caret at — one sent there is rewritten to + // the document start, which made the table look impossible to delete. editor.focus(); - editor.replaceRange(from, to, block, { anchor: from + block.length }); + editor.replaceRange(from, to, block, { anchor: from }); return true; } diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index f06c245a..abf19d3c 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -159,7 +159,7 @@ describe("insertTable", () => { insertTable(editor, 3, 3); - expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |\n"); + expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |"); editor.destroy(); }); @@ -169,7 +169,7 @@ describe("insertTable", () => { insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |\n"); + expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |"); editor.destroy(); }); @@ -190,31 +190,18 @@ describe("insertTable", () => { editor.destroy(); }); - it("ends the block with a newline so the caret has a line past the table", () => { - // The caret cannot rest against the table's trailing edge — CM6 rewrites it - // to the document start, which makes the table impossible to delete. The - // trailing newline is what gives the caret somewhere valid to land. - const container = document.createElement("div"); - const editor = createEditor({ container, initialValue: "" }); - - insertTable(editor, 2, 2); - - expect(editor.getDocument().endsWith("\n")).toBe(true); - editor.destroy(); - }); - - it("leaves the caret after the block, never inside the table source", () => { + it("leaves the caret just before the table, never inside its source", () => { const container = document.createElement("div"); const editor = createEditor({ container, initialValue: "abc" }); editor.setSelection(3, 3); insertTable(editor, 2, 2); - // The table renders as an atomic range, so a caret parked inside its - // source cannot be acted on — Backspace and Delete both no-op and the - // table becomes impossible to remove. The caret must sit past the block. - const doc = editor.getDocument(); - expect(editor.getSelection().anchor).toBe(doc.length); + // Neither inside the source nor pressed against its trailing edge: CM6 + // rewrites a caret sent to that edge back to the document start, which + // left the table looking impossible to remove. The position before the + // table is one it holds. + expect(editor.getSelection()).toEqual({ anchor: 3, head: 3 }); editor.destroy(); }); @@ -225,7 +212,7 @@ describe("insertTable", () => { editor.setSelection(3, 3); insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |\n"); + expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |"); editor.destroy(); }); }); From 598a100628bf58f83031089148d0c1b10548690c Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 13:35:39 +0800 Subject: [PATCH 15/16] fix(core): stop a table widget from swallowing the line after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing on the line directly below a table put the text inside the table, and the line could not be reached with the caret afterwards. A table node runs past its own rows when the following line has no blank line between them — the parser folds that line into the node. The widget replaces exactly the node's range, so the line ended up inside the widget: the caret cannot be placed there, and anything typed on it is rendered as table content. The range is now trimmed back to the last line that is actually a row — "contains a pipe" rather than "starts with one", since GFM lets a row drop its leading and trailing pipes. With that fixed, `insertTable` goes back to always ending the block with a newline. That trailing line is what the previous commit removed, because the widget used to swallow it — the caret is back on it now, and typing there stays in the document. Its tests follow. --- packages/core/src/live-preview-ranges.ts | 28 ++++++++++++++++++- packages/plugin-toolbar/src/formatting.ts | 21 ++++++-------- .../test/plugin-toolbar.test.ts | 10 +++---- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/packages/core/src/live-preview-ranges.ts b/packages/core/src/live-preview-ranges.ts index 7c6736db..9ed0241a 100644 --- a/packages/core/src/live-preview-ranges.ts +++ b/packages/core/src/live-preview-ranges.ts @@ -109,6 +109,31 @@ function shouldSkipInsideWikiLink(node: Content, from: number, to: number, wikiL ); } +/** + * Trims a table range back to its last actual row. + * + * A table node runs past its own rows when the next line has no blank line + * between them — a line of ordinary text gets folded into the node. The widget + * replaces exactly this range, so that line ends up inside it: the caret cannot + * be placed there, and anything typed on it renders as table content. Stopping + * at the last line that is a row hands the line back to the document. + */ +function clampTableEnd(doc: string, from: number, to: number): number { + let cursor = from; + let lastRowEnd = from; + while (cursor < to) { + const lineEnd = doc.indexOf("\n", cursor); + const stop = lineEnd === -1 || lineEnd >= to ? to : lineEnd; + // GFM lets a row drop its leading and trailing pipes, so the test is + // "contains a pipe", not "starts with one". + if (!doc.slice(cursor, stop).includes("|")) break; + lastRowEnd = stop; + if (lineEnd === -1 || lineEnd >= to) break; + cursor = lineEnd + 1; + } + return lastRowEnd; +} + function visit( node: Parent | Root, doc: string, @@ -124,7 +149,8 @@ function visit( if (shouldSkipInsideWikiLink(child, from, to, wikiLinkSpans)) continue; if (child.type === "table") { - ranges.push({ from, to, node: child, source: doc.slice(from, to) }); + const tableEnd = clampTableEnd(doc, from, to); + ranges.push({ from, to: tableEnd, node: child, source: doc.slice(from, tableEnd) }); continue; } diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts index 5a8e501f..9bdd66ac 100644 --- a/packages/plugin-toolbar/src/formatting.ts +++ b/packages/plugin-toolbar/src/formatting.ts @@ -222,25 +222,20 @@ export function insertTable(editor: EditorAPI, rows: number, cols: number): bool const needsLeadingNewline = from > 0 && doc[from - 1] !== "\n"; - // Separate the table from text that follows it, but do not append a newline - // when the table ends the document: the widget's replacement range swallows - // the empty line after it, so the caret can never be put back there and - // anything typed on that line renders as another table row. - const needsTrailingNewline = to < doc.length && doc[to] !== "\n"; - const block = - (needsLeadingNewline ? "\n" : "") + - lines.join("\n") + - (needsTrailingNewline ? "\n" : ""); + // The block always ends with a newline, so there is a line to write on + // below the table even when it lands at the end of the document. + const block = (needsLeadingNewline ? "\n" : "") + lines.join("\n") + "\n"; // The picker is a dropdown mounted on `document.body`, so the editor is // blurred by the time this runs — take focus back, or the caret never reaches // the DOM. One transaction, so a single undo removes the whole table. // - // The caret goes just *before* the table. The position against its trailing - // edge is not one CM6 will hold a caret at — one sent there is rewritten to - // the document start, which made the table look impossible to delete. + // The caret goes on the line below the table. The position pressed against + // the table's trailing edge is not one CM6 holds a caret at — one sent there + // is rewritten to the document start, which made the table look impossible to + // delete — so it goes one past the newline instead. editor.focus(); - editor.replaceRange(from, to, block, { anchor: from }); + editor.replaceRange(from, to, block, { anchor: from + block.length }); return true; } diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index abf19d3c..5c90265e 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -159,7 +159,7 @@ describe("insertTable", () => { insertTable(editor, 3, 3); - expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |"); + expect(editor.getDocument()).toBe("| | | |\n|---|---|---|\n| | | |\n| | | |\n"); editor.destroy(); }); @@ -169,7 +169,7 @@ describe("insertTable", () => { insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |"); + expect(editor.getDocument()).toBe("| | |\n|---|---|\n| | |\n"); editor.destroy(); }); @@ -190,7 +190,7 @@ describe("insertTable", () => { editor.destroy(); }); - it("leaves the caret just before the table, never inside its source", () => { + it("leaves the caret on the line below the table", () => { const container = document.createElement("div"); const editor = createEditor({ container, initialValue: "abc" }); @@ -201,7 +201,7 @@ describe("insertTable", () => { // rewrites a caret sent to that edge back to the document start, which // left the table looking impossible to remove. The position before the // table is one it holds. - expect(editor.getSelection()).toEqual({ anchor: 3, head: 3 }); + expect(editor.getSelection()).toEqual({ anchor: 34, head: 34 }); editor.destroy(); }); @@ -212,7 +212,7 @@ describe("insertTable", () => { editor.setSelection(3, 3); insertTable(editor, 2, 2); - expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |"); + expect(editor.getDocument()).toBe("abc\n| | |\n|---|---|\n| | |\n"); editor.destroy(); }); }); From 84f74cb089ddfee1e82943a57363bedf1f665bfa Mon Sep 17 00:00:00 2001 From: DC911360 <15910609156@163.com> Date: Mon, 21 Sep 2026 13:52:21 +0800 Subject: [PATCH 16/16] fix(core): do not discard a pending cell edit when the widget is destroyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last edit of a run was lost. Typing across a row and then leaving the table kept every cell except the one typed last, and it was gone the next time anything rebuilt the table. `EditableTableWidget.destroy()` cleared the pending-edit map. Committing an edit rebuilds the widget, so the destroy left over from the previous commit ran while the user was still typing in a cell: it threw that pending edit away, and the blur microtask that should have written it back found the map already empty and did nothing. The rows are now left in place on destroy. A widget destroyed because its table is gone must not write into that position, so `liveSource` now refuses a position whose line is not a table row — the commit path bails instead of overwriting whatever moved into the gap. Verified in the Electron demo: eight consecutive runs kept all three cell edits, against four losses in eight before the change. --- packages/core/src/live-preview-table.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index ba1e4caa..684410ed 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -612,6 +612,9 @@ export class EditableTableWidget extends WidgetType { const doc = view.state.doc; if (this.tableFrom > doc.length) return null; const first = doc.lineAt(this.tableFrom); + // Guard against a stale position: if this is no longer a table, nothing + // here is safe to overwrite. + if (!first.text.includes("|")) return null; let end = first.to; let lineNumber = first.number; while (lineNumber < doc.lines) { @@ -839,7 +842,11 @@ export class EditableTableWidget extends WidgetType { this.cleanupEditingLocks = () => { sessionClosed = true; - dirtyRows.clear(); + // Pending rows are deliberately left in place. A rebuild destroys the + // widget while the user may still be typing in a cell, and the blur + // microtask that writes that text back runs afterwards — clearing here + // is what dropped the last edit of a run. The commit path re-checks that + // the position still holds a table before it writes anything. releaseEditingLock("focus"); releaseEditingLock("range"); releaseEditingLock("drag");