From 817561df8f52c204df2393a8f6cbeb152383f37f Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 15 Jun 2026 09:33:16 +0800 Subject: [PATCH] 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. --- .../components/MemoEditor/Editor/index.tsx | 28 +++++++++++++ web/src/index.css | 11 +++++ web/tests/editor-controller.test.tsx | 40 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index bdaa4865..d0006625 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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(function Editor(props, TagSuggestion.configure({ getTags: () => tagsRef.current }), SlashCommand, SaveShortcutPassthrough, + CollapseAllSelectionAfterDelete, ], [], ); diff --git a/web/src/index.css b/web/src/index.css index 48d895cd..5ad8f1eb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 diff --git a/web/tests/editor-controller.test.tsx b/web/tests/editor-controller.test.tsx index fadffb8b..607f6c0b 100644 --- a/web/tests/editor-controller.test.tsx +++ b/web/tests/editor-controller.test.tsx @@ -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); + }); +});