Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/super-editor/src/core/extensions/editable.js
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selection.anchor and selection.head are always defined - the ?? fallbacks never trigger.

could simplify to just selection.anchor > selection.head

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);
Comment thread
artem-harbour marked this conversation as resolved.
event.preventDefault();

return true;
};

/**
* Editable extension controls whether the editor accepts user input.
*
Expand All @@ -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) => {
Expand Down
50 changes: 50 additions & 0 deletions packages/super-editor/src/core/extensions/editable.test.js
Original file line number Diff line number Diff line change
@@ -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: '<p>PREAMBLE</p>',
}));

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');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }>) =>
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);
});
Loading