fix(editor): collapse lingering select-all selection after delete
Ctrl+A creates a whole-document AllSelection; deleting it (Backspace, Delete, or Cut) maps the AllSelection onto the now-empty paragraph instead of collapsing to a caret, so the view paints a "selected" empty block. A ProseMirror appendTransaction now collapses a leftover AllSelection to a caret after any doc-changing edit.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Placeholder } from "@tiptap/extensions";
|
||||
import { AllSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||
import type { EditorProps as ProseMirrorEditorProps } from "@tiptap/pm/view";
|
||||
import { EditorContent as RichTextContent, useEditor } from "@tiptap/react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
@@ -31,6 +32,32 @@ const SaveShortcutPassthrough = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Ctrl+A makes ProseMirror's whole-document AllSelection. Deleting it
|
||||
// (Backspace / Delete / Cut) empties the document, but AllSelection.map()
|
||||
// always returns another AllSelection — so the editor is left holding a
|
||||
// non-empty selection spanning the now-empty paragraph, which the view paints
|
||||
// as a "selected" empty block (the reported artifact). After any doc-changing
|
||||
// edit that leaves an AllSelection behind, collapse it to a caret at the start,
|
||||
// the same way deleting an ordinary text selection collapses to a cursor.
|
||||
// The `instanceof AllSelection` guard re-evaluates against the post-collapse
|
||||
// state, so the appended selection-only transaction is not re-processed.
|
||||
const CollapseAllSelectionAfterDelete = Extension.create({
|
||||
name: "collapseAllSelectionAfterDelete",
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("collapseAllSelectionAfterDelete"),
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
if (!transactions.some((tr) => tr.docChanged) || !(newState.selection instanceof AllSelection)) {
|
||||
return null;
|
||||
}
|
||||
return newState.tr.setSelection(Selection.atStart(newState.doc));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export interface EditorProps {
|
||||
className?: string;
|
||||
initialContent: string;
|
||||
@@ -97,6 +124,7 @@ const Editor = forwardRef<EditorController, EditorProps>(function Editor(props,
|
||||
TagSuggestion.configure({ getTags: () => tagsRef.current }),
|
||||
SlashCommand,
|
||||
SaveShortcutPassthrough,
|
||||
CollapseAllSelectionAfterDelete,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -41,6 +41,17 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Block styles (e.g. a paragraph's mb-2) add a trailing bottom margin that the
|
||||
raw textarea editor — whose box has no trailing space — does not have, so the
|
||||
WYSIWYG editor renders ~8px taller. Most visible when empty: a lone empty
|
||||
paragraph sits a line-plus-margin tall against the textarea's single line.
|
||||
Dropping the final block's bottom margin keeps both editor modes the same
|
||||
height. Scoped with `>` to the top-level block only (nested last children,
|
||||
e.g. the last list item, keep their spacing). */
|
||||
.memo-wysiwyg > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Editor-only element styles: task lists and code blocks have no shared markup
|
||||
with the read-only memo view (MemoContent renders those via TaskListItem.tsx
|
||||
and CodeBlock.tsx), so unlike the elements in markdownStyles.ts they stay
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, render } from "@testing-library/react";
|
||||
import type { Editor as EditorInstance } from "@tiptap/core";
|
||||
import { AllSelection } from "@tiptap/pm/state";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Editor from "@/components/MemoEditor/Editor";
|
||||
@@ -170,3 +171,42 @@ describe("keyboard shortcuts", () => {
|
||||
expect(ref.current?.getMarkdown()).not.toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("select-all then delete", () => {
|
||||
it("collapses to a caret instead of leaving a highlighted empty block", () => {
|
||||
// Repro of the reported bug: Ctrl+A makes an AllSelection, Backspace empties
|
||||
// the document. AllSelection.map() always returns another AllSelection, so
|
||||
// without intervention the selection is left as a non-empty whole-document
|
||||
// AllSelection over the empty paragraph — which the view paints as a
|
||||
// "selected" empty block (the screenshot artifact).
|
||||
const { ref, editor } = setup("hello world");
|
||||
const proseMirrorEl = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
});
|
||||
expect(editor.state.selection).toBeInstanceOf(AllSelection);
|
||||
act(() => {
|
||||
fireEvent.keyDown(proseMirrorEl, { key: "Backspace" });
|
||||
});
|
||||
expect(ref.current?.isEmpty()).toBe(true);
|
||||
// The fix: the leftover selection must be a collapsed caret, never a
|
||||
// lingering non-empty AllSelection.
|
||||
expect(editor.state.selection.empty).toBe(true);
|
||||
expect(editor.state.selection).not.toBeInstanceOf(AllSelection);
|
||||
});
|
||||
|
||||
it("deleting an all-selection via the deleteSelection command also collapses (covers Cut)", () => {
|
||||
// Cut (Cmd-X) and programmatic deletes go through deleteSelection rather
|
||||
// than the Backspace keymap, but hit the same AllSelection.map() trap.
|
||||
const { ref, editor } = setup("# Title\n\nsome **bold** text");
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
});
|
||||
act(() => {
|
||||
editor.commands.deleteSelection();
|
||||
});
|
||||
expect(ref.current?.isEmpty()).toBe(true);
|
||||
expect(editor.state.selection.empty).toBe(true);
|
||||
expect(editor.state.selection).not.toBeInstanceOf(AllSelection);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user