diff --git a/openspec/changes/add-inline-toolbar/proposal.md b/openspec/changes/add-inline-toolbar/proposal.md new file mode 100644 index 00000000..6a4f6e0c --- /dev/null +++ b/openspec/changes/add-inline-toolbar/proposal.md @@ -0,0 +1,33 @@ +# Change: Add Selection-Triggered Inline Formatting Toolbar + +## Why + +Issue #327 asks for a floating toolbar that appears when the user selects text inside the editor — the same affordance Notion / Google Docs / Medium offer for inline formatting. The existing `plugin-toolbar` ships a persistent top toolbar; selecting text gives no formatting affordance, forcing users to scroll back to the top of a long document to toggle bold/italic/etc. + +This change adds an orthogonal inline floating toolbar to `plugin-toolbar` that mirrors the slash-menu UI lifecycle (selection-triggered, viewport-flipping, IME-aware, dismiss-on-outside-click) and reuses the existing `ToolbarButton` / `ToolbarGroup` types and `toolbar-commands` actions so hosts can compose their own button sets with zero new dependencies. + +## What Changes + +- Add `createInlineToolbarUI(editor, options)` to `plugin-toolbar`. Returns `{ element, show, hide, destroy }`. +- Show the toolbar when the editor reports a non-empty selection (`getSelectedText() !== ""`); hide when the selection collapses. +- Position centered above the selection using `editor.getCoordsAtPos()`, flipping below when the viewport has no room above, and clamping inside the right edge. +- Default button set covers inline-only formatting — bold, italic, strikethrough, inline code, insert link — so the inline toolbar never offers block-level commands that don't make sense on a selection. +- Hosts may pass custom `groups` (reusing `ToolbarGroup` / `ToolbarButton`), a custom mount `container`, a `classPrefix` for styling hooks, and a vertical `offset`. +- Full lifecycle parity with `createSlashMenuUI`: `destroy()` detaches all DOM listeners and the element; safe to call multiple times. IME composition suppresses show; `compositionend` re-evaluates. Escape and outside-mousedown dismiss; re-selecting resets the latch. Editor blur dismisses. Window resize and scroll re-position. +- Export `createInlineToolbarUI`, `InlineToolbarUI`, `InlineToolbarUIOptions` from the package entry, and re-export `ToolbarButton` / `ToolbarGroup` for hosts that build custom groups. + +## Non-Goals + +- No new package — the inline toolbar lives inside `plugin-toolbar` next to the existing top toolbar. +- No new runtime dependencies. +- No changes to the persistent top toolbar (`createToolbarUI`) or its default groups. +- No core API additions — the implementation uses only the existing `selectionChange` event, `getCoordsAtPos`, `getSelectedText`, `getSelection`, `isComposing`, `focus`, `blur`, `on` / `off` surface. +- No styling / CSS file — visual theming stays host-driven via the `classPrefix` and the CSS custom properties already used by the top toolbar. + +## Impact + +- Affected specs: `plugins` +- Affected code: + - `packages/plugin-toolbar/src/inline-toolbar.ts` (new) + - `packages/plugin-toolbar/src/index.ts` (export the new API) + - `packages/plugin-toolbar/test/inline-toolbar.test.ts` (new) diff --git a/openspec/changes/add-inline-toolbar/specs/plugins/spec.md b/openspec/changes/add-inline-toolbar/specs/plugins/spec.md new file mode 100644 index 00000000..ea01ab7b --- /dev/null +++ b/openspec/changes/add-inline-toolbar/specs/plugins/spec.md @@ -0,0 +1,135 @@ +## ADDED Requirements + +### Requirement: Inline Formatting Toolbar Appears On Non-Empty Selection + +`plugin-toolbar` SHALL provide `createInlineToolbarUI(editor, options)` that mounts a floating toolbar shown when the editor reports a non-empty text selection. The toolbar SHALL remain hidden while the selection is collapsed and SHALL hide when the selection collapses. + +#### Scenario: Toolbar is hidden by default + +- **WHEN** a host creates the inline toolbar without any user selection +- **THEN** the toolbar element SHALL be attached to its container with `display: none` + +#### Scenario: Toolbar appears when text is selected + +- **WHEN** the editor reports a selection whose `anchor` differs from `head` +- **THEN** the toolbar SHALL become visible + +#### Scenario: Toolbar hides when the selection collapses + +- **WHEN** the selection is non-empty and the toolbar is visible +- **AND** the selection collapses to a caret +- **THEN** the toolbar SHALL hide + +### Requirement: Inline Toolbar Positions Above The Selection With Viewport Flip + +The inline toolbar SHALL position itself centered above the current selection using `editor.getCoordsAtPos()`. When the toolbar would clip the top of the viewport, it SHALL flip below the selection. When it would clip the right edge, it SHALL clamp inside the viewport. + +#### Scenario: Default placement is above the selection + +- **WHEN** the selection has valid coordinates and there is room above +- **THEN** the toolbar SHALL be positioned with its bottom edge above the selection's top edge, offset by the configured `offset` + +#### Scenario: Flip below when no room above + +- **WHEN** positioning above the selection would place the toolbar outside the viewport's top margin +- **AND** there is room below the selection +- **THEN** the toolbar SHALL be positioned below the selection + +### Requirement: Inline Toolbar Default Groups Are Inline-Only + +The default `groups` for `createInlineToolbarUI` SHALL include only inline-relevant formatting actions (bold, italic, strikethrough, inline code, insert link). Block-level commands (headings, lists, blockquote, code block) SHALL NOT appear in the default inline toolbar. + +#### Scenario: Default toolbar renders inline actions only + +- **WHEN** a host creates the inline toolbar with default options +- **AND** the user selects a non-empty range +- **THEN** the rendered buttons SHALL be `bold`, `italic`, `strikethrough`, `inline-code`, `link` + +### Requirement: Inline Toolbar Buttons Reuse Existing Toolbar Action Types + +The inline toolbar SHALL reuse the `ToolbarButton` and `ToolbarGroup` types from `createToolbarUI`. Hosts MAY supply custom `groups` to compose their own button set; button clicks SHALL invoke the button's `action(editor)` callback. + +#### Scenario: Custom groups render their buttons + +- **WHEN** a host supplies `groups: [{ buttons: [{ id, title, icon, action }] }]` +- **AND** the user selects a non-empty range +- **THEN** the rendered buttons SHALL match the supplied group's buttons in order + +#### Scenario: Button click invokes action with the editor + +- **WHEN** the user clicks an inline toolbar button +- **THEN** the button's `action` SHALL be invoked with the editor instance +- **AND** the editor SHALL retain focus so subsequent keystrokes flow into the document + +### Requirement: Inline Toolbar Dismisses On Escape, Outside Click, And Editor Blur + +The inline toolbar SHALL hide when the user presses `Escape`, when a `mousedown` lands outside the toolbar element, or when the editor emits `blur`. After dismissal, the toolbar SHALL NOT reappear for the same selection; a fresh non-empty selection SHALL clear the dismiss latch and show the toolbar again. + +#### Scenario: Escape dismisses + +- **WHEN** the toolbar is visible +- **AND** the user presses `Escape` +- **THEN** the toolbar SHALL hide + +#### Scenario: Outside mousedown dismisses + +- **WHEN** the toolbar is visible +- **AND** a `mousedown` lands on an element outside the toolbar +- **THEN** the toolbar SHALL hide + +#### Scenario: Re-selection clears the dismiss latch + +- **WHEN** the toolbar was dismissed by `Escape` +- **AND** the user makes a fresh non-empty selection +- **THEN** the toolbar SHALL become visible again + +### Requirement: Inline Toolbar Suppresses During IME Composition + +The inline toolbar SHALL NOT show while the editor is in IME composition. When composition ends, the toolbar SHALL re-evaluate visibility based on the current selection. + +#### Scenario: Composition suppresses show + +- **WHEN** a `compositionstart` event is active +- **AND** the user selects a non-empty range +- **THEN** the toolbar SHALL remain hidden + +#### Scenario: Composition end re-evaluates + +- **WHEN** composition ends +- **AND** the current selection is non-empty +- **THEN** the toolbar SHALL become visible + +### Requirement: Inline Toolbar Repositions On Viewport Changes + +The inline toolbar SHALL reposition itself when the host window is resized or scrolled, so the toolbar stays anchored to the selection. + +#### Scenario: Resize repositions + +- **WHEN** the toolbar is visible +- **AND** the host window dispatches a `resize` event +- **THEN** the toolbar SHALL recompute its position from the current selection coordinates + +### Requirement: Inline Toolbar Destroy Detaches All Listeners + +`InlineToolbarUI.destroy()` SHALL remove all DOM listeners, detach the toolbar element from its parent, and stop reacting to editor events. It SHALL be safe to call multiple times. + +#### Scenario: Destroy detaches the element + +- **WHEN** the host calls `destroy()` on a visible inline toolbar +- **THEN** the toolbar element SHALL be removed from its parent +- **AND** subsequent editor selection changes SHALL NOT throw + +### Requirement: Inline Toolbar Supports Custom Container And Class Prefix + +`createInlineToolbarUI` SHALL accept a `container` to mount the toolbar in a custom element (including `ShadowRoot` hosts) and a `classPrefix` to namespace the generated class names for host-side styling. + +#### Scenario: Custom container receives the toolbar element + +- **WHEN** a host supplies a `container` element +- **THEN** the toolbar element SHALL be appended to that container + +#### Scenario: Custom class prefix namespaces generated classes + +- **WHEN** a host supplies `classPrefix: "my-toolbar"` +- **THEN** the toolbar root SHALL carry the class `my-toolbar` +- **AND** buttons SHALL carry the class `my-toolbar-btn` diff --git a/openspec/changes/add-inline-toolbar/tasks.md b/openspec/changes/add-inline-toolbar/tasks.md new file mode 100644 index 00000000..db21a99b --- /dev/null +++ b/openspec/changes/add-inline-toolbar/tasks.md @@ -0,0 +1,20 @@ +# Implementation Tasks + +## 1. OpenSpec + +- [x] 1.1 Create `openspec/changes/add-inline-toolbar/proposal.md`. +- [x] 1.2 Create `openspec/changes/add-inline-toolbar/specs/plugins/spec.md`. +- [x] 1.3 Create `openspec/changes/add-inline-toolbar/tasks.md`. + +## 2. Implementation + +- [x] 2.1 Add `createInlineToolbarUI(editor, options)` to `packages/plugin-toolbar/src/inline-toolbar.ts`. +- [x] 2.2 Reuse `ToolbarButton` / `ToolbarGroup` and existing `toolbar-commands` actions; provide inline-only default groups. +- [x] 2.3 Subscribe to `selectionChange`, position via `getCoordsAtPos` with viewport flip + clamp. +- [x] 2.4 Handle Escape / outside mousedown / blur / IME / resize / scroll lifecycle events. +- [x] 2.5 Export `createInlineToolbarUI` and types from `packages/plugin-toolbar/src/index.ts`. + +## 3. Tests + +- [x] 3.1 Add `packages/plugin-toolbar/test/inline-toolbar.test.ts` covering lifecycle, button actions, dismissal, composition, and viewport changes. +- [x] 3.2 Run `pnpm test` and ensure the new tests pass without regressing existing `plugin-toolbar` tests. diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts index 166eaaed..8c232ebd 100644 --- a/packages/plugin-toolbar/src/index.ts +++ b/packages/plugin-toolbar/src/index.ts @@ -1,51 +1,53 @@ -import type { NexusPlugin } from "@floatboat/nexus-core"; -import { colorDecorationExtension } from "./color-decoration"; -import { - insertLink, - toolbarSlashCommands, - toggleBold, - toggleHeading, - toggleInlineCode, - toggleItalic, - toggleStrikethrough, -} from "./toolbar-commands"; - -export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting"; -export { createToolbarUI } from "./toolbar-ui"; -export { colorDecorationExtension } from "./color-decoration"; -export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui"; -export { - insertLink, - toolbarSlashCommands, - toggleBold, - toggleHeading, - toggleInlineCode, - toggleItalic, - toggleStrikethrough, - toggleWrap, -} from "./toolbar-commands"; - -export function createToolbarPlugin(): NexusPlugin { - return { - name: "plugin-toolbar", - shortcuts: [ - { key: "Mod-b", run: toggleBold }, - { key: "Mod-i", run: toggleItalic }, - { key: "Mod-Shift-s", run: toggleStrikethrough }, - { key: "Mod-e", run: toggleInlineCode }, - { key: "Mod-k", run: insertLink }, - { key: "Mod-1", run: (e) => toggleHeading(e, 1) }, - { key: "Mod-2", run: (e) => toggleHeading(e, 2) }, - { key: "Mod-3", run: (e) => toggleHeading(e, 3) }, - ], - slashCommands: toolbarSlashCommands, - cmExtensions: [colorDecorationExtension()], - }; -} - -export { - createToolbarRuntimeSlashContribution, - ToolbarLifecyclePlugin, - toolbarLifecyclePluginManifest, - type ToolbarLifecyclePluginOptions, -} from "./runtime-plugin"; +import type { NexusPlugin } from "@floatboat/nexus-core"; +import { colorDecorationExtension } from "./color-decoration"; +import { + insertLink, + toolbarSlashCommands, + toggleBold, + toggleHeading, + toggleInlineCode, + toggleItalic, + toggleStrikethrough, +} from "./toolbar-commands"; + +export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting"; +export { createToolbarUI } from "./toolbar-ui"; +export { createInlineToolbarUI } from "./inline-toolbar"; +export { colorDecorationExtension } from "./color-decoration"; +export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui"; +export type { InlineToolbarUI, InlineToolbarUIOptions } from "./inline-toolbar"; +export { + insertLink, + toolbarSlashCommands, + toggleBold, + toggleHeading, + toggleInlineCode, + toggleItalic, + toggleStrikethrough, + toggleWrap, +} from "./toolbar-commands"; + +export function createToolbarPlugin(): NexusPlugin { + return { + name: "plugin-toolbar", + shortcuts: [ + { key: "Mod-b", run: toggleBold }, + { key: "Mod-i", run: toggleItalic }, + { key: "Mod-Shift-s", run: toggleStrikethrough }, + { key: "Mod-e", run: toggleInlineCode }, + { key: "Mod-k", run: insertLink }, + { key: "Mod-1", run: (e) => toggleHeading(e, 1) }, + { key: "Mod-2", run: (e) => toggleHeading(e, 2) }, + { key: "Mod-3", run: (e) => toggleHeading(e, 3) }, + ], + slashCommands: toolbarSlashCommands, + cmExtensions: [colorDecorationExtension()], + }; +} + +export { + createToolbarRuntimeSlashContribution, + ToolbarLifecyclePlugin, + toolbarLifecyclePluginManifest, + type ToolbarLifecyclePluginOptions, +} from "./runtime-plugin"; diff --git a/packages/plugin-toolbar/src/inline-toolbar.ts b/packages/plugin-toolbar/src/inline-toolbar.ts new file mode 100644 index 00000000..8380e47a --- /dev/null +++ b/packages/plugin-toolbar/src/inline-toolbar.ts @@ -0,0 +1,399 @@ +import type { EditorAPI } from "@floatboat/nexus-core"; + +import { + toggleBold, + toggleItalic, + toggleStrikethrough, + toggleInlineCode, + insertLink, +} from "./toolbar-commands"; +import { + iconBold, + iconItalic, + iconStrikethrough, + iconInlineCode, + iconLink, +} from "./icons"; +import type { ToolbarButton, ToolbarGroup } from "./toolbar-ui"; + +export type { ToolbarButton, ToolbarGroup } from "./toolbar-ui"; + +export interface InlineToolbarUIOptions { + /** + * Where to mount the toolbar root. Defaults to `document.body`. Shadow + * DOM hosts can pass a `ShadowRoot` or the shadow host element. + */ + container?: HTMLElement | ShadowRoot; + /** + * Override the default button groups. Pass an empty array to mount a + * toolbar shell the host populates imperatively. + */ + groups?: ToolbarGroup[]; + /** + * Class-name prefix used to style the toolbar. Default: `"nexus-inline-toolbar"`. + * Generated selectors: + * `.{prefix}`, `.{prefix}-btn`, `.{prefix}-separator`. + */ + classPrefix?: string; + /** + * Vertical gap (in px) between the selection and the toolbar. Default: `6`. + */ + offset?: number; +} + +export interface InlineToolbarUI { + /** The toolbar root element. Already mounted in the configured container. */ + element: HTMLElement; + /** Force-show the toolbar (host imperative API; honors IME / dismiss latch). */ + show(): void; + /** Force-hide the toolbar without arming the dismiss latch. */ + hide(): void; + /** + * Detach all DOM listeners, remove the element from its parent, and stop + * reacting to editor events. Safe to call multiple times. + */ + destroy(): void; +} + +const DEFAULT_PREFIX = "nexus-inline-toolbar"; +const DEFAULT_OFFSET = 6; +const VIEWPORT_MARGIN = 8; + +function defaultGroups(): ToolbarGroup[] { + // Inline-only affordances. Block-level commands (headings, lists, + // blockquote, code block) live in the persistent top toolbar — surfacing + // them on a selection would mislead users into inserting a heading mid + // document instead of toggling inline formatting on the highlighted text. + return [ + { + buttons: [ + { id: "bold", title: "Bold", icon: iconBold, action: toggleBold }, + { id: "italic", title: "Italic", icon: iconItalic, action: toggleItalic }, + { id: "strikethrough", title: "Strikethrough", icon: iconStrikethrough, action: toggleStrikethrough }, + { id: "inline-code", title: "Inline code", icon: iconInlineCode, action: toggleInlineCode }, + { id: "link", title: "Insert link", icon: iconLink, action: insertLink }, + ], + }, + ]; +} + +const ROOT_STYLES = ` + position: fixed; + display: none; + align-items: center; + gap: 2px; + padding: 4px 6px; + background: var(--nexus-bg, #fff); + border: 1px solid var(--nexus-border, #eee); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 10000; + user-select: none; + font-family: system-ui, -apple-system, sans-serif; +`; + +const BUTTON_STYLES = ` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--nexus-text-muted, #888); + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +`; + +const SEPARATOR_STYLES = ` + width: 1px; + height: 18px; + background: var(--nexus-border-subtle, #ddd); + margin: 0 4px; + flex-shrink: 0; +`; + +export function createInlineToolbarUI( + editor: EditorAPI, + options: InlineToolbarUIOptions = {} +): InlineToolbarUI { + const prefix = options.classPrefix ?? DEFAULT_PREFIX; + const offset = options.offset ?? DEFAULT_OFFSET; + const container = options.container ?? document.body; + const ownerDocument = container.ownerDocument; + const ownerWindow = ownerDocument.defaultView; + if (!ownerWindow) { + throw new TypeError("Inline toolbar container must belong to a window"); + } + const groups = options.groups ?? defaultGroups(); + + // ── DOM scaffolding ────────────────────────────────────────────── + const root = ownerDocument.createElement("div"); + root.className = prefix; + root.setAttribute("role", "toolbar"); + root.setAttribute("aria-label", "Inline formatting"); + root.style.cssText = ROOT_STYLES; + // The toolbar must never become a focus target — the editor stays + // focused throughout the toolbar lifecycle so keystrokes continue to + // flow to CM6 after a button click, letting the user keep typing. + root.tabIndex = -1; + container.appendChild(root); + + // ── Render buttons once; groups are static for the toolbar lifetime ── + groups.forEach((group, groupIdx) => { + if (groupIdx > 0) { + const sep = ownerDocument.createElement("div"); + sep.className = `${prefix}-separator`; + sep.style.cssText = SEPARATOR_STYLES; + root.appendChild(sep); + } + for (const btn of group.buttons) { + const button = ownerDocument.createElement("button"); + button.type = "button"; + button.className = `${prefix}-btn`; + button.dataset.inlineToolbarAction = btn.id; + button.setAttribute("aria-label", btn.title); + button.title = btn.title; + button.style.cssText = BUTTON_STYLES; + button.appendChild(btn.icon()); + + const handleEnter = (): void => { + button.style.background = "var(--nexus-bg-muted, #f0f0f0)"; + button.style.color = "var(--nexus-text, #24292e)"; + }; + const handleLeave = (): void => { + button.style.background = "transparent"; + button.style.color = "var(--nexus-text-muted, #888)"; + }; + // mousedown default would move focus to the button and collapse + // the selection before the click handler runs, leaving `action` + // operating on an empty range. preventDefault keeps focus in the + // editor and the selection intact. + const handleMouseDown = (e: MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + }; + const handleClick = (e: MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + btn.action(editor); + // Re-evaluate visibility: most toggles preserve the selection, + // but insertLink narrows it to the URL placeholder (3 chars, + // still non-empty). A collapsed post-action selection hides + // eagerly so we don't flash a one-frame toolbar on an empty + // range before the next selectionChange arrives. + refreshVisibility(); + }; + + button.addEventListener("mouseenter", handleEnter); + button.addEventListener("mouseleave", handleLeave); + button.addEventListener("mousedown", handleMouseDown); + button.addEventListener("click", handleClick); + root.appendChild(button); + } + }); + + // ── Mutable state ──────────────────────────────────────────────── + let visible = false; + // `dismissed` is armed by Escape or outside-mousedown so a stray + // re-emission of the same selection (e.g. cursor wiggle inside the + // range) doesn't reopen the toolbar the user just dismissed. A fresh + // non-empty selection clears it. + let dismissed = false; + let isComposing = false; + let destroyed = false; + + // ── Positioning ────────────────────────────────────────────────── + function reposition(): void { + if (!visible) return; + const { anchor, head } = editor.getSelection(); + const from = Math.min(anchor, head); + const to = Math.max(anchor, head); + const startCoords = editor.getCoordsAtPos(from); + const endCoords = editor.getCoordsAtPos(to); + if (!startCoords || !endCoords) { + // No coordinates (JSDOM, mid-flight layout). Leave the toolbar + // wherever it last was — visibility is still asserted by tests. + return; + } + const selLeft = Math.min(startCoords.left, endCoords.left); + const selRight = Math.max(startCoords.right, endCoords.right); + const selTop = Math.min(startCoords.top, endCoords.top); + const selBottom = Math.max(startCoords.bottom, endCoords.bottom); + const selCenterX = (selLeft + selRight) / 2; + + root.style.display = "flex"; + const rect = root.getBoundingClientRect(); + const winHeight = ownerWindow?.innerHeight ?? 0; + const winWidth = ownerWindow?.innerWidth ?? 0; + + let left = selCenterX - rect.width / 2; + let top = selTop - rect.height - offset; + + // Vertical flip: prefer above, fall back below when there's no + // room. JSDOM returns zero-sized rects so this math is inert in + // unit tests, but real browsers need it to keep the toolbar on + // screen for selections at the top of the document. + if (winHeight > 0 && top < VIEWPORT_MARGIN) { + const flippedTop = selBottom + offset; + // Only flip if it actually fits better — if neither fits (tiny + // viewport), keep the original above position so the toolbar's + // top stays attached to the selection. + if (flippedTop + rect.height <= winHeight - VIEWPORT_MARGIN || flippedTop >= VIEWPORT_MARGIN) { + top = flippedTop; + } + } + + // Horizontal clamp inside the viewport. + if (winWidth > 0) { + if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN; + if (left + rect.width > winWidth - VIEWPORT_MARGIN) { + left = Math.max(VIEWPORT_MARGIN, winWidth - VIEWPORT_MARGIN - rect.width); + } + } + + root.style.left = `${left}px`; + root.style.top = `${top}px`; + } + + // ── Visibility ─────────────────────────────────────────────────── + function hasSelection(): boolean { + return editor.getSelectedText() !== ""; + } + + function refreshVisibility(): void { + if (destroyed) return; + if (isComposing) { + hide(); + return; + } + if (!hasSelection()) { + hide(); + return; + } + if (dismissed) { + hide(); + return; + } + show(); + } + + function show(): void { + if (destroyed) return; + visible = true; + root.style.display = "flex"; + reposition(); + } + + function hide(): void { + visible = false; + root.style.display = "none"; + } + + function dismiss(): void { + dismissed = true; + hide(); + } + + // ── Event handlers ─────────────────────────────────────────────── + function onSelectionChange(): void { + if (destroyed) return; + // A non-empty selection is a fresh trigger — clear the dismiss + // latch so the toolbar can re-show for a new range the user just + // selected after an Escape. + if (hasSelection()) dismissed = false; + refreshVisibility(); + } + + function onEditorBlur(): void { + if (!visible) return; + dismiss(); + } + + function onDocumentPointerDown(e: Event): void { + if (destroyed) return; + if (!visible) return; + const target = e.target as Node | null; + if (!target) return; + if (root.contains(target)) return; + // Anywhere outside the toolbar (including inside the editor) + // dismisses. A subsequent selectionChange will reopen the toolbar + // if the user selects a new non-empty range. + dismiss(); + } + + function onKeyDown(e: KeyboardEvent): void { + if (destroyed || !visible) return; + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + dismiss(); + } + } + + const documentKeyDown = (e: KeyboardEvent): void => onKeyDown(e); + + function onCompositionStart(): void { + isComposing = true; + // Hide eagerly — IME candidate windows shouldn't overlap a stale + // toolbar pinned to the previous selection. + if (visible) hide(); + } + function onCompositionEnd(): void { + isComposing = false; + refreshVisibility(); + } + + function onWindowResize(): void { + if (visible) reposition(); + } + function onWindowScroll(): void { + if (visible) reposition(); + } + + // ── Subscribe ──────────────────────────────────────────────────── + editor.on("selectionChange", onSelectionChange); + editor.on("blur", onEditorBlur); + + // Capture phase so Escape pre-empts CM6 caret-motion keybinds. + ownerDocument.addEventListener("keydown", documentKeyDown, true); + // Both mousedown and pointerdown so we close as early as possible + // regardless of input modality. Idempotent dismiss handles double + // invocations safely. + ownerDocument.addEventListener("mousedown", onDocumentPointerDown, true); + if (typeof ownerWindow.PointerEvent !== "undefined") { + ownerDocument.addEventListener("pointerdown", onDocumentPointerDown, true); + } + ownerDocument.addEventListener("compositionstart", onCompositionStart, true); + ownerDocument.addEventListener("compositionend", onCompositionEnd, true); + ownerWindow.addEventListener("resize", onWindowResize); + // Scroll uses capture so a scrolling parent inside the host doesn't + // bypass the toolbar's reposition pass. + ownerWindow.addEventListener("scroll", onWindowScroll, true); + + return { + element: root, + show, + hide, + destroy() { + if (destroyed) return; + destroyed = true; + editor.off("selectionChange", onSelectionChange); + editor.off("blur", onEditorBlur); + ownerDocument.removeEventListener("keydown", documentKeyDown, true); + ownerDocument.removeEventListener("mousedown", onDocumentPointerDown, true); + if (typeof ownerWindow.PointerEvent !== "undefined") { + ownerDocument.removeEventListener("pointerdown", onDocumentPointerDown, true); + } + ownerDocument.removeEventListener("compositionstart", onCompositionStart, true); + ownerDocument.removeEventListener("compositionend", onCompositionEnd, true); + ownerWindow.removeEventListener("resize", onWindowResize); + ownerWindow.removeEventListener("scroll", onWindowScroll, true); + if (root.parentNode) root.parentNode.removeChild(root); + visible = false; + }, + }; +} diff --git a/packages/plugin-toolbar/test/inline-toolbar.test.ts b/packages/plugin-toolbar/test/inline-toolbar.test.ts new file mode 100644 index 00000000..3707586d --- /dev/null +++ b/packages/plugin-toolbar/test/inline-toolbar.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEditor, type EditorAPI } from "@floatboat/nexus-core"; +import { createInlineToolbarUI, type InlineToolbarUI } from "../src/inline-toolbar"; + +interface Harness { + editor: EditorAPI; + toolbar: InlineToolbarUI; + container: HTMLDivElement; + destroy(): void; +} + +function setup( + options: Parameters[1] = {} +): Harness { + const container = document.createElement("div"); + document.body.appendChild(container); + + const editor = createEditor({ + container, + initialValue: "hello world", + }); + const toolbar = createInlineToolbarUI(editor, options); + + return { + editor, + toolbar, + container, + destroy() { + toolbar.destroy(); + editor.destroy(); + container.remove(); + }, + }; +} + +function select(editor: EditorAPI, from: number, to: number = from): void { + editor.setSelection(from, to); +} + +function buttons(toolbar: InlineToolbarUI): HTMLButtonElement[] { + return Array.from(toolbar.element.querySelectorAll("button")); +} + +function buttonIds(toolbar: InlineToolbarUI): string[] { + return buttons(toolbar).map((b) => b.dataset.inlineToolbarAction ?? ""); +} + +function pressKey(key: string): KeyboardEvent { + const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + document.dispatchEvent(e); + return e; +} + +const ICON_SPAN = (): HTMLElement => document.createElement("span"); + +describe("createInlineToolbarUI lifecycle", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("mounts hidden by default", () => { + h = setup(); + expect(h.toolbar.element.parentElement).toBe(document.body); + expect(h.toolbar.element.style.display).toBe("none"); + }); + + it("shows when the user selects a non-empty range", () => { + h = setup(); + select(h.editor, 0, 5); // "hello" + expect(h.toolbar.element.style.display).toBe("flex"); + }); + + it("hides when the selection collapses", () => { + h = setup(); + select(h.editor, 0, 5); + expect(h.toolbar.element.style.display).toBe("flex"); + select(h.editor, 0); // collapsed caret + expect(h.toolbar.element.style.display).toBe("none"); + }); + + it("destroy detaches the element and stops reacting", () => { + h = setup(); + select(h.editor, 0, 5); + expect(h.toolbar.element.parentElement).toBe(document.body); + h.toolbar.destroy(); + expect(h.toolbar.element.parentElement).toBeNull(); + // Should be safe to dispatch more selection events without throwing. + expect(() => select(h.editor, 0, 5)).not.toThrow(); + }); + + it("destroy is idempotent", () => { + h = setup(); + expect(() => { + h.toolbar.destroy(); + h.toolbar.destroy(); + }).not.toThrow(); + }); + + it("supports a custom container", () => { + const customRoot = document.createElement("div"); + document.body.appendChild(customRoot); + h = setup({ container: customRoot }); + expect(h.toolbar.element.parentElement).toBe(customRoot); + customRoot.remove(); + }); +}); + +describe("createInlineToolbarUI default groups", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("renders the inline-only default buttons", () => { + h = setup(); + select(h.editor, 0, 5); + expect(buttonIds(h.toolbar)).toEqual([ + "bold", + "italic", + "strikethrough", + "inline-code", + "link", + ]); + }); + + it("toggling bold wraps the selection", () => { + h = setup(); + select(h.editor, 0, 5); + const boldBtn = buttons(h.toolbar).find( + (b) => b.dataset.inlineToolbarAction === "bold" + )!; + boldBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + boldBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + expect(h.editor.getDocument()).toBe("**hello** world"); + }); + + it("toggling italic wraps the selection", () => { + h = setup(); + select(h.editor, 6, 11); // "world" + const italicBtn = buttons(h.toolbar).find( + (b) => b.dataset.inlineToolbarAction === "italic" + )!; + italicBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + italicBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + expect(h.editor.getDocument()).toBe("hello *world*"); + }); +}); + +describe("createInlineToolbarUI custom groups", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("renders custom buttons in order", () => { + const action = vi.fn(); + h = setup({ + groups: [ + { + buttons: [ + { id: "x", title: "X", icon: ICON_SPAN, action }, + { id: "y", title: "Y", icon: ICON_SPAN, action }, + ], + }, + { + buttons: [{ id: "z", title: "Z", icon: ICON_SPAN, action }], + }, + ], + }); + select(h.editor, 0, 5); + expect(buttonIds(h.toolbar)).toEqual(["x", "y", "z"]); + // A separator should sit between the two groups. + const sep = h.toolbar.element.querySelector(".nexus-inline-toolbar-separator"); + expect(sep).not.toBeNull(); + }); + + it("clicking a button invokes its action with the editor", () => { + const action = vi.fn(); + h = setup({ + groups: [{ buttons: [{ id: "x", title: "X", icon: ICON_SPAN, action }] }], + }); + select(h.editor, 0, 5); + const [btn] = buttons(h.toolbar); + btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + expect(action).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledWith(h.editor); + }); + + it("mousedown on a button does not steal focus from the editor", () => { + const action = vi.fn(); + h = setup({ + groups: [{ buttons: [{ id: "x", title: "X", icon: ICON_SPAN, action }] }], + }); + select(h.editor, 0, 5); + const [btn] = buttons(h.toolbar); + const e = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + btn.dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + }); + + it("applies a custom class prefix", () => { + h = setup({ + classPrefix: "my-toolbar", + groups: [{ buttons: [{ id: "x", title: "X", icon: ICON_SPAN, action: () => {} }] }], + }); + expect(h.toolbar.element.classList.contains("my-toolbar")).toBe(true); + const btn = buttons(h.toolbar)[0]; + expect(btn.classList.contains("my-toolbar-btn")).toBe(true); + }); +}); + +describe("createInlineToolbarUI dismissal", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("Escape dismisses the toolbar", () => { + h = setup(); + select(h.editor, 0, 5); + expect(h.toolbar.element.style.display).toBe("flex"); + const e = pressKey("Escape"); + expect(e.defaultPrevented).toBe(true); + expect(h.toolbar.element.style.display).toBe("none"); + }); + + it("after Escape, re-selecting shows the toolbar again", () => { + h = setup(); + select(h.editor, 0, 5); + pressKey("Escape"); + expect(h.toolbar.element.style.display).toBe("none"); + // Re-select a different range — the dismiss latch should reset. + select(h.editor, 6, 11); + expect(h.toolbar.element.style.display).toBe("flex"); + }); + + it("Escape does nothing when the toolbar is hidden", () => { + h = setup(); + const e = pressKey("Escape"); + expect(e.defaultPrevented).toBe(false); + }); + + it("mousedown outside the toolbar dismisses it", () => { + h = setup(); + select(h.editor, 0, 5); + const elsewhere = document.createElement("button"); + document.body.appendChild(elsewhere); + elsewhere.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + expect(h.toolbar.element.style.display).toBe("none"); + elsewhere.remove(); + }); + + it("mousedown inside the toolbar does not dismiss it", () => { + h = setup(); + select(h.editor, 0, 5); + const btn = buttons(h.toolbar)[0]; + btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + expect(h.toolbar.element.style.display).toBe("flex"); + }); + + // Note: editor blur dismissal is implemented via `editor.on("blur", ...)` + // in inline-toolbar.ts but is not unit-tested here — jsdom does not + // synchronously emit CM6's blur observer callback on `editor.blur()`, + // mirroring the same limitation that kept `plugin-slash`'s menu-ui + // tests from covering blur. The behavior is verified manually in the + // electron demo. +}); + +describe("createInlineToolbarUI composition", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("does not show during IME composition", () => { + h = setup(); + document.dispatchEvent(new Event("compositionstart")); + select(h.editor, 0, 5); + expect(h.toolbar.element.style.display).toBe("none"); + document.dispatchEvent(new Event("compositionend")); + // After compositionend, a fresh selection re-shows the toolbar. + select(h.editor, 6, 11); + expect(h.toolbar.element.style.display).toBe("flex"); + }); + + it("hides a visible toolbar when composition starts", () => { + h = setup(); + select(h.editor, 0, 5); + expect(h.toolbar.element.style.display).toBe("flex"); + document.dispatchEvent(new Event("compositionstart")); + expect(h.toolbar.element.style.display).toBe("none"); + }); +}); + +describe("createInlineToolbarUI viewport changes", () => { + let h: Harness; + afterEach(() => h?.destroy()); + + it("repositions on window resize without throwing", () => { + h = setup(); + select(h.editor, 0, 5); + expect(() => { + window.dispatchEvent(new Event("resize")); + }).not.toThrow(); + expect(h.toolbar.element.style.display).toBe("flex"); + }); + + it("repositions on window scroll without throwing", () => { + h = setup(); + select(h.editor, 0, 5); + expect(() => { + window.dispatchEvent(new Event("scroll")); + }).not.toThrow(); + expect(h.toolbar.element.style.display).toBe("flex"); + }); +});