fix(editor): let CodeMirror own caret layout

Keep caret and placeholder behavior on CodeMirror's standard extensions, and move height and scrolling to the editor elements so empty space stays interactive across browsers.\n\n- disable unused multi-cursor selection\n- upgrade CodeMirror state and view patches
This commit is contained in:
boojack
2026-07-10 08:51:16 +08:00
parent 0150040056
commit a9ac008a68
7 changed files with 86 additions and 60 deletions
+2 -2
View File
@@ -21,8 +21,8 @@
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@github/relative-time-element": "^5.0.0",
+36 -31
View File
@@ -21,11 +21,11 @@ importers:
specifier: ^6.12.4
version: 6.12.4
'@codemirror/state':
specifier: ^6.7.0
version: 6.7.0
specifier: ^6.7.1
version: 6.7.1
'@codemirror/view':
specifier: ^6.43.3
version: 6.43.3
specifier: ^6.43.6
version: 6.43.6
'@connectrpc/connect':
specifier: ^2.1.1
version: 2.1.1(@bufbuild/protobuf@2.12.0)
@@ -498,11 +498,11 @@ packages:
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/state@6.7.0':
resolution: {integrity: sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==}
'@codemirror/state@6.7.1':
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
'@codemirror/view@6.43.3':
resolution: {integrity: sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==}
'@codemirror/view@6.43.6':
resolution: {integrity: sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==}
'@connectrpc/connect-web@2.1.1':
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
@@ -633,8 +633,8 @@ packages:
'@lezer/markdown@1.6.4':
resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@mermaid-js/parser@1.1.0':
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
@@ -1828,6 +1828,9 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -3360,22 +3363,22 @@ snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.4':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.0
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/css': 1.3.3
@@ -3385,8 +3388,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.3
'@lezer/html': 1.3.13
@@ -3396,8 +3399,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
@@ -3406,15 +3409,15 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/markdown': 1.6.4
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -3422,18 +3425,18 @@ snapshots:
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.0
'@codemirror/view': 6.43.3
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.6
crelt: 1.0.6
'@codemirror/state@6.7.0':
'@codemirror/state@6.7.1':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@marijn/find-cluster-break': 1.0.3
'@codemirror/view@6.43.3':
'@codemirror/view@6.43.6':
dependencies:
'@codemirror/state': 6.7.0
crelt: 1.0.6
'@codemirror/state': 6.7.1
crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
@@ -3572,7 +3575,7 @@ snapshots:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@marijn/find-cluster-break@1.0.2': {}
'@marijn/find-cluster-break@1.0.3': {}
'@mermaid-js/parser@1.1.0':
dependencies:
@@ -4657,6 +4660,8 @@ snapshots:
crelt@1.0.6: {}
crelt@1.0.7: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@@ -12,14 +12,11 @@
& .cm-editor {
background: transparent;
font-size: 1rem;
/* Fill the host so the whole visible editor area belongs to CodeMirror.
Without this the editor only grows to its content height, and any empty
space below the text (notably in focus mode, where the host is tall) is
bare host markup — clicking it can't focus the editor or place the caret,
so the caret appears to vanish in "empty" space (#6076). height:100%
collapses to auto when the host is auto-height (normal mode), so this
only fills when the host has a definite height. */
max-height: 50vh;
}
&[data-focus-mode] .cm-editor {
height: 100%;
max-height: none;
}
& .cm-editor.cm-focused {
outline: none;
@@ -27,7 +24,6 @@
& .cm-content {
font-family: inherit;
padding: 0;
caret-color: var(--foreground);
}
& .cm-line {
padding: 0;
@@ -35,6 +31,7 @@
& .cm-scroller {
font-family: inherit;
line-height: 1.5;
overflow: auto;
}
& .cm-placeholder {
color: var(--muted-foreground);
@@ -1,7 +1,7 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { indentUnit } from "@codemirror/language";
import { EditorState, type Extension } from "@codemirror/state";
import type { Extension } from "@codemirror/state";
import { placeholder as cmPlaceholder, drawSelection, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
import { GFM } from "@lezer/markdown";
import { headingDecorations } from "./headingDecorations";
@@ -50,15 +50,11 @@ export function buildEditorExtensions({ placeholder, onChange, onUpdate, onSubmi
];
return [
// Core editing behavior. Without these the editor relies on raw
// contenteditable: typing works but there is no visible caret on focus
// (drawSelection), no undo/redo (history), and Enter/selection/word-motion
// keys are unwired (defaultKeymap). They are NOT part of CodeMirror's
// minimal core — basicSetup bundles them, and we assemble them here.
// Core editing behavior. These are the pieces from CM6 setup that this memo
// editor uses, without enabling multi-cursor selection.
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
// Indent with spaces (markdown), matching the 2-space bullet nesting.
indentUnit.of(" "),
markdown({ extensions: [GFM] }),
@@ -3,7 +3,6 @@ import { EditorView } from "@codemirror/view";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
import { useTagCounts } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import type { EditorController } from "../types/editorController";
import { createController } from "./controller";
import "./editor.css";
@@ -78,13 +77,10 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
return (
<div
className={cn(
"flex flex-col justify-start items-start relative w-full bg-inherit",
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
className,
)}
className={cn("relative flex w-full flex-col items-start justify-start bg-inherit", isFocusMode && "min-h-0 flex-1", className)}
data-focus-mode={isFocusMode || undefined}
>
<div ref={hostRef} className={cn("w-full text-base overflow-y-auto", isFocusMode ? "flex-1 h-0" : "h-full")} onPaste={onPaste} />
<div ref={hostRef} className={cn("w-full text-base", isFocusMode && "min-h-0 flex-1")} onPaste={onPaste} />
</div>
);
});
@@ -8,11 +8,6 @@ export const FOCUS_MODE_STYLES = {
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
} as const;
export const EDITOR_HEIGHT = {
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
normal: "max-h-[50vh]",
} as const;
// localStorage key for the user's preference to show the formatting toolbar in
// normal (non-focus) mode. Defaults to off.
export const FORMATTING_TOOLBAR_STORAGE_KEY = "memos-editor-formatting-toolbar";
+37
View File
@@ -0,0 +1,37 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
describe("MemoEditor CodeMirror extensions", () => {
const views: EditorView[] = [];
afterEach(() => {
for (const view of views.splice(0)) {
view.destroy();
}
document.body.replaceChildren();
});
it("uses CodeMirror's selection and placeholder extensions without enabling multi-cursor selection", () => {
const parent = document.body.appendChild(document.createElement("div"));
const state = EditorState.create({
doc: "",
extensions: buildEditorExtensions({
placeholder: "Any thoughts...",
onChange: vi.fn(),
onUpdate: vi.fn(),
onSubmit: vi.fn(),
getTags: () => [],
}),
});
const view = new EditorView({ state, parent });
views.push(view);
expect(view.state.facet(EditorState.allowMultipleSelections)).toBe(false);
expect(view.dom.querySelector(".cm-selectionLayer")).not.toBeNull();
expect(view.dom.querySelector(".cm-cursorLayer")).not.toBeNull();
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
expect(view.dom.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts...");
});
});