Canvas support V1

This commit is contained in:
2026-10-04 15:28:06 +02:00
parent 7749d7c237
commit 2903986f44
20 changed files with 3223 additions and 482 deletions
+151
View File
@@ -0,0 +1,151 @@
// End-to-end coverage of the canvas view: creating a canvas from a page,
// notes, connections, moving, deleting and the autosave behind all of them.
// Like pages.spec.js, every test works in its own fresh top-level folder.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh page, optionally holding Board.canvas with doc.
function sandbox(doc) {
const root = "c" + randomWord(7);
mkdirSync(join(wiki, root), { recursive: true });
writeFileSync(join(wiki, root, "index.md"), "# Canvas host\n");
if (doc) writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(doc));
return root;
}
const readCanvas = (root, name = "Board.canvas") => JSON.parse(readFileSync(join(wiki, root, name), "utf8"));
const node = (page, id) => page.locator(`.canvas-node[data-id="${id}"]`);
function note(id, x, y, text) {
return { id, type: "text", x, y, width: 200, height: 80, text };
}
async function pageAction(page, title) {
const toggle = page.getByRole("button", { name: "ACTIONS" });
if (await toggle.isVisible()) await toggle.click();
await page.locator("header").getByTitle(title).click();
}
test("creates a canvas from a page and writes a note", async ({ page }) => {
const root = sandbox();
await page.goto(`/${root}/`);
await pageAction(page, "New canvas on this page");
await page.locator(".modal").getByPlaceholder("Canvas name").fill("Ideas");
await page.locator(".modal").getByRole("button", { name: "CREATE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Ideas\\.canvas$`));
// The canvas takes the whole shell: no folder tree, no footer.
await expect(page.locator("aside.tree-sidebar")).toHaveCount(0);
await expect(page.locator("footer")).toHaveCount(0);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node-editor .cm-content")).toBeFocused();
await page.keyboard.type("Hello **world**");
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
await expect.poll(() => readCanvas(root, "Ideas.canvas").nodes.map((n) => n.text)).toEqual(["Hello **world**"]);
await expect(page.locator(".canvas-status")).toHaveText("Saved");
await page.reload();
await expect(page.locator(".canvas-node-body strong")).toHaveText("world");
// The page lists its canvas.
await page.goto(`/${root}/`);
await expect(page.locator("main").getByRole("link", { name: "Ideas.canvas" })).toBeVisible();
});
test("an empty new note disappears", async ({ page }) => {
const root = sandbox({ nodes: [], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } });
await expect(page.locator(".canvas-node")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(page.locator(".canvas-node")).toHaveCount(0);
});
test("edits a note with the page editor's toolbar", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "plain")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").dblclick();
await expect(page.locator(".canvas-node-toolbar")).toBeVisible();
await page.keyboard.press("Control+End");
await page.keyboard.type(" ");
await page.locator(".canvas-node-toolbar").getByTitle("Bold (B)").click();
await page.keyboard.type("strong");
// Clicking the background closes the editor.
await page.locator(".canvas-viewport").click({ position: { x: 5, y: 300 } });
await expect(page.locator(".canvas-node-toolbar")).toBeHidden();
await expect(node(page, "a").locator("strong")).toHaveText("strong");
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("plain **strong**");
});
test("connects two nodes by dragging a handle", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").click();
const handle = node(page, "a").locator('.canvas-handle[data-side="right"]');
const from = await handle.boundingBox();
const to = await node(page, "b").boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 5 });
await page.mouse.up();
await expect(page.locator(".canvas-edge")).toHaveCount(1);
await expect
.poll(() => readCanvas(root).edges.map((e) => [e.fromNode, e.fromSide, e.toNode, e.toSide]))
.toEqual([["a", "right", "b", "left"]]);
});
test("moves, undoes and deletes nodes", async ({ page }) => {
const root = sandbox({
nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")],
edges: [{ id: "e", fromNode: "a", toNode: "b" }],
});
await page.goto(`/${root}/Board.canvas`);
const box = await node(page, "a").boundingBox();
await page.mouse.move(box.x + 50, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + 50, box.y + 240, { steps: 5 });
await page.mouse.up();
await expect.poll(() => readCanvas(root).nodes[0].y).toBeGreaterThan(100);
await page.keyboard.press("Control+z");
await expect.poll(() => readCanvas(root).nodes[0].y).toBe(0);
// Deleting a node removes its connections too.
await node(page, "a").click();
await page.keyboard.press("Delete");
await expect(node(page, "a")).toHaveCount(0);
await expect.poll(() => readCanvas(root)).toMatchObject({ nodes: [{ id: "b" }], edges: [] });
});
test("a task checkbox in a note persists", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "- [ ] one\n- [ ] two")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
await node(page, "a").locator("input.task-checkbox").nth(1).check();
await expect.poll(() => readCanvas(root).nodes[0].text).toBe("- [ ] one\n- [x] two");
});
test("a change made elsewhere stops the autosave", async ({ page }) => {
const root = sandbox({ nodes: [note("a", 0, 0, "A")], edges: [] });
await page.goto(`/${root}/Board.canvas`);
const other = { nodes: [note("z", 0, 0, "from elsewhere")], edges: [] };
writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(other));
await node(page, "a").click();
await page.keyboard.press("ArrowDown");
await expect(page.locator(".canvas-status")).toContainText("Changed elsewhere");
expect(readCanvas(root)).toEqual(other);
});