diff --git a/apps/web/src/editor-elements.ts b/apps/web/src/editor-elements.ts new file mode 100644 index 0000000..9fe922c --- /dev/null +++ b/apps/web/src/editor-elements.ts @@ -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; + } +} diff --git a/apps/web/src/editor-page.css b/apps/web/src/editor-page.css index 8e31751..2a35c36 100644 --- a/apps/web/src/editor-page.css +++ b/apps/web/src/editor-page.css @@ -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; diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index d3c7092..2946679 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -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(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 ; +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("saved"); const [pendingBackground, setPendingBackground] = useState(); const [notice, setNotice] = useState(""); + const [activePanel, setActivePanel] = useState("background"); + const [selectedIds, setSelectedIds] = useState([]); + const [guides, setGuides] = useState([]); + const [multiMode, setMultiMode] = useState(false); + const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>(); const queueRef = useRef(undefined); const historyRef = useRef(undefined); + const elementControllerRef = useRef(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([]); 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 = { 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
正在加载编辑器
; 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 (
@@ -219,22 +365,89 @@ export function EditorPage({ projectId }: { projectId: string }) {
-
+
+ + + + +
+
+ transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")} + onPaste={pasteSelection} + onPointerMoved={() => elementControllerRef.current?.pointerMoved()} + onSelect={selectAt} + projectId={projectId} + selectedIds={selectedIds} + /> + {candidateMenu ?
+ {candidateMenu.elements.map((element) => )} +
: null} +
+ {guides.length > 0 ? 已吸附 : null}
-
画布 {canvasState.pixel_width} × {canvasState.pixel_height}对象 {canvasState.elements.length} / 50缩放 100%本机保存 · state version {project.state_version}
diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx new file mode 100644 index 0000000..ba3601c --- /dev/null +++ b/apps/web/src/editor-stage.tsx @@ -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): 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(null); + const gestureRef = useRef(undefined); + const longPressRef = useRef | undefined>(undefined); + const [marquee, setMarquee] = useState(); + + 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) { + 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) { + 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) { + 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) { + 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 ; +} diff --git a/package.json b/package.json index 2d2e3a5..cffb060 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-wp4-02-validation.mjs b/scripts/run-wp4-02-validation.mjs new file mode 100644 index 0000000..a0be77d --- /dev/null +++ b/scripts/run-wp4-02-validation.mjs @@ -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); diff --git a/tests/e2e/generation-terminal-actions.spec.ts b/tests/e2e/generation-terminal-actions.spec.ts index 747dea8..c9ef0a3 100644 --- a/tests/e2e/generation-terminal-actions.spec.ts +++ b/tests/e2e/generation-terminal-actions.spec.ts @@ -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: { diff --git a/tests/e2e/generation-workspace.spec.ts b/tests/e2e/generation-workspace.spec.ts index af0a23c..ac156f9 100644 --- a/tests/e2e/generation-workspace.spec.ts +++ b/tests/e2e/generation-workspace.spec.ts @@ -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 }, diff --git a/tests/e2e/wp4-01-editor-background.spec.ts b/tests/e2e/wp4-01-editor-background.spec.ts index 32e6c05..bd52a4e 100644 --- a/tests/e2e/wp4-01-editor-background.spec.ts +++ b/tests/e2e/wp4-01-editor-background.spec.ts @@ -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: "更换底图" }); diff --git a/tests/e2e/wp4-02-editor-elements.spec.ts b/tests/e2e/wp4-02-editor-elements.spec.ts new file mode 100644 index 0000000..77681b4 --- /dev/null +++ b/tests/e2e/wp4-02-editor-elements.spec.ts @@ -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") }); +}); diff --git a/tests/unit/wp4-02-editor-elements.test.ts b/tests/unit/wp4-02-editor-elements.test.ts new file mode 100644 index 0000000..4991fa7 --- /dev/null +++ b/tests/unit/wp4-02-editor-elements.test.ts @@ -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 = {}) { + 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); + }); +});