feat: complete TASK-WP4-02 canvas element editing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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-muted { color: #62625d; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
.editor-sticker-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-height: 0;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
padding: 16px 32px 32px;
|
||||
background: #e8e8e5;
|
||||
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 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(100%, 720px);
|
||||
max-height: calc(100vh - 128px);
|
||||
max-height: calc(100vh - 168px);
|
||||
place-items: center;
|
||||
border: 1px solid #62625d;
|
||||
background: #ffffff;
|
||||
@@ -178,9 +231,47 @@
|
||||
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
|
||||
.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 p { margin: 0 0 4px; }
|
||||
.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 select,
|
||||
.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-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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+269
-56
@@ -4,17 +4,20 @@ 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 { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||
import { EditorStage } from "./editor-stage.js";
|
||||
|
||||
import "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
type EditorAssetPanel = "background" | "history" | "stickers";
|
||||
|
||||
interface EditorSession {
|
||||
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")}`);
|
||||
}
|
||||
|
||||
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} />;
|
||||
const stickerFixtures = [
|
||||
{ assetId: "STK001", label: "方形标记" },
|
||||
{ assetId: "STK002", label: "圆形标记" },
|
||||
] as const;
|
||||
|
||||
function newElementIdentity(): CanvasElementIdentity {
|
||||
return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
|
||||
}
|
||||
|
||||
function saveStatusLabel(status: ProjectSaveStatus) {
|
||||
@@ -113,8 +77,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||
const [pendingBackground, setPendingBackground] = useState<string>();
|
||||
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 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(() => {
|
||||
let active = true;
|
||||
@@ -129,6 +102,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setCanvasState(initial);
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
elementControllerRef.current = new CanvasElementController(initial);
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
@@ -139,8 +113,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
initialState: { canvas_state: canvasState, name: project.name },
|
||||
initialVersion: project.state_version,
|
||||
onStatus: setSaveStatus,
|
||||
onSaved: (snapshot, stateVersion) => {
|
||||
setCanvasState(snapshot.canvas_state);
|
||||
onSaved: (_snapshot, stateVersion) => {
|
||||
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
|
||||
},
|
||||
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
|
||||
@@ -163,6 +136,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function commitCanvas(next: CanvasState) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
historyRef.current?.commit(next);
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
@@ -177,6 +152,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function undo() {
|
||||
const previous = historyRef.current?.undo();
|
||||
if (previous) {
|
||||
elementControllerRef.current?.replaceState(previous);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(previous);
|
||||
setDraftAdjustments(previous.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||
@@ -186,6 +163,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function redo() {
|
||||
const next = historyRef.current?.redo();
|
||||
if (next) {
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
@@ -199,9 +178,176 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
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>;
|
||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||
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 (
|
||||
<div className="editor-page-shell">
|
||||
<header className="editor-toolbar">
|
||||
@@ -219,22 +365,89 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
<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>)}
|
||||
{([
|
||||
{ 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>
|
||||
<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">
|
||||
{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}
|
||||
{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>)}
|
||||
</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>
|
||||
<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>
|
||||
<aside aria-label="底图参数" className="editor-inspector">
|
||||
<aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
|
||||
{selectedElements.length === 0 ? <>
|
||||
<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>)}
|
||||
{(["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>
|
||||
</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>
|
||||
|
||||
@@ -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
@@ -14,7 +14,7 @@
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"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: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:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"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: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"
|
||||
"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": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -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);
|
||||
@@ -2,8 +2,10 @@ import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
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 = [
|
||||
["upstream_timeout", "使用原输入重试"],
|
||||
["upstream_failed", "稍后重试"],
|
||||
@@ -18,6 +20,16 @@ const categories = [
|
||||
|
||||
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 }) => {
|
||||
let activeCategory: typeof categories[number][0] = "upstream_timeout";
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
|
||||
@@ -2,14 +2,26 @@ import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
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 taskId = "00000000-0000-4000-8000-000000000702";
|
||||
const projectId = "00000000-0000-4000-8000-000000000703";
|
||||
|
||||
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 = {
|
||||
audience: "user", authenticated: true,
|
||||
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 expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible();
|
||||
await expect(page.getByText("城市工作室")).toBeVisible();
|
||||
await page.getByRole("button", { name: "历史", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible();
|
||||
await page.getByRole("button", { name: /生成图/ }).last().click();
|
||||
const dialog = page.getByRole("dialog", { name: "更换底图" });
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user