feat: implement scroll position restoration for long markdown files

This commit is contained in:
Vadim Melnicuk
2026-03-25 21:24:41 +00:00
parent dd049e7797
commit a48695801c
7 changed files with 501 additions and 25 deletions
+3 -2
View File
@@ -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
+7
View File
@@ -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": "",
+161 -1
View File
@@ -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<FindOptions>;
};
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<typeof setTimeout>;
};
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<void> => {
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<void> => {
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<boolean> => {
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<string, RememberedViewPosition> {
const stored = workspaceState.get<unknown>(REMEMBERED_VIEW_POSITIONS_STATE_KEY);
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
return {};
}
const entries: Record<string, RememberedViewPosition> = {};
for (const [key, value] of Object.entries(stored as Record<string, unknown>)) {
if (typeof key !== 'string' || !key) {
continue;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
continue;
}
const record = value as Partial<RememberedViewPosition>;
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<string, RememberedViewPosition>
): Record<string, RememberedViewPosition> {
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,
+13
View File
@@ -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<number>(REMEMBER_POSITION_LINES_SETTING_KEY, 100));
}
export function getOutlinePosition(): OutlinePosition {
const value = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get<string>('outline.position', 'right');
return value === 'left' ? 'left' : 'right';
@@ -173,6 +179,13 @@ function getToggleSettingValue(
return context.globalState.get<boolean>(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,
+139 -18
View File
@@ -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();
+176 -3
View File
@@ -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<CreateEditorFactory> | 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;
+2 -1
View File
@@ -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' }