Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span` with `id="b">` 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/help/file-and-folder-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion e2e/desktop/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const createWorkerContext = async (workerIndex: number): Promise<WorkerContext>
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");
Expand Down Expand Up @@ -186,6 +187,7 @@ const createWorkerContext = async (workerIndex: number): Promise<WorkerContext>
document: {
initialMarker: "Initial fixture marker.",
path: documentPath,
scrollPath: documentScrollPath,
savedMarkdown: `${savedMarker}\n`,
savedMarker,
},
Expand Down Expand Up @@ -243,7 +245,7 @@ const createWorkerContext = async (workerIndex: number): Promise<WorkerContext>
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" },
Expand Down
129 changes: 127 additions & 2 deletions e2e/desktop/specs/document-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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<HTMLElement>(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();

Expand Down
1 change: 1 addition & 0 deletions e2e/desktop/support/runContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface DesktopE2ERunContext {
document: {
initialMarker: string;
path: string;
scrollPath: string;
savedMarkdown: string;
savedMarker: string;
};
Expand Down
68 changes: 67 additions & 1 deletion src/components/screens/document-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(<DocumentScreen activeDocument={activeDocument} />);
await waitFor(() => {
expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready");
});
const viewport = screen
.getByTestId("document-surface-scroll-area")
.querySelector<HTMLElement>('[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(<DocumentScreen activeDocument={replacement} />);
});
await waitFor(() => {
expect(documentEditorBridge.getCommandState(replacement.path).status).toBe("ready");
});

expect(
screen
.getByTestId("document-surface-scroll-area")
.querySelector<HTMLElement>('[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(<DocumentScreen activeDocument={activeDocument} />);
await waitFor(() => {
expect(documentEditorBridge.getCommandState(activeDocument.path).status).toBe("ready");
});
const viewport = screen
.getByTestId("document-surface-scroll-area")
.querySelector<HTMLElement>('[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(<DocumentScreen activeDocument={reloaded} />);
});
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({
Expand Down
9 changes: 8 additions & 1 deletion src/components/screens/document-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
useSessionStore,
} from "@/features/session";
import { useLocalization } from "@/lib/i18n";
import { getPathIdentityKey } from "@/lib/path";

interface DocumentScreenProps {
activeDocument: ActiveDocumentState;
Expand Down Expand Up @@ -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;
Expand All @@ -87,7 +90,11 @@ export function DocumentScreen({ activeDocument }: DocumentScreenProps) {
>
<Card className="min-h-0 min-w-0 flex-1 gap-0 py-0">
<CardContent className="relative min-h-0 flex-1 p-0">
<ScrollArea className="min-h-0 flex-1" data-testid="document-surface-scroll-area">
<ScrollArea
key={viewportKey}
className="min-h-0 flex-1"
data-testid="document-surface-scroll-area"
>
<MilkdownEditor
key={editorKey}
ref={setEditorBridgeRef}
Expand Down
8 changes: 4 additions & 4 deletions src/features/editor/hooks/useMilkdownEditorInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,15 +730,15 @@ const restoreViewState = (editor: MilkdownEditorInstance, viewState: EditorViewS
const resolve = (position: number) =>
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");
}
Expand Down
2 changes: 1 addition & 1 deletion src/features/editor/utils/wikiHeadings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};