feat: complete TASK-WP4-02 canvas element editing

This commit is contained in:
suyx
2026-08-03 10:58:17 +08:00
parent 8d8fbeb816
commit d16802c164
11 changed files with 1314 additions and 67 deletions
+313
View File
@@ -0,0 +1,313 @@
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
type CanvasElement = CanvasState["elements"][number];
export interface CanvasPoint {
x: number;
y: number;
}
export interface CanvasRect extends CanvasPoint {
height: number;
width: number;
}
export interface CanvasElementIdentity {
createdAt: string;
elementId: string;
}
export type CanvasLayerCommand = "back" | "backward" | "forward" | "front";
interface StaticStickerInput {
assetId: string;
identity: CanvasElementIdentity;
opacity?: number;
position: CanvasPoint;
resourceVersion: string;
rotation?: number;
scale?: CanvasElement["scale"];
style_parameters?: CanvasElement["style_parameters"];
z_index?: number;
zIndex: number;
}
const snapThreshold = 0.015;
const hitHalfExtent = 0.08;
function cloneState(state: CanvasState) {
return structuredClone(state);
}
function requireState(state: CanvasState) {
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
return cloneState(state);
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
function normalizedRotation(value: number) {
const normalized = ((value + 180) % 360 + 360) % 360 - 180;
return normalized === -180 ? 180 : normalized;
}
function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) {
return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001);
}
function reindex(elements: CanvasElement[]) {
return elements.map((element, zIndex) => ({ ...element, z_index: zIndex }));
}
export function createStaticStickerElement(input: StaticStickerInput): CanvasElement {
return {
created_at: input.identity.createdAt,
element_id: input.identity.elementId,
opacity: input.opacity ?? 1,
position: structuredClone(input.position),
resource_version: input.resourceVersion,
rotation: input.rotation ?? 0,
scale: structuredClone(input.scale ?? { x: 1, y: 1 }),
style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }),
template_or_asset_id: input.assetId,
type: "static_sticker",
z_index: input.z_index ?? input.zIndex,
};
}
export class CanvasElementController {
private current: CanvasState;
private selection: string[] = [];
private cyclePoint: CanvasPoint | undefined = undefined;
private cycleSignature = "";
private cycleIndex = -1;
constructor(initial: CanvasState) {
this.current = requireState(initial);
}
get value() {
return cloneState(this.current);
}
get selectedIds() {
return [...this.selection];
}
replaceState(next: CanvasState) {
this.current = requireState(next);
const existing = new Set(this.current.elements.map((element) => element.element_id));
this.selection = this.selection.filter((elementId) => existing.has(elementId));
this.pointerMoved();
}
clearSelection() {
this.selection = [];
this.pointerMoved();
}
pointerMoved() {
this.cyclePoint = undefined;
this.cycleSignature = "";
this.cycleIndex = -1;
}
selectById(elementId: string, append = false) {
if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds;
if (!append) this.selection = [elementId];
else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId);
else this.selection = [...this.selection, elementId];
return this.selectedIds;
}
selectIds(elementIds: readonly string[]) {
const existing = new Set(this.current.elements.map((element) => element.element_id));
this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))];
this.pointerMoved();
return this.selectedIds;
}
candidatesAt(point: CanvasPoint) {
return this.current.elements
.filter((element) => {
const halfWidth = hitHalfExtent * element.scale.x;
const halfHeight = hitHalfExtent * element.scale.y;
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
})
.sort((left, right) => right.z_index - left.z_index)
.map((element) => structuredClone(element));
}
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
const candidates = this.candidatesAt(point);
if (candidates.length === 0) {
if (!options.append) this.selection = [];
this.pointerMoved();
return this.selectedIds;
}
if (options.append) {
this.pointerMoved();
return this.selectById(candidates[0]!.element_id, true);
}
const signature = candidates.map((candidate) => candidate.element_id).join("|");
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
else this.cycleIndex = 0;
this.cyclePoint = { ...point };
this.cycleSignature = signature;
this.selection = [candidates[this.cycleIndex]!.element_id];
return this.selectedIds;
}
marqueeSelect(rectangle: CanvasRect, append = false) {
const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width);
const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width);
const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height);
const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height);
const hits = this.current.elements
.filter((element) => element.position.x >= minimumX && element.position.x <= maximumX
&& element.position.y >= minimumY && element.position.y <= maximumY)
.sort((left, right) => left.z_index - right.z_index)
.map((element) => element.element_id);
this.selection = append ? [...new Set([...this.selection, ...hits])] : hits;
this.pointerMoved();
return this.selectedIds;
}
add(element: CanvasElement) {
if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached");
const nextElement = structuredClone(element);
nextElement.z_index = this.current.elements.length;
this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] });
this.selection = [nextElement.element_id];
this.pointerMoved();
return this.value;
}
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
const selected = new Set(this.selection);
this.current = requireState({
...this.current,
elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element),
});
this.pointerMoved();
return this.value;
}
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
const selected = new Set(this.selection);
const primary = this.current.elements.find((element) => selected.has(element.element_id));
if (!primary) return { guides: [] as string[], state: this.value };
let nextX = primary.position.x + delta.x;
let nextY = primary.position.y + delta.y;
const guides: string[] = [];
const snapAxis = (value: number, axis: "x" | "y") => {
const canvasTargets = [0, 0.5, 1];
const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold);
if (target !== undefined) {
guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`);
return target;
}
const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold);
if (other) {
guides.push(`element-${axis}:${other.element_id}`);
return other.position[axis];
}
return value;
};
if (options.snap !== false) {
nextX = snapAxis(nextX, "x");
nextY = snapAxis(nextY, "y");
}
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
const state = this.updateSelected((element) => ({
...element,
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
}));
return { guides, state };
}
scaleSelected(factor: number) {
if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid");
return this.updateSelected((element) => ({
...element,
scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) },
}));
}
rotateSelected(degrees: number) {
if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid");
return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) }));
}
flipSelectedHorizontal() {
return this.updateSelected((element) => element.type === "static_sticker" ? {
...element,
style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true },
} : element);
}
setSelectedOpacity(opacity: number) {
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid");
return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element);
}
copySelected() {
const selected = new Set(this.selection);
return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element));
}
pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached");
const pasted = elements.map((source, index) => {
const identity = identityFactory(source, index);
return {
...structuredClone(source),
created_at: identity.createdAt,
element_id: identity.elementId,
position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) },
z_index: this.current.elements.length + index,
};
});
this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] });
this.selection = pasted.map((element) => element.element_id);
this.pointerMoved();
return this.value;
}
duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
return this.pasteElements(this.copySelected(), identityFactory);
}
deleteSelected() {
const selected = new Set(this.selection);
this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) });
this.selection = [];
this.pointerMoved();
return this.value;
}
changeLayer(command: CanvasLayerCommand) {
const selected = new Set(this.selection);
const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index);
if (command === "front" || command === "back") {
const selectedElements = ordered.filter((element) => selected.has(element.element_id));
const unselectedElements = ordered.filter((element) => !selected.has(element.element_id));
ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements]));
}
else if (command === "forward") {
for (let index = ordered.length - 2; index >= 0; index -= 1) {
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!];
}
} else {
for (let index = 1; index < ordered.length; index += 1) {
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!];
}
}
this.current = requireState({ ...this.current, elements: reindex(ordered) });
this.pointerMoved();
return this.value;
}
}
+125 -3
View File
@@ -155,20 +155,73 @@
.editor-thumb { width: 52px; height: 68px; flex: 0 0 auto; border: 1px solid #b9b9b3; background: #e8e8e5 center / cover no-repeat; } .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-muted { color: #62625d; font-size: 12px; }
.editor-workspace { .editor-sticker-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.editor-sticker-grid button {
display: grid;
min-width: 0;
min-height: 132px;
place-items: center;
gap: 4px;
padding: 10px 6px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
}
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
.editor-sticker-preview { display: block; width: 58px; height: 58px; border: 3px solid #111111; background: #f2f400; }
.editor-sticker-preview.stk002 { border-radius: 50%; background: #1769aa; }
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
.editor-workspace {
position: relative;
display: grid;
grid-template-rows: 40px minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
place-items: center; place-items: center;
padding: 32px; padding: 16px 32px 32px;
background: #e8e8e5; background: #e8e8e5;
overflow: auto; overflow: auto;
} }
.editor-canvas-tools {
display: flex;
min-width: 0;
min-height: 36px;
align-items: center;
gap: 4px;
align-self: start;
}
.editor-canvas-tools button {
min-width: 76px;
min-height: 32px;
padding: 5px 8px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
font-size: 12px;
font-weight: 700;
}
.editor-canvas-tools button.active { border-color: #005fcc; background: #ffffff; box-shadow: inset 0 -3px #005fcc; }
.editor-canvas-tools button:disabled { color: #62625d; background: #d8d8d3; cursor: not-allowed; }
.editor-canvas-frame { .editor-canvas-frame {
position: relative;
display: grid; display: grid;
width: min(100%, 720px); width: min(100%, 720px);
max-height: calc(100vh - 128px); max-height: calc(100vh - 168px);
place-items: center; place-items: center;
border: 1px solid #62625d; border: 1px solid #62625d;
background: #ffffff; background: #ffffff;
@@ -178,9 +231,47 @@
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; } .editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
.editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; } .editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; }
.editor-candidates {
position: absolute;
z-index: 3;
display: grid;
width: min(260px, 80%);
max-height: 200px;
overflow: auto;
border: 2px solid #111111;
background: #ffffff;
box-shadow: 6px 6px 0 #111111;
transform: translate(10px, 10px);
}
.editor-candidates button {
min-height: 36px;
padding: 7px 9px;
border: 0;
border-bottom: 1px solid #b9b9b3;
background: #ffffff;
color: #111111;
text-align: left;
font: inherit;
font-size: 12px;
}
.editor-snap-status {
position: absolute;
align-self: end;
margin-bottom: 8px;
padding: 5px 9px;
border: 1px solid #005fcc;
background: #ffffff;
color: #005fcc;
font-size: 11px;
font-weight: 700;
}
.editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; } .editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; }
.editor-inspector header p { margin: 0 0 4px; } .editor-inspector header p { margin: 0 0 4px; }
.editor-inspector h2 { margin: 0; font-size: 20px; } .editor-inspector h2 { margin: 0; font-size: 20px; }
.editor-object-id { margin: 12px 0 0; font-family: Consolas, monospace; font-size: 12px; }
.editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; } .editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; }
.editor-inspector select, .editor-inspector select,
.editor-inspector input[type="range"] { width: 100%; } .editor-inspector input[type="range"] { width: 100%; }
@@ -190,6 +281,37 @@
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; } .editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
.editor-primary { background: #f2f400 !important; } .editor-primary { background: #f2f400 !important; }
.editor-object-moves,
.editor-object-tools,
.editor-layer-tools,
.editor-object-actions {
display: grid;
gap: 6px;
margin-top: 18px;
}
.editor-object-moves { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.editor-object-tools,
.editor-layer-tools { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-object-actions { grid-template-columns: 1fr 1fr; padding-top: 18px; border-top: 1px solid #b9b9b3; }
.editor-object-moves button,
.editor-object-tools button,
.editor-layer-tools button,
.editor-object-actions button,
.editor-wide-command {
min-height: 36px;
padding: 6px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
font-size: 12px;
font-weight: 700;
}
.editor-wide-command { width: 100%; margin-top: 18px; }
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
.editor-statusbar { .editor-statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
+273 -60
View File
@@ -4,17 +4,20 @@ import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js"; import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
import { import {
CanvasEditHistory, CanvasEditHistory,
cssFilterForBackground,
createEditorCanvasState, createEditorCanvasState,
defaultBackgroundAdjustments, defaultBackgroundAdjustments,
resetBackgroundAdjustments, resetBackgroundAdjustments,
switchBackground, switchBackground,
updateBackgroundAdjustments, updateBackgroundAdjustments,
} from "./editor-canvas.js"; } from "./editor-canvas.js";
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
import { EditorStage } from "./editor-stage.js";
import "./editor-page.css"; import "./editor-page.css";
type Ratio = CanvasState["ratio"]; type Ratio = CanvasState["ratio"];
type CanvasElement = CanvasState["elements"][number];
type EditorAssetPanel = "background" | "history" | "stickers";
interface EditorSession { interface EditorSession {
csrf_token: string; csrf_token: string;
@@ -53,52 +56,13 @@ function paletteForAsset(assetId: string) {
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`); 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 stickerFixtures = [
const canvasRef = useRef<HTMLCanvasElement>(null); { assetId: "STK001", label: "方形标记" },
useEffect(() => { { assetId: "STK002", label: "圆形标记" },
const canvas = canvasRef.current; ] as const;
if (!canvas) return undefined;
canvas.width = canvasState.pixel_width; function newElementIdentity(): CanvasElementIdentity {
canvas.height = canvasState.pixel_height; return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
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) { function saveStatusLabel(status: ProjectSaveStatus) {
@@ -113,8 +77,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved"); const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
const [pendingBackground, setPendingBackground] = useState<string>(); const [pendingBackground, setPendingBackground] = useState<string>();
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [guides, setGuides] = useState<string[]>([]);
const [multiMode, setMultiMode] = useState(false);
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined); const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined); const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -129,6 +102,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setCanvasState(initial); setCanvasState(initial);
setDraftAdjustments(initial.background.adjustments); setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial); historyRef.current = new CanvasEditHistory(initial);
elementControllerRef.current = new CanvasElementController(initial);
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); }); }).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
return () => { active = false; }; return () => { active = false; };
}, [projectId]); }, [projectId]);
@@ -139,8 +113,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
initialState: { canvas_state: canvasState, name: project.name }, initialState: { canvas_state: canvasState, name: project.name },
initialVersion: project.state_version, initialVersion: project.state_version,
onStatus: setSaveStatus, onStatus: setSaveStatus,
onSaved: (snapshot, stateVersion) => { onSaved: (_snapshot, stateVersion) => {
setCanvasState(snapshot.canvas_state);
setProject((current) => current ? { ...current, state_version: stateVersion } : current); setProject((current) => current ? { ...current, state_version: stateVersion } : current);
}, },
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => { save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
@@ -163,6 +136,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
function commitCanvas(next: CanvasState) { function commitCanvas(next: CanvasState) {
if (!project || saveStatus === "conflicted") return; if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next); historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next); setCanvasState(next);
setDraftAdjustments(next.background.adjustments); setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name }); queueRef.current?.commit({ canvas_state: next, name: project.name });
@@ -177,6 +152,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
function undo() { function undo() {
const previous = historyRef.current?.undo(); const previous = historyRef.current?.undo();
if (previous) { if (previous) {
elementControllerRef.current?.replaceState(previous);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(previous); setCanvasState(previous);
setDraftAdjustments(previous.background.adjustments); setDraftAdjustments(previous.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name }); if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
@@ -186,6 +163,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
function redo() { function redo() {
const next = historyRef.current?.redo(); const next = historyRef.current?.redo();
if (next) { if (next) {
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next); setCanvasState(next);
setDraftAdjustments(next.background.adjustments); setDraftAdjustments(next.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name }); if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
@@ -199,9 +178,176 @@ export function EditorPage({ projectId }: { projectId: string }) {
setNotice("已更换底图,覆盖元素保留,底图处理已重置"); setNotice("已更换底图,覆盖元素保留,底图处理已重置");
} }
function controllerForCurrent() {
const controller = elementControllerRef.current;
if (!controller) return undefined;
if (controller.selectedIds.length !== selectedIds.length || controller.selectedIds.some((elementId, index) => elementId !== selectedIds[index])) controller.selectIds(selectedIds);
return controller;
}
function commitElementOperation(controller: CanvasElementController, message: string) {
const next = controller.value;
setSelectedIds(controller.selectedIds);
commitCanvas(next);
setGuides([]);
setCandidateMenu(undefined);
setNotice(message);
}
function addSticker(assetId: string) {
const controller = controllerForCurrent();
if (!controller || !canvasState) return;
try {
controller.add(createStaticStickerElement({
assetId,
identity: newElementIdentity(),
position: { x: 0.5, y: 0.5 },
resourceVersion: "fixture-v1",
zIndex: canvasState.elements.length,
}));
commitElementOperation(controller, "贴纸已加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("贴纸未能加入画布");
}
}
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
try {
action(controller);
commitElementOperation(controller, message);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("对象操作未完成");
}
}
function beginOpacityGesture() {
if (!canvasState || selectedIds.length === 0) return;
opacityGestureRef.current = { base: canvasState, last: canvasState, selectedIds: [...selectedIds] };
}
function changeOpacity(value: number) {
const gesture = opacityGestureRef.current;
if (!gesture) {
transformSelection((controller) => { controller.setSelectedOpacity(value); }, "贴纸透明度已提交");
return;
}
const previewController = new CanvasElementController(gesture.base);
previewController.selectIds(gesture.selectedIds);
previewController.setSelectedOpacity(value);
gesture.last = previewController.value;
setCanvasState(gesture.last);
}
function commitOpacityGesture() {
const gesture = opacityGestureRef.current;
if (!gesture) return;
opacityGestureRef.current = undefined;
if (gesture.last === gesture.base) return;
commitCanvas(gesture.last);
setNotice("贴纸透明度已提交");
}
function duplicateSelection() {
transformSelection((controller) => controller.duplicateSelected(() => newElementIdentity()), "已复制选中对象");
}
function changeSelectionLayer(command: CanvasLayerCommand) {
const labels: Record<CanvasLayerCommand, string> = { back: "已置底", backward: "已下移一层", forward: "已上移一层", front: "已置顶" };
transformSelection((controller) => { controller.changeLayer(command); }, labels[command]);
}
function deleteSelection() {
transformSelection((controller) => { controller.deleteSelected(); }, "已删除选中对象");
}
function copySelection() {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
clipboardRef.current = controller.copySelected();
setNotice("已复制到画布剪贴板");
}
function pasteSelection() {
const controller = controllerForCurrent();
if (!controller || clipboardRef.current.length === 0) return;
try {
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
commitElementOperation(controller, "已粘贴画布对象");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
}
}
function selectAt(point: CanvasPoint, append: boolean) {
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
setSelectedIds(selection);
setCandidateMenu(undefined);
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
return candidates.length > 0;
}
function previewMove(delta: CanvasPoint) {
const drag = dragRef.current;
if (!drag) return;
const previewController = new CanvasElementController(drag.base);
previewController.selectIds(drag.selectedIds);
const preview = previewController.moveSelected(delta);
drag.last = preview.state;
setCanvasState(preview.state);
setGuides(preview.guides);
}
function commitMove() {
const drag = dragRef.current;
if (!drag) return;
commitCanvas(drag.last);
setNotice("对象位置已提交");
setGuides([]);
dragRef.current = undefined;
}
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
setCandidateMenu(undefined);
}
function showCandidates(point: CanvasPoint) {
const controller = controllerForCurrent();
if (!controller) return;
const elements = controller.candidatesAt(point);
if (elements.length > 0) setCandidateMenu({ elements, point });
}
function chooseCandidate(elementId: string) {
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.selectById(elementId, multiMode));
setCandidateMenu(undefined);
}
function clearSelection() {
elementControllerRef.current?.clearSelection();
setSelectedIds([]);
setCandidateMenu(undefined);
setGuides([]);
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>; 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 imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted"; const canEdit = saveStatus !== "conflicted";
const selectedElements = canvasState.elements.filter((element) => selectedIds.includes(element.element_id));
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined;
return ( return (
<div className="editor-page-shell"> <div className="editor-page-shell">
<header className="editor-toolbar"> <header className="editor-toolbar">
@@ -219,22 +365,89 @@ export function EditorPage({ projectId }: { projectId: string }) {
<main className="editor-layout"> <main className="editor-layout">
<aside aria-label="素材与底图来源" className="editor-assets-panel"> <aside aria-label="素材与底图来源" className="editor-assets-panel">
<nav aria-label="编辑器素材分类" className="editor-asset-tabs"> <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>)} {([
{ label: "底图", panel: "background" as const },
{ label: "历史", panel: "history" as const },
{ label: "文字模板" },
{ label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡" },
{ label: "动态贴纸" },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} disabled={!item.panel} key={item.label} onClick={() => { if (item.panel) setActivePanel(item.panel); }} type="button">{item.label}</button>)}
</nav> </nav>
<section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section> {activePanel === "background" ? <section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section> : null}
<section><h2></h2><div className="editor-history-list"> {activePanel === "history" ? <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>)} {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> </div></section> : null}
{activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid">
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null}
</aside> </aside>
<section aria-label="画布工作区" className="editor-workspace"> <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> <div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button"></button>
<button disabled={selectedElements.length === 0} onClick={() => { const first = selectedElements[0]; if (first) showCandidates(first.position); }} type="button"></button>
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button"></button>
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button"></button>
</div>
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
<EditorStage
assetId={canvasState.background.asset_id}
canvasState={canvasState}
guides={guides}
onCandidates={showCandidates}
onClearSelection={clearSelection}
onCopy={copySelection}
onDelete={deleteSelection}
onMarquee={marqueeSelect}
onMoveCommit={commitMove}
onMovePreview={previewMove}
onNudge={(delta) => transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")}
onPaste={pasteSelection}
onPointerMoved={() => elementControllerRef.current?.pointerMoved()}
onSelect={selectAt}
projectId={projectId}
selectedIds={selectedIds}
/>
{candidateMenu ? <div className="editor-candidates" role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
</div> : null}
</div>
{guides.length > 0 ? <span aria-live="polite" className="editor-snap-status"></span> : null}
</section> </section>
<aside aria-label="底图参数" className="editor-inspector"> <aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
<header><p>BACKGROUND</p><h2></h2></header> {selectedElements.length === 0 ? <>
<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> <header><p>BACKGROUND</p><h2></h2></header>
<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> <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>
{(["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>)} <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>
<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> {(["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={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>
</> : <>
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
<div className="editor-object-moves" role="group" aria-label="移动对象">
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button"></button>
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button"></button>
<button aria-label="向下移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: 0.01 }, { snap: false }); }, "对象位置已提交")} title="向下移动" type="button"></button>
<button aria-label="向右移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向右移动" type="button"></button>
</div>
<div className="editor-object-tools">
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(0.9); }, "对象尺寸已提交")} type="button"></button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(1.1); }, "对象尺寸已提交")} type="button"></button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(-15); }, "对象旋转已提交")} type="button"></button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(15); }, "对象旋转已提交")} type="button"></button>
</div>
{selectedStickerOpacity !== undefined ? <>
<button className="editor-wide-command" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.flipSelectedHorizontal(); }, "贴纸翻转已提交")} type="button"></button>
<label className="editor-range"><span><output>{selectedStickerOpacity}%</output></span><input disabled={!canEdit} max={100} min={0} onBlur={commitOpacityGesture} onChange={(event) => changeOpacity(Number(event.target.value) / 100)} onPointerCancel={commitOpacityGesture} onPointerDown={beginOpacityGesture} onPointerUp={commitOpacityGesture} type="range" value={selectedStickerOpacity} /></label>
</> : null}
<div className="editor-layer-tools" role="group" aria-label="对象层级">
<button disabled={!canEdit} onClick={() => changeSelectionLayer("front")} type="button"></button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("forward")} type="button"></button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("backward")} type="button"></button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("back")} type="button"></button>
</div>
<div className="editor-object-actions"><button disabled={!canEdit} onClick={duplicateSelection} type="button"></button><button className="editor-danger" disabled={!canEdit} onClick={deleteSelection} type="button"></button></div>
</>}
</aside> </aside>
</main> </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> <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>
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
interface Gesture {
append: boolean;
hit: boolean;
longPressOpened: boolean;
pointerId: number;
start: CanvasPoint;
}
interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState;
guides: readonly string[];
onCandidates: (point: CanvasPoint) => void;
onClearSelection: () => void;
onCopy: () => void;
onDelete: () => void;
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
onMoveCommit: () => void;
onMovePreview: (delta: CanvasPoint) => void;
onNudge: (delta: CanvasPoint) => void;
onPaste: () => void;
onPointerMoved: () => void;
onSelect: (point: CanvasPoint, append: boolean) => boolean;
projectId: string;
selectedIds: readonly string[];
}
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
const bounds = event.currentTarget.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
};
}
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number) {
const x = element.position.x * width;
const y = element.position.y * height;
context.save();
context.globalAlpha = element.opacity;
context.translate(x, y);
context.rotate((element.rotation * Math.PI) / 180);
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
context.scale(element.scale.x * flip, 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 if (element.type === "static_sticker") {
context.fillStyle = element.template_or_asset_id.endsWith("2") ? "#1769aa" : "#f2f400";
context.strokeStyle = "#111111";
context.lineWidth = 5;
context.beginPath();
context.roundRect(-62, -62, 124, 124, element.template_or_asset_id.endsWith("2") ? 62 : 8);
context.fill();
context.stroke();
context.fillStyle = "#111111";
context.font = "700 22px Consolas, monospace";
context.textAlign = "center";
context.fillText(element.template_or_asset_id, 0, 8);
} else {
context.fillStyle = "#111111";
context.font = "700 48px Microsoft YaHei, sans-serif";
context.fillText(element.content ?? "DADA", -80, 16);
}
context.restore();
}
export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined);
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [marquee, setMarquee] = useState<CanvasRect>();
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
const render = (image?: HTMLImageElement) => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
context.filter = "none";
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height);
context.lineWidth = 4;
context.strokeStyle = "#005fcc";
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
const halfWidth = 78 * element.scale.x;
const halfHeight = 78 * element.scale.y;
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
}
context.save();
context.strokeStyle = "#005fcc";
context.lineWidth = 2;
context.setLineDash([12, 8]);
if (props.guides.some((guide) => guide.includes("-x"))) {
context.beginPath(); context.moveTo(canvas.width / 2, 0); context.lineTo(canvas.width / 2, canvas.height); context.stroke();
}
if (props.guides.some((guide) => guide.includes("-y"))) {
context.beginPath(); context.moveTo(0, canvas.height / 2); context.lineTo(canvas.width, canvas.height / 2); context.stroke();
}
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore();
};
if (!props.assetId) {
render();
return undefined;
}
const image = new Image();
image.onload = () => render(image);
image.onerror = () => render();
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
return () => { image.onload = null; image.onerror = null; };
}, [marquee, props.assetId, props.canvasState, props.guides, props.projectId, props.selectedIds]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
if (event.button !== 0) return;
const start = pointFromEvent(event);
const append = event.shiftKey;
const hit = props.onSelect(start, append);
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
event.currentTarget.setPointerCapture(event.pointerId);
longPressRef.current = setTimeout(() => {
const gesture = gestureRef.current;
if (!gesture || !gesture.hit) return;
gesture.longPressOpened = true;
props.onCandidates(gesture.start);
}, 550);
}
function handlePointerMove(event: PointerEvent<HTMLCanvasElement>) {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
props.onPointerMoved();
return;
}
const point = pointFromEvent(event);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
props.onPointerMoved();
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
else if (!gesture.hit) setMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y });
}
function handlePointerUp(event: PointerEvent<HTMLCanvasElement>) {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
const point = pointFromEvent(event);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
setMarquee(undefined);
gestureRef.current = undefined;
event.currentTarget.releasePointerCapture(event.pointerId);
}
function handleKeyDown(event: KeyboardEvent<HTMLCanvasElement>) {
const step = event.shiftKey ? 0.01 : 0.002;
if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); props.onDelete(); }
else if (event.key === "Escape") { event.preventDefault(); props.onClearSelection(); }
else if (event.ctrlKey && event.key.toLowerCase() === "c") { event.preventDefault(); props.onCopy(); }
else if (event.ctrlKey && event.key.toLowerCase() === "v") { event.preventDefault(); props.onPaste(); }
else if (event.key.startsWith("Arrow")) {
event.preventDefault();
props.onNudge({ x: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0, y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0 });
}
}
return <canvas aria-label="编辑画布" className="editor-canvas" onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} ref={canvasRef} tabIndex={0} />;
}
+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 tests/e2e/wp4-01-editor-background.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 tests/e2e/wp4-02-editor-elements.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",
@@ -75,7 +75,9 @@
"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": "node scripts/run-wp4-01-validation.mjs",
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red" "test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red",
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
+87
View File
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, 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-02-${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-27"], evidence: ["canvas-state.json", "db-diff.json", "trace.zip", "performance.json"], id: "TDD-WP4-CAN-001-fifty-elements", requirements: ["EDITOR-04", "EDITOR-05", "EDITOR-06", "EDITOR-07", "EDITOR-08", "EDITOR-09"] },
{ acceptance_criteria: ["AC-13", "AC-27"], evidence: ["canvas-state.json", "db-diff.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-STK-001-transform-sticker", requirements: ["EDITOR-04", "EDITOR-05", "EDITOR-06", "EDITOR-07", "EDITOR-08", "EDITOR-09", "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_ELEMENTS: 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;
}
if (phase === "green") {
const traces = findTraces(outputDirectory);
const limitTrace = traces.find((path) => path.includes("fifty-first"));
const stickerTrace = traces.find((path) => path.includes("ordinary-stickers"));
if (limitTrace) copyFileSync(limitTrace, resolve(casesDirectory, cases[0].id, "trace.zip"));
if (stickerTrace) copyFileSync(stickerTrace, 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: "Canvas selection, 50-element limit, sticker transforms and editor controls were absent before TASK-WP4-02", observed_commands: ["pnpm vitest run tests/unit/wp4-02-editor-elements.test.ts"], observed_errors: ["Cannot find module '../../apps/web/src/editor-elements.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", "VIS-PERF"], layer_notes: { PERFORMANCE: "current root runner reports not_applicable for TASK-WP0-01", VISUAL: "current root runner reports not_applicable for TASK-WP0-01" },
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, run_id: runId, status,
task_id: "TASK-WP4-02", 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);
+13 -1
View File
@@ -2,8 +2,10 @@ import { mkdirSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
const webUrl = "http://127.0.0.1:4173"; let vite: ViteDevServer;
let webUrl: string;
const categories = [ const categories = [
["upstream_timeout", "使用原输入重试"], ["upstream_timeout", "使用原输入重试"],
["upstream_failed", "稍后重试"], ["upstream_failed", "稍后重试"],
@@ -18,6 +20,16 @@ const categories = [
test.use({ trace: "off" }); test.use({ trace: "off" });
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());
test("TASK-WP2-06 renders the single frozen action for every generation category", async ({ page }) => { test("TASK-WP2-06 renders the single frozen action for every generation category", async ({ page }) => {
let activeCategory: typeof categories[number][0] = "upstream_timeout"; let activeCategory: typeof categories[number][0] = "upstream_timeout";
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: { await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: {
+13 -1
View File
@@ -2,14 +2,26 @@ import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
const webUrl = "http://127.0.0.1:4173"; let vite: ViteDevServer;
let webUrl: string;
const userId = "00000000-0000-4000-8000-000000000701"; const userId = "00000000-0000-4000-8000-000000000701";
const taskId = "00000000-0000-4000-8000-000000000702"; const taskId = "00000000-0000-4000-8000-000000000702";
const projectId = "00000000-0000-4000-8000-000000000703"; const projectId = "00000000-0000-4000-8000-000000000703";
test.use({ trace: "off" }); test.use({ trace: "off" });
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 session = { const session = {
audience: "user", authenticated: true, audience: "user", authenticated: true,
credits: { available_balance: 9, reserved_balance: 1 }, credits: { available_balance: 9, reserved_balance: 1 },
@@ -75,6 +75,7 @@ test("TDD-WP4-BG-001 preserves overlays while switching the background", async (
await page.goto(`${webUrl}/app/projects/${projectId}/editor`); await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible(); await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible();
await expect(page.getByText("城市工作室")).toBeVisible(); await expect(page.getByText("城市工作室")).toBeVisible();
await page.getByRole("button", { name: "历史", exact: true }).click();
await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible(); await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible();
await page.getByRole("button", { name: /生成图/ }).last().click(); await page.getByRole("button", { name: /生成图/ }).last().click();
const dialog = page.getByRole("dialog", { name: "更换底图" }); const dialog = page.getByRole("dialog", { name: "更换底图" });
+184
View File
@@ -0,0 +1,184 @@
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";
import type { CanvasState } from "@dada/shared-contracts";
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 session = {
audience: "user", authenticated: true,
credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-editor-elements-00000000000000000000000000000000000",
expires_at: "2026-09-03T08:00:00.000Z",
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
};
function uuid(index: number) {
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
}
function sticker(index: number, position: { x: number; y: number }, zIndex = index - 1): CanvasState["elements"][number] {
return {
created_at: "2026-08-03T08:00:00.000Z", element_id: uuid(600 + index), opacity: 1, position,
resource_version: "fixture-v1", rotation: 0, scale: { x: 1, y: 1 }, style_parameters: { flip_horizontal: false },
template_or_asset_id: `STK${String(index).padStart(3, "0")}`, type: "static_sticker", z_index: zIndex,
};
}
function canvas(elements: CanvasState["elements"]): CanvasState {
return {
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
elements, pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
};
}
function writeEvidence(caseId: string, name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS;
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, projectId: string, backend: { canvas: CanvasState; saves: number; version: number }) {
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({
canvas_state: backend.canvas, created_at: "2026-08-03T08:00:00.000Z", current_image_id: null,
draft_prompt: "贴纸测试", generations: [], images: [], name: "贴纸画布", pixel_height: 1440, pixel_width: 1080,
project_id: projectId, ratio: "3:4", save_status: "saved", state_version: backend.version,
status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
}), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
backend.saves += 1;
backend.version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
});
}
test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", async ({ page }) => {
const projectId = uuid(510);
const elements = Array.from({ length: 49 }, (_, index) => sticker(index + 1, { x: 0.15 + (index % 7) * 0.1, y: 0.15 + (Math.floor(index / 7) % 7) * 0.1 }));
const backend = { canvas: canvas(elements), saves: 0, version: 3 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
await page.getByRole("button", { name: "添加贴纸 STK001" }).click();
await expect(page.getByText("对象 50 / 50")).toBeVisible();
await expect(page.getByRole("button", { name: "添加贴纸 STK001" })).toBeDisabled();
await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible();
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
const beforeBlockedAction = structuredClone(backend.canvas);
await page.getByRole("button", { name: "复制", exact: true }).last().click();
await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible();
await page.waitForTimeout(1_200);
expect(backend.canvas).toEqual(beforeBlockedAction);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const started = await page.evaluate(() => performance.now());
const multiMode = page.getByRole("button", { name: "多选模式" });
await multiMode.click();
await expect(multiMode).toHaveAttribute("aria-pressed", "true");
await multiMode.click();
await stage.press("Escape");
await page.mouse.move(bounds.x + bounds.width * 0.02, bounds.y + bounds.height * 0.02);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width * 0.32, bounds.y + bounds.height * 0.22, { steps: 3 });
await page.mouse.up();
await expect(page.getByRole("heading", { name: /已选 [2-9][0-9]* 个对象/ })).toBeVisible();
await page.getByRole("button", { name: "向右移动" }).click();
const elapsed = await page.evaluate((start) => performance.now() - start, started);
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(1);
expect(backend.canvas.elements).toHaveLength(50);
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.16);
expect(page.getByText("图层面板", { exact: true })).toHaveCount(0);
expect(page.getByText("隐藏", { exact: true })).toHaveCount(0);
expect(page.getByText("锁定", { exact: true })).toHaveCount(0);
expect(page.getByText("编组", { exact: true })).toHaveCount(0);
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "canvas-state.json", backend.canvas);
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "db-diff.json", { element_count: 50, fifty_first_state_delta: 0, saves: backend.saves });
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "performance.json", { interaction_elapsed_ms: elapsed, sustained_unresponsive: false });
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") });
});
test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => {
const projectId = uuid(520);
const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
await page.mouse.move(center.x, center.y);
await page.mouse.down();
await page.mouse.up();
await expect(page.getByText("STK002", { exact: true })).toBeVisible();
await page.mouse.down();
await page.mouse.up();
await expect(page.getByText("STK001", { exact: true })).toBeVisible();
await stage.press("Escape");
await page.mouse.move(center.x, center.y);
await page.mouse.down();
await page.waitForTimeout(650);
await expect(page.getByRole("menu")).toBeVisible();
await expect(page.getByRole("menuitem")).toHaveCount(2);
await page.mouse.up();
await page.getByRole("menuitem", { name: /STK001/ }).click();
await page.getByRole("button", { name: "向右移动" }).click();
await page.getByRole("button", { name: "放大" }).click();
await page.getByRole("button", { name: "顺时针" }).click();
await page.getByRole("button", { name: "水平翻转" }).click();
const opacity = page.locator(".editor-inspector input[type=range]");
await opacity.dispatchEvent("pointerdown");
await opacity.fill("80");
await expect(page.locator(".editor-inspector output")).toHaveText("80%");
await opacity.fill("60");
await expect(page.locator(".editor-inspector output")).toHaveText("60%");
await opacity.fill("35");
await expect(page.locator(".editor-inspector output")).toHaveText("35%");
await opacity.dispatchEvent("pointerup");
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.locator(".editor-inspector output")).toHaveText("100%");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.locator(".editor-inspector output")).toHaveText("35%");
await page.getByRole("button", { name: "置顶" }).click();
await page.getByRole("button", { name: "复制", exact: true }).last().click();
await expect(page.getByText("对象 3 / 50")).toBeVisible();
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByText("对象 2 / 50")).toBeVisible();
await page.getByRole("button", { name: "重做" }).click();
await expect(page.getByText("对象 3 / 50")).toBeVisible();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
const transformed = backend.canvas.elements.find((element) => element.element_id === uuid(601));
expect(transformed).toMatchObject({
opacity: 0.35, position: { x: 0.51, y: 0.5 }, rotation: 15,
scale: { x: 1.1, y: 1.1 }, style_parameters: { flip_horizontal: true }, template_or_asset_id: "STK001",
});
await page.reload();
await expect(page.getByText("对象 3 / 50")).toBeVisible();
writeEvidence("TDD-WP4-STK-001-transform-sticker", "canvas-state.json", backend.canvas);
writeEvidence("TDD-WP4-STK-001-transform-sticker", "db-diff.json", { reopened_element_count: backend.canvas.elements.length, saves: backend.saves, state_version: backend.version });
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import type { CanvasState } from "@dada/shared-contracts";
type CanvasElement = CanvasState["elements"][number];
import {
CanvasElementController,
createStaticStickerElement,
type CanvasElementIdentity,
} from "../../apps/web/src/editor-elements.js";
import { createEditorCanvasState } from "../../apps/web/src/editor-canvas.js";
function identity(index: number): CanvasElementIdentity {
return {
createdAt: "2026-08-03T00:00:00.000Z",
elementId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
};
}
function sticker(index: number, overrides: Partial<CanvasElement> = {}) {
return createStaticStickerElement({
assetId: `STK${String(index).padStart(3, "0")}`,
identity: identity(index),
position: { x: 0.5, y: 0.5 },
resourceVersion: "fixture-v1",
zIndex: index - 1,
...overrides,
});
}
function state(elements: CanvasElement[] = []): CanvasState {
return { ...createEditorCanvasState({ assetId: null, ratio: "3:4" }), elements };
}
describe("TDD-WP4-CAN-001 canvas selection and limit", () => {
it("keeps fifty editable elements and rejects the fifty-first without mutation", () => {
const controller = new CanvasElementController(state());
for (let index = 1; index <= 50; index += 1) controller.add(sticker(index));
const before = controller.value;
expect(before.elements).toHaveLength(50);
expect(() => controller.add(sticker(51))).toThrowError("canvas_element_limit_reached");
expect(controller.value).toEqual(before);
});
it("cycles overlapping hits, resets after pointer movement, and exposes only point candidates", () => {
const controller = new CanvasElementController(state([
sticker(1, { position: { x: 0.5, y: 0.5 }, z_index: 0 }),
sticker(2, { position: { x: 0.5, y: 0.5 }, z_index: 1 }),
sticker(3, { position: { x: 0.8, y: 0.8 }, z_index: 2 }),
]));
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(2).elementId]);
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(1).elementId]);
controller.pointerMoved();
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(2).elementId]);
expect(controller.candidatesAt({ x: 0.5, y: 0.5 }).map((entry) => entry.element_id)).toEqual([
identity(2).elementId,
identity(1).elementId,
]);
});
it("supports Shift/multi-mode toggles and marquee selection without creating a layer panel", () => {
const controller = new CanvasElementController(state([
sticker(1, { position: { x: 0.2, y: 0.2 }, z_index: 0 }),
sticker(2, { position: { x: 0.4, y: 0.4 }, z_index: 1 }),
sticker(3, { position: { x: 0.8, y: 0.8 }, z_index: 2 }),
]));
controller.selectAt({ x: 0.2, y: 0.2 });
controller.selectAt({ x: 0.4, y: 0.4 }, { append: true });
expect(controller.selectedIds).toEqual([identity(1).elementId, identity(2).elementId]);
controller.selectAt({ x: 0.2, y: 0.2 }, { append: true });
expect(controller.selectedIds).toEqual([identity(2).elementId]);
expect(controller.marqueeSelect({ height: 0.5, width: 0.5, x: 0.1, y: 0.1 })).toEqual([
identity(1).elementId,
identity(2).elementId,
]);
});
});
describe("TDD-WP4-STK-001 sticker transformations", () => {
it("moves with center snapping and applies scale, rotation, flip and opacity", () => {
const controller = new CanvasElementController(state([sticker(1, { position: { x: 0.42, y: 0.48 } })]));
controller.selectById(identity(1).elementId);
const moved = controller.moveSelected({ x: 0.075, y: 0.015 });
expect(moved.guides).toEqual(expect.arrayContaining(["canvas-center-x", "canvas-center-y"]));
expect(controller.value.elements[0]?.position).toEqual({ x: 0.5, y: 0.5 });
expect(controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }).guides).toEqual([]);
expect(controller.value.elements[0]?.position).toEqual({ x: 0.51, y: 0.5 });
controller.scaleSelected(1.5);
controller.rotateSelected(45);
controller.flipSelectedHorizontal();
controller.setSelectedOpacity(0.35);
expect(controller.value.elements[0]).toMatchObject({
opacity: 0.35,
rotation: 45,
scale: { x: 1.5, y: 1.5 },
style_parameters: { flip_horizontal: true },
});
});
it("duplicates, reorders and deletes selected stickers while preserving stable resource identity", () => {
const controller = new CanvasElementController(state([sticker(1), sticker(2, { position: { x: 0.7, y: 0.7 } })]));
controller.selectById(identity(1).elementId);
controller.duplicateSelected(() => identity(10));
const duplicate = controller.value.elements.find((entry) => entry.element_id === identity(10).elementId);
expect(duplicate).toMatchObject({ resource_version: "fixture-v1", template_or_asset_id: "STK001" });
controller.changeLayer("back");
expect(controller.value.elements.find((entry) => entry.element_id === identity(10).elementId)?.z_index).toBe(0);
controller.changeLayer("front");
expect(controller.value.elements.find((entry) => entry.element_id === identity(10).elementId)?.z_index).toBe(2);
controller.deleteSelected();
expect(controller.value.elements.map((entry) => entry.element_id)).not.toContain(identity(10).elementId);
});
});