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.
pull/6027/head
boojack 3 weeks ago
parent 46685b1906
commit 817561df8f

@ -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);
});
});

Loading…
Cancel
Save