fix(editor): keep reading position across preview/edit switches

The markdown preview container re-mounts on every mode flip (its scrollTop
lives on that DOM element), and the save-then-switch-to-preview reload
(#215) rebuilds the whole TextEditor instance - so after editing and
saving, the preview returned to the top, and entering edit mode opened the
editor at the top instead of where the reader was.

- Module-level per-file (session + path) preview scroll memory, seeded on
  component mount and re-applied in a before-paint layout effect on
  [mode, mdText]; a restoring guard keeps the collapsed-reload clamp from
  polluting the remembered position.
- Preview -> edit handoff: throttle-capture the block text at the preview
  viewport top, reverse-map it to a source line (linesOfSelection) and
  scroll the editor there, with a proportional fallback when the text match
  is ambiguous (ratio === 1, i.e. the reader is exactly at the bottom, also
  lands the editor at the bottom).
- The editor jump writes scrollDOM.scrollTop directly instead of using
  CodeMirror's scrollIntoView: that API walks every scrollable ancestor -
  and even the window when the browser is zoomed (visualViewport <
  innerHeight) - to reveal the target in the sidebar, which dragged the
  whole sidebar/page up when the reader was at the very end of the
  document. A plain scrollTop write on the editor scroller can never
  scroll anything outside the editor.

Closes #466
This commit is contained in:
elysia395
2026-09-03 09:59:11 +08:00
committed by Menghuan1918
parent 396017b459
commit 6ca2364a12
+119 -4
View File
@@ -13,7 +13,7 @@
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -52,6 +52,14 @@ type ViewMode = 'preview' | 'edit'
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
/** Per-file preview scroll memory. Module-level so it survives viewer
* remounts: the save-then-switch-to-preview reload (EditorHost #215 case B)
* rebuilds the whole TextEditor instance, and without this the preview
* would remount at the top. Keyed by session + path; a fresh entry reads 0
* (new file opens at the top), re-opens/toggles restore the last position. */
const previewScrollMemory = new Map<string, number>()
const previewScrollKey = (scope: { sessionId: string }, path: string): string => `${scope.sessionId}::${path}`
export function TextEditor(props: FileViewerProps) {
const { ctx, scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
@@ -70,6 +78,22 @@ export function TextEditor(props: FileViewerProps) {
const mdRef = useRef<HTMLDivElement>(null)
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** Preview scroll position across the preview<->edit toggle. The preview
* container re-mounts on every mode switch and its scrollTop lives on that
* element, so capture it on scroll and restore after each remount. Seeded
* from the module-level per-file memory so a full viewer rebuild
* (save-then-switch-to-preview reload) also keeps the position. */
const previewScrollRef = useRef(previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0)
/** True while a programmatic restore is in flight; raw scroll events caused
* by the restore (or by the browser clamping a collapsed reload container
* to 0) must not overwrite the remembered position. */
const restoringRef = useRef(false)
/** Preview-side handoff data for the preview -> edit switch: the text at the
* top of the preview viewport (best-effort) plus the scroll ratio. Captured
* throttled on preview scroll; consumed when entering edit mode so the
* editor opens where the reader was instead of at the file top. */
const previewSyncRef = useRef<{ text: string | null; ratio: number }>({ text: null, ratio: 0 })
const anchorThrottleRef = useRef(false)
/**
* The floating "add to conversation" popup (viewport-anchored; null =
@@ -95,6 +119,12 @@ export function TextEditor(props: FileViewerProps) {
selectionPopup.hide()
}, [content])
// A different file switches the remembered preview scroll position to that
// file's own entry (first open: none, so the preview starts at the top).
useEffect(() => {
previewScrollRef.current = previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0
}, [path])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
@@ -203,10 +233,49 @@ export function TextEditor(props: FileViewerProps) {
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
// flip also invalidates any anchored selection popup. When entering edit
// from a scrolled preview, the editor opens where the reader was: the line
// mapped from the text anchored at the preview viewport top (or, failing a
// unique text match, the proportional scroll position).
useEffect(() => {
selectionPopup.hide()
if (mode === 'edit') viewRef.current?.requestMeasure()
if (mode !== 'edit') return
const view = viewRef.current
if (view === null) return
const sync = previewSyncRef.current
if (!markdown) return
const doc = view.state.doc
let target: number | undefined
if (sync.text !== null) {
const lines = linesOfSelection(mdText, sync.text)
if (lines !== null) target = doc.line(Math.min(lines.start, doc.lines)).from
}
// ratio === 1 means the reader was at the very bottom (e.g. the last
// block's text is a repeated filler line that linesOfSelection rejects
// as ambiguous) — it must still land the editor at the bottom.
if (target === undefined && sync.ratio > 0 && sync.ratio <= 1) {
target = Math.max(1, Math.min(doc.length - 1, Math.round(doc.length * sync.ratio)))
}
if (target === undefined) return
// Position the editor by writing its OWN scroller directly (after a fresh
// measure) instead of CodeMirror's scrollIntoView: that path walks every
// scrollable ancestor — and even the window when the browser is zoomed
// (visualViewport < innerHeight) — to reveal the target, which dragged
// the whole sidebar up when the reader was at the very end of the
// document. A plain scrollTop write on the editor scroller can never
// touch anything outside the editor.
view.requestMeasure()
view.dispatch({ selection: { anchor: target } })
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const block = view.lineBlockAt(target)
view.scrollDOM.scrollTop = Math.max(0, block.top - 8)
// Force CodeMirror to re-measure and re-render its virtualized
// viewport at the NEW scroll position (its scroll-observer is async
// and can lag a direct write).
view.requestMeasure()
})
})
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
@@ -243,6 +312,23 @@ export function TextEditor(props: FileViewerProps) {
* lookup. All preview renderers share this source so plain Markdown,
* Mermaid, and documents containing raw HTML behave consistently. */
const previewMdText = markdown ? markdownPreviewSource(mdText) : mdText
// Re-apply the remembered preview scroll position whenever the preview
// container mounts or its content changes (mode flip back to preview, or a
// same-file reload — e.g. the save-then-switch-to-preview reload — which
// temporarily collapses the container and clamps scrollTop to 0). Restored
// in a before-paint layout effect so the user never sees the top flash.
useLayoutEffect(() => {
if (mode !== 'preview') return
const el = mdRef.current
if (el === null || previewScrollRef.current <= 0) return
if (el.scrollHeight <= el.clientHeight) return
if (el.scrollTop === previewScrollRef.current) return
restoringRef.current = true
el.scrollTop = previewScrollRef.current
requestAnimationFrame(() => { restoringRef.current = false })
}, [mode, previewMdText])
/** The preview source with local image destinations rewritten to absolute
* media URLs (see {@link rewriteLocalImageUrls}). */
const previewText = markdown
@@ -396,7 +482,36 @@ export function TextEditor(props: FileViewerProps) {
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={selectionPopup.hide}
onScroll={(event) => {
const el = event.currentTarget
if (!restoringRef.current && el.scrollHeight > el.clientHeight) {
previewScrollRef.current = el.scrollTop
previewScrollMemory.set(previewScrollKey(scope, path), el.scrollTop)
}
if (!anchorThrottleRef.current) {
anchorThrottleRef.current = true
setTimeout(() => { anchorThrottleRef.current = false }, 120)
const ratio = el.scrollHeight > el.clientHeight
? el.scrollTop / (el.scrollHeight - el.clientHeight)
: 0
// The first block below the viewport's top edge, chosen with
// layout coordinates (caretRangeFromPoint needs an in-viewport
// point and returns null when the panel is partially off-screen).
// Its text is the anchor the editor syncs to on preview -> edit.
let text: string | null = null
const base = el.getBoundingClientRect()
const blocks = el.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li')
for (const block of blocks) {
if (block.getBoundingClientRect().top - base.top + el.scrollTop >= el.scrollTop - 2) {
const t = (block.textContent ?? '').replace(/[ \t\r\n]+/g, ' ').trim()
if (t.length >= 8) text = t
break
}
}
previewSyncRef.current = { text, ratio }
}
selectionPopup.hide()
}}
>
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and