From 77e1b67894d9bfbdbe9eb23aae281bc0e46e346c Mon Sep 17 00:00:00 2001 From: MuRong Date: Fri, 4 Sep 2026 22:41:07 +0800 Subject: [PATCH 1/2] fix(editor): preserve source scroll position while editing --- .../src/components/MarkdownSourceEditor.tsx | 4 + .../src/codemirror/external-scroll.test.ts | 187 ++++++++++++++++++ .../editor/src/codemirror/external-scroll.ts | 160 +++++++++++++++ packages/editor/src/codemirror/index.ts | 2 + 4 files changed, 353 insertions(+) create mode 100644 packages/editor/src/codemirror/external-scroll.test.ts create mode 100644 packages/editor/src/codemirror/external-scroll.ts diff --git a/packages/app/src/components/MarkdownSourceEditor.tsx b/packages/app/src/components/MarkdownSourceEditor.tsx index 491ccd64..0ac0ea69 100644 --- a/packages/app/src/components/MarkdownSourceEditor.tsx +++ b/packages/app/src/components/MarkdownSourceEditor.tsx @@ -5,6 +5,7 @@ import { Decoration, EditorView, keymap, lineNumbers } from "@codemirror/view"; import { minimalSetup } from "codemirror"; import { t, type AppLanguage, type SearchRange } from "@markra/shared"; import { + codeMirrorExternalScroll, codeMirrorLocationCue, codeMirrorTypewriterMode, markdownShortcutsPlugin, @@ -361,6 +362,9 @@ export function MarkdownSourceEditor({ markdownSourcePlainTextPasteExtension(markdownShortcuts, readClipboardText) ), codeMirrorLocationCue(), + codeMirrorExternalScroll({ + getScrollContainer: (view) => view.dom.closest(".paper-scroll") + }), typewriterModeCompartmentRef.current.of( codeMirrorTypewriterMode({ enabled: typewriterModeEnabled }) ), diff --git a/packages/editor/src/codemirror/external-scroll.test.ts b/packages/editor/src/codemirror/external-scroll.test.ts new file mode 100644 index 00000000..b99661ba --- /dev/null +++ b/packages/editor/src/codemirror/external-scroll.test.ts @@ -0,0 +1,187 @@ +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { codeMirrorExternalScroll } from "./external-scroll.ts"; +import "./dom.test-support.ts"; + +const views: EditorView[] = []; + +function syntheticRect(top: number, bottom: number): DOMRect { + return { + bottom, + height: bottom - top, + left: 0, + right: 100, + top, + width: 100, + x: 0, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +function createView(targetRect: DOMRect, scrollTop = 200) { + const scrollContainer = document.createElement("section"); + const parent = document.createElement("div"); + scrollContainer.append(parent); + document.body.append(scrollContainer); + scrollContainer.scrollTop = scrollTop; + scrollContainer.getBoundingClientRect = () => syntheticRect(100, 500); + Object.defineProperties(scrollContainer, { + clientHeight: { configurable: true, value: 400 }, + scrollHeight: { configurable: true, value: 1_000 }, + }); + + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: "first\nsecond\nthird", + extensions: [ + codeMirrorExternalScroll({ + getScrollContainer: () => scrollContainer, + }), + ], + selection: EditorSelection.cursor(8), + }), + }); + vi.spyOn(view, "coordsAtPos").mockReturnValue(targetRect); + views.push(view); + + return { scrollContainer, view }; +} + +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("CodeMirror external scroll", () => { + it("restores the external viewport after a focused document input", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "!", + })); + scrollContainer.scrollTop = 330; + view.contentDOM.dispatchEvent(new InputEvent("beforeinput", { + bubbles: true, + data: "!", + inputType: "insertText", + })); + + view.dispatch({ + changes: { from: 8, insert: "!" }, + selection: EditorSelection.cursor(9), + scrollIntoView: true, + userEvent: "input.type", + }); + scrollContainer.scrollTop = 480; + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(200); + }); + + it("minimally reveals a cursor that moves below the restored viewport", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(520, 540)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "Enter", + })); + + view.dispatch({ + changes: { from: 8, insert: "\n" }, + selection: EditorSelection.cursor(9), + scrollIntoView: true, + userEvent: "input.type", + }); + scrollContainer.scrollTop = 480; + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(245); + }); + + it("captures the viewport before IME composition changes the DOM", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { + bubbles: true, + data: "", + })); + scrollContainer.scrollTop = 330; + view.contentDOM.dispatchEvent(new InputEvent("beforeinput", { + bubbles: true, + data: "示", + inputType: "insertCompositionText", + })); + + view.dispatch({ + changes: { from: 8, insert: "示" }, + selection: EditorSelection.cursor(9), + scrollIntoView: true, + userEvent: "input.type.compose", + }); + scrollContainer.scrollTop = 480; + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(200); + }); + + it("does not reuse a stale snapshot from a navigation key", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "ArrowDown", + })); + scrollContainer.scrollTop = 300; + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "!", + })); + view.contentDOM.dispatchEvent(new InputEvent("beforeinput", { + bubbles: true, + data: "!", + inputType: "insertText", + })); + + view.dispatch({ + changes: { from: 8, insert: "!" }, + selection: EditorSelection.cursor(9), + scrollIntoView: true, + userEvent: "input.type", + }); + scrollContainer.scrollTop = 480; + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(300); + }); + + it("restores the viewport after a keyboard deletion", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "Backspace", + })); + + view.dispatch({ + changes: { from: 7, to: 8 }, + selection: EditorSelection.cursor(7), + scrollIntoView: true, + userEvent: "delete.backward", + }); + scrollContainer.scrollTop = 480; + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(200); + }); +}); diff --git a/packages/editor/src/codemirror/external-scroll.ts b/packages/editor/src/codemirror/external-scroll.ts new file mode 100644 index 00000000..7500098c --- /dev/null +++ b/packages/editor/src/codemirror/external-scroll.ts @@ -0,0 +1,160 @@ +import type { Extension } from "@codemirror/state"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; + +export interface CodeMirrorExternalScrollOptions { + getScrollContainer: (view: EditorView) => HTMLElement | null; +} + +interface ScrollSnapshot { + container: HTMLElement; + left: number; + top: number; +} + +function keyMayEditDocument(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && !event.altKey) { + const key = event.key.toLowerCase(); + return key === "v" || key === "x"; + } + + return event.key.length === 1 || [ + "Backspace", + "Delete", + "Enter", + "Tab", + ].includes(event.key); +} + +function revealExternalCursor(view: EditorView, container: HTMLElement) { + const selection = view.state.selection.main; + const cursor = view.coordsAtPos(selection.head, selection.assoc || undefined); + if (!cursor) return; + + const viewport = container.getBoundingClientRect(); + const margin = 5; + const delta = cursor.top < viewport.top + margin + ? cursor.top - viewport.top - margin + : cursor.bottom > viewport.bottom - margin + ? cursor.bottom - viewport.bottom + margin + : 0; + if (delta === 0) return; + + const maximum = Math.max(0, container.scrollHeight - container.clientHeight); + container.scrollTop = Math.max( + 0, + Math.min(maximum, container.scrollTop + delta), + ); +} + +class CodeMirrorExternalScrollView { + private animationFrame: number | null = null; + private microtaskQueued = false; + private snapshot: ScrollSnapshot | null = null; + + constructor( + private readonly view: EditorView, + private readonly getScrollContainer: (view: EditorView) => HTMLElement | null, + ) {} + + capture(replace = false) { + if (this.view.dom.dataset.typewriterMode === "true") return; + if ( + this.snapshot && + (!replace || this.microtaskQueued || this.animationFrame !== null) + ) { + return; + } + + const container = this.getScrollContainer(this.view); + if (!container || container === this.view.scrollDOM) return; + this.snapshot = { + container, + left: container.scrollLeft, + top: container.scrollTop, + }; + } + + clearUnusedCapture() { + if (!this.microtaskQueued && this.animationFrame === null) { + this.snapshot = null; + } + } + + update(update: ViewUpdate) { + if (!update.docChanged) return; + if (this.view.dom.dataset.typewriterMode === "true") { + this.snapshot = null; + return; + } + + if ( + !this.snapshot && + update.transactions.some((transaction) => transaction.isUserEvent("input")) + ) { + this.capture(); + } + if (!this.snapshot || this.microtaskQueued) return; + this.microtaskQueued = true; + queueMicrotask(() => { + this.microtaskQueued = false; + if (!this.snapshot) return; + if (this.animationFrame !== null) { + window.cancelAnimationFrame(this.animationFrame); + } + this.animationFrame = window.requestAnimationFrame(() => { + this.animationFrame = null; + const snapshot = this.snapshot; + this.snapshot = null; + if (!snapshot) return; + if (!snapshot.container.isConnected) return; + + snapshot.container.scrollTop = snapshot.top; + snapshot.container.scrollLeft = snapshot.left; + revealExternalCursor(this.view, snapshot.container); + }); + }); + } + + destroy() { + if (this.animationFrame !== null) { + window.cancelAnimationFrame(this.animationFrame); + } + this.snapshot = null; + } +} + +export function codeMirrorExternalScroll( + options: CodeMirrorExternalScrollOptions, +): Extension { + let externalScrollView: ViewPlugin; + externalScrollView = ViewPlugin.define( + (view) => new CodeMirrorExternalScrollView( + view, + options.getScrollContainer, + ), + { + eventHandlers: { + beforeinput(_event, view) { + view.plugin(externalScrollView)?.capture(); + return false; + }, + compositionstart(_event, view) { + view.plugin(externalScrollView)?.capture(true); + return false; + }, + keydown(event, view) { + if (keyMayEditDocument(event)) { + view.plugin(externalScrollView)?.capture(true); + } + return false; + }, + keyup(_event, view) { + view.plugin(externalScrollView)?.clearUnusedCapture(); + return false; + }, + }, + }, + ); + + return externalScrollView.extension; +} diff --git a/packages/editor/src/codemirror/index.ts b/packages/editor/src/codemirror/index.ts index e2ceb24c..bac9bbee 100644 --- a/packages/editor/src/codemirror/index.ts +++ b/packages/editor/src/codemirror/index.ts @@ -235,6 +235,8 @@ export { } from "./spellcheck.ts"; export { markraTheme } from "./theme.ts"; export { convertCodeMirrorClipboardHtml } from "./html-paste.ts"; +export type { CodeMirrorExternalScrollOptions } from "./external-scroll.ts"; +export { codeMirrorExternalScroll } from "./external-scroll.ts"; export type { TableFragmentMergePluginOptions } from "./table-fragment-merge.ts"; export { tableFragmentMergePlugin } from "./table-fragment-merge.ts"; export type { From 1742e63b908f229f168a087d6c99f460de5b6d41 Mon Sep 17 00:00:00 2001 From: MuRong Date: Sat, 5 Sep 2026 15:09:13 +0800 Subject: [PATCH 2/2] fix(editor): retain viewport before native text updates --- .../src/codemirror/external-scroll.test.ts | 89 +++++++++++++++++- .../editor/src/codemirror/external-scroll.ts | 92 +++++++++++++------ 2 files changed, 154 insertions(+), 27 deletions(-) diff --git a/packages/editor/src/codemirror/external-scroll.test.ts b/packages/editor/src/codemirror/external-scroll.test.ts index b99661ba..90a40d88 100644 --- a/packages/editor/src/codemirror/external-scroll.test.ts +++ b/packages/editor/src/codemirror/external-scroll.test.ts @@ -1,4 +1,4 @@ -import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorSelection, EditorState, StateEffect } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vitest"; import { codeMirrorExternalScroll } from "./external-scroll.ts"; @@ -45,6 +45,7 @@ function createView(targetRect: DOMRect, scrollTop = 200) { }), }); vi.spyOn(view, "coordsAtPos").mockReturnValue(targetRect); + view.focus(); views.push(view); return { scrollContainer, view }; @@ -58,6 +59,92 @@ afterEach(() => { }); describe("CodeMirror external scroll", () => { + it("leaves background document updates at their current scroll position", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.blur(); + scrollContainer.scrollTop = 420; + view.dispatch({ changes: { from: 8, insert: "!" }, userEvent: "input.type" }); + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(420); + }); + + it("does not restore when typewriter mode is enabled before the pending frame", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "!" })); + view.dispatch({ changes: { from: 8, insert: "!" }, userEvent: "input.type" }); + await Promise.resolve(); + view.dispatch({ effects: StateEffect.appendConfig.of(EditorView.editorAttributes.of({ "data-typewriter-mode": "true" })) }); + scrollContainer.scrollTop = 350; + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(350); + }); + + it("converts viewport pixels into scroll offsets when the UI is zoomed", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(310, 330)); + scrollContainer.getBoundingClientRect = () => syntheticRect(100, 300); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + view.dispatch({ changes: { from: 8, insert: "\n" }, userEvent: "input.type" }); + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(270); + }); + + it("lets an intentional scroll supersede a pending input restore", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "!" })); + view.dispatch({ changes: { from: 8, insert: "!" }, userEvent: "input.type" }); + await Promise.resolve(); + + view.contentDOM.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: 120 })); + scrollContainer.scrollTop = 420; + scrollContainer.dispatchEvent(new Event("scroll")); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(420); + }); + + it("does not replay an input restore after a later selection navigation", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "!" })); + view.dispatch({ changes: { from: 8, insert: "!" }, userEvent: "input.type" }); + await Promise.resolve(); + view.dispatch({ selection: EditorSelection.cursor(0), userEvent: "select" }); + scrollContainer.scrollTop = 50; + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(50); + }); + + it("uses the last observed viewport when native input scrolls before a transaction", async () => { + vi.useFakeTimers(); + const { scrollContainer, view } = createView(syntheticRect(280, 300)); + scrollContainer.scrollTop = 300; + scrollContainer.dispatchEvent(new Event("scroll")); + + // Native text replacement may scroll before CodeMirror observes the edit, + // without delivering a preceding keydown/beforeinput to the editor. + scrollContainer.scrollTop = 480; + view.dispatch({ + changes: { from: 8, insert: "!" }, + selection: EditorSelection.cursor(9), + scrollIntoView: true, + userEvent: "input.type", + }); + await Promise.resolve(); + vi.runAllTimers(); + + expect(scrollContainer.scrollTop).toBe(300); + }); + it("restores the external viewport after a focused document input", async () => { vi.useFakeTimers(); const { scrollContainer, view } = createView(syntheticRect(280, 300)); diff --git a/packages/editor/src/codemirror/external-scroll.ts b/packages/editor/src/codemirror/external-scroll.ts index 7500098c..1eed5f74 100644 --- a/packages/editor/src/codemirror/external-scroll.ts +++ b/packages/editor/src/codemirror/external-scroll.ts @@ -31,18 +31,22 @@ function revealExternalCursor(view: EditorView, container: HTMLElement) { if (!cursor) return; const viewport = container.getBoundingClientRect(); + const measuredScale = viewport.height / (container.offsetHeight || container.clientHeight); + const scaleY = Number.isFinite(measuredScale) && measuredScale > 0 ? measuredScale : 1; + const viewportTop = viewport.top + container.clientTop * scaleY; + const viewportBottom = viewportTop + container.clientHeight * scaleY; const margin = 5; - const delta = cursor.top < viewport.top + margin - ? cursor.top - viewport.top - margin - : cursor.bottom > viewport.bottom - margin - ? cursor.bottom - viewport.bottom + margin + const delta = cursor.top < viewportTop + margin + ? cursor.top - viewportTop - margin + : cursor.bottom > viewportBottom - margin + ? cursor.bottom - viewportBottom + margin : 0; if (delta === 0) return; const maximum = Math.max(0, container.scrollHeight - container.clientHeight); container.scrollTop = Math.max( 0, - Math.min(maximum, container.scrollTop + delta), + Math.min(maximum, container.scrollTop + delta / scaleY), ); } @@ -50,11 +54,24 @@ class CodeMirrorExternalScrollView { private animationFrame: number | null = null; private microtaskQueued = false; private snapshot: ScrollSnapshot | null = null; + private observedScroll: ScrollSnapshot | null = null; constructor( private readonly view: EditorView, private readonly getScrollContainer: (view: EditorView) => HTMLElement | null, - ) {} + ) { + this.observeScroll(); + } + + private readScroll(): ScrollSnapshot | null { + const container = this.getScrollContainer(this.view); + if (!container || container === this.view.scrollDOM) return null; + return { container, left: container.scrollLeft, top: container.scrollTop }; + } + + observeScroll() { + if (!this.snapshot) this.observedScroll = this.readScroll(); + } capture(replace = false) { if (this.view.dom.dataset.typewriterMode === "true") return; @@ -65,13 +82,7 @@ class CodeMirrorExternalScrollView { return; } - const container = this.getScrollContainer(this.view); - if (!container || container === this.view.scrollDOM) return; - this.snapshot = { - container, - left: container.scrollLeft, - top: container.scrollTop, - }; + this.snapshot = this.readScroll(); } clearUnusedCapture() { @@ -81,18 +92,26 @@ class CodeMirrorExternalScrollView { } update(update: ViewUpdate) { - if (!update.docChanged) return; + if (!update.docChanged) { + if (update.selectionSet) this.cancelRestore(); + return; + } if (this.view.dom.dataset.typewriterMode === "true") { - this.snapshot = null; + this.cancelRestore(); return; } - if ( - !this.snapshot && - update.transactions.some((transaction) => transaction.isUserEvent("input")) - ) { - this.capture(); + const editing = update.transactions.some((transaction) => + transaction.isUserEvent("input") || transaction.isUserEvent("delete"), + ); + if (!editing || !this.view.hasFocus) { + this.cancelRestore(); + return; } + // Native text replacement can move the scroller before delivering input + // events. Reading scrollTop here would remember the jump, not the viewport + // the user chose, so fall back to the last observed scroll position. + this.snapshot ??= this.observedScroll ?? this.readScroll(); if (!this.snapshot || this.microtaskQueued) return; this.microtaskQueued = true; queueMicrotask(() => { @@ -107,20 +126,30 @@ class CodeMirrorExternalScrollView { this.snapshot = null; if (!snapshot) return; if (!snapshot.container.isConnected) return; + if (!this.view.hasFocus || this.view.dom.dataset.typewriterMode === "true") return; + // Run after the input's measurement frame. New scroll/selection intent + // cancels this restoration, so it cannot pull navigation back. snapshot.container.scrollTop = snapshot.top; snapshot.container.scrollLeft = snapshot.left; revealExternalCursor(this.view, snapshot.container); + this.observeScroll(); }); }); } - destroy() { + cancelRestore() { if (this.animationFrame !== null) { window.cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; } this.snapshot = null; } + + destroy() { + this.cancelRestore(); + this.observedScroll = null; + } } export function codeMirrorExternalScroll( @@ -133,24 +162,35 @@ export function codeMirrorExternalScroll( options.getScrollContainer, ), { - eventHandlers: { + eventObservers: { + scroll(_event, view) { + view.plugin(externalScrollView)?.observeScroll(); + }, + wheel(_event, view) { + view.plugin(externalScrollView)?.cancelRestore(); + }, + pointerdown(_event, view) { + view.plugin(externalScrollView)?.cancelRestore(); + }, + touchstart(_event, view) { + view.plugin(externalScrollView)?.cancelRestore(); + }, + blur(_event, view) { + view.plugin(externalScrollView)?.cancelRestore(); + }, beforeinput(_event, view) { view.plugin(externalScrollView)?.capture(); - return false; }, compositionstart(_event, view) { view.plugin(externalScrollView)?.capture(true); - return false; }, keydown(event, view) { if (keyMayEditDocument(event)) { view.plugin(externalScrollView)?.capture(true); } - return false; }, keyup(_event, view) { view.plugin(externalScrollView)?.clearUnusedCapture(); - return false; }, }, },