From 98dba9cfccdac0beb9e0a96ed0ed0bb19738edb9 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 13 May 2026 20:27:59 +0300 Subject: [PATCH 1/2] feat: improve format painter ux to match ms word --- .../v1/components/toolbar/super-toolbar.js | 5 + .../format-commands/format-commands.js | 203 +++++++++++++----- .../format-commands/format-painter.test.js | 167 ++++++++++++++ .../tests/toolbar/updateToolbarState.test.js | 32 +++ 4 files changed, 357 insertions(+), 50 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js 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 f88f77bb3a..315a61d7e7 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 @@ -632,6 +632,11 @@ export class SuperToolbar extends EventEmitter { if (commandState?.value != null) item.activate({ styleId: commandState.value }); else item.label.value = this.config.texts?.formatText || 'Format text'; }, + copyFormat: () => { + const hasStoredFormat = Boolean(this.activeEditor?.storage?.formatCommands?.storedStyle); + if (hasStoredFormat || commandState?.active) item.activate(); + else item.deactivate(); + }, list: () => { if (commandState?.active) { item.activate(); diff --git a/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js b/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js index 412cef401d..2d49090fe3 100644 --- a/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js +++ b/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js @@ -3,6 +3,8 @@ import { Extension } from '@core/Extension.js'; import { getMarksFromSelection } from '@core/helpers/getMarksFromSelection.js'; import { toggleMarkCascade } from '@core/commands/toggleMarkCascade.js'; +const FORMAT_PAINTER_DOUBLE_CLICK_MS = 500; + /** * Stored format style * @typedef {Object} StoredStyle @@ -36,6 +38,10 @@ export const FormatCommands = Extension.create({ * @type {StoredStyle[]|null} */ storedStyle: null, + sourceSelection: null, + persistent: false, + lastCopyFormatClickAt: 0, + releaseCleanup: null, }; }, @@ -86,75 +92,172 @@ export const FormatCommands = Extension.create({ * @category Command * @example * editor.commands.copyFormat() - * @note Works like format painter - first click copies, second click applies + * @note Works like format painter: click copies for one target selection; double-click keeps it active */ copyFormat: () => ({ chain }) => { - // If we don't have a saved style, save the current one + const currentSelection = getSelectionRange(this.editor.state); + if (!this.storage.storedStyle) { const marks = getMarksFromSelection(this.editor.state, this.editor); this.storage.storedStyle = marks; + this.storage.sourceSelection = currentSelection; + this.storage.persistent = false; + this.storage.lastCopyFormatClickAt = Date.now(); + armFormatPainterRelease({ storage: this.storage, editor: this.editor }); + return true; + } + + if (this.storage.persistent) { + clearFormatPainterStorage(this.storage); return true; } - // Special case: if there are no stored marks, but this is still an apply action - // We just clear the format - if (!this.storage.storedStyle.length) { - this.storage.storedStyle = null; - return chain().clearFormat().run(); + const clickedSourceAgain = isSameSelection(currentSelection, this.storage.sourceSelection); + const isDoubleClick = + clickedSourceAgain && Date.now() - this.storage.lastCopyFormatClickAt <= FORMAT_PAINTER_DOUBLE_CLICK_MS; + + if (isDoubleClick && !this.storage.persistent) { + this.storage.persistent = true; + this.storage.lastCopyFormatClickAt = 0; + return true; + } + + if (clickedSourceAgain) { + clearFormatPainterStorage(this.storage); + return true; } - // If we do have a stored style, apply it - const storedMarks = this.storage.storedStyle; - const processedMarks = []; - storedMarks.forEach((mark) => { - const { type, attrs } = mark; - const { name } = type; - - if (name === 'textStyle') { - Object.keys(attrs).forEach((key) => { - if (!attrs[key]) return; - const attributes = {}; - attributes[key] = attrs[key]; - processedMarks.push({ name: key, attrs: attributes }); - }); - } else { - processedMarks.push({ name, attrs }); - } - }); - - const marksToCommands = { - bold: ['setBold', 'unsetBold'], - italic: ['setItalic', 'unsetItalic'], - underline: ['setUnderline', 'unsetUnderline'], - color: ['setColor', 'setColor', null], - fontSize: ['setFontSize', 'unsetFontSize'], - fontFamily: ['setFontFamily', 'unsetFontFamily'], - }; - - // Apply marks present, clear ones that are not, by chaining commands - let result = chain(); - Object.keys(marksToCommands).forEach((key) => { - const [setCommand, unsetCommand, defaultParam] = marksToCommands[key]; - const markToApply = processedMarks.find((mark) => mark.name === key); - const hasEmptyAttrs = markToApply?.attrs && markToApply?.attrs[key]; - - let cmd = {}; - if (!markToApply && !hasEmptyAttrs) cmd = { command: unsetCommand, argument: defaultParam }; - else cmd = { command: setCommand, argument: markToApply.attrs[key] || defaultParam }; - result = result[cmd.command](cmd.argument); - }); - - this.storage.storedStyle = null; - return result; + return applyStoredFormat({ chain, storage: this.storage }); + }, + + /** + * Apply the stored format painter style to the current selection. + * @category Command + * @example + * editor.commands.applyStoredFormat() + */ + applyStoredFormat: + () => + ({ chain }) => { + return applyStoredFormat({ chain, storage: this.storage }); }, }; }, + onSelectionUpdate({ editor }) { + const { storedStyle, sourceSelection } = this.storage; + if (!storedStyle) return; + + const currentSelection = getSelectionRange(editor.state); + if (editor.state.selection.empty || isSameSelection(currentSelection, sourceSelection)) return; + if (!this.storage.releaseCleanup) editor.commands.applyStoredFormat(); + }, + + onDestroy() { + clearFormatPainterStorage(this.storage); + }, + addShortcuts() { return { 'Mod-Alt-c': () => this.editor.commands.clearFormat(), }; }, }); + +function getSelectionRange(state) { + const { from, to } = state.selection; + return { from, to }; +} + +function isSameSelection(selection, otherSelection) { + if (!selection || !otherSelection) return false; + return selection.from === otherSelection.from && selection.to === otherSelection.to; +} + +function clearFormatPainterStorage(storage) { + storage.releaseCleanup?.(); + storage.storedStyle = null; + storage.sourceSelection = null; + storage.persistent = false; + storage.lastCopyFormatClickAt = 0; + storage.releaseCleanup = null; +} + +function armFormatPainterRelease({ storage, editor }) { + if (storage.releaseCleanup) return; + if (typeof document === 'undefined' || !document?.addEventListener) return; + + const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; + const handleRelease = (event) => { + if (event?.target?.closest?.('[data-editor-ui-surface]')) return; + if (!storage.storedStyle) return; + const selection = editor.state.selection; + const currentSelection = getSelectionRange(editor.state); + if (selection.empty || isSameSelection(currentSelection, storage.sourceSelection)) return; + + editor.commands.applyStoredFormat(); + }; + + document.addEventListener(eventName, handleRelease, true); + storage.releaseCleanup = () => { + document.removeEventListener(eventName, handleRelease, true); + }; +} + +function applyStoredFormat({ chain, storage }) { + if (!storage.storedStyle) return false; + + const shouldStayActive = storage.persistent; + try { + if (!storage.storedStyle.length) { + if (!shouldStayActive) clearFormatPainterStorage(storage); + return chain().clearFormat().run(); + } + + const storedMarks = storage.storedStyle; + const processedMarks = []; + storedMarks.forEach((mark) => { + const { type, attrs } = mark; + const { name } = type; + + if (name === 'textStyle') { + Object.keys(attrs).forEach((key) => { + if (!attrs[key]) return; + const attributes = {}; + attributes[key] = attrs[key]; + processedMarks.push({ name: key, attrs: attributes }); + }); + } else { + processedMarks.push({ name, attrs }); + } + }); + + const marksToCommands = { + bold: ['setBold', 'unsetBold'], + italic: ['setItalic', 'unsetItalic'], + underline: ['setUnderline', 'unsetUnderline'], + color: ['setColor', 'setColor', null], + fontSize: ['setFontSize', 'unsetFontSize'], + fontFamily: ['setFontFamily', 'unsetFontFamily'], + }; + + let result = chain(); + Object.keys(marksToCommands).forEach((key) => { + const [setCommand, unsetCommand, defaultParam] = marksToCommands[key]; + const markToApply = processedMarks.find((mark) => mark.name === key); + const hasEmptyAttrs = markToApply?.attrs && markToApply?.attrs[key]; + + let cmd = {}; + if (!markToApply && !hasEmptyAttrs) cmd = { command: unsetCommand, argument: defaultParam }; + else cmd = { command: setCommand, argument: markToApply.attrs[key] || defaultParam }; + result = result[cmd.command](cmd.argument); + }); + + if (!shouldStayActive) clearFormatPainterStorage(storage); + return result; + } finally { + if (!shouldStayActive) clearFormatPainterStorage(storage); + } +} diff --git a/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js b/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js new file mode 100644 index 0000000000..77c144c5e9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +const doc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'Source', + marks: [{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#aa0000', fontSize: '18pt' } }], + }, + { + type: 'text', + text: ' Target Other', + }, + ], + }, + ], + }, + ], +}; + +const ranges = { + source: { from: 2, to: 8 }, + partialTarget: { from: 9, to: 11 }, + target: { from: 9, to: 15 }, + other: { from: 16, to: 21 }, +}; + +function selectRange(editor, range) { + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, range.from, range.to))); +} + +function releasePointer() { + const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; + document.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +function releasePointerFromToolbar() { + const toolbar = document.createElement('div'); + toolbar.setAttribute('data-editor-ui-surface', ''); + document.body.appendChild(toolbar); + + const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; + toolbar.dispatchEvent(new Event(eventName, { bubbles: true })); + toolbar.remove(); +} + +function getFirstTextMarks(editor, range) { + let marks = []; + editor.state.doc.nodesBetween(range.from, range.to, (node) => { + if (!node.isText || marks.length) return; + marks = node.marks; + }); + return marks; +} + +function getMark(editor, range, markName) { + return getFirstTextMarks(editor, range).find((mark) => mark.type.name === markName); +} + +function allTextInRangeHasMark(editor, range, markName) { + let foundText = false; + let allHaveMark = true; + editor.state.doc.nodesBetween(range.from, range.to, (node, pos) => { + if (!node.isText) return; + const textFrom = pos; + const textTo = pos + node.nodeSize; + if (textTo <= range.from || textFrom >= range.to) return; + + foundText = true; + if (!node.marks.some((mark) => mark.type.name === markName)) allHaveMark = false; + }); + return foundText && allHaveMark; +} + +describe('format painter', () => { + let editor = null; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('applies copied formatting when the next target selection is made', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + + selectRange(editor, ranges.target); + + expect(getMark(editor, ranges.target, 'bold')).toBeUndefined(); + + releasePointer(); + + expect(allTextInRangeHasMark(editor, ranges.target, 'bold')).toBe(true); + expect(getMark(editor, ranges.target, 'textStyle')?.attrs).toMatchObject({ + color: '#AA0000', + fontSize: '18pt', + }); + expect(editor.storage.formatCommands.storedStyle).toBeNull(); + }); + + it('keeps applying copied formatting after double-clicking format painter', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + editor.commands.copyFormat(); + + selectRange(editor, ranges.target); + releasePointer(); + selectRange(editor, ranges.other); + releasePointer(); + + expect(allTextInRangeHasMark(editor, ranges.target, 'bold')).toBe(true); + expect(allTextInRangeHasMark(editor, ranges.other, 'bold')).toBe(true); + expect(getMark(editor, ranges.other, 'textStyle')?.attrs).toMatchObject({ + color: '#AA0000', + fontSize: '18pt', + }); + expect(editor.storage.formatCommands.storedStyle).not.toBeNull(); + + editor.commands.copyFormat(); + + expect(editor.storage.formatCommands.storedStyle).toBeNull(); + }); + + it('deactivates persistent format painter when the toolbar button is clicked again', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + editor.commands.copyFormat(); + + selectRange(editor, ranges.target); + releasePointerFromToolbar(); + editor.commands.copyFormat(); + + expect(editor.storage.formatCommands.storedStyle).toBeNull(); + }); + + it('waits for a drag selection to settle before applying copied formatting', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + + selectRange(editor, ranges.partialTarget); + expect(getMark(editor, ranges.partialTarget, 'bold')).toBeUndefined(); + + selectRange(editor, ranges.target); + releasePointer(); + + expect(allTextInRangeHasMark(editor, ranges.partialTarget, 'bold')).toBe(true); + expect(allTextInRangeHasMark(editor, ranges.target, 'bold')).toBe(true); + expect(editor.storage.formatCommands.storedStyle).toBeNull(); + }); +}); 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 08c3e40ea3..6b2d46e242 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 @@ -877,6 +877,38 @@ describe('updateToolbarState', () => { expect(item.activate).toHaveBeenCalledWith({}, true); }); + it('activates copyFormat when the editor has stored format painter state', () => { + const item = buildItem('copyFormat'); + mockEditor.storage = { formatCommands: { storedStyle: [{ type: { name: 'bold' }, attrs: {} }] } }; + toolbar.toolbarItems = [item]; + toolbar.snapshot = { + commands: { + 'document-mode': { value: 'editing' }, + 'copy-format': { active: false, disabled: false }, + }, + }; + + toolbar.updateToolbarState(); + expect(item.activate).toHaveBeenCalled(); + expect(item.deactivate).not.toHaveBeenCalled(); + }); + + it('deactivates copyFormat when stored format painter state is cleared', () => { + const item = buildItem('copyFormat'); + mockEditor.storage = { formatCommands: { storedStyle: null } }; + toolbar.toolbarItems = [item]; + toolbar.snapshot = { + commands: { + 'document-mode': { value: 'editing' }, + 'copy-format': { active: false, disabled: false }, + }, + }; + + toolbar.updateToolbarState(); + expect(item.activate).not.toHaveBeenCalled(); + expect(item.deactivate).toHaveBeenCalled(); + }); + it('disables tableActions when every table command is disabled', () => { const item = buildItem('tableActions', { disabled: { value: false } }); toolbar.toolbarItems = [item]; From 272998bec80d2ad889d4b13e9ba00ab8f3f06ed7 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 14 May 2026 17:01:24 +0300 Subject: [PATCH 2/2] fix: address review comments --- .../format-commands/format-commands.js | 62 +++++++++++++++-- .../format-commands/format-painter.test.js | 66 ++++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js b/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js index 2d49090fe3..4e8a0c8cda 100644 --- a/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js +++ b/packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js @@ -4,6 +4,8 @@ import { getMarksFromSelection } from '@core/helpers/getMarksFromSelection.js'; import { toggleMarkCascade } from '@core/commands/toggleMarkCascade.js'; const FORMAT_PAINTER_DOUBLE_CLICK_MS = 500; +const FORMAT_PAINTER_UI_SELECTOR = + '[data-editor-ui-surface], .toolbar-dropdown-menu, .sd-toolbar-dropdown-menu, .sd-tooltip-content'; /** * Stored format style @@ -42,6 +44,8 @@ export const FormatCommands = Extension.create({ persistent: false, lastCopyFormatClickAt: 0, releaseCleanup: null, + pointerSelecting: false, + keyboardSelecting: false, }; }, @@ -152,7 +156,9 @@ export const FormatCommands = Extension.create({ const currentSelection = getSelectionRange(editor.state); if (editor.state.selection.empty || isSameSelection(currentSelection, sourceSelection)) return; - if (!this.storage.releaseCleanup) editor.commands.applyStoredFormat(); + if (this.storage.pointerSelecting || this.storage.keyboardSelecting) return; + + editor.commands.applyStoredFormat(); }, onDestroy() { @@ -183,15 +189,19 @@ function clearFormatPainterStorage(storage) { storage.persistent = false; storage.lastCopyFormatClickAt = 0; storage.releaseCleanup = null; + storage.pointerSelecting = false; + storage.keyboardSelecting = false; } function armFormatPainterRelease({ storage, editor }) { if (storage.releaseCleanup) return; if (typeof document === 'undefined' || !document?.addEventListener) return; - const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; - const handleRelease = (event) => { - if (event?.target?.closest?.('[data-editor-ui-surface]')) return; + const pointerDownEventName = typeof PointerEvent === 'undefined' ? 'mousedown' : 'pointerdown'; + const pointerUpEventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; + const isToolbarEvent = (event) => event?.target?.closest?.(FORMAT_PAINTER_UI_SELECTOR); + + const applyIfTargetSelected = () => { if (!storage.storedStyle) return; const selection = editor.state.selection; const currentSelection = getSelectionRange(editor.state); @@ -200,12 +210,51 @@ function armFormatPainterRelease({ storage, editor }) { editor.commands.applyStoredFormat(); }; - document.addEventListener(eventName, handleRelease, true); + const handlePointerDown = (event) => { + if (isToolbarEvent(event)) { + storage.pointerSelecting = false; + return; + } + storage.pointerSelecting = true; + }; + + const handleRelease = (event) => { + if (isToolbarEvent(event)) { + storage.pointerSelecting = false; + return; + } + storage.pointerSelecting = false; + applyIfTargetSelected(); + }; + + const handleKeyDown = (event) => { + if (isToolbarEvent(event)) return; + if (isFormatPainterSelectionKey(event)) storage.keyboardSelecting = true; + }; + + const handleKeyUp = () => { + if (!storage.keyboardSelecting) return; + storage.keyboardSelecting = false; + applyIfTargetSelected(); + }; + + document.addEventListener(pointerDownEventName, handlePointerDown, true); + document.addEventListener(pointerUpEventName, handleRelease, true); + document.addEventListener('keydown', handleKeyDown, true); + document.addEventListener('keyup', handleKeyUp, true); storage.releaseCleanup = () => { - document.removeEventListener(eventName, handleRelease, true); + document.removeEventListener(pointerDownEventName, handlePointerDown, true); + document.removeEventListener(pointerUpEventName, handleRelease, true); + document.removeEventListener('keydown', handleKeyDown, true); + document.removeEventListener('keyup', handleKeyUp, true); }; } +function isFormatPainterSelectionKey(event) { + if (!event?.shiftKey) return false; + return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key); +} + function applyStoredFormat({ chain, storage }) { if (!storage.storedStyle) return false; @@ -255,7 +304,6 @@ function applyStoredFormat({ chain, storage }) { result = result[cmd.command](cmd.argument); }); - if (!shouldStayActive) clearFormatPainterStorage(storage); return result; } finally { if (!shouldStayActive) clearFormatPainterStorage(storage); diff --git a/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js b/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js index 77c144c5e9..bf1da4a5cf 100644 --- a/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js +++ b/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js @@ -38,21 +38,47 @@ function selectRange(editor, range) { editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, range.from, range.to))); } +function pressPointer() { + const eventName = typeof PointerEvent === 'undefined' ? 'mousedown' : 'pointerdown'; + document.dispatchEvent(new Event(eventName, { bubbles: true })); +} + function releasePointer() { const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; document.dispatchEvent(new Event(eventName, { bubbles: true })); } +function pressSelectionKey() { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true })); +} + +function releaseSelectionKey() { + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', shiftKey: false, bubbles: true })); +} + function releasePointerFromToolbar() { const toolbar = document.createElement('div'); toolbar.setAttribute('data-editor-ui-surface', ''); document.body.appendChild(toolbar); - const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; - toolbar.dispatchEvent(new Event(eventName, { bubbles: true })); + releasePointerFromElement(toolbar); toolbar.remove(); } +function releasePointerFromDropdownMenu(className = 'toolbar-dropdown-menu') { + const menu = document.createElement('div'); + menu.className = className; + document.body.appendChild(menu); + + releasePointerFromElement(menu); + menu.remove(); +} + +function releasePointerFromElement(element) { + const eventName = typeof PointerEvent === 'undefined' ? 'mouseup' : 'pointerup'; + element.dispatchEvent(new Event(eventName, { bubbles: true })); +} + function getFirstTextMarks(editor, range) { let marks = []; editor.state.doc.nodesBetween(range.from, range.to, (node) => { @@ -95,6 +121,7 @@ describe('format painter', () => { selectRange(editor, ranges.source); editor.commands.copyFormat(); + pressPointer(); selectRange(editor, ranges.target); expect(getMark(editor, ranges.target, 'bold')).toBeUndefined(); @@ -116,8 +143,10 @@ describe('format painter', () => { editor.commands.copyFormat(); editor.commands.copyFormat(); + pressPointer(); selectRange(editor, ranges.target); releasePointer(); + pressPointer(); selectRange(editor, ranges.other); releasePointer(); @@ -148,12 +177,28 @@ describe('format painter', () => { expect(editor.storage.formatCommands.storedStyle).toBeNull(); }); + it('does not apply formatting when pointer is released over a teleported toolbar dropdown', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + + pressPointer(); + selectRange(editor, ranges.target); + releasePointerFromDropdownMenu(); + + expect(getMark(editor, ranges.target, 'bold')).toBeUndefined(); + expect(editor.storage.formatCommands.storedStyle).not.toBeNull(); + expect(editor.storage.formatCommands.pointerSelecting).toBe(false); + }); + it('waits for a drag selection to settle before applying copied formatting', () => { ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); selectRange(editor, ranges.source); editor.commands.copyFormat(); + pressPointer(); selectRange(editor, ranges.partialTarget); expect(getMark(editor, ranges.partialTarget, 'bold')).toBeUndefined(); @@ -164,4 +209,21 @@ describe('format painter', () => { expect(allTextInRangeHasMark(editor, ranges.target, 'bold')).toBe(true); expect(editor.storage.formatCommands.storedStyle).toBeNull(); }); + + it('applies copied formatting when keyboard target selection completes', () => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: structuredClone(doc) })); + + selectRange(editor, ranges.source); + editor.commands.copyFormat(); + + pressSelectionKey(); + selectRange(editor, ranges.target); + + expect(getMark(editor, ranges.target, 'bold')).toBeUndefined(); + + releaseSelectionKey(); + + expect(allTextInRangeHasMark(editor, ranges.target, 'bold')).toBe(true); + expect(editor.storage.formatCommands.storedStyle).toBeNull(); + }); });