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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions openspec/changes/add-inline-toolbar/proposal.md
Original file line number Diff line number Diff line change
@@ -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)
135 changes: 135 additions & 0 deletions openspec/changes/add-inline-toolbar/specs/plugins/spec.md
Original file line number Diff line number Diff line change
@@ -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`
20 changes: 20 additions & 0 deletions openspec/changes/add-inline-toolbar/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 53 additions & 51 deletions packages/plugin-toolbar/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading