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..77c0ed63 100644
--- a/apps/electron-demo/src/renderer/style.css
+++ b/apps/electron-demo/src/renderer/style.css
@@ -343,3 +343,31 @@ 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; }
+
+/* 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);
+}
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..fa23eb64
--- /dev/null
+++ b/openspec/changes/add-slash-menu-reorder/proposal.md
@@ -0,0 +1,50 @@
+# 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.
+- 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
+
+- 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/**` or `packages/plugin-search/**`.
+- No change to the library default: the demo opts in, the package does not.
+- 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`
+ - `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/**`
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..72ff5ebb
--- /dev/null
+++ b/openspec/changes/add-slash-menu-reorder/tasks.md
@@ -0,0 +1,47 @@
+# 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/**` 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 - 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.
+- [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/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..edeac118
--- /dev/null
+++ b/openspec/changes/add-toolbar-table-insert/specs/plugin-toolbar/spec.md
@@ -0,0 +1,107 @@
+## 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| | | |\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| | |\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, 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 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| | |\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
+
+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/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-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/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts
index 29d9f7c4..684410ed 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,
@@ -592,6 +599,67 @@ 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);
+ // 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) {
+ 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;
@@ -599,65 +667,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 {
@@ -761,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");
@@ -841,13 +926,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;
@@ -884,7 +969,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 };
}
@@ -892,25 +980,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 }
@@ -1502,6 +1598,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 +1763,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 +1800,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 +1962,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 +2472,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 +2693,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..5869b913 100644
--- a/packages/core/test/live-preview.test.ts
+++ b/packages/core/test/live-preview.test.ts
@@ -2022,6 +2022,184 @@ 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();
+ });
+
+ // Skipped: this cannot be reproduced under jsdom.
+ //
+ // 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);
+ 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
+ // — 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
+ // `| 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 +2210,8 @@ describe("live preview", () => {
deleteRow: "删除行",
deleteColumn: "删除列",
insertRowBelow: "在下方插入行",
- insertColumnAfter: "在右侧插入列"
+ insertColumnAfter: "在右侧插入列",
+ autoFitWidth: "自适应宽度"
},
plugins: [createGfmPreset()]
});
@@ -2055,7 +2234,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();
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..7478a4dc 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,14 @@ 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;
+// 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 {
@@ -89,6 +116,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 +158,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 +192,20 @@ 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: {
+ 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 {
if (destroyed) return false;
@@ -180,10 +252,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 +291,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 +317,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 +333,199 @@ 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
+ * (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 before = measureRows();
+ 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);
+ playRowSlide(before);
+ 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 = {
+ el: item,
+ 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;
+ 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();
+ }
+
+ 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;
+ 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. */
+ 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 +566,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 +657,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 +688,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 +808,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..71ae04ff 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,476 @@ 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("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();
+ 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);
+ });
+});
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..9bdd66ac 100644
--- a/packages/plugin-toolbar/src/formatting.ts
+++ b/packages/plugin-toolbar/src/formatting.ts
@@ -196,6 +196,56 @@ 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";
+
+ // 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 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 + block.length });
+ 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 ab2d9cbb..3762e4f5 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(
- `` +
- ``
+ `` +
+ `` +
+ `` +
+ ``
);
}
@@ -174,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 a56ae862..464039c3 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";
@@ -89,6 +94,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 = () => {
@@ -181,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?.() },
],
@@ -252,6 +269,169 @@ const DROPDOWN_ITEM_STYLES = `
line-height: 1.5;
`;
+const TABLE_GRID_ROWS = 10;
+const TABLE_GRID_COLS = 10;
+
+/**
+ * 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,
@@ -441,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);
@@ -510,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 3fc97f53..5c90265e 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,133 @@ 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| | | |\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| | |\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("leaves the caret on the line below the table", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "abc" });
+
+ editor.setSelection(3, 3);
+ insertTable(editor, 2, 2);
+
+ // 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: 34, head: 34 });
+ 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| | |\n");
+ editor.destroy();
+ });
+});
+
describe("toggleHeading", () => {
it("adds heading prefix to current line", () => {
const container = document.createElement("div");
@@ -494,6 +623,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", () => {