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() {}