Skip to content

feat: headless toolbar (caio)#2669

Merged
caio-pizzol merged 15 commits intoartem/headless-toolbarfrom
caio-pizzol/headless-toolbar-dx-fixes
Apr 1, 2026
Merged

feat: headless toolbar (caio)#2669
caio-pizzol merged 15 commits intoartem/headless-toolbarfrom
caio-pizzol/headless-toolbar-dx-fixes

Conversation

@caio-pizzol
Copy link
Copy Markdown
Contributor

No description provided.

@caio-pizzol caio-pizzol changed the base branch from main to artem/headless-toolbar April 1, 2026 13:18
@chatgpt-codex-connector
Copy link
Copy Markdown

πŸ’‘ Codex Review

value: superdoc?.config?.documentMode ?? 'editing',

P2 Badge Derive document-mode from effective editor mode

createDocumentModeStateDeriver uses superdoc.config.documentMode, which can diverge from the actual active mode when setDocumentMode() coerces by role (for example, a viewer request to switch to editing is redirected but the config value is still the requested mode). In those cases the headless snapshot reports the wrong mode and downstream toolbars can show/select a mode that is not actually active; derive this from the active editor/presentation editor state (or a canonical emitted mode) instead of raw config.


"./headless-toolbar": {
"types": "./dist/superdoc/src/headless-toolbar.d.ts",
"source": "./src/headless-toolbar.js",
"import": "./dist/headless-toolbar.es.js"
},

P2 Badge Add CJS export for headless-toolbar subpath

The new ./headless-toolbar subpath only exposes an import target, so CommonJS consumers cannot load it (require('superdoc/headless-toolbar') will fail with package exports resolution errors) even though this package already supports CJS on sibling subpaths and emits CJS bundles. This makes the feature unusable in CJS integrations that currently work with other SuperDoc subpath exports.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@artem-harbour artem-harbour force-pushed the artem/headless-toolbar branch 2 times, most recently from 0db926c to ee0494d Compare April 1, 2026 13:33
@caio-pizzol caio-pizzol force-pushed the caio-pizzol/headless-toolbar-dx-fixes branch from 3ed9cfb to 4f0a675 Compare April 1, 2026 14:27
@caio-pizzol caio-pizzol changed the base branch from artem/headless-toolbar to main April 1, 2026 16:17
@caio-pizzol caio-pizzol changed the base branch from main to artem/headless-toolbar April 1, 2026 16:17
artem-harbour and others added 8 commits April 1, 2026 13:20
Correctness:
- Use resolveStateEditor for undo/redo history depth (fixes header/footer)
- Remove early return gating on color/highlight annotation sync
- Subscribe to zoomChange event for immediate zoom state updates
- Refresh snapshot after execute() for superdoc-level commands
- Fix redundant documentMode self-comparison in isCommandDisabled

DX:
- Make execute() required on HeadlessToolbarController type
- Normalize font-size values with unit (e.g. '12pt' not '12')
- Preserve full font-family CSS value (e.g. 'Arial, sans-serif' not 'Arial')
- Normalize color values to lowercase
- Add execute('image') handler (file picker + insertion)
- Fix demo to use execute() consistently for all commands
- Fix demo font selects to use option.value not option.label
- Remove unused RegistryMode/mode abstraction
- Rewrite README with toolbar: null setup and command reference table
Replace single Vue demo with 5 framework examples showcasing different
toolbar patterns:
- react-shadcn: classic top ribbon (Radix + Tailwind + Lucide)
- react-mui: floating bubble bar (MUI + Material Icons)
- vue-vuetify: sidebar panel (Vuetify 3 + MDI)
- svelte-shadcn: compact bottom bar (Svelte 5 + Tailwind + Lucide)
- vanilla: minimal top bar (plain HTML/CSS/JS + Lucide)

API improvements:
- execute() now auto-restores editor focus after commands
- Add DEFAULT_TEXT_COLOR_OPTIONS and DEFAULT_HIGHLIGHT_COLOR_OPTIONS
  constants
- Color execute: run annotation sync unconditionally but return the
  mark command result (not always true)
- Image execute: add .catch() with console.error instead of silently
  swallowing errors
- MUI example: remove unused variables, guard exec against null
  controller on first render
Ship useHeadlessToolbar() for React and Vue:

  import { useHeadlessToolbar } from 'superdoc/headless-toolbar/react';
  const { snapshot, execute } = useHeadlessToolbar(superdoc, commands);

Handles subscribe/unsubscribe, state updates, and cleanup
automatically. Eliminates the useState + useEffect + useRef
boilerplate that every React consumer would write.

Vue composable follows the same API with shallowRef reactivity
and onBeforeUnmount cleanup.

Update react-shadcn example to use the hook as proof.
Add ToolbarPayloadMap and ToolbarValueMap type maps that give
compile-time safety to execute() and snapshot.commands[id].value:

  toolbar.execute('font-size', '14pt')  // βœ“
  toolbar.execute('font-size', 14)      // βœ— type error
  toolbar.execute('bold', 'wrong')      // βœ— type error

  snapshot.commands['zoom']?.value      // type: number | undefined
  snapshot.commands['font-size']?.value  // type: string | undefined

No runtime changes β€” types only.
documentMode defaults to 'editing' at runtime but the JSDoc typedef
marked it as required, causing TypeScript errors when constructing
SuperDoc without explicitly passing it.
@caio-pizzol caio-pizzol force-pushed the caio-pizzol/headless-toolbar-dx-fixes branch from a31795a to af106c9 Compare April 1, 2026 16:20
Restructure toolbar docs into a group with four pages:
- overview: decision page (built-in vs headless)
- built-in: existing toolbar docs (moved from toolbar.mdx)
- headless: full API reference with command table and typed examples
- examples: 5 framework showcases (React shadcn, React MUI, Vue
  Vuetify, Svelte, vanilla JS)

Add doctest support for headless toolbar code examples.
Update SuperDoc configuration docs for toolbar parameter.
Add redirect from /modules/toolbar to /modules/toolbar/overview.
Add superdoc/headless-toolbar to import allowlist.
Aptos constant now includes fallback fonts ('Aptos, Arial, sans-serif')
matching what documents actually store. Without fallbacks, the snapshot
value wouldn't match the constant, breaking select components.

superdoc.on('editorCreate', onChange);
// superdoc.on('editor-update', onChange);
superdoc.on('editorDestroy', onChange);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to delete this (may raise issues).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@artem-harbour good catch β€” removed in 0dee78c. the editor might already be gone when this event fires, so reading from it can break things.

return () => {
superdoc.off?.('editorCreate', onChange);
// superdoc.off?.('editor-update', onChange);
superdoc.off?.('editorDestroy', onChange);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed along with the one above.

id: 'image',
mode: 'special',
state: createDisabledStateDeriver(),
execute: createImageExecute(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was intentionally omitted here because it is asynchronous (to avoid mixing contracts).

But I see that then is used internally, we will simply return true without waiting for the function to execute. If it's ok, feel free to keep it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, the async tradeoff is fine β€” execute('image') returns right away, the picker runs in the background, errors get logged.

const editor = resolveStateEditor(snapshot.context);
const result = executeRegistryCommand(id, options.superdoc, snapshot, toolbarRegistry, payload);
if (result) {
editor?.view?.focus();
Copy link
Copy Markdown
Contributor

@artem-harbour artem-harbour Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would skip focus handling for now and probably handle it in a follow-up task. There are corner cases in the current logic.

Or please check this logic.
packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js - emitCommand method

From source code:

const shouldRestoreFocus = Boolean(item?.restoreEditorFocus);
const hasArgument = argument !== null && argument !== undefined;
const isDropdownOpen = item?.type === 'dropdown' && !hasArgument;
const isFontCommand = item?.command === 'setFontFamily' || item?.command === 'setFontSize';
if (isDropdownOpen && isFontCommand) {
  // Opening/closing a dropdown should not shift editor focus or alter selection state.
  return;
}

// If the editor wasn't focused and this is a mark toggle, queue it and keep the button active
// until the next selection update (after the user clicks into the editor).
if (!wasFocused && isMarkToggle) {
  this.pendingMarkCommands.push({ command, argument, item });
  const labelAttr = item?.labelAttr?.value;
  if (labelAttr && argument) {
    item?.activate?.({ [labelAttr]: argument });
  } else {
    item?.activate?.();
  }
  if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter) {
    this.activeEditor.focus();
  }
  return;
}

if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter) {
  this.activeEditor.focus();
}
if (shouldRestoreFocus && this.activeEditor && !this.activeEditor.options.isHeaderOrFooter) {
  this._restoreFocusTimeoutId = setTimeout(() => {
    this._restoreFocusTimeoutId = null;
    if (!this.activeEditor || this.activeEditor.options.isHeaderOrFooter) return;
    this.activeEditor.focus();
  }, 0);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right β€” removed in 0dee78c. the built-in toolbar handles way more cases (unfocused marks, header/footer, dropdowns) than a simple .focus() covers. better as a follow-up. examples use onMouseDown preventDefault for now.

editor?.view?.focus();
}
if (result && !destroyed) {
refreshControllerState();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably makes sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘ kept β€” without it, ruler/zoom/document-mode changes didn't show up until the next unrelated event.

const [numericValue, unit] = parseSizeUnit(value);
if (!Number.isNaN(numericValue)) {
return String(numericValue);
return `${numericValue}${unit || 'pt'}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this break anything? Or does it correspond to the current logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intentional β€” before this, the snapshot returned '12' but execute() expected '12pt'. now they match. tests updated.

@artem-harbour
Copy link
Copy Markdown
Contributor

@caio-pizzol - added a few minor comments regarding the changes in core logic.

These are the main items that need to be fixed before the merge.

  • removing editorDestroy event subscription
  • focus handling (with corner cases) or removing for now

- Remove editorDestroy subscription β€” the event fires during teardown
  when the editor may be in an inconsistent state, causing the refresh
  cycle to read from a dying editor. editorCreate is sufficient.
- Remove auto-focus from execute() β€” the built-in toolbar has nuanced
  focus logic (pending marks, header/footer exclusion, dropdown
  detection) that a simple view.focus() doesn't replicate. Better
  handled as a follow-up with proper parity.
- Restore onMouseDown preventDefault in react-shadcn example since
  focus is no longer handled by execute().
- Update docs and README to remove focus handling claims.
@caio-pizzol caio-pizzol merged commit 938ce39 into artem/headless-toolbar Apr 1, 2026
5 checks passed
@caio-pizzol caio-pizzol deleted the caio-pizzol/headless-toolbar-dx-fixes branch April 1, 2026 19:20
artem-harbour added a commit that referenced this pull request Apr 2, 2026
* feat: headless toolbar

* fix(headless-toolbar): correctness fixes and DX improvements

Correctness:
- Use resolveStateEditor for undo/redo history depth (fixes header/footer)
- Remove early return gating on color/highlight annotation sync
- Subscribe to zoomChange event for immediate zoom state updates
- Refresh snapshot after execute() for superdoc-level commands
- Fix redundant documentMode self-comparison in isCommandDisabled

DX:
- Make execute() required on HeadlessToolbarController type
- Normalize font-size values with unit (e.g. '12pt' not '12')
- Preserve full font-family CSS value (e.g. 'Arial, sans-serif' not 'Arial')
- Normalize color values to lowercase
- Add execute('image') handler (file picker + insertion)
- Fix demo to use execute() consistently for all commands
- Fix demo font selects to use option.value not option.label
- Remove unused RegistryMode/mode abstraction
- Rewrite README with toolbar: null setup and command reference table

* feat(headless-toolbar): add multi-framework examples and DX improvements

Replace single Vue demo with 5 framework examples showcasing different
toolbar patterns:
- react-shadcn: classic top ribbon (Radix + Tailwind + Lucide)
- react-mui: floating bubble bar (MUI + Material Icons)
- vue-vuetify: sidebar panel (Vuetify 3 + MDI)
- svelte-shadcn: compact bottom bar (Svelte 5 + Tailwind + Lucide)
- vanilla: minimal top bar (plain HTML/CSS/JS + Lucide)

API improvements:
- execute() now auto-restores editor focus after commands
- Add DEFAULT_TEXT_COLOR_OPTIONS and DEFAULT_HIGHLIGHT_COLOR_OPTIONS
  constants

* fix(headless-toolbar): address review findings

- Color execute: run annotation sync unconditionally but return the
  mark command result (not always true)
- Image execute: add .catch() with console.error instead of silently
  swallowing errors
- MUI example: remove unused variables, guard exec against null
  controller on first render

* feat(headless-toolbar): add React hook and Vue composable

Ship useHeadlessToolbar() for React and Vue:

  import { useHeadlessToolbar } from 'superdoc/headless-toolbar/react';
  const { snapshot, execute } = useHeadlessToolbar(superdoc, commands);

Handles subscribe/unsubscribe, state updates, and cleanup
automatically. Eliminates the useState + useEffect + useRef
boilerplate that every React consumer would write.

Vue composable follows the same API with shallowRef reactivity
and onBeforeUnmount cleanup.

Update react-shadcn example to use the hook as proof.

* feat(headless-toolbar): add typed payloads and snapshot values

Add ToolbarPayloadMap and ToolbarValueMap type maps that give
compile-time safety to execute() and snapshot.commands[id].value:

  toolbar.execute('font-size', '14pt')  // βœ“
  toolbar.execute('font-size', 14)      // βœ— type error
  toolbar.execute('bold', 'wrong')      // βœ— type error

  snapshot.commands['zoom']?.value      // type: number | undefined
  snapshot.commands['font-size']?.value  // type: string | undefined

No runtime changes β€” types only.

* fix(superdoc): make documentMode optional in Config type

documentMode defaults to 'editing' at runtime but the JSDoc typedef
marked it as required, causing TypeScript errors when constructing
SuperDoc without explicitly passing it.

* chore(examples): remove toolbar: null from all examples

* docs(headless-toolbar): add headless toolbar documentation

Restructure toolbar docs into a group with four pages:
- overview: decision page (built-in vs headless)
- built-in: existing toolbar docs (moved from toolbar.mdx)
- headless: full API reference with command table and typed examples
- examples: 5 framework showcases (React shadcn, React MUI, Vue
  Vuetify, Svelte, vanilla JS)

Add doctest support for headless toolbar code examples.
Update SuperDoc configuration docs for toolbar parameter.
Add redirect from /modules/toolbar to /modules/toolbar/overview.
Add superdoc/headless-toolbar to import allowlist.

* fix(headless-toolbar): include fallback fonts in Aptos constant

Aptos constant now includes fallback fonts ('Aptos, Arial, sans-serif')
matching what documents actually store. Without fallbacks, the snapshot
value wouldn't match the constant, breaking select components.

* fix(examples): register Vuetify components and directives

* refactor(examples): simplify Vue Vuetify sidebar toolbar layout

* fix(examples): add Tailwind v4 @reference directive for Svelte styles

* fix(examples): only set select value when it matches an option

* fix(headless-toolbar): address author review feedback

- Remove editorDestroy subscription β€” the event fires during teardown
  when the editor may be in an inconsistent state, causing the refresh
  cycle to read from a dying editor. editorCreate is sufficient.
- Remove auto-focus from execute() β€” the built-in toolbar has nuanced
  focus logic (pending marks, header/footer exclusion, dropdown
  detection) that a simple view.focus() doesn't replicate. Better
  handled as a follow-up with proper parity.
- Restore onMouseDown preventDefault in react-shadcn example since
  focus is no longer handled by execute().
- Update docs and README to remove focus handling claims.

---------

Co-authored-by: Artem Nistuley <artem@harbourshare.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants