diff --git a/apps/docs/modules/toolbar/built-in.mdx b/apps/docs/modules/toolbar/built-in.mdx
index 6a62897f6f..1a2436da2f 100644
--- a/apps/docs/modules/toolbar/built-in.mdx
+++ b/apps/docs/modules/toolbar/built-in.mdx
@@ -484,12 +484,12 @@ const superdoc = new SuperDoc({
### `setZoom`
-Set the editor zoom level programmatically.
+Set the editor zoom level programmatically through the owning `SuperDoc` instance.
```javascript Usage
-toolbar.setZoom(150); // 150%
+superdoc.setZoom(150); // 150%
```
```javascript Full Example
@@ -501,8 +501,7 @@ const superdoc = new SuperDoc({
document: yourFile,
toolbar: '#toolbar',
onReady: (superdoc) => {
- const toolbar = superdoc.toolbar;
- toolbar.setZoom(150); // 150%
+ superdoc.setZoom(150); // 150%
},
});
```
@@ -536,38 +535,6 @@ const superdoc = new SuperDoc({
-## Events
-
-### `superdoc-command`
-
-Fired when a SuperDoc-level command is executed (zoom, document mode).
-
-
-
-```javascript Usage
-toolbar.on('superdoc-command', ({ item, argument }) => {
- console.log(`Command: ${item.command}, arg: ${argument}`);
-});
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- toolbar: '#toolbar',
- onReady: (superdoc) => {
- const toolbar = superdoc.toolbar;
- toolbar.on('superdoc-command', ({ item, argument }) => {
- console.log(`Command: ${item.command}, arg: ${argument}`);
- });
- },
-});
-```
-
-
### `exception`
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
index d6b58992c0..4217900c4e 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
@@ -53,3 +53,59 @@ export const TOOLBAR_FONT_SIZES = [
{ label: '72', key: '72pt', props: { 'data-item': 'btn-fontSize-option' } },
{ label: '96', key: '96pt', props: { 'data-item': 'btn-fontSize-option' } },
];
+
+export const HEADLESS_ITEM_MAP = {
+ undo: 'undo',
+ redo: 'redo',
+ bold: 'bold',
+ italic: 'italic',
+ underline: 'underline',
+ strike: 'strikethrough',
+ acceptTrackedChangeBySelection: 'track-changes-accept-selection',
+ rejectTrackedChangeOnSelection: 'track-changes-reject-selection',
+ ruler: 'ruler',
+ zoom: 'zoom',
+ documentMode: 'document-mode',
+ link: 'link',
+ fontFamily: 'font-family',
+ fontSize: 'font-size',
+ list: 'bullet-list',
+ numberedlist: 'numbered-list',
+ table: 'table-insert',
+ image: 'image',
+ color: 'text-color',
+ highlight: 'highlight-color',
+ textAlign: 'text-align',
+ lineHeight: 'line-height',
+ linkedStyles: 'linked-style',
+ indentleft: 'indent-decrease',
+ indentright: 'indent-increase',
+ clearFormatting: 'clear-formatting',
+ copyFormat: 'copy-format',
+};
+
+export const TABLE_ACTION_COMMAND_MAP = {
+ addRowBefore: 'table-add-row-before',
+ addRowAfter: 'table-add-row-after',
+ deleteRow: 'table-delete-row',
+ addColumnBefore: 'table-add-column-before',
+ addColumnAfter: 'table-add-column-after',
+ deleteColumn: 'table-delete-column',
+ deleteTable: 'table-delete',
+ deleteCellAndTableBorders: 'table-remove-borders',
+ mergeCells: 'table-merge-cells',
+ splitCell: 'table-split-cell',
+ fixTables: 'table-fix',
+};
+
+export const TABLE_ACTION_COMMAND_IDS = Object.values(TABLE_ACTION_COMMAND_MAP);
+
+export const HEADLESS_TOOLBAR_COMMANDS = [
+ ...new Set([...Object.values(HEADLESS_ITEM_MAP), ...TABLE_ACTION_COMMAND_IDS]),
+];
+
+const NON_HEADLESS_EXECUTE_ITEM_NAMES = new Set(['link']);
+
+export const HEADLESS_EXECUTE_ITEMS = new Set(
+ Object.keys(HEADLESS_ITEM_MAP).filter((itemName) => !NON_HEADLESS_EXECUTE_ITEM_NAMES.has(itemName)),
+);
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
index 5c4568cdff..a16e4b00dc 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js
@@ -697,14 +697,6 @@ export const makeDefaultItems = ({
},
});
- // const overflowOptions = useToolbarItem({
- // type: 'options',
- // name: 'overflowOptions',
- // preCommand(self, argument) {
- // self.parentItem.active = false;
- // },
- // });
-
// zoom
const zoom = useToolbarItem({
type: 'dropdown',
@@ -767,20 +759,6 @@ export const makeDefaultItems = ({
},
});
- // Track changes test buttons
- const toggleTrackChanges = useToolbarItem({
- type: 'button',
- disabled: false,
- name: 'toggleTrackChanges',
- tooltip: toolbarTexts.trackChanges,
- command: 'toggleTrackChanges',
- icon: toolbarIcons.trackChanges,
- group: 'left',
- attributes: {
- ariaLabel: 'Track changes',
- },
- });
-
const acceptTrackedChangeBySelection = useToolbarItem({
type: 'button',
disabled: false,
@@ -807,32 +785,6 @@ export const makeDefaultItems = ({
},
});
- const toggleTrackChangesOriginal = useToolbarItem({
- type: 'button',
- disabled: false,
- name: 'toggleTrackChangesShowOriginal',
- tooltip: toolbarTexts.trackChangesOriginal,
- command: 'toggleTrackChangesShowOriginal',
- icon: toolbarIcons.trackChangesOriginal,
- group: 'left',
- attributes: {
- ariaLabel: 'Toggle tracked changes show original',
- },
- });
-
- const toggleTrackChangesFinal = useToolbarItem({
- type: 'button',
- disabled: false,
- name: 'toggleTrackChangesShowFinal',
- tooltip: toolbarTexts.trackChangesFinal,
- command: 'toggleTrackChangesShowFinal',
- icon: toolbarIcons.trackChangesFinal,
- group: 'left',
- attributes: {
- ariaLabel: 'Toggle tracked changes show final',
- },
- });
-
const clearFormatting = useToolbarItem({
type: 'button',
name: 'clearFormatting',
@@ -1059,13 +1011,8 @@ export const makeDefaultItems = ({
let toolbarItems = [
undo,
redo,
-
- // Dev - tracked changes
- // toggleTrackChanges,
acceptTrackedChangeBySelection,
rejectTrackedChangeOnSelection,
- // toggleTrackChangesOriginal,
- // toggleTrackChangesFinal,
zoom,
fontButton,
@@ -1119,8 +1066,8 @@ export const makeDefaultItems = ({
toolbarItems = toolbarItems.filter((item) => !filterItems.includes(item.name.value));
}
- // Track changes test buttons
- const devItems = [toggleTrackChanges, toggleTrackChangesOriginal, toggleTrackChangesFinal];
+ // Track changes accept/reject are hidden outside dev mode for viewers.
+ const devItems = [];
if (!isDev) {
if (role === 'viewer') {
devItems.push(...[acceptTrackedChangeBySelection, rejectTrackedChangeOnSelection]);
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/helpers/general.js b/packages/super-editor/src/editors/v1/components/toolbar/helpers/general.js
new file mode 100644
index 0000000000..8d0b46056b
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/components/toolbar/helpers/general.js
@@ -0,0 +1,26 @@
+import { encodeMarksFromRPr } from '@core/super-converter/styles.js';
+
+export const getParagraphFontFamilyFromProperties = (paragraphProps, convertedXml = {}) => {
+ const fontFamilyProps = paragraphProps?.runProperties?.fontFamily;
+ if (!fontFamilyProps) return null;
+ const [markDef] = encodeMarksFromRPr({ fontFamily: fontFamilyProps }, convertedXml);
+ return markDef?.attrs?.fontFamily ?? null;
+};
+
+export const findElementBySelector = (selector) => {
+ let el = null;
+
+ if (selector) {
+ if (selector.startsWith('#') || selector.startsWith('.')) {
+ el = document.querySelector(selector);
+ } else {
+ el = document.getElementById(selector);
+ }
+
+ if (!el) {
+ return null;
+ }
+ }
+
+ return el;
+};
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
index 5ce7271857..0dcebd0bda 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
@@ -1,27 +1,25 @@
import { EventEmitter } from 'eventemitter3';
import { createApp } from 'vue';
-import { undoDepth, redoDepth } from 'prosemirror-history';
import { makeDefaultItems } from './defaultItems';
+import { createHeadlessToolbar } from '../../../../headless-toolbar/create-headless-toolbar.js';
import { getActiveFormatting } from '@core/helpers/getActiveFormatting.js';
import { findParentNode } from '@helpers/index.js';
import { vClickOutside } from '@superdoc/common';
import Toolbar from './Toolbar.vue';
-import { getFileOpener, processAndInsertImageFile } from '../../extensions/image/imageHelpers/index.js';
import { toolbarIcons } from './toolbarIcons.js';
import { toolbarTexts } from './toolbarTexts.js';
-import { getQuickFormatList } from '@extensions/linked-styles/index.js';
+import {
+ HEADLESS_TOOLBAR_COMMANDS,
+ HEADLESS_ITEM_MAP,
+ HEADLESS_EXECUTE_ITEMS,
+ TABLE_ACTION_COMMAND_IDS,
+ TABLE_ACTION_COMMAND_MAP,
+} from './constants.js';
import { getAvailableColorOptions, makeColorOption, renderColorOptions } from './color-dropdown-helpers.js';
-import { isInTable } from '@helpers/isInTable.js';
import { useToolbarItem } from '@components/toolbar/use-toolbar-item';
-import { yUndoPluginKey } from 'y-prosemirror';
-import { isNegatedMark } from './format-negation.js';
-import { collectTrackedChanges, isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js';
-import { isList } from '@core/commands/list-helpers';
import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js';
-import { twipsToLines } from '@converter/helpers';
import { parseSizeUnit } from '@core/utilities';
-import { encodeMarksFromRPr } from '@core/super-converter/styles.js';
-import { NodeSelection } from 'prosemirror-state';
+import { findElementBySelector, getParagraphFontFamilyFromProperties } from './helpers/general.js';
/**
* @typedef {function(CommandItem): void} CommandCallback
@@ -193,12 +191,15 @@ export class SuperToolbar extends EventEmitter {
this.config = { ...this.config, ...config };
this.toolbarItems = [];
this.overflowItems = [];
- this.documentMode = config.documentMode || 'editing';
this.isDev = config.isDev || false;
this.superdoc = config.superdoc;
this.role = config.role || 'editor';
this.toolbarContainer = null;
+ this.controller = null;
+ this.snapshot = null;
+ this._unsubscribeController = null;
+
if (this.config.editor) {
this.config.mode = this.config.editor.options.mode;
}
@@ -254,7 +255,7 @@ export class SuperToolbar extends EventEmitter {
this.config.selector = this.config.element;
}
- this.toolbarContainer = this.findElementBySelector(this.config.selector);
+ this.toolbarContainer = findElementBySelector(this.config.selector);
if (this.toolbarContainer) {
const uiFontFamily =
(this.config?.uiDisplayFallbackFont || '').toString().trim() || 'Arial, Helvetica, sans-serif';
@@ -263,7 +264,9 @@ export class SuperToolbar extends EventEmitter {
// to ensure consistent typography across all UI surfaces (dropdowns, tooltips, etc.)
this.toolbarContainer.style.setProperty('--sd-ui-font-family', uiFontFamily);
}
+
this.#initToolbarGroups();
+
this.#makeToolbarItems({
superToolbar: this,
icons: this.config.icons,
@@ -284,25 +287,40 @@ export class SuperToolbar extends EventEmitter {
this.toolbar = this.app.mount(this.toolbarContainer);
}
this.activeEditor = config.editor || null;
+
+ this.initHeadlessToolbar();
this.updateToolbarState();
}
- findElementBySelector(selector) {
- let el = null;
+ createHeadlessToolbar() {
+ if (!this.superdoc) return null;
- if (selector) {
- if (selector.startsWith('#') || selector.startsWith('.')) {
- el = document.querySelector(selector);
- } else {
- el = document.getElementById(selector);
- }
+ return createHeadlessToolbar({
+ superdoc: this.superdoc,
+ commands: HEADLESS_TOOLBAR_COMMANDS,
+ });
+ }
- if (!el) {
- return null;
- }
- }
+ initHeadlessToolbar() {
+ if (!this.superdoc) return;
- return el;
+ this.destroyHeadlessToolbar();
+
+ this.controller = this.createHeadlessToolbar();
+ this.snapshot = this.controller.getSnapshot();
+
+ this._unsubscribeController = this.controller.subscribe(({ snapshot }) => {
+ this.snapshot = snapshot;
+ this.updateToolbarState();
+ });
+ }
+
+ destroyHeadlessToolbar() {
+ this._unsubscribeController?.();
+ this.controller?.destroy();
+ this.controller = null;
+ this.snapshot = null;
+ this._unsubscribeController = null;
}
/**
@@ -317,354 +335,6 @@ export class SuperToolbar extends EventEmitter {
}
}
- /**
- * Custom commands that override default behavior
- * @private
- * @type {Object.}
- */
- #interceptedCommands = {
- /**
- * Handles zoom level changes
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string|number} params.argument - The zoom level (percentage)
- * @returns {void}
- */
- setZoom: ({ item, argument }) => {
- // Currently only set up to work with full SuperDoc
- if (!argument) return;
- item.onActivate({ zoom: argument });
-
- this.emit('superdoc-command', { item, argument });
-
- // NOTE: Zoom is now handled by PresentationEditor via transform: scale() on #viewportHost.
- // We do NOT apply CSS zoom on .layers anymore because:
- // 1. It causes coordinate system mismatches between zoomed content and overlays
- // 2. PresentationEditor.setGlobalZoom() is called when activeZoom changes (via SuperDoc.vue watcher)
- // 3. Centralizing zoom in PresentationEditor ensures both content and selection overlays scale together
-
- this.superdoc.superdocStore.activeZoom = parseInt(argument, 10);
- },
-
- /**
- * Sets the document mode
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string} params.argument - The document mode to set
- * @returns {void}
- */
- setDocumentMode: ({ item, argument }) => {
- if (!argument) return;
-
- this.emit('superdoc-command', { item, argument });
- },
-
- /**
- * Sets the font size for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string|number} params.argument - The font size to set
- * @returns {void}
- */
- setFontSize: ({ item, argument }) => {
- if (this.#isFieldAnnotationSelection() && argument) {
- this.activeEditor?.commands.setFieldAnnotationsFontSize(argument, true);
- this.updateToolbarState();
- return;
- }
-
- this.#runCommandWithArgumentOnly({ item, argument }, () => {
- this.activeEditor?.commands.setFieldAnnotationsFontSize(argument, true);
- });
- },
-
- /**
- * Sets the font family for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string} params.argument - The font family to set
- * @returns {void}
- */
- setFontFamily: ({ item, argument }) => {
- if (this.#isFieldAnnotationSelection() && argument) {
- this.activeEditor?.commands.setFieldAnnotationsFontFamily(argument, true);
- this.updateToolbarState();
- return;
- }
-
- this.#runCommandWithArgumentOnly({ item, argument }, () => {
- this.activeEditor?.commands.setFieldAnnotationsFontFamily(argument, true);
- });
- },
-
- /**
- * Sets the text color
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string} params.argument - The color to set
- * @returns {void}
- */
- setColor: ({ argument }) => {
- if (!argument || !this.activeEditor) return;
- const isNone = argument === 'none';
- const value = isNone ? 'inherit' : argument;
- // Apply inline color; 'inherit' acts as a cascade-aware negation of style color
- if (this.activeEditor?.commands?.setColor) this.activeEditor.commands.setColor(value);
- // Update annotations color, but use null for none
- const argValue = isNone ? null : argument;
- this.activeEditor?.commands.setFieldAnnotationsTextColor(argValue, true);
- this.updateToolbarState();
- },
-
- /**
- * Sets the highlight color for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {string} params.argument - The highlight color to set
- * @returns {void}
- */
- setHighlight: ({ argument }) => {
- if (!argument || !this.activeEditor) return;
- // For cascade-aware negation, keep a highlight mark present using 'transparent'
- const inlineColor = argument !== 'none' ? argument : 'transparent';
- if (this.activeEditor?.commands?.setHighlight) this.activeEditor.commands.setHighlight(inlineColor);
- // Update annotations highlight; 'none' -> null
- const argValue = argument !== 'none' ? argument : null;
- this.activeEditor?.commands.setFieldAnnotationsTextHighlight(argValue, true);
- this.activeEditor?.commands.setCellBackground(argValue);
- this.updateToolbarState();
- },
-
- /**
- * Toggles the ruler visibility
- * @returns {void}
- */
- toggleRuler: () => {
- this.superdoc.toggleRuler();
- this.updateToolbarState();
- },
-
- /**
- * Initiates the image upload process
- * @async
- * @returns {Promise}
- */
- startImageUpload: async () => {
- try {
- let open = getFileOpener();
- let result = await open();
-
- if (!result?.file) {
- return;
- }
-
- await processAndInsertImageFile({
- file: result.file,
- editor: this.activeEditor,
- view: this.activeEditor.view,
- editorOptions: this.activeEditor.options,
- getMaxContentSize: () => this.activeEditor.getMaxContentSize(),
- });
- } catch (error) {
- const err = new Error('[super-toolbar 🎨] Image upload failed');
- this.emit('exception', { error: err, editor: this.activeEditor, originalError: error });
- console.error(err, error);
- }
- },
-
- /**
- * Increases text indentation or list level
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {void}
- */
- increaseTextIndent: ({ item, argument }) => {
- let command = item.command;
-
- if (this.activeEditor.commands.increaseListIndent?.()) {
- return true;
- }
-
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- }
- },
-
- /**
- * Decreases text indentation or list level
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {boolean}
- */
- decreaseTextIndent: ({ item, argument }) => {
- let command = item.command;
-
- if (this.activeEditor.commands.decreaseListIndent?.()) {
- return true;
- }
-
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- }
- },
-
- /**
- * Toggles bold formatting for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {void}
- */
- toggleBold: ({ item, argument }) => {
- if (this.#isFieldAnnotationSelection()) {
- this.activeEditor?.commands.toggleFieldAnnotationsFormat('bold', true);
- this.updateToolbarState();
- return;
- }
-
- let command = item.command;
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- this.activeEditor.commands.toggleFieldAnnotationsFormat('bold', true);
- }
-
- this.updateToolbarState();
- },
-
- /**
- * Toggles italic formatting for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {void}
- */
- toggleItalic: ({ item, argument }) => {
- if (this.#isFieldAnnotationSelection()) {
- this.activeEditor?.commands.toggleFieldAnnotationsFormat('italic', true);
- this.updateToolbarState();
- return;
- }
-
- let command = item.command;
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- this.activeEditor.commands.toggleFieldAnnotationsFormat('italic', true);
- }
-
- this.updateToolbarState();
- },
-
- /**
- * Toggles underline formatting for text
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {void}
- */
- toggleUnderline: ({ item, argument }) => {
- if (this.#isFieldAnnotationSelection()) {
- this.activeEditor?.commands.toggleFieldAnnotationsFormat('underline', true);
- this.updateToolbarState();
- return;
- }
-
- let command = item.command;
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- this.activeEditor.commands.toggleFieldAnnotationsFormat('underline', true);
- }
-
- this.updateToolbarState();
- },
-
- /**
- * Toggles link formatting and updates cursor position
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {*} params.argument - Command arguments
- * @returns {void}
- */
- toggleLink: ({ item, argument }) => {
- let command = item.command;
-
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
-
- // move cursor to end
- const { view } = this.activeEditor;
- let { selection } = view.state;
- if (this.activeEditor.options.isHeaderOrFooter) {
- selection = this.activeEditor.options.lastSelection;
- }
- const endPos = selection.$to.pos;
-
- const newSelection = new TextSelection(view.state.doc.resolve(endPos));
- const tr = view.state.tr.setSelection(newSelection);
- const state = view.state.apply(tr);
- view.updateState(state);
-
- if (!this.activeEditor.options.isHeaderOrFooter) {
- setTimeout(() => {
- view.focus();
- }, 100);
- }
- }
- this.updateToolbarState();
- },
-
- /**
- * Inserts a table into the document
- * @param {Object} params - Command parameters
- * @param {CommandItem} params.item - The command item
- * @param {Object} params.argument - Table configuration
- * @returns {void}
- */
- insertTable: ({ item, argument }) => {
- this.#runCommandWithArgumentOnly({ item, argument });
- },
-
- /**
- * Executes a table-related command
- * @param {Object} params - Command parameters
- * @param {Object} params.argument - The table command and its parameters
- * @param {string} params.argument.command - The specific table command to execute
- * @returns {void}
- */
- executeTableCommand: ({ argument }) => {
- if (!argument) return;
-
- let command = argument.command;
-
- if (command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- }
-
- this.updateToolbarState();
- },
- };
-
- /**
- * Log debug information to the console
- * @param {...*} args - Arguments to log
- * @returns {void}
- */
- log(...args) {
- console.debug('[🎨 super-toolbar]', ...args);
- }
-
- /**
- * Set the zoom level
- * @param {number} percent_int - The zoom percentage as an integer
- * @returns {void}
- */
- setZoom(percent_int) {
- const allItems = [...this.toolbarItems, ...this.overflowItems];
- const item = allItems.find((item) => item.name.value === 'zoom');
- this.#interceptedCommands.setZoom({ item, argument: percent_int });
- }
-
/**
* The toolbar expects an active Super Editor instance.
* Removes listeners from the previous editor (if any) before attaching to the new one.
@@ -819,7 +489,8 @@ export class SuperToolbar extends EventEmitter {
const documentModeItem = this.getToolbarItemByName('documentMode');
if (!documentModeItem) return;
- const mode = (this.documentMode || 'editing').toLowerCase();
+ const snapshotMode = this.snapshot?.commands?.['document-mode']?.value;
+ const mode = (snapshotMode || 'editing').toLowerCase();
const texts = this.config.texts || {};
const icons = this.config.icons || {};
const map = {
@@ -849,6 +520,141 @@ export class SuperToolbar extends EventEmitter {
}
}
+ // Empty-paragraph fallback kept in the toolbar adapter for now.
+ #getFontFamilyFallbackValue() {
+ if (!this.activeEditor?.state) return null;
+
+ const { state } = this.activeEditor;
+ const selection = state.selection;
+ if (!selection?.empty) return null;
+
+ const paragraphParent = findParentNode((n) => n.type.name === 'paragraph')(selection);
+ if (!paragraphParent || paragraphParent.node?.content?.size !== 0) return null;
+
+ const paragraphProps = calculateResolvedParagraphProperties(
+ this.activeEditor,
+ paragraphParent.node,
+ state.doc.resolve(paragraphParent.pos),
+ );
+ const convertedXml = this.activeEditor?.converter?.convertedXml ?? {};
+ const fontFamily = getParagraphFontFamilyFromProperties(paragraphProps, convertedXml);
+ return fontFamily || null;
+ }
+
+ // Headless currently represents mixed font-size selections as active + null value.
+ // The built-in toolbar still needs to translate that shape into the legacy isMultiple UI state.
+ #isFontSizeMixedState(commandState) {
+ return Boolean(commandState?.active) && commandState?.value == null;
+ }
+
+ #applyHeadlessState(item) {
+ if (item.name.value === 'tableActions') {
+ const tableActionStates = TABLE_ACTION_COMMAND_IDS.map((commandId) => this.snapshot?.commands?.[commandId]);
+ const hasAnyEnabled = tableActionStates.some((state) => state && !state.disabled);
+ item.setDisabled(!hasAnyEnabled);
+ return true;
+ }
+
+ const commandId = HEADLESS_ITEM_MAP[item.name.value];
+ if (!commandId) return false;
+
+ const commandState = this.snapshot?.commands?.[commandId];
+
+ const setDisabled = () => {
+ item.setDisabled(Boolean(commandState?.disabled));
+ };
+
+ const handlers = {
+ textAlign: () => {
+ if (commandState?.value) item.activate({ textAlign: commandState.value });
+ else item.deactivate();
+ },
+ lineHeight: () => {
+ item.selectedValue.value = commandState?.value != null ? commandState.value : '';
+ },
+ zoom: () => {
+ if (commandState?.value != null) {
+ const value = typeof commandState.value === 'number' ? `${commandState.value}%` : String(commandState.value);
+ item.onActivate({ zoom: value });
+ }
+ },
+ documentMode: () => {
+ this.#syncDocumentModeUi();
+ },
+ link: () => {
+ item.active.value = Boolean(commandState?.active);
+ item.attributes.value = commandState?.value ? { href: commandState.value } : {};
+ },
+ fontFamily: () => {
+ if (commandState?.value != null) {
+ item.activate({ fontFamily: commandState.value });
+ return;
+ }
+
+ const fallbackFontFamily = this.#getFontFamilyFallbackValue();
+ if (fallbackFontFamily) {
+ item.activate({ fontFamily: fallbackFontFamily });
+ return;
+ }
+
+ item.deactivate();
+ },
+ fontSize: () => {
+ if (commandState?.value != null) {
+ item.activate({ fontSize: commandState.value });
+ return;
+ }
+ if (this.#isFontSizeMixedState(commandState)) {
+ item.activate({}, true);
+ return;
+ }
+ item.deactivate();
+ },
+ color: () => {
+ if (commandState?.value != null) item.activate({ color: commandState.value });
+ else item.deactivate();
+ },
+ highlight: () => {
+ if (commandState?.value != null) item.activate({ color: commandState.value });
+ else item.deactivate();
+ },
+ linkedStyles: () => {
+ if (commandState?.value != null) item.activate({ styleId: commandState.value });
+ else item.label.value = this.config.texts?.formatText || 'Format text';
+ },
+ default: () => {
+ if (commandState?.active) item.activate();
+ else item.deactivate();
+ },
+ };
+
+ const handler = handlers[item.name.value] ?? handlers.default;
+
+ setDisabled();
+ handler();
+
+ return true;
+ }
+
+ #executeHeadlessCommand(item, argument) {
+ // Note: 'link' is intentionally excluded from this router for now.
+ // Its submit/remove execute flow lives in LinkInput.vue as a special-case UI component flow.
+ const isTableActions = item?.name?.value === 'tableActions';
+ const commandId = isTableActions
+ ? TABLE_ACTION_COMMAND_MAP[argument?.command]
+ : HEADLESS_ITEM_MAP[item?.name?.value];
+
+ if (!commandId || !this.controller?.execute) return false;
+
+ if (isTableActions) {
+ this.controller.execute(commandId);
+ } else {
+ this.controller.execute(commandId, argument);
+ }
+
+ return true;
+ }
+
/**
* Update the toolbar state based on the current editor state
* Updates active/inactive state of all toolbar items
@@ -856,13 +662,15 @@ export class SuperToolbar extends EventEmitter {
*/
updateToolbarState() {
this.#syncDocumentModeUi();
- this.#updateToolbarHistory();
this.#initDefaultFonts();
this.#updateHighlightColors();
// Deactivate toolbar items if no active editor
// This will skip buttons that are marked as allowWithoutEditor
- if (!this.activeEditor || this.documentMode === 'viewing') {
+ const snapshotMode = this.snapshot?.commands?.['document-mode']?.value;
+ const currentMode = snapshotMode || 'editing';
+
+ if (!this.activeEditor || currentMode === 'viewing') {
this.#deactivateAll();
return;
}
@@ -872,161 +680,10 @@ export class SuperToolbar extends EventEmitter {
this.#deactivateAll();
return;
}
- const selection = state.selection;
- const selectionTrackedChanges = this.#enrichTrackedChanges(
- collectTrackedChanges({ state, from: selection.from, to: selection.to }),
- );
- const hasTrackedChanges = selectionTrackedChanges.length > 0;
- const hasValidSelection = hasTrackedChanges;
- const canAcceptTrackedChanges =
- hasValidSelection &&
- isTrackedChangeActionAllowed({
- editor: this.activeEditor,
- action: 'accept',
- trackedChanges: selectionTrackedChanges,
- });
- const canRejectTrackedChanges =
- hasValidSelection &&
- isTrackedChangeActionAllowed({
- editor: this.activeEditor,
- action: 'reject',
- trackedChanges: selectionTrackedChanges,
- });
-
- const marks = getActiveFormatting(this.activeEditor);
- const inTable = isInTable(this.activeEditor.state);
- const paragraphParent = findParentNode((n) => n.type.name === 'paragraph')(selection);
- const paragraphProps = paragraphParent
- ? calculateResolvedParagraphProperties(
- this.activeEditor,
- paragraphParent.node,
- state.doc.resolve(paragraphParent.pos),
- )
- : null;
- const selectionIsCollapsed = selection.empty;
- const paragraphIsEmpty = paragraphParent?.node?.content?.size === 0;
- const paragraphFontFamily = getParagraphFontFamilyFromProperties(
- paragraphProps,
- this.activeEditor?.converter?.convertedXml ?? {},
- );
this.toolbarItems.forEach((item) => {
item.resetDisabled();
- let activatedFromLinkedStyle = false;
-
- if (item.name.value === 'undo') {
- item.setDisabled(this.undoDepth === 0);
- }
-
- if (item.name.value === 'redo') {
- item.setDisabled(this.redoDepth === 0);
- }
-
- if (item.name.value === 'acceptTrackedChangeBySelection') {
- item.setDisabled(!canAcceptTrackedChanges);
- }
-
- if (item.name.value === 'rejectTrackedChangeOnSelection') {
- item.setDisabled(!canRejectTrackedChanges);
- }
-
- // Linked Styles dropdown behaves a bit different from other buttons.
- // We need to disable it manually if there are no linked styles to show
- if (item.name.value === 'linkedStyles') {
- if (this.activeEditor && !getQuickFormatList(this.activeEditor).length) {
- return item.deactivate();
- } else {
- return item.activate({ styleId: paragraphProps?.styleId || null });
- }
- }
-
- const rawActiveMark = marks.find((mark) => mark.name === item.name.value);
- const markNegated = rawActiveMark ? isNegatedMark(rawActiveMark.name, rawActiveMark.attrs) : false;
- const activeMark = markNegated ? null : rawActiveMark;
-
- if (activeMark) {
- if (activeMark.name === 'fontSize') {
- const fontSizes = marks.filter((i) => i.name === 'fontSize').map((i) => i.attrs.fontSize);
- const isMultiple = [...new Set(fontSizes)].length > 1;
- item.activate(activeMark.attrs, isMultiple);
- } else {
- item.activate(activeMark.attrs);
- }
- } else {
- item.deactivate();
- }
-
- // Activate toolbar items based on linked styles (if there's no active mark to avoid overriding it)
- if (!activeMark && !markNegated && paragraphParent && paragraphProps?.styleId) {
- const markToStyleMap = {
- fontSize: 'font-size',
- fontFamily: 'font-family',
- bold: 'bold',
- };
- const linkedStyles = this.activeEditor.converter?.linkedStyles.find(
- (style) => style.id === paragraphProps.styleId,
- );
- if (
- linkedStyles &&
- linkedStyles.definition &&
- linkedStyles.definition.styles &&
- markToStyleMap[item.name.value] in linkedStyles.definition.styles
- ) {
- const linkedStylesItem = linkedStyles.definition.styles[markToStyleMap[item.name.value]];
- const value = {
- [item.name.value]: linkedStylesItem,
- };
- item.activate(value);
- activatedFromLinkedStyle = true;
- }
- }
- if (item.name.value === 'textAlign' && paragraphProps?.justification) {
- item.activate({ textAlign: paragraphProps.justification });
- }
-
- if (
- item.name.value === 'fontFamily' &&
- selectionIsCollapsed &&
- paragraphIsEmpty &&
- !activeMark &&
- !markNegated &&
- !activatedFromLinkedStyle &&
- paragraphFontFamily
- ) {
- item.activate({ fontFamily: paragraphFontFamily });
- }
-
- if (item.name.value === 'lineHeight') {
- if (paragraphProps?.spacing) {
- item.selectedValue.value = twipsToLines(paragraphProps.spacing.line);
- } else {
- item.selectedValue.value = '';
- }
- }
-
- if (item.name.value === 'tableActions') {
- item.disabled.value = !inTable;
- }
-
- // Activate list buttons when selections is inside list
- const listParent = isList(paragraphParent?.node) ? paragraphParent.node : null;
- if (listParent) {
- const numberingType = listParent.attrs.listRendering.numberingType;
- if (item.name.value === 'list' && numberingType === 'bullet') {
- item.activate();
- } else if (item.name.value === 'numberedlist' && numberingType !== 'bullet') {
- item.activate();
- }
- }
-
- // Activate ruler button when rulers are visible
- if (item.name.value === 'ruler') {
- if (this.superdoc?.config?.rulers) {
- item.activate();
- } else {
- item.deactivate();
- }
- }
+ this.#applyHeadlessState(item);
});
}
@@ -1065,45 +722,6 @@ export class SuperToolbar extends EventEmitter {
});
}
- /**
- * Update undo/redo history state in the toolbar
- * @private
- * @returns {void}
- */
- #updateToolbarHistory() {
- if (!this.activeEditor?.state) return;
-
- try {
- if (this.activeEditor.options.ydoc) {
- const undoManager = yUndoPluginKey.getState(this.activeEditor.state)?.undoManager;
- this.undoDepth = undoManager?.undoStack.length || 0;
- this.redoDepth = undoManager?.redoStack.length || 0;
- } else {
- this.undoDepth = undoDepth(this.activeEditor.state);
- this.redoDepth = redoDepth(this.activeEditor.state);
- }
- } catch {
- // History plugin may not be registered yet during initialization
- this.undoDepth = 0;
- this.redoDepth = 0;
- }
- }
-
- #enrichTrackedChanges(trackedChanges = []) {
- if (!trackedChanges?.length) return trackedChanges;
- const store = this.superdoc?.commentsStore;
- if (!store?.getComment) return trackedChanges;
-
- return trackedChanges.map((change) => {
- const commentId = change.id;
- if (!commentId) return change;
- const storeComment = store.getComment(commentId);
- if (!storeComment) return change;
- const comment = typeof storeComment.getValues === 'function' ? storeComment.getValues() : storeComment;
- return { ...change, comment };
- });
- }
-
/**
* React to editor transactions. Might want to debounce this.
* @param {Object} params - Transaction parameters
@@ -1115,6 +733,16 @@ export class SuperToolbar extends EventEmitter {
this.updateToolbarState();
}
+ #scheduleRestoreEditorFocus() {
+ if (!this.activeEditor || this.activeEditor.options.isHeaderOrFooter) return;
+
+ this._restoreFocusTimeoutId = setTimeout(() => {
+ this._restoreFocusTimeoutId = null;
+ if (!this.activeEditor || this.activeEditor.options.isHeaderOrFooter) return;
+ this.activeEditor.focus();
+ }, 0);
+ }
+
/**
* Main handler for toolbar commands
* @param {CommandItem} params - Command parameters
@@ -1132,6 +760,7 @@ export class SuperToolbar extends EventEmitter {
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;
@@ -1158,15 +787,24 @@ export class SuperToolbar extends EventEmitter {
this.activeEditor.focus();
}
- if (!command) {
- return;
+ if (!command) return;
+
+ if (item?.name?.value === 'tableActions') {
+ const handledByHeadless = this.#executeHeadlessCommand(item, argument);
+ if (handledByHeadless) {
+ this.updateToolbarState();
+ return;
+ }
}
- // Check if we have a custom or overloaded command defined
- if (command in this.#interceptedCommands) {
- const result = this.#interceptedCommands[command]({ item, argument });
- if (isMarkToggle) this.#syncStickyMarksFromState();
- return result;
+ if (HEADLESS_EXECUTE_ITEMS.has(item?.name?.value)) {
+ const handledByHeadless = this.#executeHeadlessCommand(item, argument);
+ if (handledByHeadless) {
+ if (isMarkToggle) this.#syncStickyMarksFromState();
+ this.updateToolbarState();
+ if (shouldRestoreFocus) this.#scheduleRestoreEditorFocus();
+ return;
+ }
}
if (this.activeEditor && this.activeEditor.commands && command in this.activeEditor.commands) {
@@ -1188,14 +826,7 @@ export class SuperToolbar extends EventEmitter {
if (isMarkToggle) this.#syncStickyMarksFromState();
this.updateToolbarState();
-
- 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);
- }
+ if (shouldRestoreFocus) this.#scheduleRestoreEditorFocus();
}
/**
@@ -1215,9 +846,15 @@ export class SuperToolbar extends EventEmitter {
if (!command) return;
try {
- if (command in this.#interceptedCommands) {
- this.#interceptedCommands[command]({ item, argument });
- } else if (this.activeEditor.commands && command in this.activeEditor.commands) {
+ if (HEADLESS_EXECUTE_ITEMS.has(item?.name?.value)) {
+ const handledByHeadless = this.#executeHeadlessCommand(item, argument);
+ if (handledByHeadless) {
+ this.#ensureStoredMarksForMarkToggle({ command, argument });
+ return;
+ }
+ }
+
+ if (this.activeEditor.commands && command in this.activeEditor.commands) {
this.activeEditor.commands[command](argument);
}
this.#ensureStoredMarksForMarkToggle({ command, argument });
@@ -1263,41 +900,6 @@ export class SuperToolbar extends EventEmitter {
return SuperToolbar.#MARK_TOGGLE_NAMES.has(name);
}
- /**
- * Run a command that requires an argument
- * @private
- * @param {CommandItem} params - Command parameters
- * @param {ToolbarItem} params.item - The toolbar item
- * @param {*} params.argument - The argument for the command
- * @param {boolean} params.noArgumentCallback - Whether to call callback even if argument === 'none'
- * @param {Function} [callback] - Optional callback to run after the command
- * @returns {void}
- */
- #runCommandWithArgumentOnly({ item, argument, noArgumentCallback = false }, callback) {
- if (!argument || !this.activeEditor) return;
-
- let command = item.command;
- const noArgumentCommand = item.noArgumentCommand;
-
- if (
- argument === 'none' &&
- this.activeEditor &&
- this.activeEditor.commands &&
- noArgumentCommand in this.activeEditor.commands
- ) {
- this.activeEditor.commands[noArgumentCommand]();
- if (typeof callback === 'function' && noArgumentCallback) callback(argument);
- this.updateToolbarState();
- return;
- }
-
- if (this.activeEditor && this.activeEditor.commands && command in this.activeEditor.commands) {
- this.activeEditor.commands[command](argument);
- if (typeof callback === 'function') callback(argument);
- this.updateToolbarState();
- }
- }
-
/**
* Capture stored marks when a mark toggle is used on an empty selection
* so they can be re-applied after focus/selection changes.
@@ -1371,11 +973,6 @@ export class SuperToolbar extends EventEmitter {
view.dispatch(tr);
}
- #isFieldAnnotationSelection() {
- const selection = this.activeEditor?.state?.selection;
- return selection instanceof NodeSelection && selection?.node?.type?.name === 'fieldAnnotation';
- }
-
/**
* Cleans up resources when the toolbar is destroyed.
* Clears any pending timeouts to prevent callbacks firing after unmount.
@@ -1386,12 +983,7 @@ export class SuperToolbar extends EventEmitter {
clearTimeout(this._restoreFocusTimeoutId);
this._restoreFocusTimeoutId = null;
}
- }
-}
-function getParagraphFontFamilyFromProperties(paragraphProps, convertedXml = {}) {
- const fontFamilyProps = paragraphProps?.runProperties?.fontFamily;
- if (!fontFamilyProps) return null;
- const [markDef] = encodeMarksFromRPr({ fontFamily: fontFamilyProps }, convertedXml);
- return markDef?.attrs?.fontFamily ?? null;
+ this.destroyHeadlessToolbar();
+ }
}
diff --git a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
index e98e4c78cf..e61acfc20b 100644
--- a/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
+++ b/packages/super-editor/src/editors/v1/dev/components/DeveloperPlayground.vue
@@ -41,12 +41,6 @@ const onCreate = ({ editor }) => {
window.editor = editor;
editor.setToolbar(initToolbar());
- editor.toolbar.on('superdoc-command', ({ item, argument }) => {
- const { command } = item;
- if (command === 'setDocumentMode') {
- editor.setDocumentMode(argument);
- }
- });
attachAnnotationEventHandlers();
// Set debugging pagination value from editor plugin state
diff --git a/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js b/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js
index cc37f5b184..e8fc53f7c3 100644
--- a/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js
+++ b/packages/super-editor/src/editors/v1/tests/toolbar/super-toolbar-commands.test.js
@@ -45,9 +45,10 @@ const ensureDomApis = () => {
}
};
-describe('SuperToolbar intercepted color commands', () => {
+describe('SuperToolbar headless execute routing', () => {
let toolbar;
let mockEditor;
+ let mockController;
beforeEach(() => {
ensureDomApis();
@@ -62,6 +63,9 @@ describe('SuperToolbar intercepted color commands', () => {
mockEditor = {
focus: vi.fn(),
+ view: {
+ hasFocus: vi.fn(() => true),
+ },
options: { isHeaderOrFooter: false, mode: 'docx' },
state: {
selection: { from: 1, to: 1, $from: mockResolvedPos },
@@ -78,106 +82,57 @@ describe('SuperToolbar intercepted color commands', () => {
setHighlight: vi.fn(),
setFieldAnnotationsTextHighlight: vi.fn(),
setCellBackground: vi.fn(),
+ someLegacyCommand: vi.fn(),
},
};
toolbar = new SuperToolbar({ editor: mockEditor, hideButtons: false });
toolbar.updateToolbarState = vi.fn();
+ mockController = {
+ execute: vi.fn(),
+ };
+ toolbar.controller = mockController;
});
- const emitCommand = (command, argument) => {
- const item = { command };
+ const emitCommand = (item, argument) => {
toolbar.emitCommand({ item, argument });
};
- it('setColor applies inline color (#123456) and updates field annotations with the same color', () => {
- emitCommand('setColor', '#123456');
+ it('routes color through headless controller with text-color id', () => {
+ const item = { command: 'setColor', name: { value: 'color' } };
+ emitCommand(item, '#123456');
expect(mockEditor.focus).toHaveBeenCalled();
- expect(mockEditor.commands.setColor).toHaveBeenCalledWith('#123456');
- expect(mockEditor.commands.setFieldAnnotationsTextColor).toHaveBeenCalledWith('#123456', true);
- expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
- });
-
- it('setColor treats "none" argument as "inherit" for inline color and null for annotations', () => {
- emitCommand('setColor', 'none');
-
- expect(mockEditor.commands.setColor).toHaveBeenCalledWith('inherit');
- expect(mockEditor.commands.setFieldAnnotationsTextColor).toHaveBeenCalledWith(null, true);
- expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
- });
-
- it('setColor skips work when argument is missing', () => {
- emitCommand('setColor');
-
- expect(mockEditor.commands.setColor).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextColor).not.toHaveBeenCalled();
- expect(toolbar.updateToolbarState).not.toHaveBeenCalled();
- });
-
- it('setColor skips work when argument is undefined', () => {
- emitCommand('setColor', undefined);
-
- expect(mockEditor.commands.setColor).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextColor).not.toHaveBeenCalled();
- expect(toolbar.updateToolbarState).not.toHaveBeenCalled();
- });
-
- it('setColor skips work when argument is empty string', () => {
- emitCommand('setColor', '');
-
+ expect(mockController.execute).toHaveBeenCalledWith('text-color', '#123456');
expect(mockEditor.commands.setColor).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextColor).not.toHaveBeenCalled();
- expect(toolbar.updateToolbarState).not.toHaveBeenCalled();
- });
-
- it('setColor applies color value even with potentially invalid format (browser handles validation)', () => {
- emitCommand('setColor', 'invalid-color-format');
-
- expect(mockEditor.commands.setColor).toHaveBeenCalledWith('invalid-color-format');
- expect(mockEditor.commands.setFieldAnnotationsTextColor).toHaveBeenCalledWith('invalid-color-format', true);
expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
});
- it('setHighlight applies highlight color (#fedcba) to inline marks, field annotations, and table cell background', () => {
- emitCommand('setHighlight', '#fedcba');
+ it('routes highlight through headless controller with highlight-color id', () => {
+ const item = { command: 'setHighlight', name: { value: 'highlight' } };
+ emitCommand(item, '#fedcba');
- expect(mockEditor.commands.setHighlight).toHaveBeenCalledWith('#fedcba');
- expect(mockEditor.commands.setFieldAnnotationsTextHighlight).toHaveBeenCalledWith('#fedcba', true);
- expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#fedcba');
+ expect(mockController.execute).toHaveBeenCalledWith('highlight-color', '#fedcba');
+ expect(mockEditor.commands.setHighlight).not.toHaveBeenCalled();
expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
});
- it('setHighlight with "none" argument sets transparent inline mark for cascade-aware negation while clearing annotations', () => {
- emitCommand('setHighlight', 'none');
+ it('falls back to editor.commands for non-headless items', () => {
+ const item = { command: 'someLegacyCommand', name: { value: 'customButton' } };
+ emitCommand(item, 'payload');
- expect(mockEditor.commands.setHighlight).toHaveBeenCalledWith('transparent');
- expect(mockEditor.commands.setFieldAnnotationsTextHighlight).toHaveBeenCalledWith(null, true);
- expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith(null);
+ expect(mockController.execute).not.toHaveBeenCalled();
+ expect(mockEditor.commands.someLegacyCommand).toHaveBeenCalledWith('payload');
expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
});
- it('setHighlight skips work when argument is missing', () => {
- emitCommand('setHighlight');
-
- expect(mockEditor.commands.setHighlight).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextHighlight).not.toHaveBeenCalled();
- expect(mockEditor.commands.setCellBackground).not.toHaveBeenCalled();
- expect(toolbar.updateToolbarState).not.toHaveBeenCalled();
- });
-
- it('setColor and setHighlight do not execute any commands when activeEditor is null', () => {
- toolbar.activeEditor = null;
+ it('does not route headless commands when controller is unavailable', () => {
+ toolbar.controller = null;
+ const item = { command: 'setColor', name: { value: 'color' } };
- emitCommand('setColor', '#abcdef');
- emitCommand('setHighlight', '#abcdef');
-
- expect(mockEditor.commands.setColor).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextColor).not.toHaveBeenCalled();
- expect(mockEditor.commands.setHighlight).not.toHaveBeenCalled();
- expect(mockEditor.commands.setFieldAnnotationsTextHighlight).not.toHaveBeenCalled();
- expect(mockEditor.commands.setCellBackground).not.toHaveBeenCalled();
- expect(toolbar.updateToolbarState).not.toHaveBeenCalled();
+ expect(() => emitCommand(item, '#abcdef')).not.toThrow();
+ expect(mockEditor.commands.setColor).toHaveBeenCalledWith('#abcdef');
+ expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(1);
});
});
@@ -229,7 +184,7 @@ describe('SuperToolbar sticky mark persistence', () => {
toolbar.onEditorSelectionUpdate();
expect(mockEditor.commands.toggleBold).toHaveBeenCalled();
expect(toolbar.pendingMarkCommands).toHaveLength(0);
- expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(2);
+ expect(toolbar.updateToolbarState).toHaveBeenCalled();
// Simulate moving the caret to an empty area that has no marks
mockEditor.state.storedMarks = null;
@@ -237,7 +192,7 @@ describe('SuperToolbar sticky mark persistence', () => {
expect(mockTransaction.setStoredMarks).toHaveBeenCalledWith([{ type: 'bold' }]);
expect(mockEditor.view.dispatch).toHaveBeenCalledWith({ storedMarksSet: true });
- expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(3);
+ expect(toolbar.updateToolbarState).toHaveBeenCalled();
});
it('clears sticky stored marks and does not restore them when user toggles formatting off on empty selection', () => {
@@ -260,24 +215,19 @@ describe('SuperToolbar sticky mark persistence', () => {
expect(toolbar.updateToolbarState).toHaveBeenCalledTimes(2);
});
- it('uses intercepted command implementation (setFontSize) instead of direct editor command when replaying pending mark commands', () => {
- const throwingSetFontSize = vi.fn(() => {
- throw new Error('should not be called directly');
- });
-
- mockEditor.commands.setFontSize = throwingSetFontSize;
- mockEditor.commands.setFieldAnnotationsFontSize = vi.fn();
+ it('replays pending fontSize commands through the current command path and clears the queue', () => {
+ const setFontSize = vi.fn();
+ mockEditor.commands.setFontSize = setFontSize;
mockEditor.view.hasFocus = vi.fn(() => false);
const item = { command: 'setFontSize', name: { value: 'fontSize' }, activate: vi.fn() };
- toolbar.emitCommand({ item });
+ toolbar.emitCommand({ item, argument: '24pt' });
expect(toolbar.pendingMarkCommands).toHaveLength(1);
- // Should use intercepted command, so the direct command never runs
expect(() => toolbar.onEditorSelectionUpdate()).not.toThrow();
- expect(throwingSetFontSize).not.toHaveBeenCalled();
+ expect(setFontSize).toHaveBeenCalledWith('24pt');
expect(toolbar.pendingMarkCommands).toHaveLength(0);
});
diff --git a/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js b/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js
index 934f00dcee..1850abcb45 100644
--- a/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js
+++ b/packages/super-editor/src/editors/v1/tests/toolbar/updateToolbarState.test.js
@@ -139,6 +139,7 @@ describe('updateToolbarState', () => {
activate: vi.fn(),
deactivate: vi.fn(),
setDisabled: vi.fn(),
+ label: { value: '' },
allowWithoutEditor: { value: false },
},
{
@@ -206,7 +207,11 @@ describe('updateToolbarState', () => {
];
toolbar.activeEditor = mockEditor;
- toolbar.documentMode = 'editing';
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ },
+ };
});
afterEach(() => {
@@ -230,7 +235,7 @@ describe('updateToolbarState', () => {
});
it('should sync to suggesting mode', () => {
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -240,7 +245,7 @@ describe('updateToolbarState', () => {
});
it('should sync to editing mode', () => {
- toolbar.documentMode = 'editing';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'editing' } } };
toolbar.updateToolbarState();
@@ -250,7 +255,7 @@ describe('updateToolbarState', () => {
});
it('should sync to viewing mode', () => {
- toolbar.documentMode = 'viewing';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'viewing' } } };
toolbar.updateToolbarState();
@@ -260,7 +265,7 @@ describe('updateToolbarState', () => {
});
it('should default to editing when documentMode is null', () => {
- toolbar.documentMode = null;
+ toolbar.snapshot = { commands: { 'document-mode': { value: null } } };
toolbar.updateToolbarState();
@@ -269,7 +274,7 @@ describe('updateToolbarState', () => {
});
it('should default to editing when documentMode is undefined', () => {
- toolbar.documentMode = undefined;
+ toolbar.snapshot = { commands: { 'document-mode': { value: undefined } } };
toolbar.updateToolbarState();
@@ -278,7 +283,7 @@ describe('updateToolbarState', () => {
});
it('should default to editing when documentMode is an unknown value', () => {
- toolbar.documentMode = 'unknown-mode';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'unknown-mode' } } };
toolbar.updateToolbarState();
@@ -287,7 +292,7 @@ describe('updateToolbarState', () => {
});
it('should handle uppercase mode values via toLowerCase', () => {
- toolbar.documentMode = 'SUGGESTING';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'SUGGESTING' } } };
toolbar.updateToolbarState();
@@ -296,7 +301,7 @@ describe('updateToolbarState', () => {
});
it('should handle mixed case mode values', () => {
- toolbar.documentMode = 'Viewing';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'Viewing' } } };
toolbar.updateToolbarState();
@@ -306,7 +311,7 @@ describe('updateToolbarState', () => {
it('should use custom config.texts labels when provided', () => {
toolbar.config.texts.documentSuggestingMode = 'Custom Suggesting Label';
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -319,7 +324,7 @@ describe('updateToolbarState', () => {
documentModeItem.icon.value = originalIcon;
toolbar.config.icons.documentSuggestingMode = undefined;
toolbar.config.icons.documentMode = undefined;
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -331,7 +336,7 @@ describe('updateToolbarState', () => {
const fallbackIcon = { type: 'fallback-icon' };
toolbar.config.icons.documentEditingMode = undefined;
toolbar.config.icons.documentMode = fallbackIcon;
- toolbar.documentMode = 'editing';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'editing' } } };
toolbar.updateToolbarState();
@@ -340,14 +345,14 @@ describe('updateToolbarState', () => {
it('should not throw when documentModeItem is missing from toolbar', () => {
toolbar.toolbarItems = [];
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
expect(() => toolbar.updateToolbarState()).not.toThrow();
});
it('should not update label when label.value is undefined', () => {
documentModeItem.label = {};
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -357,7 +362,7 @@ describe('updateToolbarState', () => {
it('should not update defaultLabel when defaultLabel.value is undefined', () => {
documentModeItem.defaultLabel = {};
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -367,7 +372,7 @@ describe('updateToolbarState', () => {
it('should not update icon when icon.value is undefined', () => {
documentModeItem.icon = {};
- toolbar.documentMode = 'suggesting';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'suggesting' } } };
toolbar.updateToolbarState();
@@ -375,30 +380,31 @@ describe('updateToolbarState', () => {
});
});
- it('should update toolbar state with active formatting marks', () => {
- mockGetActiveFormatting.mockReturnValue([
- { name: 'bold', attrs: {} },
- { name: 'italic', attrs: {} },
- ]);
-
- mockIsInTable.mockReturnValue(false);
- mockGetQuickFormatList.mockReturnValue(['style1', 'style2']);
+ it('should update toolbar state from headless command state', () => {
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ bold: { active: true, disabled: false },
+ italic: { active: true, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
expect(toolbar.toolbarItems[0].resetDisabled).toHaveBeenCalled();
- expect(toolbar.toolbarItems[0].activate).toHaveBeenCalledWith({}); // bold
+ expect(toolbar.toolbarItems[0].activate).toHaveBeenCalledWith(); // bold
expect(toolbar.toolbarItems[1].resetDisabled).toHaveBeenCalled();
- expect(toolbar.toolbarItems[1].activate).toHaveBeenCalledWith({}); // italic
-
- expect(mockGetActiveFormatting).toHaveBeenCalledWith(mockEditor);
+ expect(toolbar.toolbarItems[1].activate).toHaveBeenCalledWith(); // italic
});
- it('should keep toggles inactive when negation marks are active', () => {
- mockGetActiveFormatting.mockReturnValue([
- { name: 'bold', attrs: { value: '0' } },
- { name: 'underline', attrs: { underlineType: 'none' } },
- ]);
+ it('should keep toggles inactive when commands are inactive', () => {
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ bold: { active: false, disabled: false },
+ underline: { active: false, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
@@ -411,35 +417,32 @@ describe('updateToolbarState', () => {
expect(underlineItem.deactivate).toHaveBeenCalled();
});
- it('should not reactivate via linked styles when a negation mark is present', () => {
- mockGetActiveFormatting.mockReturnValue([
- { name: 'bold', attrs: { value: '0' } },
- { name: 'styleId', attrs: { styleId: 'style-1' } },
- ]);
-
- mockEditor.converter.linkedStyles = [
- {
- id: 'style-1',
- definition: { styles: { bold: { value: true } } },
+ it('should reset linked styles label when there is no active linked style', () => {
+ const linkedStylesItem = toolbar.toolbarItems.find((item) => item.name.value === 'linkedStyles');
+ linkedStylesItem.label.value = 'Some Style';
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'linked-style': { value: null, disabled: false },
},
- ];
+ };
toolbar.updateToolbarState();
- const boldItem = toolbar.toolbarItems.find((item) => item.name.value === 'bold');
- expect(boldItem.activate).not.toHaveBeenCalled();
- expect(boldItem.deactivate).toHaveBeenCalled();
+ expect(linkedStylesItem.label.value).toBe(toolbar.config.texts?.formatText || 'Format text');
});
- it('disables tracked change buttons when permission resolver denies access', () => {
- mockGetActiveFormatting.mockReturnValue([]);
- mockCollectTrackedChanges.mockReturnValue([{ id: 'change-1', attrs: { authorEmail: 'author@example.com' } }]);
- mockIsTrackedChangeActionAllowed.mockImplementation(({ action }) => action === 'reject');
+ it('disables tracked change buttons from headless command state', () => {
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'track-changes-accept-selection': { active: false, disabled: true },
+ 'track-changes-reject-selection': { active: false, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
- expect(mockCollectTrackedChanges).toHaveBeenCalled();
-
const acceptItem = toolbar.toolbarItems.find((item) => item.name.value === 'acceptTrackedChangeBySelection');
const rejectItem = toolbar.toolbarItems.find((item) => item.name.value === 'rejectTrackedChangeOnSelection');
@@ -447,9 +450,14 @@ describe('updateToolbarState', () => {
expect(rejectItem.setDisabled).toHaveBeenCalledWith(false);
});
- it('disables tracked change buttons when there are no tracked changes in selection', () => {
- mockGetActiveFormatting.mockReturnValue([]);
- mockCollectTrackedChanges.mockReturnValue([]);
+ it('disables both tracked change buttons when both headless commands are disabled', () => {
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'track-changes-accept-selection': { active: false, disabled: true },
+ 'track-changes-reject-selection': { active: false, disabled: true },
+ },
+ };
toolbar.updateToolbarState();
@@ -460,11 +468,14 @@ describe('updateToolbarState', () => {
expect(rejectItem.setDisabled).toHaveBeenCalledWith(true);
});
- it('keeps tracked change buttons enabled for collapsed selection within change', () => {
- mockEditor.state.selection.from = 5;
- mockEditor.state.selection.to = 5;
- mockCollectTrackedChanges.mockReturnValue([{ id: 'change-1', attrs: { authorEmail: 'author@example.com' } }]);
- mockGetActiveFormatting.mockReturnValue([]);
+ it('keeps tracked change buttons enabled when headless commands are enabled', () => {
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'track-changes-accept-selection': { active: false, disabled: false },
+ 'track-changes-reject-selection': { active: false, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
@@ -486,7 +497,7 @@ describe('updateToolbarState', () => {
});
it('should deactivate toolbar items when in viewing mode', () => {
- toolbar.documentMode = 'viewing';
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'viewing' } } };
toolbar.updateToolbarState();
@@ -507,17 +518,12 @@ describe('updateToolbarState', () => {
});
it('should prioritize active mark over linked styles (font family)', () => {
- mockGetActiveFormatting.mockReturnValue([
- { name: 'fontFamily', attrs: { fontFamily: 'Roboto' } },
- { name: 'styleId', attrs: { styleId: 'test-style' } },
- ]);
-
- mockEditor.converter.linkedStyles = [
- {
- id: 'test-style',
- definition: { styles: { 'font-family': 'Arial' } },
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'font-family': { value: 'Roboto', disabled: false },
},
- ];
+ };
toolbar.updateToolbarState();
@@ -540,7 +546,7 @@ describe('updateToolbarState', () => {
mockCalculateResolvedParagraphProperties.mockReturnValue({
runProperties: { fontFamily: { 'w:ascii': paragraphFontFamily } },
});
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'editing' } } };
toolbar.updateToolbarState();
@@ -562,7 +568,7 @@ describe('updateToolbarState', () => {
mockCalculateResolvedParagraphProperties.mockReturnValue({
runProperties: { fontFamily: { 'w:ascii': 'Never Used' } },
});
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = { commands: { 'document-mode': { value: 'editing' } } };
toolbar.updateToolbarState();
@@ -584,13 +590,12 @@ describe('updateToolbarState', () => {
styleId: 'test-style',
runProperties: { fontFamily: { 'w:ascii': 'Paragraph Font, serif' } },
});
- mockEditor.converter.linkedStyles = [
- {
- id: 'test-style',
- definition: { styles: { 'font-family': 'Linked Style Font' } },
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'font-family': { value: 'Linked Style Font', disabled: false },
},
- ];
- mockGetActiveFormatting.mockReturnValue([]);
+ };
toolbar.updateToolbarState();
@@ -600,31 +605,21 @@ describe('updateToolbarState', () => {
});
it('should prioritize active mark over linked styles (font size)', () => {
- mockGetActiveFormatting.mockReturnValue([
- { name: 'fontSize', attrs: { fontSize: '20pt' } },
- { name: 'styleId', attrs: { styleId: 'test-style' } },
- ]);
-
- mockEditor.converter.linkedStyles = [
- {
- id: 'test-style',
- definition: { styles: { 'font-size': '14pt' } },
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ 'font-size': { value: '20pt', disabled: false },
},
- ];
+ };
toolbar.updateToolbarState();
const fontSizeItem = toolbar.toolbarItems.find((item) => item.name.value === 'fontSize');
- expect(fontSizeItem.activate).toHaveBeenCalledWith({ fontSize: '20pt' }, false);
- expect(fontSizeItem.activate).not.toHaveBeenCalledWith({ fontSize: '14pt' });
+ expect(fontSizeItem.activate).toHaveBeenCalledWith({ fontSize: '20pt' });
});
describe('undo/redo button state', () => {
- it('should disable undo button when undoDepth is 0', async () => {
- const { undoDepth: mockUndoDepth, redoDepth: mockRedoDepth } = await import('prosemirror-history');
- mockUndoDepth.mockReturnValue(0);
- mockRedoDepth.mockReturnValue(0);
-
+ it('should disable undo button when undo command is disabled', () => {
const undoItem = {
name: { value: 'undo' },
resetDisabled: vi.fn(),
@@ -636,18 +631,19 @@ describe('updateToolbarState', () => {
toolbar.toolbarItems = [undoItem];
toolbar.activeEditor = mockEditor;
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ undo: { active: false, disabled: true },
+ },
+ };
toolbar.updateToolbarState();
expect(undoItem.setDisabled).toHaveBeenCalledWith(true);
});
- it('should enable undo button when undoDepth is greater than 0', async () => {
- const { undoDepth: mockUndoDepth, redoDepth: mockRedoDepth } = await import('prosemirror-history');
- mockUndoDepth.mockReturnValue(3);
- mockRedoDepth.mockReturnValue(0);
-
+ it('should enable undo button when undo command is enabled', () => {
const undoItem = {
name: { value: 'undo' },
resetDisabled: vi.fn(),
@@ -659,18 +655,19 @@ describe('updateToolbarState', () => {
toolbar.toolbarItems = [undoItem];
toolbar.activeEditor = mockEditor;
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ undo: { active: false, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
expect(undoItem.setDisabled).toHaveBeenCalledWith(false);
});
- it('should disable redo button when redoDepth is 0', async () => {
- const { undoDepth: mockUndoDepth, redoDepth: mockRedoDepth } = await import('prosemirror-history');
- mockUndoDepth.mockReturnValue(0);
- mockRedoDepth.mockReturnValue(0);
-
+ it('should disable redo button when redo command is disabled', () => {
const redoItem = {
name: { value: 'redo' },
resetDisabled: vi.fn(),
@@ -682,18 +679,19 @@ describe('updateToolbarState', () => {
toolbar.toolbarItems = [redoItem];
toolbar.activeEditor = mockEditor;
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ redo: { active: false, disabled: true },
+ },
+ };
toolbar.updateToolbarState();
expect(redoItem.setDisabled).toHaveBeenCalledWith(true);
});
- it('should enable redo button when redoDepth is greater than 0', async () => {
- const { undoDepth: mockUndoDepth, redoDepth: mockRedoDepth } = await import('prosemirror-history');
- mockUndoDepth.mockReturnValue(0);
- mockRedoDepth.mockReturnValue(2);
-
+ it('should enable redo button when redo command is enabled', () => {
const redoItem = {
name: { value: 'redo' },
resetDisabled: vi.fn(),
@@ -705,18 +703,19 @@ describe('updateToolbarState', () => {
toolbar.toolbarItems = [redoItem];
toolbar.activeEditor = mockEditor;
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ redo: { active: false, disabled: false },
+ },
+ };
toolbar.updateToolbarState();
expect(redoItem.setDisabled).toHaveBeenCalledWith(false);
});
- it('should update both undo and redo buttons correctly', async () => {
- const { undoDepth: mockUndoDepth, redoDepth: mockRedoDepth } = await import('prosemirror-history');
- mockUndoDepth.mockReturnValue(5);
- mockRedoDepth.mockReturnValue(0);
-
+ it('should update both undo and redo buttons correctly from snapshot', () => {
const undoItem = {
name: { value: 'undo' },
resetDisabled: vi.fn(),
@@ -737,7 +736,13 @@ describe('updateToolbarState', () => {
toolbar.toolbarItems = [undoItem, redoItem];
toolbar.activeEditor = mockEditor;
- mockGetActiveFormatting.mockReturnValue([]);
+ toolbar.snapshot = {
+ commands: {
+ 'document-mode': { value: 'editing' },
+ undo: { active: false, disabled: false },
+ redo: { active: false, disabled: true },
+ },
+ };
toolbar.updateToolbarState();
diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts
index f504d0d7d4..4a7b132469 100644
--- a/packages/super-editor/src/headless-toolbar/helpers/document.ts
+++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts
@@ -101,11 +101,13 @@ export const createRulerExecute =
export const createZoomExecute =
() =>
({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => {
- if (typeof payload !== 'number' || payload <= 0) {
+ const normalizedPayload = Number.parseInt(String(payload), 10);
+
+ if (!Number.isFinite(normalizedPayload) || normalizedPayload <= 0) {
return false;
}
- superdoc.setZoom?.(payload);
+ superdoc.setZoom?.(normalizedPayload);
return true;
};
@@ -113,16 +115,17 @@ export const createDocumentModeExecute =
() =>
({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => {
const validModes = ['editing', 'suggesting', 'viewing'];
+ const normalizedPayload = typeof payload === 'string' ? payload.toLowerCase() : payload;
if (
typeof superdoc?.setDocumentMode !== 'function' ||
- typeof payload !== 'string' ||
- !validModes.includes(payload)
+ typeof normalizedPayload !== 'string' ||
+ !validModes.includes(normalizedPayload)
) {
return false;
}
- superdoc.setDocumentMode(payload);
+ superdoc.setDocumentMode(normalizedPayload);
return true;
};
diff --git a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts
index 7c7cba66f5..ebcc929322 100644
--- a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts
+++ b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts
@@ -526,7 +526,10 @@ export const createImageExecute =
});
})
.catch((err: unknown) => {
- console.error('[headless-toolbar] Image insertion failed:', err);
+ const originalError = err instanceof Error ? err : new Error(String(err));
+ const error = new Error('[headless-toolbar] Image insertion failed');
+ editor?.emit?.('exception', { error, editor, originalError });
+ console.error(error, originalError);
});
return true;
diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts
index e54cbb7175..c0771b32a0 100644
--- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts
+++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts
@@ -33,7 +33,6 @@ describe('resolveToolbarSources', () => {
},
},
getActiveEditor: () => headerEditor,
- getEffectiveSelectionContext: () => ({ surface: 'header' }),
};
const result = resolveToolbarSources({
diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts
index ea339f70e3..f73bdd3065 100644
--- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts
+++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts
@@ -6,15 +6,12 @@ import type { ResolvedToolbarSources } from './internal-types.js';
// Normalize raw Editor and PresentationEditor into one toolbar-facing shape.
// PresentationEditor remains the routing authority whenever it is available.
-const resolveSurface = (editor: PresentationEditor): HeadlessToolbarSurface => {
- const activeEditor = editor.getActiveEditor();
+const resolveSurface = (activeEditor: Editor | null | undefined): HeadlessToolbarSurface => {
if (activeEditor?.options?.isHeaderOrFooter) {
const headerFooterType = activeEditor.options?.headerFooterType;
if (headerFooterType === 'footer') return 'footer';
if (headerFooterType === 'header') return 'header';
}
- const mode = editor.getEffectiveSelectionContext?.()?.surface;
- if (mode === 'header' || mode === 'footer') return mode;
return 'body';
};
@@ -70,15 +67,16 @@ export const resolveToolbarSources = (superdoc: {
if (presentationEditor) {
// Follow PresentationEditor routing instead of superdoc.activeEditor so
- // toolbar state stays aligned with the active body/header/footer surface.
+ // toolbar state stays aligned with the active body/header/footer editor.
+ // Surface is derived from the routed editor directly to avoid selection-range
+ // resolution during snapshot rebuilds (for example, CellSelection).
const routedEditor = presentationEditor.getActiveEditor();
-
return {
activeEditor: routedEditor ?? null,
presentationEditor,
context: {
target: createPresentationToolbarTarget(presentationEditor),
- surface: resolveSurface(presentationEditor),
+ surface: resolveSurface(routedEditor),
isEditable: presentationEditor.isEditable,
selectionEmpty: resolveSelectionEmpty(presentationEditor),
editor: routedEditor ?? undefined,
diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js
index 40304631ae..0c61761db8 100644
--- a/packages/superdoc/src/core/SuperDoc.js
+++ b/packages/superdoc/src/core/SuperDoc.js
@@ -1160,7 +1160,6 @@ export class SuperDoc extends EventEmitter {
this.toolbar = new SuperToolbar(config);
- this.toolbar.on('superdoc-command', this.onToolbarCommand.bind(this));
this.toolbar.on('exception', this.config.onException);
this.once('editorCreate', () => this.toolbar.updateToolbarState());
}
@@ -1260,20 +1259,6 @@ export class SuperDoc extends EventEmitter {
});
}
- /**
- * Triggered when a toolbar command is executed
- * @param {Object} param0
- * @param {Object} param0.item The toolbar item that was clicked
- * @param {string} param0.argument The argument passed to the command
- */
- onToolbarCommand({ item, argument }) {
- if (item.command === 'setDocumentMode') {
- this.setDocumentMode(argument);
- } else if (item.command === 'setZoom') {
- this.superdocStore.activeZoom = argument;
- }
- }
-
/**
* Set the document mode.
* @param {DocumentMode} type
@@ -1352,7 +1337,6 @@ export class SuperDoc extends EventEmitter {
});
if (this.toolbar) {
- this.toolbar.documentMode = 'editing';
this.toolbar.updateToolbarState();
}
}
@@ -1373,7 +1357,6 @@ export class SuperDoc extends EventEmitter {
});
if (this.toolbar) {
- this.toolbar.documentMode = 'suggesting';
this.toolbar.updateToolbarState();
}
}
@@ -1403,7 +1386,6 @@ export class SuperDoc extends EventEmitter {
});
if (this.toolbar) {
- this.toolbar.documentMode = 'viewing';
this.toolbar.updateToolbarState();
}
}
@@ -1483,11 +1465,6 @@ export class SuperDoc extends EventEmitter {
this.superdocStore.activeZoom = percent;
}
- // Update toolbar UI so the dropdown label reflects the new zoom level
- if (this.toolbar && typeof this.toolbar.setZoom === 'function') {
- this.toolbar.setZoom(percent);
- }
-
this.emit('zoomChange', { zoom: percent });
}
@@ -1735,6 +1712,9 @@ export class SuperDoc extends EventEmitter {
if (this.#surfaceManager) {
this.#surfaceManager.destroy();
}
+
+ this.toolbar?.destroy();
+
// Unmount the app FIRST so editors are destroyed — this triggers each
// extension's onDestroy() which cancels debounced Y.js writes and
// unobserves Y.js maps. Only then is it safe to destroy the ydoc/provider.
diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js
index 007f309d2c..02a72005f8 100644
--- a/packages/superdoc/src/core/SuperDoc.test.js
+++ b/packages/superdoc/src/core/SuperDoc.test.js
@@ -16,7 +16,6 @@ vi.mock('uuid', () => ({
const toolbarUpdateSpy = vi.fn();
const toolbarSetActiveSpy = vi.fn();
-const toolbarSetZoomSpy = vi.fn();
class MockToolbar {
constructor(config) {
@@ -24,6 +23,7 @@ class MockToolbar {
this.listeners = {};
this.activeEditor = null;
this.updateToolbarState = toolbarUpdateSpy;
+ this.destroy = vi.fn();
}
on(event, handler) {
@@ -38,10 +38,6 @@ class MockToolbar {
this.activeEditor = editor;
toolbarSetActiveSpy(editor);
}
-
- setZoom(percent) {
- toolbarSetZoomSpy(percent);
- }
}
const createZipMock = vi.fn(async (blobs, names) => ({ zip: true, blobs, names }));
@@ -178,7 +174,6 @@ describe('SuperDoc core', () => {
vi.resetModules();
toolbarUpdateSpy.mockClear();
toolbarSetActiveSpy.mockClear();
- toolbarSetZoomSpy.mockClear();
createZipMock.mockClear();
createDownloadMock.mockClear();
cleanNameMock.mockClear();
@@ -1299,35 +1294,6 @@ describe('SuperDoc core', () => {
expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.25);
});
- it('setZoom updates toolbar zoom UI for programmatic calls', async () => {
- const { superdocStore } = createAppHarness();
- const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() };
-
- superdocStore.documents = [
- {
- id: 'doc-1',
- type: DOCX,
- getPresentationEditor: vi.fn(() => mockPresentationEditor),
- },
- ];
-
- const instance = new SuperDoc({
- selector: '#host',
- document: 'https://example.com/doc.docx',
- documents: [],
- modules: { comments: {}, toolbar: {} },
- colors: ['red'],
- user: { name: 'Jane', email: 'jane@example.com' },
- });
- await flushMicrotasks();
- toolbarSetZoomSpy.mockClear();
-
- instance.setZoom(140);
-
- expect(toolbarSetZoomSpy).toHaveBeenCalledWith(140);
- expect(toolbarSetZoomSpy).toHaveBeenCalledTimes(1);
- });
-
it('setZoom warns and returns early for invalid values', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { superdocStore } = createAppHarness();
@@ -1377,25 +1343,6 @@ describe('SuperDoc core', () => {
warnSpy.mockRestore();
});
-
- it('setZoom is consistent with toolbar zoom command', async () => {
- const { superdocStore } = createAppHarness();
-
- const instance = new SuperDoc({
- selector: '#host',
- document: 'https://example.com/doc.docx',
- });
- await flushMicrotasks();
-
- // Programmatic API should update the same store property as the toolbar
- instance.setZoom(150);
- expect(superdocStore.activeZoom).toBe(150);
-
- // Simulate toolbar zoom (same path)
- instance.onToolbarCommand({ item: { command: 'setZoom' }, argument: 200 });
- expect(superdocStore.activeZoom).toBe(200);
- expect(instance.getZoom()).toBe(200);
- });
});
describe('Web layout mode configuration', () => {
diff --git a/packages/superdoc/src/core/upgrade-collaboration.test.js b/packages/superdoc/src/core/upgrade-collaboration.test.js
index 8942b426cc..76825cf7bf 100644
--- a/packages/superdoc/src/core/upgrade-collaboration.test.js
+++ b/packages/superdoc/src/core/upgrade-collaboration.test.js
@@ -22,6 +22,7 @@ class MockToolbar {
constructor() {
this.activeEditor = null;
}
+ destroy() {}
on() {}
once() {}
updateToolbarState() {}