From 5ee2d39ef88df2d147900607eb7b630890ff4b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Tue, 6 Oct 2026 01:11:46 -0300 Subject: [PATCH] Reset the viewport when opening another document --- CHANGELOG.md | 2 + docs/help/file-and-folder-workflows.md | 2 + docs/specification.md | 1 + e2e/desktop/run.ts | 4 +- e2e/desktop/specs/document-lifecycle.spec.ts | 129 +++++++++++++++++- e2e/desktop/support/runContext.ts | 1 + .../screens/document-screen.test.tsx | 68 ++++++++- src/components/screens/document-screen.tsx | 9 +- .../editor/hooks/useMilkdownEditorInstance.ts | 8 +- src/features/editor/utils/wikiHeadings.ts | 2 +- 10 files changed, 216 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1453e765..8a0cd727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,8 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Start a replacement document at the top instead of retaining the previous document's scroll position, while heading and search navigation still reveal their targets and external reloads restore the caret. + - Keep the text after a code block's language on its opening fence, such as `title="app.js"` or `{1,3}` in ` ```js title="app.js" {1,3} `, which documentation tools read for captions and highlighted lines. Saving used to drop it and keep only the language, and copying the block within Leafdown dropped it too. - Keep the indentation of a raw HTML tag or a code span that continues onto indented lines, such as `` indented on the next line. Every save added indentation to those lines, so the file changed each time it was saved, and a code span's text grew with it. Such lines now save as written, tabs included, in a paragraph, a heading, a list item, or a blockquote. The only exception is a code span line indented with a tab that a list item or blockquote takes only part of: that line is saved once with the spaces the tab covered, and its text is unchanged. - Keep the backslashes in a footnote's label, such as `[^a\*b]` and its definition `[^a\*b]: note`. Saving wrote both as `[^a*b]`, which GitHub reads as a different label: in a file that also held `[^a*b]`, the two footnotes became one, both references led to the first, and the other stopped showing. Each reference now keeps the label it was written with and leads to its own footnote while the file is open, and a definition's label shows those backslashes. A label renamed to end in a backslash is refused, because that backslash would take the closing `]` into the label. diff --git a/docs/help/file-and-folder-workflows.md b/docs/help/file-and-folder-workflows.md index b540bc1d..db925c26 100644 --- a/docs/help/file-and-folder-workflows.md +++ b/docs/help/file-and-folder-workflows.md @@ -6,6 +6,8 @@ Leafdown keeps the open document and the folder context separate. The folder con **File > Open** opens one Markdown file: `.md`, `.markdown`, `.mdown`, or `.mkd`. If no folder is open, Leafdown also opens its parent folder as context. If a folder is already pinned, opening a file elsewhere leaves that context in place. **File > Open folder** scans the folder for Markdown articles and opens a matching root-level `readme` or `index` file when present. Ignored directories do not appear in the navigator. +Opening another document starts it at the top. Following a heading link or choosing a search result instead brings its target into view. + On Windows, installing Leafdown adds it to File Explorer's **Open with** menu for these files and to **Settings > Apps > Default apps**, without changing which app opens them by default. Choose Leafdown there to open Markdown files from File Explorer. Files associated with Leafdown show a folded-page icon with the green leaf and Markdown symbol. Each file opened this way starts its own Leafdown window, with the file's folder as context. **File > Open** works either way. The welcome screen and **File > Open recent** show previously opened files and folders when **Record recent files and folders** is on. You can remove one recent item or clear both lists without deleting files. See [Settings reference](settings-reference.md) for the setting. diff --git a/docs/specification.md b/docs/specification.md index 15afd209..58f10a58 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -481,6 +481,7 @@ Workflows execute upon successful completion of dirty-state checks. If a dirty c - If no folder context is active, set the current folder context to the file's parent folder and scan that folder for supported Markdown files, skipping ignored directories. - If a folder context is already active, keep it unchanged. - Open the selected file in the document surface. +- When another document replaces the active document, start its viewport at the top. Explicit heading or search navigation instead reveals its target. Reloading the same document after an external change retains its caret-restoration behavior. - Select the opened file in the article navigator only when it exists in the current article navigator. - Add the file to recents when `Record recent files and folders` is enabled. Add the file's parent folder to recent folders only when the file open bootstraps a folder context. diff --git a/e2e/desktop/run.ts b/e2e/desktop/run.ts index 90aa6469..b44dee30 100644 --- a/e2e/desktop/run.ts +++ b/e2e/desktop/run.ts @@ -133,6 +133,7 @@ const createWorkerContext = async (workerIndex: number): Promise const settingsPath = path.join(storeDirectory, "settings.dev.json"); const releaseNotesPath = path.join(storeDirectory, "release-notes.dev.json"); const documentPath = path.join(fixtureRoot, "document-lifecycle.md"); + const documentScrollPath = path.join(fixtureRoot, "document-scroll.md"); const blocksPath = path.join(fixtureRoot, "block-selection.md"); const calloutsPath = path.join(fixtureRoot, "callouts.md"); const citationsPath = path.join(fixtureRoot, "citations.md"); @@ -186,6 +187,7 @@ const createWorkerContext = async (workerIndex: number): Promise document: { initialMarker: "Initial fixture marker.", path: documentPath, + scrollPath: documentScrollPath, savedMarkdown: `${savedMarker}\n`, savedMarker, }, @@ -243,7 +245,7 @@ const createWorkerContext = async (workerIndex: number): Promise recentFiles: [frontmatterYamlPath, frontmatterTomlPath, frontmatterJsonPath], }, { name: "diagnostics" }, - { name: "document-lifecycle", recentFiles: [documentPath] }, + { name: "document-lifecycle", recentFiles: [documentPath, documentScrollPath] }, { name: "document-watcher", recentFiles: [documentWatcherPath] }, { name: "find-and-replace", recentFiles: [searchPath] }, { name: "keyboard-shortcuts" }, diff --git a/e2e/desktop/specs/document-lifecycle.spec.ts b/e2e/desktop/specs/document-lifecycle.spec.ts index 46a2aa3e..05a29bac 100644 --- a/e2e/desktop/specs/document-lifecycle.spec.ts +++ b/e2e/desktop/specs/document-lifecycle.spec.ts @@ -1,11 +1,136 @@ import { $, browser, expect } from "@wdio/globals"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; import { Key } from "webdriverio"; import { getDesktopE2ERunContext } from "../support/runContext.js"; -import { getSaveMenuItem, openRecentPath, selectFileMenuItem } from "../support/ui.js"; +import { + findTreeItem, + getSaveMenuItem, + openRecentPath, + selectFileMenuItem, +} from "../support/ui.js"; + +const VIEWPORT = '[data-testid="document-surface-scroll-area"] [data-slot="scroll-area-viewport"]'; + +const viewportGeometry = () => + browser.execute((selector) => { + const viewport = document.querySelector(selector)!; + const firstBlock = document.querySelector(".ProseMirror > :first-child")!; + const viewportRect = viewport.getBoundingClientRect(); + const blockRect = firstBlock.getBoundingClientRect(); + return { + scrollTop: viewport.scrollTop, + overflow: viewport.scrollHeight - viewport.clientHeight, + firstBlockVisible: + blockRect.top >= viewportRect.top && blockRect.bottom <= viewportRect.bottom, + }; + }, VIEWPORT); + +const scrollDocument = async () => { + const offset = await browser.execute((selector) => { + const viewport = document.querySelector(selector)!; + viewport.scrollTop = 800; + viewport.dispatchEvent(new Event("scroll")); + return viewport.scrollTop; + }, VIEWPORT); + expect(offset).toBeGreaterThan(0); +}; + +const expectDocumentAtTop = async (marker: string) => { + await expect($(".ProseMirror")).toHaveText(expect.stringContaining(marker)); + await browser.waitUntil(async () => (await viewportGeometry()).firstBlockVisible, { + timeoutMsg: "The replacement document's first block was outside its viewport.", + }); + expect((await viewportGeometry()).scrollTop).toBe(0); +}; + +const selectedHeadingGeometry = () => + browser.execute((selector) => { + const viewport = document.querySelector(selector)!; + const heading = document.getSelection()?.anchorNode?.parentElement?.closest("h1"); + if (!heading) return null; + const viewportRect = viewport.getBoundingClientRect(); + const headingRect = heading.getBoundingClientRect(); + return { + text: heading.textContent, + visible: headingRect.top >= viewportRect.top && headingRect.bottom <= viewportRect.bottom, + scrollTop: viewport.scrollTop, + }; + }, VIEWPORT); describe("desktop document lifecycle", () => { + it("starts replacement documents at the top and preserves explicit navigation and reloads", async () => { + const { document: fixture } = await getDesktopE2ERunContext(); + const replacementPath = path.join(path.dirname(fixture.path), "viewport-replacement.md"); + const filler = Array.from( + { length: 80 }, + (_, index) => `Viewport paragraph ${index + 1}.`, + ).join("\n\n"); + const original = `# First viewport document\n\n${filler}\n\n# Scroll target\n`; + await writeFile(fixture.scrollPath, original); + await writeFile( + replacementPath, + `# Second viewport document\n\n[[document-scroll#Scroll target|Jump to target]]\n\n${filler}\n`, + ); + + await openRecentPath(fixture.scrollPath); + await expectDocumentAtTop("First viewport document"); + expect((await viewportGeometry()).overflow).toBeGreaterThan(800); + await scrollDocument(); + await (await findTreeItem("viewport-replacement.md")).click(); + await expectDocumentAtTop("Second viewport document"); + expect((await viewportGeometry()).overflow).toBeGreaterThan(800); + + await scrollDocument(); + await openRecentPath(fixture.scrollPath); + await expectDocumentAtTop("First viewport document"); + await scrollDocument(); + await (await findTreeItem("document-lifecycle.md")).click(); + await expectDocumentAtTop(fixture.initialMarker); + + await openRecentPath(replacementPath); + await expectDocumentAtTop("Second viewport document"); + await scrollDocument(); + await openRecentPath(fixture.path); + await expectDocumentAtTop(fixture.initialMarker); + + await openRecentPath(replacementPath); + await expectDocumentAtTop("Second viewport document"); + await $('[data-type="wiki-link"]').execute((target) => { + target.dispatchEvent( + new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ctrlKey: true }), + ); + }); + await browser.waitUntil( + async () => { + const heading = await selectedHeadingGeometry(); + return heading?.text === "Scroll target" && heading.visible; + }, + { timeoutMsg: "The cross-document heading link did not reveal its target." }, + ); + expect(await selectedHeadingGeometry()).toMatchObject({ text: "Scroll target", visible: true }); + expect((await selectedHeadingGeometry())!.scrollTop).toBeGreaterThan(800); + + await writeFile(fixture.scrollPath, `${original}\nReloaded viewport marker.\n`); + await browser.waitUntil( + () => + browser.execute( + () => + document + .querySelector(".ProseMirror") + ?.textContent?.includes("Reloaded viewport marker.") ?? false, + ), + { timeoutMsg: "The external reload did not replace the editor's text." }, + ); + await browser.waitUntil(async () => (await selectedHeadingGeometry())?.visible === true, { + timeoutMsg: "The external reload did not restore the caret's heading into view.", + }); + expect(await selectedHeadingGeometry()).toMatchObject({ text: "Scroll target", visible: true }); + expect((await selectedHeadingGeometry())!.scrollTop).toBeGreaterThan(800); + await selectFileMenuItem("Close document"); + }); + it("opens, edits, saves, and reopens a fixture through real IPC", async () => { const { document } = await getDesktopE2ERunContext(); diff --git a/e2e/desktop/support/runContext.ts b/e2e/desktop/support/runContext.ts index 941da64b..24cf73bd 100644 --- a/e2e/desktop/support/runContext.ts +++ b/e2e/desktop/support/runContext.ts @@ -17,6 +17,7 @@ export interface DesktopE2ERunContext { document: { initialMarker: string; path: string; + scrollPath: string; savedMarkdown: string; savedMarker: string; }; diff --git a/src/components/screens/document-screen.test.tsx b/src/components/screens/document-screen.test.tsx index ea1a55b5..5583a3b0 100644 --- a/src/components/screens/document-screen.test.tsx +++ b/src/components/screens/document-screen.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { getActiveDocumentKey } from "@/features/document"; import { useSettingsStore } from "@/features/preferences"; import { documentEditorBridge, useSessionStore } from "@/features/session"; -import { createUntitledDocument } from "@/test/factories/document"; +import { createSavedDocument, createUntitledDocument } from "@/test/factories/document"; import { setDefaultSession, setDefaultSettings } from "@/test/utils/appStores"; import { act, render, renderWithUser, screen, waitFor, within } from "@/test/utils/react"; @@ -14,6 +14,72 @@ import { DocumentScreen } from "./document-screen"; const MARKDOWN = "# Notes\n\nBody text.\n"; describe("DocumentScreen", () => { + it("resets the viewport when another document replaces the active document", async () => { + const activeDocument = createSavedDocument({ content: MARKDOWN }); + setDefaultSession({ activeDocument }); + const { rerender } = render(); + await waitFor(() => { + expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready"); + }); + const viewport = screen + .getByTestId("document-surface-scroll-area") + .querySelector('[data-slot="scroll-area-viewport"]')!; + viewport.scrollTop = 600; + + const replacement = createSavedDocument({ path: "C:/Notes/other.md", content: "# Other\n" }); + act(() => { + useSessionStore.getState().setActiveDocument(replacement); + rerender(); + }); + await waitFor(() => { + expect(documentEditorBridge.getCommandState(replacement.path).status).toBe("ready"); + }); + + expect( + screen + .getByTestId("document-surface-scroll-area") + .querySelector('[data-slot="scroll-area-viewport"]')!.scrollTop, + ).toBe(0); + expect(documentEditorBridge.getMarkdown(replacement.path)).toBe(replacement.content); + expect(useSessionStore.getState().activeDocument?.isDirty).toBe(false); + }); + + it.each([false, true])("restores a same-document reload with focused=%s", async (focused) => { + const activeDocument = createSavedDocument({ content: MARKDOWN }); + setDefaultSession({ activeDocument }); + const { rerender } = render(); + await waitFor(() => { + expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready"); + }); + const viewport = screen + .getByTestId("document-surface-scroll-area") + .querySelector('[data-slot="scroll-area-viewport"]')!; + viewport.scrollTop = 600; + const reloaded = { ...activeDocument, content: `${MARKDOWN}\nNew text.\n` }; + act(() => { + useSessionStore.getState().setActiveDocument(reloaded, { + reload: true, + viewState: { anchor: 10, head: 10, focused }, + }); + rerender(); + }); + await waitFor(() => { + expect(documentEditorBridge.getMarkdown(reloaded.path)).toBe(reloaded.content); + }); + + expect( + screen + .getByTestId("document-surface-scroll-area") + .querySelector('[data-slot="scroll-area-viewport"]'), + ).toBe(viewport); + expect(viewport.scrollTop).toBe(600); + expect(documentEditorBridge.getViewState(reloaded.path)).toMatchObject({ + anchor: 10, + head: 10, + focused, + }); + }); + it("navigates to an exact heading from the floating outline without editing", async () => { setDefaultSettings(); const activeDocument = createUntitledDocument({ diff --git a/src/components/screens/document-screen.tsx b/src/components/screens/document-screen.tsx index 3fd33632..3cd8f291 100644 --- a/src/components/screens/document-screen.tsx +++ b/src/components/screens/document-screen.tsx @@ -23,6 +23,7 @@ import { useSessionStore, } from "@/features/session"; import { useLocalization } from "@/lib/i18n"; +import { getPathIdentityKey } from "@/lib/path"; interface DocumentScreenProps { activeDocument: ActiveDocumentState; @@ -65,6 +66,8 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { const loadId = useSessionStore((state) => state.activeDocumentLoadId); const initialViewState = useSessionStore((state) => state.activeDocumentViewState); const documentKey = getActiveDocumentKey(activeDocument); + const viewportKey = + activeDocument.status === "saved" ? getPathIdentityKey(activeDocument.path) : activeDocument.id; const editorKey = `${documentKey}:${loadId}`; const [publishedOutline, setPublishedOutline] = useState<{ editorKey: string; @@ -87,7 +90,11 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) { > - + doc.resolve(Math.min(Math.max(position, 0), doc.content.size)); + if (viewState.focused) { + view.focus(); + } + view.dispatch( view.state.tr .setSelection(TextSelection.between(resolve(viewState.anchor), resolve(viewState.head))) .scrollIntoView(), ); - - if (viewState.focused) { - view.focus(); - } } catch (error) { handleUnexpectedError(error, "restoreEditorViewState"); } diff --git a/src/features/editor/utils/wikiHeadings.ts b/src/features/editor/utils/wikiHeadings.ts index 9b65eb2c..1f3ff32c 100644 --- a/src/features/editor/utils/wikiHeadings.ts +++ b/src/features/editor/utils/wikiHeadings.ts @@ -53,7 +53,7 @@ export const jumpToWikiHeading = (view: EditorView, text: string) => { const heading = findWikiHeading(view.state.doc, text); if (!heading) return false; const selection = TextSelection.near(view.state.doc.resolve(heading.position), 1); - view.dispatch(view.state.tr.setSelection(selection).scrollIntoView()); view.focus(); + view.dispatch(view.state.tr.setSelection(selection).scrollIntoView()); return true; };