mirror of
https://github.com/vadimmelnicuk/meo.git
synced 2026-05-29 17:10:54 +00:00
feat: implement scroll position restoration for long markdown files
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+2
-1
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user