feat(web): close remaining editor, layout and persistence gaps
- Area headers get a per-area editor switcher backed by a shared AreaEditorContext; Preferences can hide it. - Animation camera area is a real navigable viewport aligned to the scene camera; Graph Editor adds channel selection, keyframe drag/ insert/delete and interpolation. - Geometry Nodes node locations round-trip through the native writer and reader, so saved graphs keep their layout. - Texture Paint can make linked library images local via a new native makeImageLocal command before packing strokes. - Compositor IMAGE and Render Layers nodes now persist to Main and execute against packed images / the last PBR render. - Spreadsheet row cap raised to 250k; Outliner gains Ctrl+A and arrow/ Home/End navigation. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
66
web/tests/e2e/web-area-graph-editor.spec.ts
Normal file
66
web/tests/e2e/web-area-graph-editor.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function openStudio(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
||||
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 });
|
||||
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
|
||||
}
|
||||
|
||||
async function workspace(page: import("@playwright/test").Page, name: string): Promise<void> {
|
||||
await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click());
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
test("Area headers switch editor types and restore the default", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openStudio(page);
|
||||
const area = page.locator(".properties-area");
|
||||
await expect(area.locator(".area-type-switch select")).toBeVisible();
|
||||
await area.locator(".area-type-switch select").selectOption("Spreadsheet");
|
||||
await expect(area).toHaveAttribute("data-editor", "Spreadsheet");
|
||||
await expect(area.getByTestId("spreadsheet-view")).toBeVisible();
|
||||
await area.locator(".area-type-switch select").selectOption("Properties");
|
||||
await expect(area).toHaveAttribute("data-editor", "Properties");
|
||||
await expect(area.locator(".area-type-switch select")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Camera viewport is real and Graph Editor edits animation keys", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await openStudio(page);
|
||||
await workspace(page, "Animation");
|
||||
await expect(page.locator(".camera-view-area canvas").first()).toBeVisible();
|
||||
const key = page.getByRole("button", { name: "插入位置关键帧", exact: true });
|
||||
await expect(key).toBeVisible();
|
||||
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
||||
await key.evaluate((element) => (element as HTMLElement).click());
|
||||
const graph = page.getByTestId("graph-editor");
|
||||
await graph.waitFor({ timeout: 60_000 });
|
||||
await expect.poll(async () => Number(await graph.getAttribute("data-channel-count")), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect.poll(async () => Number(await graph.getAttribute("data-keyframe-count")), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
|
||||
|
||||
await page.getByLabel("当前帧").fill("24");
|
||||
await page.waitForTimeout(800);
|
||||
const countBefore = Number(await graph.getAttribute("data-keyframe-count"));
|
||||
await graph.getByRole("button", { name: "插入关键帧", exact: true }).click();
|
||||
await expect.poll(async () => Number(await graph.getAttribute("data-keyframe-count")), { timeout: 60_000 }).toBeGreaterThan(countBefore);
|
||||
|
||||
const dot = graph.locator("circle").first();
|
||||
const box = await dot.boundingBox();
|
||||
if (!box) throw new Error("graph keyframe dot has no box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 35, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => graph.getAttribute("data-selected-frame"), { timeout: 30_000 }).not.toBe("");
|
||||
await expect(graph.getByText(/关键帧已移动/)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
@@ -82,6 +82,34 @@ test("Compositor canvas builds, executes and commits a graph", async ({ page })
|
||||
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("2");
|
||||
});
|
||||
|
||||
test("Compositor IMAGE and Render Layers nodes persist through OPFS", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await openStudio(page);
|
||||
const canvas = await openCompositor(page);
|
||||
await addNode(page, canvas, "IMAGE");
|
||||
await addNode(page, canvas, "RENDER_LAYER");
|
||||
await connect(page, "node-image-1", "node-composite");
|
||||
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("1");
|
||||
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
||||
await canvas.getByRole("button", { name: "提交图", exact: true }).click();
|
||||
await expect.poll(async () => Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(before);
|
||||
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
|
||||
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
|
||||
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
|
||||
await page.reload();
|
||||
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
|
||||
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
|
||||
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
|
||||
if (await discard.count()) await discard.click();
|
||||
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
|
||||
const reopened = await openCompositor(page);
|
||||
await expect.poll(async () => reopened.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||
await expect.poll(async () => reopened.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("1");
|
||||
await expect(reopened.locator(".react-flow__node", { hasText: "图像 Image" })).toHaveCount(1);
|
||||
await expect(reopened.locator(".react-flow__node", { hasText: "渲染层 Render Layers" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Compositor canvas graph survives an OPFS save and reopen", async ({ page }) => {
|
||||
test.setTimeout(360_000);
|
||||
await openStudio(page);
|
||||
|
||||
@@ -65,6 +65,16 @@ test("Geometry Nodes canvas graph survives an OPFS save and reopen", async ({ pa
|
||||
await openStudioAndMonkey(page);
|
||||
const canvas = await openCanvas(page);
|
||||
await addTransformAndConnect(page, canvas);
|
||||
const node = page.locator('.react-flow__node[data-id="node-geometrynodetransform"]');
|
||||
const nodeBox = await node.boundingBox();
|
||||
if (!nodeBox) throw new Error("geometry node has no box");
|
||||
await page.mouse.move(nodeBox.x + 30, nodeBox.y + 15);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(nodeBox.x + 150, nodeBox.y + 85, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(400);
|
||||
const expectedPosition = JSON.parse(await canvas.getAttribute("data-node-positions") ?? "[]").at(-1) as { x: number; y: number };
|
||||
expect(expectedPosition).toBeTruthy();
|
||||
await canvas.getByRole("button", { name: "提交图", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
||||
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
||||
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||
@@ -82,4 +92,6 @@ test("Geometry Nodes canvas graph survives an OPFS save and reopen", async ({ pa
|
||||
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
||||
await expect.poll(async () => reopened.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||
await expect.poll(async () => reopened.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
||||
const positions = JSON.parse(await reopened.getAttribute("data-node-positions") ?? "[]") as Array<{ x: number; y: number } | null>;
|
||||
expect(positions.some((position) => position && Math.abs(position.x - expectedPosition.x) <= 1 && Math.abs(position.y - expectedPosition.y) <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,34 @@ test("Texture Paint paints a local image, commits one stroke and undoes it", asy
|
||||
expect(undone?.[2]).toBe(before?.[2]);
|
||||
});
|
||||
|
||||
test("Texture Paint makes a linked library image local before painting", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await openStudioAndMonkey(page);
|
||||
await clickWorkspace(page, "Shading");
|
||||
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
|
||||
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
|
||||
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6);
|
||||
await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick();
|
||||
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 }).toContain("material:black_tiles_wall");
|
||||
await clickWorkspace(page, "Texture Paint");
|
||||
const view = page.locator(".texture-paint-view");
|
||||
await view.waitFor({ timeout: 60_000 });
|
||||
const imageSelect = view.getByLabel("纹理图像");
|
||||
await expect.poll(async () => (await imageSelect.locator("option").allTextContents()).some((text) => text.includes("black_tiles_wall")), { timeout: 60_000 }).toBe(true);
|
||||
await imageSelect.selectOption({ label: (await imageSelect.locator("option").allTextContents()).find((text) => text.includes("black_tiles_wall"))! });
|
||||
await view.getByRole("button", { name: "设为本地图像", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
||||
await expect.poll(async () => view.locator(".texture-paint-last-action").innerText(), { timeout: 60_000 }).toContain("已设为本地");
|
||||
const canvas = view.getByTestId("texture-paint-canvas");
|
||||
await canvas.waitFor({ timeout: 60_000 });
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("texture paint canvas has no box");
|
||||
await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.55, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => view.getAttribute("data-last-action"), { timeout: 60_000 }).toBe("笔画已提交");
|
||||
});
|
||||
|
||||
test("Texture Paint stroke survives an OPFS save and reopen", async ({ page }) => {
|
||||
test.setTimeout(360_000);
|
||||
await openStudioAndMonkey(page);
|
||||
|
||||
Reference in New Issue
Block a user