Andrey Antukh a39b9766db 🐛 Guard blur-selection entity against stale text selections
Skip the PENPOT_SELECTION stamp when the selection is collapsed
or points at blocks that no longer exist: blurring the editor
rebuilds the EditorState with fresh block keys while a delayed
on-change still carries the old keys, which crashed Draft.js
reading getCharacterList of undefined and killed the workspace.

Harden the sibling helpers (removeBlurSelectionEntity,
cursorToEnd, select-all/end builders) the same way and add a
vitest suite to the draft-js package covering stale, empty,
and collapsed selections.

Closes #11667

AI-assisted-by: muse-spark-1.3-contributor
2026-10-19 15:40:13 +02:00

138 lines
3.8 KiB
JavaScript

/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC Sucursal en España SL
*/
import { describe, test, expect } from "vitest";
import {
EditorState,
SelectionState,
addBlurSelectionEntity,
convertFromRaw,
createEditorState,
cursorToEnd,
removeBlurSelectionEntity,
} from "./index.js";
/* @vitest-environment node */
function makeContent() {
return convertFromRaw({
blocks: [
{
key: "first",
text: "Hello",
type: "unstyled",
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {},
},
{
key: "second",
text: "World",
type: "unstyled",
depth: 0,
inlineStyleRanges: [],
entityRanges: [],
data: {},
},
],
entityMap: {},
});
}
function makeState() {
return createEditorState(makeContent(), null);
}
function makeSelection(anchorKey, anchorOffset, focusKey, focusOffset) {
return new SelectionState({
anchorKey,
anchorOffset,
focusKey,
focusOffset,
isBackward: false,
hasFocus: true,
});
}
function withSelection(state, selection) {
return EditorState.forceSelection(state, selection);
}
describe("addBlurSelectionEntity", () => {
test("does not throw on stale selection keys and returns state unchanged", () => {
const state = makeState();
const stale = withSelection(
state,
makeSelection("missing-start", 0, "missing-end", 3),
);
expect(() => addBlurSelectionEntity(stale)).not.toThrow();
expect(addBlurSelectionEntity(stale)).toBe(stale);
});
test("skips collapsed selections and returns state unchanged", () => {
const state = makeState();
const collapsed = withSelection(
state,
makeSelection("first", 2, "first", 2),
);
expect(addBlurSelectionEntity(collapsed)).toBe(collapsed);
});
test("applies PENPOT_SELECTION entity on valid non-collapsed selections", () => {
const state = makeState();
const selected = withSelection(
state,
makeSelection("first", 0, "first", 5),
);
const result = addBlurSelectionEntity(selected);
const block = result.getCurrentContent().getBlockForKey("first");
expect(block.getEntityAt(0)).not.toBeNull();
expect(block.getEntityAt(4)).not.toBeNull();
expect(block.getEntityAt(0)).toBe(block.getEntityAt(4));
});
});
describe("removeBlurSelectionEntity", () => {
test("does not throw on stale selection keys and returns state unchanged", () => {
const state = makeState();
const stale = withSelection(
state,
makeSelection("missing-start", 0, "missing-end", 3),
);
expect(() => removeBlurSelectionEntity(stale)).not.toThrow();
expect(removeBlurSelectionEntity(stale)).toBe(stale);
});
test("clears entities and restores the user selection", () => {
const state = makeState();
const selected = withSelection(
state,
makeSelection("first", 1, "second", 2),
);
const blurred = addBlurSelectionEntity(selected);
const result = removeBlurSelectionEntity(blurred);
expect(
result.getCurrentContent().getBlockForKey("first").getEntityAt(1),
).toBeNull();
expect(result.getSelection().getAnchorKey()).toBe(
blurred.getSelection().getAnchorKey(),
);
});
});
describe("cursorToEnd", () => {
test("moves the selection to the end of the last block", () => {
const state = makeState();
const result = cursorToEnd(state);
const selection = result.getSelection();
expect(selection.getAnchorKey()).toBe("second");
expect(selection.getAnchorOffset()).toBe(5);
expect(selection.isCollapsed()).toBe(true);
});
});