diff --git a/packages/super-editor/src/core/extensions/editable.js b/packages/super-editor/src/core/extensions/editable.js index 2f0ce45251..d514327511 100644 --- a/packages/super-editor/src/core/extensions/editable.js +++ b/packages/super-editor/src/core/extensions/editable.js @@ -1,6 +1,32 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Extension } from '../Extension.js'; +const handleBackwardReplaceInsertText = (view, event) => { + const isInsertTextInput = event?.inputType === 'insertText'; + const hasTextData = typeof event?.data === 'string' && event.data.length > 0; + const hasNonEmptySelection = !view.state.selection.empty; + + if (!isInsertTextInput || !hasTextData || !hasNonEmptySelection) { + return false; + } + + const selection = view.state.selection; + const anchor = selection.anchor ?? selection.from; + const head = selection.head ?? selection.to; + const isBackwardSelection = anchor > head; + + if (!isBackwardSelection) { + return false; + } + + const tr = view.state.tr.insertText(event.data, selection.from, selection.to); + tr.setMeta('inputType', 'insertText'); + view.dispatch(tr); + event.preventDefault(); + + return true; +}; + /** * Editable extension controls whether the editor accepts user input. * @@ -22,11 +48,17 @@ export const Editable = Extension.create({ props: { editable: () => editor.options.editable, handleDOMEvents: { - beforeinput: (_view, event) => { + beforeinput: (view, event) => { if (!editor.options.editable) { event.preventDefault(); return true; } + + // Backward (right-to-left) replacement can be misinterpreted downstream as + // deleteContentBackward. Handle this narrow case explicitly at beforeinput level. + if (handleBackwardReplaceInsertText(view, event)) { + return true; + } return false; }, mousedown: (_view, event) => { diff --git a/packages/super-editor/src/core/extensions/editable.test.js b/packages/super-editor/src/core/extensions/editable.test.js new file mode 100644 index 0000000000..40d5a6056d --- /dev/null +++ b/packages/super-editor/src/core/extensions/editable.test.js @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +const findTextRange = (doc, text) => { + let range = null; + doc.descendants((node, pos) => { + if (node.isText && node.text === text) { + range = { + from: pos, + to: pos + node.text.length, + }; + return false; + } + return true; + }); + return range; +}; + +describe('Editable extension backward replace handling', () => { + let editor = null; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('replaces backward non-empty selection on beforeinput insertText', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

PREAMBLE

', + })); + + const range = findTextRange(editor.state.doc, 'PREAMBLE'); + expect(range).not.toBeNull(); + + const backwardSelection = TextSelection.create(editor.state.doc, range.to, range.from); + editor.view.dispatch(editor.state.tr.setSelection(backwardSelection)); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'Z', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + editor.view.dom.dispatchEvent(beforeInputEvent); + + expect(editor.state.doc.textContent).toBe('Z'); + }); +}); diff --git a/tests/behavior/tests/formatting/replace-text-preserves-style.spec.ts b/tests/behavior/tests/formatting/replace-text-preserves-style.spec.ts new file mode 100644 index 0000000000..6e1be363e3 --- /dev/null +++ b/tests/behavior/tests/formatting/replace-text-preserves-style.spec.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/basic/sd-site-doc-2026.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +/** + * SD-1951: Highlighting text right-to-left (backward selection) and typing a + * replacement should preserve the original text's styling. On main this was + * broken because the backward selection caused ProseMirror to misinterpret the + * insertText input as deleteContentBackward. + */ +test('backward-selected text replacement preserves original style (SD-1951)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const titleText = 'Mutual Agreement for Document Excellence'; + await superdoc.assertTextContains(titleText); + + // Capture the title's marks before replacement + const titleStart = await superdoc.findTextPos(titleText); + const titleEnd = titleStart + titleText.length; + const originalMarks = await superdoc.getMarkAttrsAtPos(titleStart); + + // Create a BACKWARD selection (right-to-left) and dispatch a beforeinput + // event to simulate typing. This exercises the exact code path from the + // SD-1951 fix: the editable extension's beforeinput handler intercepts + // backward selection + insertText and replaces text with correct marks. + // + // We dispatch via view.dom so ProseMirror's event pipeline processes it + // naturally through runCustomHandler → handleDOMEvents → our handler. + const result = await superdoc.page.evaluate( + ({ from, to }) => { + const { state, view } = (window as any).editor; + const TextSelectionClass = state.selection.constructor; + const backward = TextSelectionClass.create(state.doc, to, from); + view.dispatch(state.tr.setSelection(backward)); + + // Dispatch a native beforeinput event on view.dom + const event = new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'Z', + bubbles: true, + cancelable: true, + }); + view.dom.dispatchEvent(event); + + return { + prevented: event.defaultPrevented, + docText: (window as any).editor.state.doc.textContent.substring(0, 80), + }; + }, + { from: titleStart, to: titleEnd }, + ); + await superdoc.waitForStable(); + + // On the fix branch, the handler intercepts and replaces text (preventDefault). + // On main, the handler doesn't intercept backward selection + insertText. + expect(result.prevented).toBe(true); + expect(result.docText).toContain('Z'); + expect(result.docText).not.toContain(titleText); + + // The replacement character must appear in the document + await superdoc.assertTextContains('Z'); + await superdoc.assertTextNotContains(titleText); + + // The replacement must retain the original title's marks (font family, size), + // not inherit from the previous paragraph or document defaults. + const zPos = await superdoc.findTextPos('Z'); + const replacementMarks = await superdoc.getMarkAttrsAtPos(zPos); + + const getTextStyleAttrs = (marks: Array<{ name: string; attrs: Record }>) => + marks.find((m) => m.name === 'textStyle')?.attrs; + + const originalStyle = getTextStyleAttrs(originalMarks); + const replacementStyle = getTextStyleAttrs(replacementMarks); + + expect(originalStyle).toBeDefined(); + expect(replacementStyle).toBeDefined(); + expect(replacementStyle!.fontFamily).toBe(originalStyle!.fontFamily); + expect(replacementStyle!.fontSize).toBe(originalStyle!.fontSize); +});