diff --git a/apps/web/src/editor-canvas.ts b/apps/web/src/editor-canvas.ts index bd7f1bf..0957aa8 100644 --- a/apps/web/src/editor-canvas.ts +++ b/apps/web/src/editor-canvas.ts @@ -2,6 +2,27 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts"; export type BackgroundAdjustments = CanvasState["background"]["adjustments"]; +interface CanvasSize { + height: number; + width: number; +} + +interface CanvasRect { + height: number; + width: number; + x: number; + y: number; +} + +export interface BackgroundDrawPlan { + destination: CanvasRect; + source: CanvasRect; +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)); +} + const elementKeys = new Set([ "colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size", "formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters", @@ -106,7 +127,99 @@ export function deserializeFabricCanvas(input: unknown): CanvasState | undefined return isCanvasState(candidate) ? structuredClone(candidate) : undefined; } +export function backgroundDrawPlan( + image: CanvasSize, + canvas: CanvasSize, + adjustments: BackgroundAdjustments, +): BackgroundDrawPlan { + if (image.width <= 0 || image.height <= 0 || canvas.width <= 0 || canvas.height <= 0) { + throw new Error("background_dimensions_invalid"); + } + const crop = adjustments.crop; + const normalizedX = crop ? clamp(crop.x, 0, 1) : 0; + const normalizedY = crop ? clamp(crop.y, 0, 1) : 0; + const normalizedWidth = crop ? Math.min(crop.width, 1 - normalizedX) : 1; + const normalizedHeight = crop ? Math.min(crop.height, 1 - normalizedY) : 1; + const source = normalizedWidth > 0 && normalizedHeight > 0 + ? { + height: image.height * normalizedHeight, + width: image.width * normalizedWidth, + x: image.width * normalizedX, + y: image.height * normalizedY, + } + : { height: image.height, width: image.width, x: 0, y: 0 }; + + if (adjustments.fit === "fill") { + return { destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, source }; + } + if (adjustments.fit === "fit") { + const scale = Math.min(canvas.width / source.width, canvas.height / source.height); + const width = source.width * scale; + const height = source.height * scale; + return { + destination: { height, width, x: (canvas.width - width) / 2, y: (canvas.height - height) / 2 }, + source, + }; + } + + const sourceAspect = source.width / source.height; + const canvasAspect = canvas.width / canvas.height; + if (sourceAspect > canvasAspect) { + const width = source.height * canvasAspect; + source.x += (source.width - width) / 2; + source.width = width; + } else if (sourceAspect < canvasAspect) { + const height = source.width / canvasAspect; + source.y += (source.height - height) / 2; + source.height = height; + } + return { + destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, + source, + }; +} + +export function adjustBackgroundPixels( + pixels: Uint8ClampedArray, + width: number, + height: number, + adjustments: Pick, +) { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) { + throw new Error("background_pixel_buffer_invalid"); + } + const source = new Uint8ClampedArray(pixels); + const output = new Uint8ClampedArray(source); + const sharpness = clamp(adjustments.sharpness, 0, 100) / 100; + const temperature = clamp(adjustments.temperature, -100, 100) / 100; + const channelOffsets = [35 * temperature, 8 * temperature, -35 * temperature]; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + for (let channel = 0; channel < 3; channel += 1) { + let value = source[index + channel]!; + if (sharpness > 0 && x > 0 && x < width - 1 && y > 0 && y < height - 1) { + const left = source[index + channel - 4]!; + const right = source[index + channel + 4]!; + const above = source[index + channel - width * 4]!; + const below = source[index + channel + width * 4]!; + value = value * (1 + 4 * sharpness) - (left + right + above + below) * sharpness; + } + output[index + channel] = value + channelOffsets[channel]!; + } + output[index + 3] = source[index + 3]!; + } + } + return output; +} + export function cssFilterForBackground(adjustments: BackgroundAdjustments) { - const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none"; - return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`; + const filters: string[] = []; + if (adjustments.filter === "grayscale") filters.push("grayscale(1)"); + else if (adjustments.filter === "sepia") filters.push("sepia(0.75)"); + if (adjustments.brightness !== 0) filters.push(`brightness(${100 + adjustments.brightness}%)`); + if (adjustments.contrast !== 0) filters.push(`contrast(${100 + adjustments.contrast}%)`); + if (adjustments.saturation !== 0) filters.push(`saturate(${100 + adjustments.saturation}%)`); + return filters.length > 0 ? filters.join(" ") : "none"; } diff --git a/apps/web/src/editor-elements.ts b/apps/web/src/editor-elements.ts index cb2fa1b..4dd73f6 100644 --- a/apps/web/src/editor-elements.ts +++ b/apps/web/src/editor-elements.ts @@ -187,7 +187,7 @@ export class CanvasElementController { .map((element) => structuredClone(element)); } - selectAt(point: CanvasPoint, options: { append?: boolean } = {}) { + selectAt(point: CanvasPoint, options: { append?: boolean; preserveSelection?: boolean } = {}) { const candidates = this.candidatesAt(point); if (candidates.length === 0) { if (!options.append) this.selection = []; @@ -196,7 +196,9 @@ export class CanvasElementController { } if (options.append) { this.pointerMoved(); - return this.selectById(candidates[0]!.element_id, true); + const elementId = candidates[0]!.element_id; + if (options.preserveSelection && this.selection.includes(elementId)) return this.selectedIds; + return this.selectById(elementId, 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; @@ -255,7 +257,8 @@ export class CanvasElementController { moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) { const selected = new Set(this.selection); - const primary = this.current.elements.find((element) => selected.has(element.element_id)); + const selectedElements = this.current.elements.filter((element) => selected.has(element.element_id)); + const primary = selectedElements[0]; if (!primary) return { guides: [] as string[], state: this.value }; let nextX = primary.position.x + delta.x; let nextY = primary.position.y + delta.y; @@ -278,10 +281,20 @@ export class CanvasElementController { nextX = snapAxis(nextX, "x"); nextY = snapAxis(nextY, "y"); } - const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y }; + const minimumX = Math.min(...selectedElements.map((element) => element.position.x)); + const maximumX = Math.max(...selectedElements.map((element) => element.position.x)); + const minimumY = Math.min(...selectedElements.map((element) => element.position.y)); + const maximumY = Math.max(...selectedElements.map((element) => element.position.y)); + const adjusted = { + x: clamp(nextX - primary.position.x, -minimumX, 1 - maximumX), + y: clamp(nextY - primary.position.y, -minimumY, 1 - maximumY), + }; 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) }, + position: { + x: clamp(Number((element.position.x + adjusted.x).toFixed(12)), 0, 1), + y: clamp(Number((element.position.y + adjusted.y).toFixed(12)), 0, 1), + }, })); return { guides, state }; } diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index e5f4841..12bde2f 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -706,7 +706,10 @@ export function EditorPage({ projectId }: { projectId: string }) { const controller = controllerForCurrent(); if (!controller || !canvasState) return false; const candidates = controller.candidatesAt(point); - const selection = controller.selectAt(point, { append: append || multiMode }); + const selection = controller.selectAt(point, { + append: append || multiMode, + preserveSelection: multiMode && !append, + }); setSelectedIds(selection); setCandidateMenu(undefined); dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection }; @@ -789,10 +792,14 @@ export function EditorPage({ projectId }: { projectId: string }) { } if (!project || !canvasState) return
正在加载编辑器
; - const renderedCanvasState = textEdit ? { + const backgroundPreviewState: CanvasState = { ...canvasState, - elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element), - } : canvasState; + background: { ...canvasState.background, adjustments: draftAdjustments }, + }; + const renderedCanvasState = textEdit ? { + ...backgroundPreviewState, + elements: backgroundPreviewState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element), + } : backgroundPreviewState; const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`; const canEdit = saveStatus !== "conflicted"; const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id)); diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx index 6f6d0c0..dc58bc2 100644 --- a/apps/web/src/editor-stage.tsx +++ b/apps/web/src/editor-stage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import type { CanvasState } from "@dada/shared-contracts"; -import { cssFilterForBackground } from "./editor-canvas.js"; +import { adjustBackgroundPixels, backgroundDrawPlan, cssFilterForBackground } from "./editor-canvas.js"; import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js"; import type { DynamicTemplateId } from "./dynamic-provider.js"; import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js"; @@ -274,9 +274,41 @@ function renderEditorScene( context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height); context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height); - context.filter = cssFilterForBackground(canvasState.background.adjustments); - if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height); - context.filter = "none"; + if (background) { + const plan = backgroundDrawPlan( + { height: background.naturalHeight || background.height, width: background.naturalWidth || background.width }, + { height: canvasState.pixel_height, width: canvasState.pixel_width }, + canvasState.background.adjustments, + ); + context.save(); + context.filter = cssFilterForBackground(canvasState.background.adjustments); + context.drawImage( + background, + plan.source.x, + plan.source.y, + plan.source.width, + plan.source.height, + plan.destination.x, + plan.destination.y, + plan.destination.width, + plan.destination.height, + ); + context.restore(); + + if (canvasState.background.adjustments.temperature !== 0 || canvasState.background.adjustments.sharpness !== 0) { + const x = Math.max(0, Math.floor(plan.destination.x)); + const y = Math.max(0, Math.floor(plan.destination.y)); + const right = Math.min(canvasState.pixel_width, Math.ceil(plan.destination.x + plan.destination.width)); + const bottom = Math.min(canvasState.pixel_height, Math.ceil(plan.destination.y + plan.destination.height)); + const width = right - x; + const height = bottom - y; + if (width > 0 && height > 0) { + const imageData = context.getImageData(x, y, width, height); + imageData.data.set(adjustBackgroundPixels(imageData.data, width, height, canvasState.background.adjustments)); + context.putImageData(imageData, x, y); + } + } + } for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) { drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages); } diff --git a/tests/e2e/wp4-01-editor-background.spec.ts b/tests/e2e/wp4-01-editor-background.spec.ts index f50fbea..223b52d 100644 --- a/tests/e2e/wp4-01-editor-background.spec.ts +++ b/tests/e2e/wp4-01-editor-background.spec.ts @@ -67,6 +67,20 @@ async function routeEditor(page: Page) { }); } +async function canvasFingerprint(page: Page) { + return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas context unavailable."); + return [ + ...context.getImageData(108, 720, 1, 1).data, + ...context.getImageData(324, 720, 1, 1).data, + ...context.getImageData(540, 720, 1, 1).data, + ...context.getImageData(756, 720, 1, 1).data, + ...context.getImageData(972, 720, 1, 1).data, + ]; + }); +} + test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => { await routeEditor(page); const saves: Array> = []; @@ -144,3 +158,35 @@ test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing" writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true }); await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined }); }); + +test("TDD-WP4-BG-002 previews background pixels before committing", async ({ page }) => { + await routeEditor(page); + const saves: Array> = []; + let version = 7; + await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => { + saves.push(route.request().postDataJSON() as Record); + version += 1; + await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 }); + }); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + await page.getByRole("button", { name: "恢复原图" }).click(); + await page.getByRole("button", { name: "应用调整" }).click(); + await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0); + await expect.poll(async () => (await canvasFingerprint(page)).some((channel) => channel < 240)).toBe(true); + const baseline = (await canvasFingerprint(page)).join(","); + + const ranges = page.locator(".editor-inspector input[type=range]"); + await ranges.nth(0).fill("40"); + await ranges.nth(1).fill("25"); + await ranges.nth(2).fill("-35"); + await ranges.nth(3).fill("70"); + await ranges.nth(4).fill("100"); + await expect.poll(async () => (await canvasFingerprint(page)).join(",")).not.toBe(baseline); + + await page.getByRole("button", { name: "应用调整" }).click(); + await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1); + const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState }; + expect(committed.canvas_state.background.adjustments).toMatchObject({ + brightness: 40, contrast: 25, saturation: -35, sharpness: 100, temperature: 70, + }); +}); diff --git a/tests/e2e/wp4-02-editor-elements.spec.ts b/tests/e2e/wp4-02-editor-elements.spec.ts index 58c260a..469fd99 100644 --- a/tests/e2e/wp4-02-editor-elements.spec.ts +++ b/tests/e2e/wp4-02-editor-elements.spec.ts @@ -26,7 +26,8 @@ const session = { user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" }, }; -const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材"); +const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT + ?? join(homedir(), "Desktop", "sticker_web_replication_assets", "sticker_normal"); const originalStickerFixtures: Readonly> = { STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"), STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"), @@ -131,6 +132,41 @@ test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", 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-CAN-001 drags distant multi-selected objects as one group", async ({ page }) => { + const projectId = uuid(519); + const backend = { + canvas: canvas([sticker(1, { x: 0.2, y: 0.2 }, 0), sticker(2, { x: 0.8, y: 0.8 }, 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 point = (x: number, y: number) => ({ x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y }); + + await page.getByRole("button", { name: "多选模式" }).click(); + await page.mouse.click(point(0.2, 0.2).x, point(0.2, 0.2).y); + await page.mouse.click(point(0.8, 0.8).x, point(0.8, 0.8).y); + await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible(); + + await page.mouse.move(point(0.2, 0.2).x, point(0.2, 0.2).y); + await page.mouse.down(); + await page.mouse.move(point(0.3, 0.3).x, point(0.3, 0.3).y, { steps: 4 }); + await page.mouse.up(); + + await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(0); + const [first, second] = backend.canvas.elements.map(({ position }) => position); + expect(first?.x).toBeCloseTo(0.3, 6); + expect(first?.y).toBeCloseTo(0.3, 6); + expect(second?.x).toBeCloseTo(0.9, 6); + expect(second?.y).toBeCloseTo(0.9, 6); + expect((first?.x ?? 0) - 0.2).toBeCloseTo((second?.x ?? 0) - 0.8, 10); + expect((first?.y ?? 0) - 0.2).toBeCloseTo((second?.y ?? 0) - 0.8, 10); + await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible(); +}); + 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 }; diff --git a/tests/unit/wp4-01-editor-canvas.test.ts b/tests/unit/wp4-01-editor-canvas.test.ts index f924f67..8a9f643 100644 --- a/tests/unit/wp4-01-editor-canvas.test.ts +++ b/tests/unit/wp4-01-editor-canvas.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { + adjustBackgroundPixels, + backgroundDrawPlan, CanvasEditHistory, createEditorCanvasState, + cssFilterForBackground, defaultBackgroundAdjustments, deserializeFabricCanvas, switchBackground, @@ -24,6 +27,35 @@ const element = { }; describe("TDD-WP4-BG-001/002 canvas boundary", () => { + it("builds valid filters and distinct contain/crop draw plans", () => { + const adjustments = { ...defaultBackgroundAdjustments(), brightness: 25, contrast: 15, saturation: -20 }; + expect(cssFilterForBackground(adjustments)).toBe("brightness(125%) contrast(115%) saturate(80%)"); + expect(cssFilterForBackground(defaultBackgroundAdjustments())).toBe("none"); + + expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "fit" })).toEqual({ + destination: { height: 50, width: 100, x: 0, y: 25 }, + source: { height: 200, width: 400, x: 0, y: 0 }, + }); + expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "crop" })).toEqual({ + destination: { height: 100, width: 100, x: 0, y: 0 }, + source: { height: 200, width: 200, x: 100, y: 0 }, + }); + }); + + it("changes pixels for temperature and sharpness without changing alpha", () => { + const flat = new Uint8ClampedArray([100, 100, 100, 255]); + expect([...adjustBackgroundPixels(flat, 1, 1, { sharpness: 0, temperature: 100 })]).toEqual([135, 108, 65, 255]); + + const edged = new Uint8ClampedArray([ + 100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255, + 100, 100, 100, 255, 180, 180, 180, 255, 100, 100, 100, 255, + 100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255, + ]); + const sharpened = adjustBackgroundPixels(edged, 3, 3, { sharpness: 100, temperature: 0 }); + expect(sharpened[16]).toBe(255); + expect(sharpened[19]).toBe(255); + }); + it("switches background without moving overlays, resets processing, and re-extracts palette", () => { const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" }); const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] }; diff --git a/tests/unit/wp4-02-editor-elements.test.ts b/tests/unit/wp4-02-editor-elements.test.ts index 4991fa7..acf7df3 100644 --- a/tests/unit/wp4-02-editor-elements.test.ts +++ b/tests/unit/wp4-02-editor-elements.test.ts @@ -73,6 +73,10 @@ describe("TDD-WP4-CAN-001 canvas selection and limit", () => { identity(1).elementId, identity(2).elementId, ]); + expect(controller.selectAt({ x: 0.2, y: 0.2 }, { append: true, preserveSelection: true })).toEqual([ + identity(1).elementId, + identity(2).elementId, + ]); }); }); @@ -97,6 +101,19 @@ describe("TDD-WP4-STK-001 sticker transformations", () => { }); }); + it("uses one bounded delta for distant selected elements", () => { + const controller = new CanvasElementController(state([ + sticker(1, { position: { x: 0.1, y: 0.2 } }), + sticker(2, { position: { x: 0.9, y: 0.8 } }), + ])); + controller.selectIds([identity(1).elementId, identity(2).elementId]); + controller.moveSelected({ x: 0.2, y: 0.3 }, { snap: false }); + expect(controller.value.elements.map(({ position }) => position)).toEqual([ + { x: 0.2, y: 0.4 }, + { x: 1, y: 1 }, + ]); + }); + 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);