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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Keep typing responsive in documents with many code blocks. Every keystroke used to highlight all of the document's code again, so typing slowed sharply as a document's code grew; now an edit highlights only the code blocks it changes, and typing outside code highlights nothing. Line numbers for code blocks, when shown, are now updated the same way instead of being redrawn for every block on each keystroke.
- Type faster in documents with many headings. Every keystroke used to work out again which headings could fold and redraw every fold marker and folded section, which took a growing share of each keystroke as headings were added; now an edit updates only the headings and folded sections it can change.
- Type without long pauses in large documents. Every keystroke used to update the block gutters of the whole document, so in several hundred kilobytes of Markdown with many headings and lists each character could take seconds to appear. The gutters now keep only the blocks in view up to date, and large documents open faster too.
- Type faster in large documents. While the editor had focus, every update to the status bar, the outline, or the rest of the window went through the whole document first, adding about 30 to 60 ms to each keystroke in a 1 MB document. The status bar's counts and the outline still update as you type.
- Open documents with many list items faster. Reading each item's indentation searched back toward the start of the file, so opening took longer with every item and with the file's size, adding about a second to a 1 MB document and much more to larger ones.
- Draw the scrollbars of wide code blocks, tables, and display math in the same thin, rounded style as the rest of Leafdown instead of the system default. Help code samples, Find in folder's skipped-file lists, the code block language and wiki link suggestion lists, and menus cut short by a small window match it too.
- Show a file opened from File Explorer, File > Open, recent files, or a drop as soon as it is read instead of after its whole folder has been scanned. A file in a large folder such as Documents could leave the window on the welcome screen for several seconds, looking as if the file had not opened. The article navigator shows placeholder rows until the folder is ready, and a folder that cannot be scanned reports its error without closing the document.
Expand Down
89 changes: 89 additions & 0 deletions src/features/editor/plugins/reactSelectionOptOut.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment happy-dom

import type { EditorView } from "@milkdown/kit/prose/view";
import { describe, expect, it, vi } from "vitest";

import { TWO_PARAGRAPH_MARKDOWN } from "@/test/fixtures/editorMarkdown";
import { setupMilkdownEditorMount } from "@/test/utils/milkdown";
import { setSelectionAtDocumentEnd } from "@/test/utils/prosemirror";
import { act, render } from "@/test/utils/react";

const mountEditor = setupMilkdownEditorMount();

const findNodeValueGetter = (node: Node) => {
for (
let prototype: object | null = Object.getPrototypeOf(node);
prototype;
prototype = Object.getPrototypeOf(prototype)
) {
const property = Object.getOwnPropertyDescriptor(prototype, "nodeValue");

if (property?.get) {
return { owner: prototype, get: property.get };
}
}

throw new Error("nodeValue has no getter");
};

const countEditorTextReadsDuringCommit = (view: EditorView, commit: () => void) => {
const textNode = view.dom.querySelector("p")?.firstChild;

if (!textNode) {
throw new Error("editor has no text node");
}

const { owner, get } = findNodeValueGetter(textNode);
let reads = 0;
const spy = vi.spyOn(owner as Node, "nodeValue", "get").mockImplementation(function (this: Node) {
if (view.dom.contains(this)) reads += 1;
return get.call(this) as string | null;
});

try {
act(commit);
} finally {
spy.mockRestore();
}

return reads;
};

describe("React selection opt-out plugin", () => {
it("keeps the editable attribute while its property no longer reads as true", async () => {
const { view } = await mountEditor(TWO_PARAGRAPH_MARKDOWN);

expect(view.dom).toHaveAttribute("contenteditable", "true");
expect(view.dom.contentEditable).toBe("inherit");

view.setProps({ editable: () => false });

expect(view.dom).toHaveAttribute("contenteditable", "false");
expect(view.dom.contentEditable).toBe("false");
});

it("removes its property when the editor is destroyed", async () => {
const mounted = await mountEditor(TWO_PARAGRAPH_MARKDOWN);
const { dom } = mounted.view;

await mounted.destroy();

expect(Object.hasOwn(dom, "contentEditable")).toBe(false);
});

it("keeps React commits from walking the focused editor's text", async () => {
const { view } = await mountEditor(TWO_PARAGRAPH_MARKDOWN);
let count = 0;
const { rerender } = render(<output>{count}</output>);
const commit = () => rerender(<output>{++count}</output>);
view.focus();
setSelectionAtDocumentEnd(view);

expect(document.activeElement).toBe(view.dom);
expect(countEditorTextReadsDuringCommit(view, commit)).toBe(0);

Reflect.deleteProperty(view.dom, "contentEditable");

expect(countEditorTextReadsDuringCommit(view, commit)).toBeGreaterThan(0);
});
});
35 changes: 35 additions & 0 deletions src/features/editor/plugins/reactSelectionOptOut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Plugin } from "@milkdown/kit/prose/state";
import { $prose } from "@milkdown/kit/utils";

/**
* Before every commit, React DOM saves the selection of a focused element whose `contentEditable`
* property reads "true" by counting text offsets across every node beneath it, so each React update
* while the editor has focus walks the whole document. The editor's selection is ProseMirror's to
* keep, so its root reports "inherit" to that property read. The attribute, `isContentEditable`,
* and editing are unchanged.
*/
export const createLeafdownReactSelectionOptOutPlugin = () =>
$prose(
() =>
new Plugin({
view: (view) => {
const dom = view.dom;
const prototype: object = Object.getPrototypeOf(dom);

Object.defineProperty(dom, "contentEditable", {
configurable: true,
get: () => {
const value: unknown = Reflect.get(prototype, "contentEditable", dom);
return value === "true" ? "inherit" : value;
},
set: (value: string) => Reflect.set(prototype, "contentEditable", value, dom),
});

return {
destroy: () => {
Reflect.deleteProperty(dom, "contentEditable");
},
};
},
}),
);
2 changes: 2 additions & 0 deletions src/features/editor/utils/createMilkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import {
} from "../plugins/math";
import { createLeafdownMermaidModePlugin } from "../plugins/mermaidMode";
import { createLeafdownPrevailingFormPlugin } from "../plugins/prevailingForm";
import { createLeafdownReactSelectionOptOutPlugin } from "../plugins/reactSelectionOptOut";
import {
commitReferenceDefinitionFields,
createLeafdownReferenceDefinitionFieldsPlugin,
Expand Down Expand Up @@ -438,6 +439,7 @@ export const createMilkdownEditor = async ({
.use(createLeafdownTaskListCheckboxPlugin())
.use(createLeafdownPrevailingFormPlugin())
.use(createLeafdownTrailingParagraphPlugin())
.use(createLeafdownReactSelectionOptOutPlugin())
.use(createLeafdownDirtyTrackerPlugin(() => onContentChanged?.()))
.config((ctx) => {
ctx.set(rootCtx, root);
Expand Down