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 @@ -29,6 +29,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Follow links again with `Ctrl+click` (`Cmd+click` on macOS). Pressing a link, wiki link, or footnote reference with the modifier no longer turns it into its Markdown source or outlines it before the click lands, so web links open in the system browser, local Markdown links open in Leafdown, and footnote references move to their definitions. A plain click still places the caret for editing.
- Leave a heading chosen from the outline with the same space above it as the document's first line, instead of close under the top edge. Choosing the heading that opens the document scrolls back to the very top.
- Open documents with large tables about ten times faster. Reading tables took time that grew with the square of their cell count, so a document with hundreds of kilobytes of tables could take most of a minute to open, and larger ones never finished opening.
- Open documents with many tables a little faster. Fitting each table's columns made the window lay the document out again for every table in turn; all of a document's tables are now measured in one layout.

## [0.1.0-alpha.2] - 2026-10-05

Expand Down
143 changes: 143 additions & 0 deletions src/features/editor/plugins/tableColumns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown";
import {
getEditorTextPosition,
setSelectionInTableCell,
setTextSelection,
typeText,
} from "@/test/utils/prosemirror";

const mountEditor = setupMilkdownEditorMount();

const TWO_TABLE_MARKDOWN = [
"Intro",
"| Name | Notes |\n| - | - |\n| a | b |",
"Between",
"| Key | Longer value |\n| - | - |\n| c | d |",
].join("\n\n");

// A cell sizes itself to its text unless its table holds a fit, in which case it reports the
// fitted width. Each read records how many tables were fitted at that moment.
let scale = 1;
let fittedTablesAtReads: number[] = [];

const getNaturalWidth = (cell: Element) => scale * (40 + 10 * (cell.textContent ?? "").length);

beforeEach(() => {
scale = 1;
fittedTablesAtReads = [];
const getBoundingClientRect = Element.prototype.getBoundingClientRect;
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
if (!(this instanceof HTMLTableCellElement)) {
return getBoundingClientRect.call(this);
}

fittedTablesAtReads.push(document.querySelectorAll("table colgroup").length);
const col = this.closest("table")?.querySelector("colgroup")?.children[this.cellIndex];
const width = col instanceof HTMLElement ? parseFloat(col.style.width) : getNaturalWidth(this);

return new DOMRect(0, 0, width, 28);
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

const getTables = (mounted: MountedMilkdownEditor) => [
...mounted.view.dom.querySelectorAll("table"),
];

const getFittedWidths = (table: HTMLTableElement) =>
table.hasAttribute("data-leafdown-table-columns")
? [...(table.querySelector("colgroup")?.children ?? [])].map(
(col) => (col as HTMLElement).style.width,
)
: null;

const getNaturalWidths = (table: HTMLTableElement) =>
[...(table.querySelector("tbody tr")?.children ?? [])].map(
(cell) => `${String(getNaturalWidth(cell))}px`,
);

const settleAnimationFrame = () =>
new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});

describe("table columns plugin", () => {
it("fits every table to its first row, reading all of them before fitting any", async () => {
const mounted = await mountEditor(TWO_TABLE_MARKDOWN);
const [first, second] = getTables(mounted);

expect(getFittedWidths(first)).toEqual(["80px", "90px"]);
expect(getFittedWidths(second)).toEqual(["70px", "160px"]);
expect(fittedTablesAtReads).toEqual([0, 0, 0, 0]);
});

it("holds the table with the caret and refits it once the caret leaves", async () => {
const mounted = await mountEditor(TWO_TABLE_MARKDOWN);
const [first, second] = getTables(mounted);
const fittedSecond = getFittedWidths(second);

mounted.view.focus();
setSelectionInTableCell(mounted, 0, 0);
typeText(mounted.view, "123");

expect(getNaturalWidths(first)).toEqual(["110px", "90px"]);
expect(getFittedWidths(first)).toEqual(["80px", "90px"]);

setTextSelection(mounted.view, getEditorTextPosition(mounted, "Intro"));

expect(getFittedWidths(first)).toEqual(["110px", "90px"]);
expect(getFittedWidths(second)).toEqual(fittedSecond);
});

it("refits every table but the held one when the editor resizes", async () => {
const observers: { callback: () => void; observed: Set<Element> }[] = [];
vi.stubGlobal(
"ResizeObserver",
class {
private readonly entry: (typeof observers)[number];

constructor(callback: () => void) {
this.entry = { callback, observed: new Set() };
observers.push(this.entry);
}

observe(element: Element) {
this.entry.observed.add(element);
}

unobserve(element: Element) {
this.entry.observed.delete(element);
}

disconnect() {
this.entry.observed.clear();
}
},
);
const mounted = await mountEditor(`${TWO_TABLE_MARKDOWN}\n\n| X | Y |\n| - | - |\n| e | f |`);
const [first, second, third] = getTables(mounted);
mounted.view.focus();
setSelectionInTableCell(mounted, 0, 0);
Object.defineProperty(mounted.view.dom, "clientWidth", { configurable: true, value: 800 });
scale = 2;
fittedTablesAtReads = [];

for (const { callback, observed } of observers) {
if (observed.has(mounted.view.dom)) callback();
}
await settleAnimationFrame();

expect(getFittedWidths(first)).toEqual(["80px", "90px"]);
expect(getFittedWidths(second)).toEqual(getNaturalWidths(second));
expect(getFittedWidths(third)).toEqual(getNaturalWidths(third));
expect(getFittedWidths(second)).toEqual(["140px", "320px"]);
expect(fittedTablesAtReads).toEqual([1, 1, 1, 1]);
});
});
51 changes: 30 additions & 21 deletions src/features/editor/plugins/tableColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,40 @@ const findSelectedTablePosition = (state: EditorState) => {
};

// Measuring means letting the columns size themselves again first, which is why the widths are
// read with the previous fit taken off rather than through it.
const fitColumns = (table: HTMLTableElement) => {
table.removeAttribute(FITTED_ATTRIBUTE_NAME);
table.querySelector("colgroup")?.remove();
// read with the previous fit taken off rather than through it. Every fit comes off before any
// width is read, and none goes back until all are read, so the tables share one layout instead of
// each forcing its own.
const fitColumns = (tables: readonly HTMLTableElement[]) => {
for (const table of tables) {
table.removeAttribute(FITTED_ATTRIBUTE_NAME);
table.querySelector("colgroup")?.remove();
}

const cells = table.querySelector("tbody tr")?.children;
const measurements = tables.map((table) => {
const cells = table.querySelector("tbody tr")?.children;

if (!cells?.length) {
return;
}
return {
table,
widths: cells ? [...cells].map((cell) => cell.getBoundingClientRect().width) : [],
};
});

const widths = [...cells].map((cell) => cell.getBoundingClientRect().width);
for (const { table, widths } of measurements) {
// Before the first layout every column measures zero. Fitting to that would pin the table
// shut, so it is left to size itself until something has a width to report.
if (!widths.length || widths.some((width) => width <= 0)) {
continue;
}

// Before the first layout every column measures zero. Fitting to that would pin the table shut,
// so it is left to size itself until something has a width to report.
if (widths.some((width) => width <= 0)) {
return;
}
const colgroup = document.createElement("colgroup");

const colgroup = document.createElement("colgroup");
for (const width of widths) {
colgroup.appendChild(document.createElement("col")).style.width = `${width}px`;
}

for (const width of widths) {
colgroup.appendChild(document.createElement("col")).style.width = `${width}px`;
table.insertBefore(colgroup, table.firstChild);
table.setAttribute(FITTED_ATTRIBUTE_NAME, "");
}

table.insertBefore(colgroup, table.firstChild);
table.setAttribute(FITTED_ATTRIBUTE_NAME, "");
};

/**
Expand Down Expand Up @@ -78,6 +85,7 @@ export const createLeafdownTableColumnsPlugin = () =>
? findSelectedTablePosition(state)
: null;
let nextHeld: HTMLTableElement | null = null;
const tablesToFit: HTMLTableElement[] = [];

state.doc.descendants((node, position) => {
if (node.type !== tableType) {
Expand Down Expand Up @@ -105,13 +113,14 @@ export const createLeafdownTableColumnsPlugin = () =>
!table.hasAttribute(FITTED_ATTRIBUTE_NAME) ||
fittedNodes.get(table) !== node
) {
fitColumns(table);
tablesToFit.push(table);
fittedNodes.set(table, node);
}

return false;
});

fitColumns(tablesToFit);
heldTable = nextHeld;
};

Expand Down