feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)

This commit is contained in:
vv-get-ti
2026-08-31 06:49:43 +08:00
committed by GitHub
parent cc4aa736ff
commit 151e99f8d9
22 changed files with 925 additions and 113 deletions

View 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();
});
});

View 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 });
});
});

View 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");
});
});

View 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}`');
});
});