feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)
This commit is contained in:
155
web/test/canvas-appearance.test.ts
Normal file
155
web/test/canvas-appearance.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
canvasAppearanceForTheme,
|
||||
customCanvasAppearanceFromTheme,
|
||||
enterCustomCanvasAppearance,
|
||||
normalizeCanvasAppearance,
|
||||
normalizeHexColor,
|
||||
readCanvasAppearanceDefault,
|
||||
resolveCanvasAppearance,
|
||||
resolveCanvasGridColor,
|
||||
writeCanvasAppearanceDefault,
|
||||
} from "../src/lib/canvas/canvas-appearance";
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
values.clear();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) || null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas custom appearance", () => {
|
||||
test("inherits the active fixed theme the first time custom mode is selected", () => {
|
||||
const light = enterCustomCanvasAppearance(canvasAppearanceForTheme("light"), "light");
|
||||
expect(light).toEqual({
|
||||
mode: "custom",
|
||||
custom: {
|
||||
baseTheme: "light",
|
||||
backgroundColor: "#EDEEEE",
|
||||
backgroundBrightness: 0,
|
||||
gridColor: "#B6B3B3",
|
||||
gridOpacity: 70,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = enterCustomCanvasAppearance(canvasAppearanceForTheme("dark"), "dark");
|
||||
expect(dark.custom).toMatchObject({ baseTheme: "dark", backgroundColor: "#262626", backgroundBrightness: 0, gridColor: "#B6B3B3", gridOpacity: 70 });
|
||||
});
|
||||
|
||||
test("restores a previous custom profile only under the same base theme", () => {
|
||||
const previous = customCanvasAppearanceFromTheme("light");
|
||||
previous.custom = { ...previous.custom!, backgroundColor: "#F3DCE5" };
|
||||
const fixedLight = canvasAppearanceForTheme("light", previous);
|
||||
expect(enterCustomCanvasAppearance(fixedLight, "light").custom).toEqual(previous.custom);
|
||||
|
||||
const fixedDark = canvasAppearanceForTheme("dark", previous);
|
||||
expect(fixedDark.custom).toBeUndefined();
|
||||
expect(enterCustomCanvasAppearance(fixedDark, "dark").custom).toMatchObject({
|
||||
baseTheme: "dark",
|
||||
backgroundColor: "#262626",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps color-picker popups inside the appearance interaction boundary", async () => {
|
||||
const toolbarSource = await Bun.file(new URL("../src/components/canvas/canvas-toolbar.tsx", import.meta.url)).text();
|
||||
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
|
||||
expect(toolbarSource).toContain('closest(".ant-color-picker,.ant-popover")');
|
||||
expect(controlsSource).toContain("if (normalized) onChange(normalized)");
|
||||
});
|
||||
|
||||
test("commits custom colors immediately instead of discarding them when the panel closes", async () => {
|
||||
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
|
||||
const updateCustomSource = controlsSource.slice(
|
||||
controlsSource.indexOf("const updateCustom"),
|
||||
controlsSource.indexOf("const resetCustom"),
|
||||
);
|
||||
expect(updateCustomSource).toContain("onAppearanceChange(next)");
|
||||
expect(updateCustomSource).not.toContain("onAppearancePreviewChange(next)");
|
||||
});
|
||||
|
||||
test("keeps hex editing live across parent updates and common hex formats", async () => {
|
||||
expect(normalizeHexColor("F3DCE5")).toBe("#F3DCE5");
|
||||
expect(normalizeHexColor("#f3d")).toBe("#FF33DD");
|
||||
|
||||
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
|
||||
expect(controlsSource).toContain("if (!editing) setTextValue(value)");
|
||||
expect(controlsSource).toContain("updateCustom({ backgroundColor, backgroundBrightness: 0 })");
|
||||
});
|
||||
|
||||
test("adjusts only the custom canvas substrate and grid", () => {
|
||||
const appearance = normalizeCanvasAppearance({
|
||||
mode: "custom",
|
||||
custom: {
|
||||
baseTheme: "light",
|
||||
backgroundColor: "#F3DCE5",
|
||||
backgroundBrightness: 0,
|
||||
backgroundOpacity: 10,
|
||||
gridColor: "#9D7182",
|
||||
gridOpacity: 22,
|
||||
},
|
||||
}, "dark");
|
||||
|
||||
const resolved = resolveCanvasAppearance(appearance, "dark");
|
||||
expect(resolved.baseTheme).toBe("light");
|
||||
expect(resolved.background).toBe("#F3DCE5");
|
||||
expect(resolveCanvasGridColor(appearance, "dark", "lines")).toBe("rgba(157,113,130,0.22)");
|
||||
|
||||
appearance.custom!.backgroundBrightness = 10;
|
||||
const brighter = resolveCanvasAppearance(appearance, "dark").background;
|
||||
appearance.custom!.backgroundBrightness = -10;
|
||||
const darker = resolveCanvasAppearance(appearance, "dark").background;
|
||||
|
||||
const brighterParts = brighter.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/);
|
||||
const darkerParts = darker.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/);
|
||||
expect(brighterParts).not.toBeNull();
|
||||
expect(darkerParts).not.toBeNull();
|
||||
expect(Number(brighterParts![1])).toBeGreaterThan(Number(darkerParts![1]));
|
||||
expect(brighterParts!.slice(2)).toEqual(darkerParts!.slice(2));
|
||||
});
|
||||
|
||||
test("switches the custom interface style without changing custom canvas colors", () => {
|
||||
const appearance = customCanvasAppearanceFromTheme("dark");
|
||||
appearance.custom = {
|
||||
...appearance.custom!,
|
||||
baseTheme: "light",
|
||||
backgroundColor: "#F3DCE5",
|
||||
gridColor: "#9D7182",
|
||||
gridOpacity: 22,
|
||||
};
|
||||
|
||||
expect(resolveCanvasAppearance(appearance, "dark")).toEqual({
|
||||
baseTheme: "light",
|
||||
background: "#F3DCE5",
|
||||
grid: "rgba(157,113,130,0.22)",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not expose background opacity after legacy values are ignored", async () => {
|
||||
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
|
||||
expect(controlsSource).not.toContain('label="背景透明度"');
|
||||
expect(controlsSource).toContain('aria-label="界面样式"');
|
||||
expect(controlsSource).toContain('label="网格强度"');
|
||||
});
|
||||
|
||||
test("stores defaults locally with the active account scope", () => {
|
||||
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-A");
|
||||
const value = { appearance: customCanvasAppearanceFromTheme("dark"), backgroundMode: "lines" as const };
|
||||
writeCanvasAppearanceDefault(value);
|
||||
|
||||
expect(values.has("infinite-canvas:canvas-appearance-default:user:account-A")).toBe(true);
|
||||
expect(readCanvasAppearanceDefault()).toEqual(value);
|
||||
|
||||
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-B");
|
||||
expect(readCanvasAppearanceDefault()).toBeNull();
|
||||
});
|
||||
});
|
||||
13
web/test/canvas-connected-node-placement.test.ts
Normal file
13
web/test/canvas-connected-node-placement.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
|
||||
|
||||
describe("connectedNodeCenterFromEdgeDrop", () => {
|
||||
it("uses an output drop as the new target node's left-edge midpoint", () => {
|
||||
expect(connectedNodeCenterFromEdgeDrop({ x: 900, y: 320 }, { width: 720, height: 405 }, "source")).toEqual({ x: 1260, y: 320 });
|
||||
});
|
||||
|
||||
it("uses an input drop as the new source node's right-edge midpoint", () => {
|
||||
expect(connectedNodeCenterFromEdgeDrop({ x: 900, y: 320 }, { width: 340, height: 120 }, "target")).toEqual({ x: 730, y: 320 });
|
||||
});
|
||||
});
|
||||
21
web/test/canvas-starter.test.ts
Normal file
21
web/test/canvas-starter.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveCanvasEmptyStateKind } from "../src/lib/canvas/canvas-starter";
|
||||
|
||||
describe("canvas starter", () => {
|
||||
test("offers the guided entry for a new short-drama canvas", () => {
|
||||
expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: false })).toBe("guided");
|
||||
});
|
||||
|
||||
test("persists the freeform choice while the canvas remains empty", () => {
|
||||
expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: false, starterMode: "freeform" })).toBe("freeform");
|
||||
});
|
||||
|
||||
test("keeps linked projects on their project-specific empty state", () => {
|
||||
expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: true, starterMode: "freeform" })).toBe("linked");
|
||||
});
|
||||
|
||||
test("hides every starter after the first node is created", () => {
|
||||
expect(resolveCanvasEmptyStateKind({ nodeCount: 1, shortDramaEnabled: true, isProjectLinked: false, starterMode: "freeform" })).toBe("none");
|
||||
});
|
||||
});
|
||||
48
web/test/canvas-visual-contrast.test.ts
Normal file
48
web/test/canvas-visual-contrast.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasThemes } from "../src/lib/canvas-theme";
|
||||
|
||||
describe("canvas visual contrast", () => {
|
||||
test("keeps the canvas substrate distinct from node surfaces in both themes", () => {
|
||||
for (const theme of Object.values(canvasThemes)) {
|
||||
expect(theme.canvas.background).not.toBe(theme.node.fill);
|
||||
expect(theme.node.panel).not.toBe(theme.canvas.background);
|
||||
}
|
||||
});
|
||||
|
||||
test("changes only the canvas substrate while retaining original surfaces", () => {
|
||||
expect(canvasThemes.light.canvas.background).toBe("#edeeee");
|
||||
expect(canvasThemes.light.node.fill).toBe("#ffffff");
|
||||
expect(canvasThemes.light.node.edge).toBe("rgba(15,23,42,.16)");
|
||||
expect(canvasThemes.light.node.shadow).toBe("0 6px 18px rgba(15,23,42,.08)");
|
||||
expect(canvasThemes.light.node.hoverShadow).toBe("0 10px 24px rgba(15,23,42,.12)");
|
||||
expect(canvasThemes.light.toolbar.panel).toBe("rgba(255,255,255,.94)");
|
||||
expect(canvasThemes.light.spatial.elevated).toBe("rgba(255,255,255,.94)");
|
||||
|
||||
expect(canvasThemes.dark.canvas.background).toBe("#262626");
|
||||
expect(canvasThemes.dark.node.fill).toBe("#181818");
|
||||
expect(canvasThemes.dark.node.edge).toBe("rgba(255,255,255,.18)");
|
||||
expect(canvasThemes.dark.node.shadow).toBe("0 8px 24px rgba(0,0,0,.34)");
|
||||
expect(canvasThemes.dark.node.hoverShadow).toBe("0 12px 30px rgba(0,0,0,.46)");
|
||||
expect(canvasThemes.dark.toolbar.panel).toBe("rgba(20,20,20,.97)");
|
||||
expect(canvasThemes.dark.toolbar.border).toBe("rgba(255,255,255,.1)");
|
||||
expect(canvasThemes.dark.spatial.elevated).toBe("rgba(15,15,15,.97)");
|
||||
});
|
||||
|
||||
test("pins intentional grid tokens while retaining canvas grid opacity", async () => {
|
||||
expect(canvasThemes.light.canvas.dot).toBe("rgba(15,23,42,.20)");
|
||||
expect(canvasThemes.light.canvas.line).toBe("rgba(15,23,42,.15)");
|
||||
expect(canvasThemes.dark.canvas.dot).toBe("rgba(205,205,205,.28)");
|
||||
expect(canvasThemes.dark.canvas.line).toBe("rgba(205,205,205,.28)");
|
||||
|
||||
const source = await Bun.file(new URL("../src/components/canvas/infinite-canvas.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('opacity: mode === "dots" ? 0.34 : 0.46');
|
||||
});
|
||||
|
||||
test("keeps the original transparent edge for standard canvas nodes", async () => {
|
||||
const source = await Bun.file(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url)).text();
|
||||
|
||||
expect(source).toContain('border: isComposerNode ? "0" : "1px solid transparent"');
|
||||
expect(source).not.toContain('border: isComposerNode ? "0" : `1px solid ${theme.node.edge}`');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user