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..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 @@ -3,6 +3,10 @@ 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; +const FORMAT_PAINTER_UI_SELECTOR = + '[data-editor-ui-surface], .toolbar-dropdown-menu, .sd-toolbar-dropdown-menu, .sd-tooltip-content'; + /** * Stored format style * @typedef {Object} StoredStyle @@ -36,6 +40,12 @@ export const FormatCommands = Extension.create({ * @type {StoredStyle[]|null} */ storedStyle: null, + sourceSelection: null, + persistent: false, + lastCopyFormatClickAt: 0, + releaseCleanup: null, + pointerSelecting: false, + keyboardSelecting: false, }; }, @@ -86,75 +96,216 @@ 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; + } + + 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; } - // 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(); + 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.pointerSelecting || this.storage.keyboardSelecting) return; + + 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; + storage.pointerSelecting = false; + storage.keyboardSelecting = false; +} + +function armFormatPainterRelease({ storage, editor }) { + if (storage.releaseCleanup) return; + if (typeof document === 'undefined' || !document?.addEventListener) 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); + if (selection.empty || isSameSelection(currentSelection, storage.sourceSelection)) return; + + editor.commands.applyStoredFormat(); + }; + + 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(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; + + 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); + }); + + 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..bf1da4a5cf --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/format-commands/format-painter.test.js @@ -0,0 +1,229 @@ +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 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); + + 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) => { + 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(); + + pressPointer(); + 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(); + + pressPointer(); + selectRange(editor, ranges.target); + releasePointer(); + pressPointer(); + 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('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(); + + 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(); + }); + + 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(); + }); +}); 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];