feat: headless toolbar (caio)#2669
Conversation
π‘ Codex Review
superdoc/packages/superdoc/package.json Lines 45 to 49 in 270de2a The new βΉοΈ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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". |
0db926c to
ee0494d
Compare
3ed9cfb to
4f0a675
Compare
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.
a31795a to
af106c9
Compare
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); |
There was a problem hiding this comment.
Need to delete this (may raise issues).
There was a problem hiding this comment.
@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); |
There was a problem hiding this comment.
This is not needed too.
There was a problem hiding this comment.
removed along with the one above.
| id: 'image', | ||
| mode: 'special', | ||
| state: createDisabledStateDeriver(), | ||
| execute: createImageExecute(), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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);
}There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
It probably makes sense.
There was a problem hiding this comment.
π 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'}`; |
There was a problem hiding this comment.
Doesn't this break anything? Or does it correspond to the current logic?
There was a problem hiding this comment.
intentional β before this, the snapshot returned '12' but execute() expected '12pt'. now they match. tests updated.
|
@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.
|
- 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.
* 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>
No description provided.