From a48695801cefacaff85bb4a4e7490e7d983b3d44 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Wed, 25 Mar 2026 21:24:41 +0000 Subject: [PATCH] feat: implement scroll position restoration for long markdown files --- README.md | 5 +- package.json | 7 ++ src/extension/panelSession.ts | 162 +++++++++++++++++++++++++++++- src/shared/extensionConfig.ts | 13 +++ webview/src/editor.ts | 157 +++++++++++++++++++++++++---- webview/src/index.ts | 179 +++++++++++++++++++++++++++++++++- webview/src/types.d.ts | 3 +- 7 files changed, 501 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9f8438d..b01afa9 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ An optimized markdown editor with live editing mode for VS Code. - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Interactive editing** - Toggle tasks, open links, find/replace, render full-screen Mermaid diagrams, and LaTeX syntax -- **Images** - Paste images for inline rendering with automatic path resolution +- **Images** - Paste images directly into notes with inline rendering and automatic path resolution ### Navigation & Organisation - **Contents outline sidebar** - Jump to and reorder sections in long documents with ease - **Table editing** - Insert and edit tables inline without writing raw syntax +- **Scroll memory** - Reopening a long file restores your previous scroll position ### Git and Agents Integration @@ -30,7 +31,7 @@ An optimized markdown editor with live editing mode for VS Code. ### Customisation & Export - **Theming** - [Guide](./docs/theming.md) - Customise syntax colors, fonts, and line height to match your style -- **Syntax highlighting** - Monokai-inspired theme for clear, readable markdown +- **Default themes** - One Monokai, One Dark Pro, Dracula, Gruvbox, Nord, Solarized Dark, Catppuccin Mocha, Tokyo Night, and GitHub Dark - **Export** - Save your document as HTML or PDF ## Getting Started diff --git a/package.json b/package.json index ca14e04..aa44c94 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,13 @@ "order": 6, "description": "Enable Vim keybindings in Source mode." }, + "markdownEditorOptimized.rememberPosition.lines": { + "type": "number", + "default": 100, + "minimum": 0, + "order": 7, + "description": "Markdown files with at least this many lines will remember the last viewed line and restore the scroll position when opened." + }, "markdownEditorOptimized.export.browserPath": { "type": "string", "default": "", diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index ba74ec0..87a5dfe 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -10,6 +10,7 @@ import { getGitDiffLineHighlightsEnabled, getOutlinePosition, getOutlineVisible, + getRememberPositionLines, getThemeSettings, getVimModeEnabled } from '../shared/extensionConfig'; @@ -42,6 +43,8 @@ type InitMessage = { outlinePosition: OutlinePosition; outlineVisible: boolean; theme: ThemeSettings; + restoreTopLine?: number; + restoreTopLineOffset?: number; }; type DocChangedMessage = { @@ -156,6 +159,12 @@ type SetFindOptionsMessage = { findOptions?: Partial; }; +type ViewPositionChangedMessage = { + type: 'viewPositionChanged'; + topLine: number; + topLineOffset?: number; +}; + type ResolvedImageSrcMessage = { type: 'resolvedImageSrc'; requestId: string; @@ -236,6 +245,7 @@ type WebviewMessage = | SetGitChangesGutterMessage | SetOutlineVisibleMessage | SetFindOptionsMessage + | ViewPositionChangedMessage | OpenLinkMessage | ResolveImageSrcMessage | ResolveWikiLinksMessage @@ -261,6 +271,15 @@ type PendingExportSnapshot = { timer: ReturnType; }; +type RememberedViewPosition = { + line: number; + lineOffset: number; + updatedAt: number; +}; + +const REMEMBERED_VIEW_POSITIONS_STATE_KEY = 'rememberedViewPositionsByDocument'; +const MAX_REMEMBERED_VIEW_POSITIONS = 300; + type PanelSessionControllerParams = { panel: vscode.WebviewPanel; document: vscode.TextDocument; @@ -323,7 +342,11 @@ export function createPanelSessionController(params: PanelSessionControllerParam let gitRefreshPendingForceReload = false; let lastSentRevealSelectionKey: string | null = null; let pendingRevealSelection: RevealSelectionPayload | null = null; + let pendingRestoreTopLine: number | null = null; + let pendingRestoreTopLineOffset = 0; let pendingDraftText: string | null = null; + let lastSavedRememberedLine: number | null = null; + let lastSavedRememberedLineOffset = 0; let disposed = false; const workspaceRoot = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath; const gitDocumentState = new GitDocumentState(documentUri.fsPath, workspaceRoot); @@ -379,6 +402,52 @@ export function createPanelSessionController(params: PanelSessionControllerParam return vscode.workspace.applyEdit(edit); }; + const clearRememberedViewPosition = async (): Promise => { + const rememberedPositions = getRememberedViewPositionMap(context.workspaceState); + if (!(documentKey in rememberedPositions)) { + return; + } + delete rememberedPositions[documentKey]; + await context.workspaceState.update(REMEMBERED_VIEW_POSITIONS_STATE_KEY, rememberedPositions); + lastSavedRememberedLine = null; + lastSavedRememberedLineOffset = 0; + }; + + const saveRememberedViewPosition = async (topLine: number, topLineOffset: number | undefined): Promise => { + const minLines = getRememberPositionLines(); + if (document.lineCount < minLines) { + await clearRememberedViewPosition(); + return; + } + + const clampedLine = clampLineNumber(topLine, document.lineCount); + const normalizedOffset = normalizeLineOffset(topLineOffset); + if (lastSavedRememberedLine === clampedLine && lastSavedRememberedLineOffset === normalizedOffset) { + return; + } + + const rememberedPositions = getRememberedViewPositionMap(context.workspaceState); + const existing = rememberedPositions[documentKey]; + if (existing?.line === clampedLine && existing.lineOffset === normalizedOffset) { + lastSavedRememberedLine = clampedLine; + lastSavedRememberedLineOffset = normalizedOffset; + return; + } + + rememberedPositions[documentKey] = { + line: clampedLine, + lineOffset: normalizedOffset, + updatedAt: Date.now() + }; + + await context.workspaceState.update( + REMEMBERED_VIEW_POSITIONS_STATE_KEY, + pruneRememberedViewPositionMap(rememberedPositions) + ); + lastSavedRememberedLine = clampedLine; + lastSavedRememberedLineOffset = normalizedOffset; + }; + const sendInit = async (): Promise => { const message: InitMessage = { type: 'init', @@ -392,7 +461,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam findOptions: getFindOptions(), outlinePosition: getOutlinePosition(), outlineVisible: getOutlineVisible(context), - theme: getThemeSettings() + theme: getThemeSettings(), + restoreTopLine: pendingRestoreTopLine ?? undefined, + restoreTopLineOffset: pendingRestoreTopLine === null ? undefined : pendingRestoreTopLineOffset }; return postToWebview(message); }; @@ -657,6 +728,33 @@ export function createPanelSessionController(params: PanelSessionControllerParam return vscode.window.visibleTextEditors.find((editor) => isTextEditorForDocument(editor)); }; + const resolveRememberedTopLineForInit = (): { line: number; lineOffset: number } | null => { + const minLines = getRememberPositionLines(); + if (document.lineCount < minLines) { + runBackground(clearRememberedViewPosition(), 'clearRememberedViewPosition.initThreshold'); + return null; + } + + if (pendingRevealSelection !== null) { + return null; + } + + const rememberedPositions = getRememberedViewPositionMap(context.workspaceState); + const remembered = rememberedPositions[documentKey]; + if (!remembered) { + return null; + } + + const clampedLine = clampLineNumber(remembered.line, document.lineCount); + const lineOffset = normalizeLineOffset(remembered.lineOffset); + lastSavedRememberedLine = clampedLine; + lastSavedRememberedLineOffset = lineOffset; + return { + line: clampedLine, + lineOffset + }; + }; + const initialRevealEditor = findEditorForDocumentReveal(); if (initialRevealEditor) { pendingRevealSelection = revealSelectionForTextEditor(initialRevealEditor); @@ -664,6 +762,11 @@ export function createPanelSessionController(params: PanelSessionControllerParam const initialOffset = parseRevealOffsetFromUriFragment(document.uri); pendingRevealSelection = initialOffset === null ? null : { anchor: initialOffset, head: initialOffset }; } + const rememberedTopLine = resolveRememberedTopLineForInit(); + if (rememberedTopLine) { + pendingRestoreTopLine = rememberedTopLine.line; + pendingRestoreTopLineOffset = rememberedTopLine.lineOffset; + } const session: PanelSession = { panel, @@ -726,6 +829,13 @@ export function createPanelSessionController(params: PanelSessionControllerParam }); return; } + case 'viewPositionChanged': + if (Number.isFinite(raw.topLine)) { + await enqueue(async () => { + await saveRememberedViewPosition(raw.topLine, raw.topLineOffset); + }); + } + return; case 'exportDocument': await onExportDocument(session, raw.format); return; @@ -918,6 +1028,56 @@ export function createPanelSessionController(params: PanelSessionControllerParam }; } +function clampLineNumber(value: number, maxLine: number): number { + const numeric = Number.isFinite(value) ? Math.floor(value) : 1; + const max = Math.max(1, Math.floor(maxLine)); + return Math.max(1, Math.min(numeric, max)); +} + +function normalizeLineOffset(value: number | undefined): number { + const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0; + return Math.max(0, Math.round(numeric * 100) / 100); +} + +function getRememberedViewPositionMap(workspaceState: vscode.Memento): Record { + const stored = workspaceState.get(REMEMBERED_VIEW_POSITIONS_STATE_KEY); + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { + return {}; + } + + const entries: Record = {}; + for (const [key, value] of Object.entries(stored as Record)) { + if (typeof key !== 'string' || !key) { + continue; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + continue; + } + const record = value as Partial; + const line = Number(record.line); + const lineOffset = Number(record.lineOffset); + const updatedAt = Number(record.updatedAt); + if (!Number.isFinite(line) || !Number.isFinite(updatedAt)) { + continue; + } + entries[key] = { + line: Math.max(1, Math.floor(line)), + lineOffset: normalizeLineOffset(lineOffset), + updatedAt: Math.floor(updatedAt) + }; + } + return entries; +} + +function pruneRememberedViewPositionMap( + entries: Record +): Record { + const sortedEntries = Object.entries(entries) + .sort(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, MAX_REMEMBERED_VIEW_POSITIONS); + return Object.fromEntries(sortedEntries); +} + async function applyDocumentChanges( document: vscode.TextDocument, message: ApplyChangesMessage, diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index e22e847..4d41c36 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -12,6 +12,7 @@ export const LINE_NUMBERS_SETTING_KEY = 'lineNumbers.visible'; export const GIT_CHANGES_GUTTER_SETTING_KEY = 'gitChanges.visible'; export const GIT_DIFF_LINE_HIGHLIGHTS_SETTING_KEY = 'gitChanges.lineHighlights'; export const VIM_MODE_SETTING_KEY = 'vimMode.enabled'; +export const REMEMBER_POSITION_LINES_SETTING_KEY = 'rememberPosition.lines'; export const LINE_NUMBERS_LEGACY_SETTING_KEY = 'lineNumbers.enabled'; export const LINE_NUMBERS_LEGACY_VISIBLE_SETTING_KEY = 'lineNumbers.visibility'; export const GIT_CHANGES_GUTTER_LEGACY_VISIBLE_SETTING_KEY = 'gitChanges.visibility'; @@ -65,6 +66,11 @@ export function getVimModeEnabled(context: vscode.ExtensionContext): boolean { return getToggleSettingValue(context, VIM_MODE_SETTING_KEY, VIM_MODE_KEY, [], false); } +export function getRememberPositionLines(): number { + const config = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION); + return normalizeRememberPositionLineCount(config.get(REMEMBER_POSITION_LINES_SETTING_KEY, 100)); +} + export function getOutlinePosition(): OutlinePosition { const value = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get('outline.position', 'right'); return value === 'left' ? 'left' : 'right'; @@ -173,6 +179,13 @@ function getToggleSettingValue( return context.globalState.get(legacyStateKey, fallbackDefault); } +function normalizeRememberPositionLineCount(value: number): number { + if (!Number.isFinite(value)) { + return 100; + } + return Math.max(0, Math.floor(value)); +} + async function migrateLegacyToggleSetting( context: vscode.ExtensionContext, settingKey: string, diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 30106b1..f035d07 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -136,10 +136,13 @@ export function createEditor({ onApplyChanges, onOpenLink, onSelectionChange, + onViewportChange, onRequestGitBlame, onOpenGitRevisionForLine, onOpenGitWorktreeForLine, initialMode = 'source', + initialTopLine = null, + initialTopLineOffset = 0, initialLineNumbers = true, initialGitGutter = true, initialVimMode = false @@ -174,8 +177,34 @@ export function createEditor({ let gitBlameHover = null; let gitDiffOverviewRuler = null; let sourceLinkHoverPointerActive = false; + const normalizeTopLineOffset = (value) => { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Number(value)); + }; const vimExtensionsForState = () => (vimModeEnabled && currentMode === 'source' ? vim() : []); + const getLineStartOffset = (docText, targetLineNumber) => { + const targetLine = Math.max(1, Math.floor(targetLineNumber)); + if (targetLine === 1) { + return 0; + } + let line = 1; + for (let index = 0; index < docText.length; index += 1) { + if (docText.charCodeAt(index) !== 10) { + continue; + } + line += 1; + if (line === targetLine) { + return index + 1; + } + } + return docText.length; + }; const initialCursorPos = (() => { + if (typeof initialTopLine === 'number' && Number.isFinite(initialTopLine)) { + return getLineStartOffset(text ?? '', initialTopLine); + } if (!text) { return 0; } @@ -842,6 +871,78 @@ export function createEditor({ } }; + const TOP_LINE_VISIBILITY_EPSILON = 0.5; + const SCROLL_RESTORE_EPSILON = 0.5; + const SCROLL_RESTORE_MAX_ATTEMPTS = 3; + + const getTopLineMetrics = (scrollTopValue = view.scrollDOM.scrollTop) => { + const scrollTop = Math.max(0, scrollTopValue); + const lineBlock = view.lineBlockAtHeight(scrollTop); + const line = view.state.doc.lineAt(lineBlock.from); + return { + line, + lineBlock, + hiddenTopPixels: scrollTop - lineBlock.top + }; + }; + + const topVisibleLineAtCurrentScroll = () => { + const { line, hiddenTopPixels } = getTopLineMetrics(); + if (hiddenTopPixels <= TOP_LINE_VISIBILITY_EPSILON) { + return line; + } + + const nextLineNumber = Math.min(view.state.doc.lines, line.number + 1); + return view.state.doc.line(nextLineNumber); + }; + + const syncCursorToTopVisibleLine = () => { + const line = topVisibleLineAtCurrentScroll(); + const anchor = line.from; + const selection = view.state.selection.main; + if (selection.anchor === anchor && selection.head === anchor) { + return; + } + view.dispatch({ + selection: { anchor }, + annotations: Transaction.addToHistory.of(false) + }); + }; + + const computeTopVisiblePosition = () => { + const { line, hiddenTopPixels } = getTopLineMetrics(); + return { + lineNumber: line.number, + lineOffset: normalizeTopLineOffset(hiddenTopPixels) + }; + }; + + const restoreTopVisibleLine = (lineNumber, lineOffset = 0, { syncCursor = true } = {}) => { + const targetLineNumber = Math.min(Math.max(1, Math.floor(lineNumber || 1)), view.state.doc.lines); + const targetOffset = normalizeTopLineOffset(lineOffset); + let attempts = 0; + const restoreScroll = () => { + if (!view || ++attempts > SCROLL_RESTORE_MAX_ATTEMPTS) { + if (syncCursor && view) { + syncCursorToTopVisibleLine(); + } + return; + } + const targetLine = view.state.doc.line(targetLineNumber); + const targetTop = Math.max(0, view.lineBlockAt(targetLine.from).top + targetOffset); + const currentTop = view.scrollDOM.scrollTop; + view.scrollDOM.scrollTop = targetTop; + if (Math.abs(currentTop - targetTop) <= SCROLL_RESTORE_EPSILON) { + if (syncCursor) { + syncCursorToTopVisibleLine(); + } + return; + } + requestAnimationFrame(restoreScroll); + }; + restoreScroll(); + }; + const findMatch = ( query, backward = false, @@ -1116,6 +1217,7 @@ export function createEditor({ emitSelectionChange(); } else if (update.viewportChanged) { emitSelectionChange(); + onViewportChange?.(); } if (!update.docChanged || applyingExternal || applyingRenumber) { @@ -1148,10 +1250,23 @@ export function createEditor({ ] }); + const initialScrollTo = (() => { + if (typeof initialTopLine !== 'number' || !Number.isFinite(initialTopLine)) { + return undefined; + } + const lineNumber = Math.min(Math.max(1, Math.floor(initialTopLine)), state.doc.lines); + const line = state.doc.line(lineNumber); + return EditorView.scrollIntoView(line.from, { y: 'start' }); + })(); + view = new EditorView({ state, - parent + parent, + scrollTo: initialScrollTo }); + if (typeof initialTopLine === 'number' && Number.isFinite(initialTopLine)) { + restoreTopVisibleLine(initialTopLine, initialTopLineOffset, { syncCursor: true }); + } onTableInteraction = (event) => { const active = Boolean(event?.detail?.active); setTableInteractionActive(active); @@ -1172,6 +1287,7 @@ export function createEditor({ onScroll = () => { emitSelectionChange(); gitBlameHover?.hide(); + onViewportChange?.(); }; view.scrollDOM.addEventListener('scroll', onScroll, { passive: true }); if (typeof onRequestGitBlame === 'function') { @@ -1353,8 +1469,7 @@ export function createEditor({ return; } - const lineBlock = view.lineBlockAtHeight(view.scrollDOM.scrollTop + 1); - const lineNumber = view.state.doc.lineAt(lineBlock.from).number; + const topPosition = computeTopVisiblePosition(); const previousMode = currentMode; currentMode = nextMode; @@ -1377,21 +1492,7 @@ export function createEditor({ syncModeClasses(); syncGitGutterVisibility(); - let attempts = 0; - const restoreScroll = () => { - if (!view || ++attempts > 3) { - return; - } - const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines)); - const targetTop = view.lineBlockAt(targetLine.from).top; - const currentTop = view.scrollDOM.scrollTop; - view.scrollDOM.scrollTop = targetTop; - if (Math.abs(currentTop - targetTop) <= 1) { - return; - } - requestAnimationFrame(restoreScroll); - }; - requestAnimationFrame(restoreScroll); + restoreTopVisibleLine(topPosition.lineNumber, topPosition.lineOffset, { syncCursor: false }); }, setLineNumbers(visible) { const nextVisible = visible !== false; @@ -1540,6 +1641,19 @@ export function createEditor({ const line = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines)); applyRevealSelection(line.from, line.from, { focusEditor: true, align }); }, + restoreTopLine(lineNumber, lineOffset) { + restoreTopVisibleLine(lineNumber, lineOffset, { syncCursor: true }); + }, + getTopVisiblePosition() { + const position = computeTopVisiblePosition(); + return { + line: position.lineNumber, + lineOffset: position.lineOffset + }; + }, + getTopVisibleLine() { + return computeTopVisiblePosition().lineNumber; + }, revealSelection(anchor, head, options) { applyRevealSelection(anchor, head, options); }, @@ -1549,6 +1663,13 @@ export function createEditor({ refreshDecorations() { view.dispatch({ effects: refreshDecorationsEffect.of(null) }); }, + refreshLayout() { + view.requestMeasure(); + if (currentMode === 'live') { + forceParsing(view, view.state.doc.length, 500); + } + emitSelectionChange(); + }, setGitBaseline(snapshot) { applyGitBaseline(view, snapshot); gitBlameHover?.hide(); diff --git a/webview/src/index.ts b/webview/src/index.ts index d6d97e8..c9de3c8 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -491,10 +491,19 @@ let modeToggleShouldRestoreEditorFocus = false; let gitClient: any = null; let pendingEditorFocus = false; let pendingRevealSelection: { anchor: number; head: number; focus?: boolean } | null = null; +let pendingRestoreTopLine: number | null = null; +let pendingRestoreTopLineOffset = 0; +let pendingViewPositionTimer: number | null = null; +let lastSentTopLine: number | null = null; +let lastSentTopLineOffset: number | null = null; let lastSentDraftText: string | null = null; let hasSentDraftText = false; let initialEditorMountInFlight = false; +let initialEditorMountFallbackTimer: number | null = null; +let pendingEditorSurfaceRecoveryRaf: number | null = null; let createEditorFactoryPromise: Promise | null = null; +const VIEW_POSITION_DEBOUNCE_MS = 250; +const INITIAL_EDITOR_MOUNT_FALLBACK_MS = 120; const setEditorNotice = (message: string, kind = 'info') => { const normalizedMessage = `${message ?? ''}`.trim(); @@ -649,6 +658,116 @@ const syncPendingDraftState = () => { vscode.postMessage({ type: 'draftChanged', text: nextDraftText }); }; +const normalizeLineNumber = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return Math.max(1, Math.floor(value)); +}; + +const normalizeLineOffset = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.round(value * 100) / 100); +}; + +const getTopVisiblePosition = (): { topLine: number; topLineOffset: number } | null => { + if (!editor) { + return null; + } + if (typeof editor.getTopVisiblePosition === 'function') { + const position = editor.getTopVisiblePosition(); + const topLine = normalizeLineNumber(position?.line); + if (topLine === null) { + return null; + } + return { + topLine, + topLineOffset: normalizeLineOffset(position?.lineOffset) + }; + } + if (typeof editor.getTopVisibleLine !== 'function') { + return null; + } + const topLine = normalizeLineNumber(editor.getTopVisibleLine()); + if (topLine === null) { + return null; + } + return { + topLine, + topLineOffset: 0 + }; +}; + +const postTopVisiblePositionIfChanged = (position: { topLine: number; topLineOffset: number } | null): void => { + if (!position) { + return; + } + if (position.topLine === lastSentTopLine && position.topLineOffset === lastSentTopLineOffset) { + return; + } + lastSentTopLine = position.topLine; + lastSentTopLineOffset = position.topLineOffset; + vscode.postMessage({ + type: 'viewPositionChanged', + topLine: position.topLine, + topLineOffset: position.topLineOffset + }); +}; + +const flushViewPositionNow = (): void => { + if (pendingViewPositionTimer !== null) { + window.clearTimeout(pendingViewPositionTimer); + pendingViewPositionTimer = null; + } + postTopVisiblePositionIfChanged(getTopVisiblePosition()); +}; + +const scheduleViewPositionCapture = (): void => { + if (pendingViewPositionTimer !== null) { + window.clearTimeout(pendingViewPositionTimer); + } + pendingViewPositionTimer = window.setTimeout(() => { + pendingViewPositionTimer = null; + postTopVisiblePositionIfChanged(getTopVisiblePosition()); + }, VIEW_POSITION_DEBOUNCE_MS); +}; + +const applyPendingRestoreTopLine = (): void => { + if (!editor || pendingRestoreTopLine === null || pendingRevealSelection !== null) { + return; + } + if (typeof editor.restoreTopLine === 'function') { + editor.restoreTopLine(pendingRestoreTopLine, pendingRestoreTopLineOffset); + pendingRestoreTopLine = null; + pendingRestoreTopLineOffset = 0; + } +}; + +const refreshEditorSurface = (): void => { + if (!editor) { + return; + } + if (typeof editor.refreshLayout === 'function') { + editor.refreshLayout(); + } +}; + +const runEditorSurfaceRecovery = (): void => { + pendingEditorSurfaceRecoveryRaf = null; + refreshEditorSurface(); + applyPendingRestoreTopLine(); + scheduleViewPositionCapture(); +}; + +const scheduleEditorSurfaceRecovery = (): void => { + if (pendingEditorSurfaceRecoveryRaf !== null) { + return; + } + pendingEditorSurfaceRecoveryRaf = window.requestAnimationFrame(runEditorSurfaceRecovery); +}; + const clampRevealOffset = (value: number, max: number): number => { if (!Number.isFinite(value)) { return 0; @@ -674,11 +793,14 @@ const applyRevealSelectionFromHost = (revealMessage: any) => { const max = editor.getText().length; const clampedAnchor = clampRevealOffset(anchor, max); const clampedHead = clampRevealOffset(head, max); + pendingRestoreTopLine = null; + pendingRestoreTopLineOffset = 0; editor.revealSelection(clampedAnchor, clampedHead, { focusEditor: focus !== false, align: 'center' }); pendingRevealSelection = null; + scheduleViewPositionCapture(); }; const focusEditorFromHost = () => { @@ -687,6 +809,7 @@ const focusEditorFromHost = () => { return; } + scheduleEditorSurfaceRecovery(); editor.focus(); pendingEditorFocus = false; }; @@ -952,6 +1075,8 @@ const mountInitialEditor = async () => { try { const createEditor = await loadCreateEditorFactory(); const initialText = pendingInitialText; + const initialTopLine = pendingRevealSelection === null ? pendingRestoreTopLine : null; + const initialTopLineOffset = pendingRevealSelection === null ? pendingRestoreTopLineOffset : 0; if (editor || initialText === null) { return; } @@ -959,6 +1084,8 @@ const mountInitialEditor = async () => { parent: editorHost, text: initialText, initialMode: currentMode, + initialTopLine, + initialTopLineOffset, initialLineNumbers: lineNumbersVisible, initialGitGutter: gitChangesGutterVisible, initialVimMode: vimModeEnabled, @@ -967,12 +1094,17 @@ const mountInitialEditor = async () => { vscode.postMessage({ type: 'openLink', href }); }, onSelectionChange: (state: any) => selectionMenuController.update(state), + onViewportChange: () => scheduleViewPositionCapture(), onRequestGitBlame: requestGitBlameForLine, onOpenGitRevisionForLine: openGitRevisionForLine, onOpenGitWorktreeForLine: openGitWorktreeForLine }); gitClient?.applyBaselineToEditor(editor); syncGitDiffLineHighlights(); + if (initialTopLine !== null) { + pendingRestoreTopLine = null; + pendingRestoreTopLineOffset = 0; + } editor.focus(); pendingInitialText = null; initialMountRecoveryAttempted = false; @@ -998,6 +1130,7 @@ const mountInitialEditor = async () => { setLocalLinkRefreshContext({ refreshDecorations: () => editor?.refreshDecorations?.() }); + scheduleEditorSurfaceRecovery(); } catch (error) { logWebviewRenderError('mountInitialEditor', error); @@ -1032,15 +1165,33 @@ const scheduleInitialEditorMount = () => { if (editor || initialEditorMountQueued) { return; } - initialEditorMountQueued = true; - window.requestAnimationFrame(() => { + + const runScheduledMount = () => { + if (!initialEditorMountQueued) { + return; + } initialEditorMountQueued = false; + if (initialEditorMountFallbackTimer !== null) { + window.clearTimeout(initialEditorMountFallbackTimer); + initialEditorMountFallbackTimer = null; + } void mountInitialEditor(); findPanelController.updateFindStatusSummary(); - }); + }; + + initialEditorMountQueued = true; + window.requestAnimationFrame(runScheduledMount); + initialEditorMountFallbackTimer = window.setTimeout( + runScheduledMount, + INITIAL_EDITOR_MOUNT_FALLBACK_MS + ); }; const handleInit = (message: any) => { + pendingRestoreTopLine = normalizeLineNumber(message.restoreTopLine); + pendingRestoreTopLineOffset = normalizeLineOffset(message.restoreTopLineOffset); + lastSentTopLine = null; + lastSentTopLineOffset = null; if (!editor) { pendingInitialText = message.text; scheduleInitialEditorMount(); @@ -1070,6 +1221,7 @@ const handleInit = (message: any) => { if (editor && outlineController.isVisible()) { outlineController.refresh(); } + scheduleEditorSurfaceRecovery(); scheduleWikiLinkStatusRefresh(message.text); scheduleLocalLinkStatusRefresh(message.text); findPanelController.updateFindStatusSummary(); @@ -1359,16 +1511,28 @@ window.addEventListener('paste', async (event) => { window.addEventListener('blur', () => { flushPendingChangesNow(); + flushViewPositionNow(); }); window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { + if (pendingEditorSurfaceRecoveryRaf !== null) { + window.cancelAnimationFrame(pendingEditorSurfaceRecoveryRaf); + pendingEditorSurfaceRecoveryRaf = null; + } forceFlushChanges(); + return; } + scheduleEditorSurfaceRecovery(); +}); + +window.addEventListener('focus', () => { + scheduleEditorSurfaceRecovery(); }); const forceFlushChanges = () => { flushChanges(); + flushViewPositionNow(); }; window.addEventListener('beforeunload', () => { @@ -1377,6 +1541,15 @@ window.addEventListener('beforeunload', () => { cancelPendingLocalLinkStatusRefresh(); clearGitBlameCache({ hideTooltip: false }); + if (initialEditorMountFallbackTimer !== null) { + window.clearTimeout(initialEditorMountFallbackTimer); + initialEditorMountFallbackTimer = null; + } + if (pendingEditorSurfaceRecoveryRaf !== null) { + window.cancelAnimationFrame(pendingEditorSurfaceRecoveryRaf); + pendingEditorSurfaceRecoveryRaf = null; + } + if (pendingDebounce !== null) { window.clearTimeout(pendingDebounce); pendingDebounce = null; diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index b7241ff..8142c04 100644 --- a/webview/src/types.d.ts +++ b/webview/src/types.d.ts @@ -15,6 +15,7 @@ type WebviewMessage = | { type: 'setGitChangesGutter'; visible: boolean } | { type: 'setOutlineVisible'; visible: boolean } | { type: 'setFindOptions'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } + | { type: 'viewPositionChanged'; topLine: number; topLineOffset?: number } | { type: 'openLink'; href: string } | { type: 'resolveImageSrc'; requestId: string; url: string } | { type: 'resolveWikiLinks'; requestId: string; targets: string[] } @@ -26,7 +27,7 @@ type WebviewMessage = | { type: 'saveImageFromClipboard'; requestId: string; imageData: string; fileName: string }; type ExtensionMessage = - | { type: 'init'; text: string; version: number; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; vimMode: boolean; findOptions: { wholeWord: boolean; caseSensitive: boolean } } + | { type: 'init'; text: string; version: number; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; vimMode: boolean; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } | { type: 'docChanged'; text: string; version: number } | { type: 'applied'; version: number } | { type: 'focusEditor' }