feat: complete TASK-WP4-01 editor shell and background

This commit is contained in:
suyx
2026-08-03 03:47:16 +08:00
parent 433ccdcf86
commit 8d8fbeb816
8 changed files with 878 additions and 2 deletions
+112
View File
@@ -0,0 +1,112 @@
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
const elementKeys = new Set([
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
"template_or_asset_id", "type", "z_index",
]);
export function defaultBackgroundAdjustments(): BackgroundAdjustments {
return { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 };
}
function pixelsForRatio(ratio: CanvasState["ratio"]) {
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
: { pixelHeight: 1920, pixelWidth: 1080 };
}
export function createEditorCanvasState(input: { assetId: string | null; ratio: CanvasState["ratio"] }): CanvasState {
const pixels = pixelsForRatio(input.ratio);
return {
background: { adjustments: defaultBackgroundAdjustments(), asset_id: input.assetId },
elements: [],
pixel_height: pixels.pixelHeight,
pixel_width: pixels.pixelWidth,
ratio: input.ratio,
schema_version: 1,
};
}
function checked(state: CanvasState): CanvasState {
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
return structuredClone(state);
}
export function updateBackgroundAdjustments(state: CanvasState, patch: Partial<BackgroundAdjustments>): CanvasState {
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: { ...state.background.adjustments, ...patch } } });
}
export function resetBackgroundAdjustments(state: CanvasState): CanvasState {
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: defaultBackgroundAdjustments() } });
}
export function switchBackground(state: CanvasState, assetId: string, palette: readonly string[]): CanvasState {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(assetId)) throw new Error("canvas_background_asset_invalid");
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
const next = structuredClone(state);
next.background = { asset_id: assetId, adjustments: defaultBackgroundAdjustments() };
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...palette] } : element);
return checked(next);
}
export class CanvasEditHistory {
private current: CanvasState;
private readonly past: CanvasState[] = [];
private readonly future: CanvasState[] = [];
constructor(initial: CanvasState) { this.current = checked(initial); }
get value() { return structuredClone(this.current); }
get canUndo() { return this.past.length > 0; }
get canRedo() { return this.future.length > 0; }
commit(next: CanvasState) {
this.past.push(this.value);
this.current = checked(next);
this.future.length = 0;
}
undo() {
const previous = this.past.pop();
if (!previous) return undefined;
this.future.push(this.value);
this.current = previous;
return this.value;
}
redo() {
const next = this.future.pop();
if (!next) return undefined;
this.past.push(this.value);
this.current = next;
return this.value;
}
}
export function deserializeFabricCanvas(input: unknown): CanvasState | undefined {
if (!input || typeof input !== "object") return undefined;
const value = input as Record<string, unknown>;
if (!value.background || typeof value.background !== "object" || !Array.isArray(value.elements)) return undefined;
const elements = value.elements.map((entry) => {
if (!entry || typeof entry !== "object" || Object.keys(entry).some((key) => !elementKeys.has(key))) return undefined;
return structuredClone(entry);
});
if (elements.some((entry) => entry === undefined)) return undefined;
const candidate = {
background: structuredClone(value.background),
elements,
pixel_height: value.pixel_height,
pixel_width: value.pixel_width,
ratio: value.ratio,
schema_version: value.schema_version,
};
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
}
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
}
+242
View File
@@ -0,0 +1,242 @@
.editor-page-shell {
min-height: 100vh;
display: grid;
grid-template-rows: 56px minmax(0, 1fr) 32px;
background: #e8e8e5;
color: #111111;
}
.editor-toolbar {
display: grid;
grid-template-columns: 40px minmax(160px, 1fr) auto minmax(128px, 160px) auto auto;
gap: 12px;
align-items: center;
min-width: 0;
padding: 0 16px;
border-bottom: 1px solid #111111;
background: #ffffff;
}
.editor-toolbar button,
.editor-back,
.editor-inspector button {
min-height: 36px;
padding: 7px 12px;
border: 1px solid #111111;
border-radius: 0;
color: #111111;
background: #ffffff;
font: inherit;
font-weight: 700;
text-decoration: none;
}
.editor-toolbar button:disabled,
.editor-inspector button:disabled {
border-color: #b9b9b3;
color: #62625d;
background: #e8e8e5;
cursor: not-allowed;
}
.editor-back {
display: grid;
width: 40px;
place-items: center;
padding: 0;
font-size: 20px;
}
.editor-title {
display: flex;
min-width: 0;
align-items: baseline;
gap: 12px;
}
.editor-title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-title span,
.editor-statusbar,
.editor-inspector header p,
.editor-confirm p:first-child {
font-family: Consolas, monospace;
font-size: 11px;
letter-spacing: 0;
}
.editor-history-actions {
display: flex;
gap: 4px;
}
.editor-history-actions button {
width: 36px;
padding: 0;
font-size: 20px;
}
.editor-save-status {
display: inline-flex;
min-width: 128px;
min-height: 36px;
align-items: center;
padding: 0 10px;
border-left: 3px solid #16794b;
background: #f7f7f5;
font-size: 12px;
font-weight: 700;
}
.editor-save-status.dirty,
.editor-save-status.saving { border-color: #1769aa; }
.editor-save-status.failed,
.editor-save-status.conflicted { border-color: #c92a24; color: #8f1d14; }
.editor-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
min-height: 0;
}
.editor-assets-panel,
.editor-inspector {
min-width: 0;
overflow: auto;
background: #f7f7f5;
}
.editor-assets-panel { border-right: 1px solid #b9b9b3; }
.editor-inspector { border-left: 1px solid #b9b9b3; padding: 20px; }
.editor-asset-tabs {
display: grid;
gap: 2px;
padding: 12px;
border-bottom: 1px solid #b9b9b3;
}
.editor-asset-tabs button {
min-height: 36px;
padding: 7px 10px;
border: 0;
border-left: 3px solid transparent;
color: #111111;
background: transparent;
text-align: left;
font-weight: 700;
}
.editor-asset-tabs button[aria-current="page"] { border-left-color: #111111; background: #ffffff; }
.editor-asset-tabs button:disabled { color: #62625d; cursor: not-allowed; }
.editor-assets-panel section { padding: 18px 16px; border-bottom: 1px solid #b9b9b3; }
.editor-assets-panel h2 { margin: 0 0 10px; font-size: 16px; }
.editor-source {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid #b9b9b3;
border-radius: 0;
background: #ffffff;
color: #111111;
text-align: left;
}
.editor-source + .editor-source { margin-top: 8px; }
.editor-source.active { border: 2px solid #111111; padding: 7px; }
.editor-thumb { width: 52px; height: 68px; flex: 0 0 auto; border: 1px solid #b9b9b3; background: #e8e8e5 center / cover no-repeat; }
.editor-muted { color: #62625d; font-size: 12px; }
.editor-workspace {
display: grid;
min-width: 0;
min-height: 0;
place-items: center;
padding: 32px;
background: #e8e8e5;
overflow: auto;
}
.editor-canvas-frame {
display: grid;
width: min(100%, 720px);
max-height: calc(100vh - 128px);
place-items: center;
border: 1px solid #62625d;
background: #ffffff;
box-shadow: 8px 8px 0 #b9b9b3;
}
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
.editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; }
.editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; }
.editor-inspector header p { margin: 0 0 4px; }
.editor-inspector h2 { margin: 0; font-size: 20px; }
.editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; }
.editor-inspector select,
.editor-inspector input[type="range"] { width: 100%; }
.editor-inspector select { min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; font: inherit; }
.editor-range > span { display: flex; justify-content: space-between; }
.editor-range output { font-family: Consolas, monospace; }
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
.editor-primary { background: #f2f400 !important; }
.editor-statusbar {
display: flex;
align-items: center;
gap: 24px;
padding: 0 16px;
border-top: 1px solid #111111;
background: #ffffff;
color: #62625d;
}
.editor-notice {
position: fixed;
z-index: 4;
top: 68px;
left: 50%;
margin: 0;
padding: 8px 14px;
border: 1px solid #1769aa;
background: #ffffff;
transform: translateX(-50%);
font-size: 12px;
}
.editor-dialog-backdrop { position: fixed; z-index: 5; inset: 0; display: grid; place-items: center; padding: 24px; background: rgb(17 17 17 / 60%); }
.editor-confirm { width: min(480px, 100%); padding: 24px; border: 2px solid #111111; background: #ffffff; box-shadow: 8px 8px 0 #111111; }
.editor-confirm h2 { margin: 4px 0 12px; font-size: 22px; }
.editor-confirm p { margin: 0; }
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
@media (max-width: 1100px) {
.editor-layout { grid-template-columns: 220px minmax(0, 1fr) 280px; }
.editor-workspace { padding: 20px; }
.editor-toolbar { grid-template-columns: 40px minmax(120px, 1fr) auto minmax(110px, 128px) auto auto; gap: 8px; padding: 0 10px; }
}
@media (max-width: 760px) {
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
.editor-toolbar { grid-template-columns: 40px minmax(0, 1fr) auto; min-height: 56px; }
.editor-toolbar > button, .editor-save-status { display: none; }
.editor-layout { grid-template-columns: 1fr; }
.editor-assets-panel, .editor-inspector { border: 0; }
.editor-assets-panel { order: 2; }
.editor-inspector { order: 3; }
.editor-workspace { min-height: 55vh; order: 1; padding: 16px; }
.editor-canvas-frame { width: min(100%, 480px); }
.editor-statusbar { flex-wrap: wrap; min-height: 32px; gap: 8px 16px; padding: 8px 12px; }
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from "react";
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
import {
CanvasEditHistory,
cssFilterForBackground,
createEditorCanvasState,
defaultBackgroundAdjustments,
resetBackgroundAdjustments,
switchBackground,
updateBackgroundAdjustments,
} from "./editor-canvas.js";
import "./editor-page.css";
type Ratio = CanvasState["ratio"];
interface EditorSession {
csrf_token: string;
user: { creator_name: string };
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
current_image_id: string | null;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
name: string;
pixel_height?: number;
pixel_width?: number;
project_id: string;
ratio: Ratio;
state_version: number;
}
async function readEditorJson<T>(url: string): Promise<T> {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("editor_request_failed");
return response.json() as Promise<T>;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
function paletteForAsset(assetId: string) {
const seed = assetId.replaceAll("-", "");
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`);
}
function CanvasStage({ assetId, canvasState, projectId }: { assetId: string | null; canvasState: CanvasState; projectId: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = canvasState.pixel_width;
canvas.height = canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
const draw = (image?: HTMLImageElement) => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.filter = cssFilterForBackground(canvasState.background.adjustments);
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
context.filter = "none";
for (const element of canvasState.elements) {
const x = element.position.x * canvas.width;
const y = element.position.y * canvas.height;
context.save();
context.globalAlpha = element.opacity;
context.translate(x, y);
context.rotate((element.rotation * Math.PI) / 180);
context.scale(element.scale.x, element.scale.y);
if (element.type === "color_card") {
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
colors.forEach((color, index) => { context.fillStyle = color; context.fillRect(index * 48 - 120, -28, 44, 56); });
} else {
context.fillStyle = "#111111";
context.font = "700 48px Microsoft YaHei, sans-serif";
context.fillText(element.content ?? "DADA", -80, 16);
}
context.restore();
}
};
if (!assetId) {
draw();
return undefined;
}
const image = new Image();
image.onload = () => draw(image);
image.onerror = () => draw();
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
return () => { image.onload = null; image.onerror = null; };
}, [assetId, canvasState, projectId]);
return <canvas aria-label="编辑画布" className="editor-canvas" ref={canvasRef} tabIndex={0} />;
}
function saveStatusLabel(status: ProjectSaveStatus) {
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
}
export function EditorPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<EditorSession>();
const [project, setProject] = useState<EditorProject>();
const [canvasState, setCanvasState] = useState<CanvasState>();
const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments);
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
const [pendingBackground, setPendingBackground] = useState<string>();
const [notice, setNotice] = useState("");
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
useEffect(() => {
let active = true;
Promise.all([
readEditorJson<EditorSession>("/api/v1/auth/session"),
readEditorJson<EditorProject>(`/api/v1/projects/${projectId}`),
]).then(([nextSession, nextProject]) => {
if (!active) return;
const initial = nextProject.canvas_state ?? createEditorCanvasState({ assetId: nextProject.current_image_id, ratio: nextProject.ratio });
setSession(nextSession);
setProject(nextProject);
setCanvasState(initial);
setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial);
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
return () => { active = false; };
}, [projectId]);
useEffect(() => {
if (!project || !session || !canvasState) return undefined;
const queue = new ProjectAutoSaveQueue({
initialState: { canvas_state: canvasState, name: project.name },
initialVersion: project.state_version,
onStatus: setSaveStatus,
onSaved: (snapshot, stateVersion) => {
setCanvasState(snapshot.canvas_state);
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
},
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
body: JSON.stringify(snapshot),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "Idempotency-Key": operationId, "If-Match": String(stateVersion), "X-CSRF-Token": session.csrf_token },
method: "PUT",
});
if (response.status === 412) throw new Error("project_state_conflict");
if (!response.ok) throw new Error("project_save_failed");
const saved = await response.json() as { state_version: number };
return { stateVersion: saved.state_version };
},
});
queueRef.current = queue;
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
function commitCanvas(next: CanvasState) {
if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
}
function applyPreview() {
if (!canvasState) return;
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
setNotice("底图调整已提交");
}
function undo() {
const previous = historyRef.current?.undo();
if (previous) {
setCanvasState(previous);
setDraftAdjustments(previous.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
}
}
function redo() {
const next = historyRef.current?.redo();
if (next) {
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
}
}
function confirmBackground() {
if (!canvasState || !pendingBackground) return;
commitCanvas(switchBackground(canvasState, pendingBackground, paletteForAsset(pendingBackground)));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
return (
<div className="editor-page-shell">
<header className="editor-toolbar">
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}></a>
<div className="editor-title"><strong>{project.name}</strong><span>{project.ratio}</span></div>
<div className="editor-history-actions">
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button"></button>
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button"></button>
</div>
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
<button disabled type="button"></button>
<button disabled type="button"></button>
</header>
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
<main className="editor-layout">
<aside aria-label="素材与底图来源" className="editor-assets-panel">
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
{['底图', '历史', '文字模板', '普通贴纸', '色卡', '动态贴纸'].map((label, index) => <button aria-current={index < 2 ? "page" : undefined} disabled={index > 1} key={label} type="button">{label}</button>)}
</nav>
<section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section>
<section><h2></h2><div className="editor-history-list">
{project.images.length === 0 ? <p className="editor-muted"></p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span> · {formatDate(image.created_at)}</span></button>)}
</div></section>
</aside>
<section aria-label="画布工作区" className="editor-workspace">
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}><CanvasStage assetId={canvasState.background.asset_id} canvasState={canvasState} projectId={projectId} /></div>
</section>
<aside aria-label="底图参数" className="editor-inspector">
<header><p>BACKGROUND</p><h2></h2></header>
<label><select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill"></option><option value="fit"></option><option value="crop"></option></select></label>
<label><select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none"></option><option value="grayscale"></option><option value="sepia"></option></select></label>
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={key === "sharpness" ? 100 : 100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button"></button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button"></button></div>
</aside>
</main>
<footer className="editor-statusbar"><span> {canvasState.pixel_width} × {canvasState.pixel_height}</span><span> {canvasState.elements.length} / 50</span><span> 100%</span><span> · state version {project.state_version}</span></footer>
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={confirmBackground} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
</div>
);
}
+3
View File
@@ -9,6 +9,7 @@ import { AdminUsersPage } from "./admin-users.js";
import { AdminModelsPage } from "./admin-models.js"; import { AdminModelsPage } from "./admin-models.js";
import { CreditsPage } from "./credits-page.js"; import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
import { EditorPage } from "./editor-page.js";
const root = document.getElementById("root"); const root = document.getElementById("root");
@@ -24,10 +25,12 @@ let authRevision = 0;
function renderAuthenticationEntry() { function renderAuthenticationEntry() {
authRevision += 1; authRevision += 1;
const editorProject = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})\/editor$/i);
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i); const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
let authenticationPage; let authenticationPage;
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />; if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />; else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
else if (editorProject?.[1]) authenticationPage = <EditorPage key={authRevision} projectId={editorProject[1]} />;
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />; else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />; else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />; else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
+4 -2
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api", "test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts --config playwright.config.ts", "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
@@ -73,7 +73,9 @@
"test:wp3-03": "node scripts/run-wp3-03-validation.mjs", "test:wp3-03": "node scripts/run-wp3-03-validation.mjs",
"test:wp3-03:red": "node scripts/run-wp3-03-validation.mjs --phase red", "test:wp3-03:red": "node scripts/run-wp3-03-validation.mjs --phase red",
"test:wp3-04": "node scripts/run-wp3-04-validation.mjs", "test:wp3-04": "node scripts/run-wp3-04-validation.mjs",
"test:wp3-04:red": "node scripts/run-wp3-04-validation.mjs --phase red" "test:wp3-04:red": "node scripts/run-wp3-04-validation.mjs --phase red",
"test:wp4-01": "node scripts/run-wp4-01-validation.mjs",
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
+78
View File
@@ -0,0 +1,78 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesDirectory = resolve(runDirectory, "cases");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(casesDirectory, { recursive: true });
const cases = [
{ acceptance_criteria: ["AC-07", "AC-20"], evidence: ["canvas-before.json", "canvas-after.json", "db-diff.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-BG-001-switch-background", requirements: ["EDITOR-01", "EDITOR-02", "GEN-12"] },
{ acceptance_criteria: ["AC-23"], evidence: ["canvas-state.json", "db-diff.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-BG-002-processing-controls", requirements: ["EDITOR-03", "EXPORT-03", "PROJECT-04"] },
];
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
const commands = phase === "red" ? [] : [
["unit", ["test:unit"]],
["e2e", ["test:e2e"]],
["visual", ["test:visual"]],
["performance", ["test:performance"]],
["tdd-trace", ["validate:tdd-trace"]],
];
const outputDirectory = resolve(runDirectory, "playwright-output");
const environment = { ...process.env, DADA_EVIDENCE_DIR_EDITOR: casesDirectory, DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory };
const commandResults = [];
for (const [name, args] of commands) {
const command = `pnpm ${args.join(" ")}`;
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
}
function findTraces(directory) {
const traces = [];
if (!existsSync(directory)) return traces;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) traces.push(...findTraces(path));
else if (entry.name === "trace.zip") traces.push(path);
}
return traces.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs);
}
if (phase === "green") {
const traces = findTraces(outputDirectory);
if (traces[0]) copyFileSync(traces[0], resolve(casesDirectory, cases[0].id, "trace.zip"));
if (traces[1]) copyFileSync(traces[1], resolve(casesDirectory, cases[1].id, "trace.zip"));
}
const commandState = phase === "red" ? true : commandResults.every((result) => result.exit_code === 0);
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
if (phase === "red") {
const observation = { expected_failure: "Editor canvas module, background switching, and processing controls were absent before TASK-WP4-01", observed_commands: ["pnpm vitest run tests/unit/wp4-01-editor-canvas.test.ts"], observed_errors: ["Cannot find module '../../apps/web/src/editor-canvas.js'"], status: "red_confirmed" };
for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
}
const summaries = [];
for (const item of cases) {
const directory = resolve(casesDirectory, item.id);
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
const status = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({ acceptance_criteria: item.acceptance_criteria, automation: ["automated"], commit, evidence_refs: evidenceRefs, layer: ["UNIT", "E2E", "VISUAL", "PERFORMANCE"], manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, run_id: runId, status, task_id: "TASK-WP4-01", test_id: item.id, work_package: "WP-4", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation" }, null, 2)}\n`);
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
}
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
if (status !== targetStatus) process.exit(1);
+140
View File
@@ -0,0 +1,140 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
const projectId = "00000000-0000-4000-8000-000000000451";
const currentImageId = "00000000-0000-4000-8000-000000000452";
const alternateImageId = "00000000-0000-4000-8000-000000000453";
const elementId = "00000000-0000-4000-8000-000000000454";
const session = {
audience: "user", authenticated: true,
credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-editor-fixture-000000000000000000000000000000000000",
expires_at: "2026-09-02T08:00:00.000Z",
user: { creator_name: "Editor User", role: "user", social_id: "@editor_user", status: "active", user_id: "00000000-0000-4000-8000-000000000455" },
};
const initialCanvasState = {
background: { adjustments: { brightness: 12, contrast: -8, crop: null, filter: "sepia", fit: "fit", saturation: 20, sharpness: 30, temperature: -10 }, asset_id: currentImageId },
elements: [{ colors: ["#111111", "#222222", "#333333", "#444444", "#555555"], created_at: "2026-08-02T08:00:00.000Z", element_id: elementId, opacity: 1, position: { x: 0.4, y: 0.3 }, resource_version: "v1", rotation: 12, scale: { x: 1.25, y: 0.8 }, template_or_asset_id: "palette-card", type: "color_card", z_index: 0 }],
pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
};
function projectPayload() {
return {
canvas_state: initialCanvasState,
created_at: "2026-08-02T08:00:00.000Z", current_image_id: currentImageId,
draft_prompt: "城市工作室", generations: [],
images: [
{ created_at: "2026-08-02T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000456", image_id: currentImageId },
{ created_at: "2026-08-02T08:05:00.000Z", generation_id: "00000000-0000-4000-8000-000000000457", image_id: alternateImageId },
],
name: "城市工作室", pixel_height: 1440, pixel_width: 1080, project_id: projectId,
ratio: "3:4", save_status: "saved", state_version: 7, status: "active", successful_image_count: 2,
updated_at: "2026-08-02T08:05:00.000Z",
};
}
function writeEvidence(caseId: string, name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_EDITOR;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
}
async function routeEditor(page: Page) {
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload()), contentType: "application/json", status: 200 }));
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => route.fulfill({ body: Buffer.from("not-an-image"), contentType: "image/png", status: 200 }));
}
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
let version = 7;
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
saves.push(payload);
version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible();
await expect(page.getByText("城市工作室")).toBeVisible();
await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible();
await page.getByRole("button", { name: /生成图/ }).last().click();
const dialog = page.getByRole("dialog", { name: "更换底图" });
await expect(dialog.getByText("覆盖元素会保留,底图编辑参数将重置。", { exact: true })).toBeVisible();
writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-before.json", initialCanvasState);
await dialog.getByRole("button", { name: "确认更换" }).click();
await expect(page.getByText("已更换底图,覆盖元素保留,底图处理已重置")).toBeVisible();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
const saved = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(saved.canvas_state.background.asset_id).toBe(alternateImageId);
expect(saved.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 });
expect(saved.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position);
expect(saved.canvas_state.elements[0]?.scale).toEqual(initialCanvasState.elements[0]?.scale);
expect(saved.canvas_state.elements[0]?.rotation).toBe(initialCanvasState.elements[0]?.rotation);
expect(saved.canvas_state.elements[0]?.colors).not.toEqual(initialCanvasState.elements[0]?.colors);
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-001-switch-background", "switch-background.png") : undefined });
writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-after.json", saved.canvas_state);
writeEvidence("TDD-WP4-BG-001-switch-background", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length });
writeEvidence("TDD-WP4-BG-001-switch-background", "pixel-diff.json", { overlay_position_preserved: true, background_processing_reset: true, palette_refreshed: true });
});
test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
let version = 7;
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
saves.push(route.request().postDataJSON() as Record<string, unknown>);
version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const ranges = page.locator("input[type=range]");
await ranges.nth(0).fill("25");
await ranges.nth(1).fill("15");
await ranges.nth(2).fill("-20");
await ranges.nth(3).fill("30");
await ranges.nth(4).fill("60");
await page.getByLabel("滤镜").selectOption("grayscale");
await page.getByLabel("适配方式").selectOption("crop");
await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled();
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(committed.canvas_state.background.adjustments).toMatchObject({ brightness: 25, contrast: 15, saturation: -20, temperature: 30, sharpness: 60, filter: "grayscale", fit: "crop" });
await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 });
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1);
const undone = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(undone.canvas_state.background.adjustments).toEqual(initialCanvasState.background.adjustments);
await page.getByRole("button", { name: "恢复原图" }).click();
await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled();
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(2);
const reset = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(reset.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 });
expect(reset.canvas_state.background.asset_id).toBe(currentImageId);
expect(reset.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position);
writeEvidence("TDD-WP4-BG-002-processing-controls", "canvas-state.json", reset.canvas_state);
for (const [key, value] of Object.entries(reset.canvas_state.background.adjustments)) writeEvidence(`TDD-WP4-BG-002-processing-controls/${key}`, "canvas-state.json", { [key]: value });
writeEvidence("TDD-WP4-BG-002-processing-controls", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length });
writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true });
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined });
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
CanvasEditHistory,
createEditorCanvasState,
defaultBackgroundAdjustments,
deserializeFabricCanvas,
switchBackground,
updateBackgroundAdjustments,
} from "../../apps/web/src/editor-canvas.js";
const element = {
colors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
created_at: "2026-08-02T18:00:00.000Z",
element_id: "00000000-0000-4000-8000-000000000001",
opacity: 1,
position: { x: 0.25, y: 0.35 },
resource_version: "palette-v1",
rotation: 14,
scale: { x: 1.2, y: 0.8 },
template_or_asset_id: "palette-fixture",
type: "color_card" as const,
z_index: 0,
};
describe("TDD-WP4-BG-001/002 canvas boundary", () => {
it("switches background without moving overlays, resets processing, and re-extracts palette", () => {
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" });
const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] };
const nextPalette = ["#abcdef", "#123456", "#fedcba", "#654321", "#a1b2c3"];
const switched = switchBackground(withOverlay, "00000000-0000-4000-8000-000000000011", nextPalette);
expect(switched.background).toEqual({ asset_id: "00000000-0000-4000-8000-000000000011", adjustments: defaultBackgroundAdjustments() });
expect(switched.elements[0]).toMatchObject({ position: element.position, scale: element.scale, rotation: element.rotation, colors: nextPalette });
});
it("keeps preview changes immutable and allows one committed history step", () => {
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "1:1" });
const history = new CanvasEditHistory(initial);
const preview = updateBackgroundAdjustments(initial, { brightness: 25, contrast: -10, fit: "fit" });
expect(initial.background.adjustments).toEqual(defaultBackgroundAdjustments());
expect(preview.background.adjustments).toMatchObject({ brightness: 25, contrast: -10, fit: "fit" });
history.commit(preview);
expect(history.canUndo).toBe(true);
expect(history.undo()).toEqual(initial);
expect(history.canRedo).toBe(true);
expect(history.redo()).toEqual(preview);
});
it("strips Fabric-private fields at the persistence boundary", () => {
const state = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "9:16" });
const decoded = deserializeFabricCanvas({ ...state, _objects: [{ type: "fabric-private" }], viewportTransform: [1, 0, 0, 1, 0, 0] });
expect(decoded).toEqual(state);
expect(deserializeFabricCanvas({ ...state, elements: [{ ...element, _cacheCanvas: {} }] })).toBeUndefined();
});
});