fix(POSTV1-editor): 修复多选拖动与底图调整
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run

This commit is contained in:
suyx
2026-08-06 10:53:38 +08:00
parent 51d613f459
commit 83fe57f319
8 changed files with 310 additions and 14 deletions
+115 -2
View File
@@ -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<BackgroundAdjustments, "sharpness" | "temperature">,
) {
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";
}
+18 -5
View File
@@ -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 };
}
+9 -2
View File
@@ -789,7 +789,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);
const dragBase = withTextDraft(canvasState, textEdit);
@@ -880,7 +883,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = withTextDraft(canvasState, textEdit);
const backgroundPreviewState: CanvasState = {
...canvasState,
background: { ...canvasState.background, adjustments: draftAdjustments },
};
const renderedCanvasState = withTextDraft(backgroundPreviewState, textEdit);
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));
+36 -4
View File
@@ -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";
@@ -307,9 +307,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);
}