feat: complete TASK-WP4-02 canvas element editing
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface CanvasPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CanvasRect extends CanvasPoint {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface CanvasElementIdentity {
|
||||
createdAt: string;
|
||||
elementId: string;
|
||||
}
|
||||
|
||||
export type CanvasLayerCommand = "back" | "backward" | "forward" | "front";
|
||||
|
||||
interface StaticStickerInput {
|
||||
assetId: string;
|
||||
identity: CanvasElementIdentity;
|
||||
opacity?: number;
|
||||
position: CanvasPoint;
|
||||
resourceVersion: string;
|
||||
rotation?: number;
|
||||
scale?: CanvasElement["scale"];
|
||||
style_parameters?: CanvasElement["style_parameters"];
|
||||
z_index?: number;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const snapThreshold = 0.015;
|
||||
const hitHalfExtent = 0.08;
|
||||
|
||||
function cloneState(state: CanvasState) {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function requireState(state: CanvasState) {
|
||||
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||
return cloneState(state);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function normalizedRotation(value: number) {
|
||||
const normalized = ((value + 180) % 360 + 360) % 360 - 180;
|
||||
return normalized === -180 ? 180 : normalized;
|
||||
}
|
||||
|
||||
function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) {
|
||||
return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001);
|
||||
}
|
||||
|
||||
function reindex(elements: CanvasElement[]) {
|
||||
return elements.map((element, zIndex) => ({ ...element, z_index: zIndex }));
|
||||
}
|
||||
|
||||
export function createStaticStickerElement(input: StaticStickerInput): CanvasElement {
|
||||
return {
|
||||
created_at: input.identity.createdAt,
|
||||
element_id: input.identity.elementId,
|
||||
opacity: input.opacity ?? 1,
|
||||
position: structuredClone(input.position),
|
||||
resource_version: input.resourceVersion,
|
||||
rotation: input.rotation ?? 0,
|
||||
scale: structuredClone(input.scale ?? { x: 1, y: 1 }),
|
||||
style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }),
|
||||
template_or_asset_id: input.assetId,
|
||||
type: "static_sticker",
|
||||
z_index: input.z_index ?? input.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export class CanvasElementController {
|
||||
private current: CanvasState;
|
||||
private selection: string[] = [];
|
||||
private cyclePoint: CanvasPoint | undefined = undefined;
|
||||
private cycleSignature = "";
|
||||
private cycleIndex = -1;
|
||||
|
||||
constructor(initial: CanvasState) {
|
||||
this.current = requireState(initial);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return cloneState(this.current);
|
||||
}
|
||||
|
||||
get selectedIds() {
|
||||
return [...this.selection];
|
||||
}
|
||||
|
||||
replaceState(next: CanvasState) {
|
||||
this.current = requireState(next);
|
||||
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||
this.selection = this.selection.filter((elementId) => existing.has(elementId));
|
||||
this.pointerMoved();
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selection = [];
|
||||
this.pointerMoved();
|
||||
}
|
||||
|
||||
pointerMoved() {
|
||||
this.cyclePoint = undefined;
|
||||
this.cycleSignature = "";
|
||||
this.cycleIndex = -1;
|
||||
}
|
||||
|
||||
selectById(elementId: string, append = false) {
|
||||
if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds;
|
||||
if (!append) this.selection = [elementId];
|
||||
else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId);
|
||||
else this.selection = [...this.selection, elementId];
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
selectIds(elementIds: readonly string[]) {
|
||||
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||
this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))];
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
candidatesAt(point: CanvasPoint) {
|
||||
return this.current.elements
|
||||
.filter((element) => {
|
||||
const halfWidth = hitHalfExtent * element.scale.x;
|
||||
const halfHeight = hitHalfExtent * element.scale.y;
|
||||
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
||||
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
||||
})
|
||||
.sort((left, right) => right.z_index - left.z_index)
|
||||
.map((element) => structuredClone(element));
|
||||
}
|
||||
|
||||
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
|
||||
const candidates = this.candidatesAt(point);
|
||||
if (candidates.length === 0) {
|
||||
if (!options.append) this.selection = [];
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
if (options.append) {
|
||||
this.pointerMoved();
|
||||
return this.selectById(candidates[0]!.element_id, true);
|
||||
}
|
||||
const signature = candidates.map((candidate) => candidate.element_id).join("|");
|
||||
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
|
||||
else this.cycleIndex = 0;
|
||||
this.cyclePoint = { ...point };
|
||||
this.cycleSignature = signature;
|
||||
this.selection = [candidates[this.cycleIndex]!.element_id];
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
marqueeSelect(rectangle: CanvasRect, append = false) {
|
||||
const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width);
|
||||
const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width);
|
||||
const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height);
|
||||
const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height);
|
||||
const hits = this.current.elements
|
||||
.filter((element) => element.position.x >= minimumX && element.position.x <= maximumX
|
||||
&& element.position.y >= minimumY && element.position.y <= maximumY)
|
||||
.sort((left, right) => left.z_index - right.z_index)
|
||||
.map((element) => element.element_id);
|
||||
this.selection = append ? [...new Set([...this.selection, ...hits])] : hits;
|
||||
this.pointerMoved();
|
||||
return this.selectedIds;
|
||||
}
|
||||
|
||||
add(element: CanvasElement) {
|
||||
if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached");
|
||||
const nextElement = structuredClone(element);
|
||||
nextElement.z_index = this.current.elements.length;
|
||||
this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] });
|
||||
this.selection = [nextElement.element_id];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
||||
const selected = new Set(this.selection);
|
||||
this.current = requireState({
|
||||
...this.current,
|
||||
elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element),
|
||||
});
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
|
||||
const selected = new Set(this.selection);
|
||||
const primary = this.current.elements.find((element) => selected.has(element.element_id));
|
||||
if (!primary) return { guides: [] as string[], state: this.value };
|
||||
let nextX = primary.position.x + delta.x;
|
||||
let nextY = primary.position.y + delta.y;
|
||||
const guides: string[] = [];
|
||||
const snapAxis = (value: number, axis: "x" | "y") => {
|
||||
const canvasTargets = [0, 0.5, 1];
|
||||
const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold);
|
||||
if (target !== undefined) {
|
||||
guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`);
|
||||
return target;
|
||||
}
|
||||
const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold);
|
||||
if (other) {
|
||||
guides.push(`element-${axis}:${other.element_id}`);
|
||||
return other.position[axis];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
if (options.snap !== false) {
|
||||
nextX = snapAxis(nextX, "x");
|
||||
nextY = snapAxis(nextY, "y");
|
||||
}
|
||||
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
|
||||
const state = this.updateSelected((element) => ({
|
||||
...element,
|
||||
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
|
||||
}));
|
||||
return { guides, state };
|
||||
}
|
||||
|
||||
scaleSelected(factor: number) {
|
||||
if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid");
|
||||
return this.updateSelected((element) => ({
|
||||
...element,
|
||||
scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) },
|
||||
}));
|
||||
}
|
||||
|
||||
rotateSelected(degrees: number) {
|
||||
if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid");
|
||||
return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) }));
|
||||
}
|
||||
|
||||
flipSelectedHorizontal() {
|
||||
return this.updateSelected((element) => element.type === "static_sticker" ? {
|
||||
...element,
|
||||
style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true },
|
||||
} : element);
|
||||
}
|
||||
|
||||
setSelectedOpacity(opacity: number) {
|
||||
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid");
|
||||
return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element);
|
||||
}
|
||||
|
||||
copySelected() {
|
||||
const selected = new Set(this.selection);
|
||||
return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element));
|
||||
}
|
||||
|
||||
pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||
if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached");
|
||||
const pasted = elements.map((source, index) => {
|
||||
const identity = identityFactory(source, index);
|
||||
return {
|
||||
...structuredClone(source),
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) },
|
||||
z_index: this.current.elements.length + index,
|
||||
};
|
||||
});
|
||||
this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] });
|
||||
this.selection = pasted.map((element) => element.element_id);
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||
return this.pasteElements(this.copySelected(), identityFactory);
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
const selected = new Set(this.selection);
|
||||
this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) });
|
||||
this.selection = [];
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
|
||||
changeLayer(command: CanvasLayerCommand) {
|
||||
const selected = new Set(this.selection);
|
||||
const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index);
|
||||
if (command === "front" || command === "back") {
|
||||
const selectedElements = ordered.filter((element) => selected.has(element.element_id));
|
||||
const unselectedElements = ordered.filter((element) => !selected.has(element.element_id));
|
||||
ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements]));
|
||||
}
|
||||
else if (command === "forward") {
|
||||
for (let index = ordered.length - 2; index >= 0; index -= 1) {
|
||||
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!];
|
||||
}
|
||||
} else {
|
||||
for (let index = 1; index < ordered.length; index += 1) {
|
||||
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!];
|
||||
}
|
||||
}
|
||||
this.current = requireState({ ...this.current, elements: reindex(ordered) });
|
||||
this.pointerMoved();
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -155,20 +155,73 @@
|
||||
.editor-thumb { width: 52px; height: 68px; flex: 0 0 auto; border: 1px solid #b9b9b3; background: #e8e8e5 center / cover no-repeat; }
|
||||
.editor-muted { color: #62625d; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
.editor-sticker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-sticker-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
|
||||
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; border: 3px solid #111111; background: #f2f400; }
|
||||
.editor-sticker-preview.stk002 { border-radius: 50%; background: #1769aa; }
|
||||
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 40px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
padding: 16px 32px 32px;
|
||||
background: #e8e8e5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-canvas-tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.editor-canvas-tools button {
|
||||
min-width: 76px;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-canvas-tools button.active { border-color: #005fcc; background: #ffffff; box-shadow: inset 0 -3px #005fcc; }
|
||||
.editor-canvas-tools button:disabled { color: #62625d; background: #d8d8d3; cursor: not-allowed; }
|
||||
|
||||
.editor-canvas-frame {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(100%, 720px);
|
||||
max-height: calc(100vh - 128px);
|
||||
max-height: calc(100vh - 168px);
|
||||
place-items: center;
|
||||
border: 1px solid #62625d;
|
||||
background: #ffffff;
|
||||
@@ -178,9 +231,47 @@
|
||||
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
|
||||
.editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; }
|
||||
|
||||
.editor-candidates {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
width: min(260px, 80%);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 6px 6px 0 #111111;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
.editor-candidates button {
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #b9b9b3;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-snap-status {
|
||||
position: absolute;
|
||||
align-self: end;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #005fcc;
|
||||
background: #ffffff;
|
||||
color: #005fcc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-inspector header p { margin: 0 0 4px; }
|
||||
.editor-inspector h2 { margin: 0; font-size: 20px; }
|
||||
.editor-object-id { margin: 12px 0 0; font-family: Consolas, monospace; font-size: 12px; }
|
||||
.editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; }
|
||||
.editor-inspector select,
|
||||
.editor-inspector input[type="range"] { width: 100%; }
|
||||
@@ -190,6 +281,37 @@
|
||||
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
|
||||
.editor-primary { background: #f2f400 !important; }
|
||||
|
||||
.editor-object-moves,
|
||||
.editor-object-tools,
|
||||
.editor-layer-tools,
|
||||
.editor-object-actions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.editor-object-moves { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.editor-object-tools,
|
||||
.editor-layer-tools { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.editor-object-actions { grid-template-columns: 1fr 1fr; padding-top: 18px; border-top: 1px solid #b9b9b3; }
|
||||
.editor-object-moves button,
|
||||
.editor-object-tools button,
|
||||
.editor-layer-tools button,
|
||||
.editor-object-actions button,
|
||||
.editor-wide-command {
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-wide-command { width: 100%; margin-top: 18px; }
|
||||
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
|
||||
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+273
-60
@@ -4,17 +4,20 @@ import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
|
||||
import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
|
||||
import {
|
||||
CanvasEditHistory,
|
||||
cssFilterForBackground,
|
||||
createEditorCanvasState,
|
||||
defaultBackgroundAdjustments,
|
||||
resetBackgroundAdjustments,
|
||||
switchBackground,
|
||||
updateBackgroundAdjustments,
|
||||
} from "./editor-canvas.js";
|
||||
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||
import { EditorStage } from "./editor-stage.js";
|
||||
|
||||
import "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
type EditorAssetPanel = "background" | "history" | "stickers";
|
||||
|
||||
interface EditorSession {
|
||||
csrf_token: string;
|
||||
@@ -53,52 +56,13 @@ function paletteForAsset(assetId: string) {
|
||||
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`);
|
||||
}
|
||||
|
||||
function CanvasStage({ assetId, canvasState, projectId }: { assetId: string | null; canvasState: CanvasState; projectId: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
canvas.width = canvasState.pixel_width;
|
||||
canvas.height = canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const draw = (image?: HTMLImageElement) => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
context.filter = "none";
|
||||
for (const element of canvasState.elements) {
|
||||
const x = element.position.x * canvas.width;
|
||||
const y = element.position.y * canvas.height;
|
||||
context.save();
|
||||
context.globalAlpha = element.opacity;
|
||||
context.translate(x, y);
|
||||
context.rotate((element.rotation * Math.PI) / 180);
|
||||
context.scale(element.scale.x, element.scale.y);
|
||||
if (element.type === "color_card") {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
colors.forEach((color, index) => { context.fillStyle = color; context.fillRect(index * 48 - 120, -28, 44, 56); });
|
||||
} else {
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 48px Microsoft YaHei, sans-serif";
|
||||
context.fillText(element.content ?? "DADA", -80, 16);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
if (!assetId) {
|
||||
draw();
|
||||
return undefined;
|
||||
}
|
||||
const image = new Image();
|
||||
image.onload = () => draw(image);
|
||||
image.onerror = () => draw();
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
|
||||
return () => { image.onload = null; image.onerror = null; };
|
||||
}, [assetId, canvasState, projectId]);
|
||||
return <canvas aria-label="编辑画布" className="editor-canvas" ref={canvasRef} tabIndex={0} />;
|
||||
const stickerFixtures = [
|
||||
{ assetId: "STK001", label: "方形标记" },
|
||||
{ assetId: "STK002", label: "圆形标记" },
|
||||
] as const;
|
||||
|
||||
function newElementIdentity(): CanvasElementIdentity {
|
||||
return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
|
||||
}
|
||||
|
||||
function saveStatusLabel(status: ProjectSaveStatus) {
|
||||
@@ -113,8 +77,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||
const [pendingBackground, setPendingBackground] = useState<string>();
|
||||
const [notice, setNotice] = useState("");
|
||||
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [guides, setGuides] = useState<string[]>([]);
|
||||
const [multiMode, setMultiMode] = useState(false);
|
||||
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -129,6 +102,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setCanvasState(initial);
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
elementControllerRef.current = new CanvasElementController(initial);
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
@@ -139,8 +113,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
initialState: { canvas_state: canvasState, name: project.name },
|
||||
initialVersion: project.state_version,
|
||||
onStatus: setSaveStatus,
|
||||
onSaved: (snapshot, stateVersion) => {
|
||||
setCanvasState(snapshot.canvas_state);
|
||||
onSaved: (_snapshot, stateVersion) => {
|
||||
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
|
||||
},
|
||||
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
|
||||
@@ -163,6 +136,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function commitCanvas(next: CanvasState) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
historyRef.current?.commit(next);
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
@@ -177,6 +152,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function undo() {
|
||||
const previous = historyRef.current?.undo();
|
||||
if (previous) {
|
||||
elementControllerRef.current?.replaceState(previous);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(previous);
|
||||
setDraftAdjustments(previous.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||
@@ -186,6 +163,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function redo() {
|
||||
const next = historyRef.current?.redo();
|
||||
if (next) {
|
||||
elementControllerRef.current?.replaceState(next);
|
||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
@@ -199,9 +178,176 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
}
|
||||
|
||||
function controllerForCurrent() {
|
||||
const controller = elementControllerRef.current;
|
||||
if (!controller) return undefined;
|
||||
if (controller.selectedIds.length !== selectedIds.length || controller.selectedIds.some((elementId, index) => elementId !== selectedIds[index])) controller.selectIds(selectedIds);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function commitElementOperation(controller: CanvasElementController, message: string) {
|
||||
const next = controller.value;
|
||||
setSelectedIds(controller.selectedIds);
|
||||
commitCanvas(next);
|
||||
setGuides([]);
|
||||
setCandidateMenu(undefined);
|
||||
setNotice(message);
|
||||
}
|
||||
|
||||
function addSticker(assetId: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return;
|
||||
try {
|
||||
controller.add(createStaticStickerElement({
|
||||
assetId,
|
||||
identity: newElementIdentity(),
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resourceVersion: "fixture-v1",
|
||||
zIndex: canvasState.elements.length,
|
||||
}));
|
||||
commitElementOperation(controller, "贴纸已加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
try {
|
||||
action(controller);
|
||||
commitElementOperation(controller, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("对象操作未完成");
|
||||
}
|
||||
}
|
||||
|
||||
function beginOpacityGesture() {
|
||||
if (!canvasState || selectedIds.length === 0) return;
|
||||
opacityGestureRef.current = { base: canvasState, last: canvasState, selectedIds: [...selectedIds] };
|
||||
}
|
||||
|
||||
function changeOpacity(value: number) {
|
||||
const gesture = opacityGestureRef.current;
|
||||
if (!gesture) {
|
||||
transformSelection((controller) => { controller.setSelectedOpacity(value); }, "贴纸透明度已提交");
|
||||
return;
|
||||
}
|
||||
const previewController = new CanvasElementController(gesture.base);
|
||||
previewController.selectIds(gesture.selectedIds);
|
||||
previewController.setSelectedOpacity(value);
|
||||
gesture.last = previewController.value;
|
||||
setCanvasState(gesture.last);
|
||||
}
|
||||
|
||||
function commitOpacityGesture() {
|
||||
const gesture = opacityGestureRef.current;
|
||||
if (!gesture) return;
|
||||
opacityGestureRef.current = undefined;
|
||||
if (gesture.last === gesture.base) return;
|
||||
commitCanvas(gesture.last);
|
||||
setNotice("贴纸透明度已提交");
|
||||
}
|
||||
|
||||
function duplicateSelection() {
|
||||
transformSelection((controller) => controller.duplicateSelected(() => newElementIdentity()), "已复制选中对象");
|
||||
}
|
||||
|
||||
function changeSelectionLayer(command: CanvasLayerCommand) {
|
||||
const labels: Record<CanvasLayerCommand, string> = { back: "已置底", backward: "已下移一层", forward: "已上移一层", front: "已置顶" };
|
||||
transformSelection((controller) => { controller.changeLayer(command); }, labels[command]);
|
||||
}
|
||||
|
||||
function deleteSelection() {
|
||||
transformSelection((controller) => { controller.deleteSelected(); }, "已删除选中对象");
|
||||
}
|
||||
|
||||
function copySelection() {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
clipboardRef.current = controller.copySelected();
|
||||
setNotice("已复制到画布剪贴板");
|
||||
}
|
||||
|
||||
function pasteSelection() {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || clipboardRef.current.length === 0) return;
|
||||
try {
|
||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||
commitElementOperation(controller, "已粘贴画布对象");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
}
|
||||
}
|
||||
|
||||
function selectAt(point: CanvasPoint, append: boolean) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return false;
|
||||
const candidates = controller.candidatesAt(point);
|
||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||
setSelectedIds(selection);
|
||||
setCandidateMenu(undefined);
|
||||
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||
return candidates.length > 0;
|
||||
}
|
||||
|
||||
function previewMove(delta: CanvasPoint) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const previewController = new CanvasElementController(drag.base);
|
||||
previewController.selectIds(drag.selectedIds);
|
||||
const preview = previewController.moveSelected(delta);
|
||||
drag.last = preview.state;
|
||||
setCanvasState(preview.state);
|
||||
setGuides(preview.guides);
|
||||
}
|
||||
|
||||
function commitMove() {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
commitCanvas(drag.last);
|
||||
setNotice("对象位置已提交");
|
||||
setGuides([]);
|
||||
dragRef.current = undefined;
|
||||
}
|
||||
|
||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||
setCandidateMenu(undefined);
|
||||
}
|
||||
|
||||
function showCandidates(point: CanvasPoint) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
const elements = controller.candidatesAt(point);
|
||||
if (elements.length > 0) setCandidateMenu({ elements, point });
|
||||
}
|
||||
|
||||
function chooseCandidate(elementId: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
setSelectedIds(controller.selectById(elementId, multiMode));
|
||||
setCandidateMenu(undefined);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
elementControllerRef.current?.clearSelection();
|
||||
setSelectedIds([]);
|
||||
setCandidateMenu(undefined);
|
||||
setGuides([]);
|
||||
}
|
||||
|
||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||
const canEdit = saveStatus !== "conflicted";
|
||||
const selectedElements = canvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
|
||||
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||
: undefined;
|
||||
return (
|
||||
<div className="editor-page-shell">
|
||||
<header className="editor-toolbar">
|
||||
@@ -219,22 +365,89 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
<main className="editor-layout">
|
||||
<aside aria-label="素材与底图来源" className="editor-assets-panel">
|
||||
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
|
||||
{['底图', '历史', '文字模板', '普通贴纸', '色卡', '动态贴纸'].map((label, index) => <button aria-current={index < 2 ? "page" : undefined} disabled={index > 1} key={label} type="button">{label}</button>)}
|
||||
{([
|
||||
{ label: "底图", panel: "background" as const },
|
||||
{ label: "历史", panel: "history" as const },
|
||||
{ label: "文字模板" },
|
||||
{ label: "普通贴纸", panel: "stickers" as const },
|
||||
{ label: "色卡" },
|
||||
{ label: "动态贴纸" },
|
||||
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} disabled={!item.panel} key={item.label} onClick={() => { if (item.panel) setActivePanel(item.panel); }} type="button">{item.label}</button>)}
|
||||
</nav>
|
||||
<section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section>
|
||||
<section><h2>历史</h2><div className="editor-history-list">
|
||||
{activePanel === "background" ? <section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section> : null}
|
||||
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
||||
{project.images.length === 0 ? <p className="editor-muted">暂无其他成功图</p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span>生成图 · {formatDate(image.created_at)}</span></button>)}
|
||||
</div></section>
|
||||
</div></section> : null}
|
||||
{activePanel === "stickers" ? <section><h2>普通贴纸</h2><div className="editor-sticker-grid">
|
||||
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
|
||||
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section> : null}
|
||||
</aside>
|
||||
<section aria-label="画布工作区" className="editor-workspace">
|
||||
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}><CanvasStage assetId={canvasState.background.asset_id} canvasState={canvasState} projectId={projectId} /></div>
|
||||
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
|
||||
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button">多选模式</button>
|
||||
<button disabled={selectedElements.length === 0} onClick={() => { const first = selectedElements[0]; if (first) showCandidates(first.position); }} type="button">候选对象</button>
|
||||
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button">复制</button>
|
||||
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button">粘贴</button>
|
||||
</div>
|
||||
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
|
||||
<EditorStage
|
||||
assetId={canvasState.background.asset_id}
|
||||
canvasState={canvasState}
|
||||
guides={guides}
|
||||
onCandidates={showCandidates}
|
||||
onClearSelection={clearSelection}
|
||||
onCopy={copySelection}
|
||||
onDelete={deleteSelection}
|
||||
onMarquee={marqueeSelect}
|
||||
onMoveCommit={commitMove}
|
||||
onMovePreview={previewMove}
|
||||
onNudge={(delta) => transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")}
|
||||
onPaste={pasteSelection}
|
||||
onPointerMoved={() => elementControllerRef.current?.pointerMoved()}
|
||||
onSelect={selectAt}
|
||||
projectId={projectId}
|
||||
selectedIds={selectedIds}
|
||||
/>
|
||||
{candidateMenu ? <div className="editor-candidates" role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
|
||||
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
|
||||
</div> : null}
|
||||
</div>
|
||||
{guides.length > 0 ? <span aria-live="polite" className="editor-snap-status">已吸附</span> : null}
|
||||
</section>
|
||||
<aside aria-label="底图参数" className="editor-inspector">
|
||||
<header><p>BACKGROUND</p><h2>底图参数</h2></header>
|
||||
<label>适配方式<select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill">填充</option><option value="fit">适配</option><option value="crop">裁剪</option></select></label>
|
||||
<label>滤镜<select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none">无滤镜</option><option value="grayscale">黑白</option><option value="sepia">复古</option></select></label>
|
||||
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={key === "sharpness" ? 100 : 100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
|
||||
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button">恢复原图</button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button">应用调整</button></div>
|
||||
<aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
|
||||
{selectedElements.length === 0 ? <>
|
||||
<header><p>BACKGROUND</p><h2>底图参数</h2></header>
|
||||
<label>适配方式<select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill">填充</option><option value="fit">适配</option><option value="crop">裁剪</option></select></label>
|
||||
<label>滤镜<select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none">无滤镜</option><option value="grayscale">黑白</option><option value="sepia">复古</option></select></label>
|
||||
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
|
||||
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button">恢复原图</button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button">应用调整</button></div>
|
||||
</> : <>
|
||||
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
|
||||
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
|
||||
<div className="editor-object-moves" role="group" aria-label="移动对象">
|
||||
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button">←</button>
|
||||
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button">↑</button>
|
||||
<button aria-label="向下移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: 0.01 }, { snap: false }); }, "对象位置已提交")} title="向下移动" type="button">↓</button>
|
||||
<button aria-label="向右移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向右移动" type="button">→</button>
|
||||
</div>
|
||||
<div className="editor-object-tools">
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(0.9); }, "对象尺寸已提交")} type="button">缩小</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(1.1); }, "对象尺寸已提交")} type="button">放大</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(-15); }, "对象旋转已提交")} type="button">逆时针</button>
|
||||
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(15); }, "对象旋转已提交")} type="button">顺时针</button>
|
||||
</div>
|
||||
{selectedStickerOpacity !== undefined ? <>
|
||||
<button className="editor-wide-command" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.flipSelectedHorizontal(); }, "贴纸翻转已提交")} type="button">水平翻转</button>
|
||||
<label className="editor-range"><span>透明度<output>{selectedStickerOpacity}%</output></span><input disabled={!canEdit} max={100} min={0} onBlur={commitOpacityGesture} onChange={(event) => changeOpacity(Number(event.target.value) / 100)} onPointerCancel={commitOpacityGesture} onPointerDown={beginOpacityGesture} onPointerUp={commitOpacityGesture} type="range" value={selectedStickerOpacity} /></label>
|
||||
</> : null}
|
||||
<div className="editor-layer-tools" role="group" aria-label="对象层级">
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("front")} type="button">置顶</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("forward")} type="button">上移一层</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("backward")} type="button">下移一层</button>
|
||||
<button disabled={!canEdit} onClick={() => changeSelectionLayer("back")} type="button">置底</button>
|
||||
</div>
|
||||
<div className="editor-object-actions"><button disabled={!canEdit} onClick={duplicateSelection} type="button">复制</button><button className="editor-danger" disabled={!canEdit} onClick={deleteSelection} type="button">删除</button></div>
|
||||
</>}
|
||||
</aside>
|
||||
</main>
|
||||
<footer className="editor-statusbar"><span>画布 {canvasState.pixel_width} × {canvasState.pixel_height}</span><span>对象 {canvasState.elements.length} / 50</span><span>缩放 100%</span><span>本机保存 · state version {project.state_version}</span></footer>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
hit: boolean;
|
||||
longPressOpened: boolean;
|
||||
pointerId: number;
|
||||
start: CanvasPoint;
|
||||
}
|
||||
|
||||
interface EditorStageProps {
|
||||
assetId: string | null;
|
||||
canvasState: CanvasState;
|
||||
guides: readonly string[];
|
||||
onCandidates: (point: CanvasPoint) => void;
|
||||
onClearSelection: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||
onMoveCommit: () => void;
|
||||
onMovePreview: (delta: CanvasPoint) => void;
|
||||
onNudge: (delta: CanvasPoint) => void;
|
||||
onPaste: () => void;
|
||||
onPointerMoved: () => void;
|
||||
onSelect: (point: CanvasPoint, append: boolean) => boolean;
|
||||
projectId: string;
|
||||
selectedIds: readonly string[];
|
||||
}
|
||||
|
||||
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number) {
|
||||
const x = element.position.x * width;
|
||||
const y = element.position.y * height;
|
||||
context.save();
|
||||
context.globalAlpha = element.opacity;
|
||||
context.translate(x, y);
|
||||
context.rotate((element.rotation * Math.PI) / 180);
|
||||
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
|
||||
context.scale(element.scale.x * flip, element.scale.y);
|
||||
if (element.type === "color_card") {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(index * 48 - 120, -28, 44, 56);
|
||||
});
|
||||
} else if (element.type === "static_sticker") {
|
||||
context.fillStyle = element.template_or_asset_id.endsWith("2") ? "#1769aa" : "#f2f400";
|
||||
context.strokeStyle = "#111111";
|
||||
context.lineWidth = 5;
|
||||
context.beginPath();
|
||||
context.roundRect(-62, -62, 124, 124, element.template_or_asset_id.endsWith("2") ? 62 : 8);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 22px Consolas, monospace";
|
||||
context.textAlign = "center";
|
||||
context.fillText(element.template_or_asset_id, 0, 8);
|
||||
} else {
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 48px Microsoft YaHei, sans-serif";
|
||||
context.fillText(element.content ?? "DADA", -80, 16);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function EditorStage(props: EditorStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
canvas.width = props.canvasState.pixel_width;
|
||||
canvas.height = props.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const render = (image?: HTMLImageElement) => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
|
||||
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
context.filter = "none";
|
||||
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
const halfWidth = 78 * element.scale.x;
|
||||
const halfHeight = 78 * element.scale.y;
|
||||
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
||||
}
|
||||
context.save();
|
||||
context.strokeStyle = "#005fcc";
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([12, 8]);
|
||||
if (props.guides.some((guide) => guide.includes("-x"))) {
|
||||
context.beginPath(); context.moveTo(canvas.width / 2, 0); context.lineTo(canvas.width / 2, canvas.height); context.stroke();
|
||||
}
|
||||
if (props.guides.some((guide) => guide.includes("-y"))) {
|
||||
context.beginPath(); context.moveTo(0, canvas.height / 2); context.lineTo(canvas.width, canvas.height / 2); context.stroke();
|
||||
}
|
||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||
context.restore();
|
||||
};
|
||||
if (!props.assetId) {
|
||||
render();
|
||||
return undefined;
|
||||
}
|
||||
const image = new Image();
|
||||
image.onload = () => render(image);
|
||||
image.onerror = () => render();
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
||||
return () => { image.onload = null; image.onerror = null; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.guides, props.projectId, props.selectedIds]);
|
||||
|
||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||
if (event.button !== 0) return;
|
||||
const start = pointFromEvent(event);
|
||||
const append = event.shiftKey;
|
||||
const hit = props.onSelect(start, append);
|
||||
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
longPressRef.current = setTimeout(() => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || !gesture.hit) return;
|
||||
gesture.longPressOpened = true;
|
||||
props.onCandidates(gesture.start);
|
||||
}, 550);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLCanvasElement>) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) {
|
||||
props.onPointerMoved();
|
||||
return;
|
||||
}
|
||||
const point = pointFromEvent(event);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
props.onPointerMoved();
|
||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||
else if (!gesture.hit) setMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y });
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent<HTMLCanvasElement>) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
const point = pointFromEvent(event);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||
setMarquee(undefined);
|
||||
gestureRef.current = undefined;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLCanvasElement>) {
|
||||
const step = event.shiftKey ? 0.01 : 0.002;
|
||||
if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); props.onDelete(); }
|
||||
else if (event.key === "Escape") { event.preventDefault(); props.onClearSelection(); }
|
||||
else if (event.ctrlKey && event.key.toLowerCase() === "c") { event.preventDefault(); props.onCopy(); }
|
||||
else if (event.ctrlKey && event.key.toLowerCase() === "v") { event.preventDefault(); props.onPaste(); }
|
||||
else if (event.key.startsWith("Arrow")) {
|
||||
event.preventDefault();
|
||||
props.onNudge({ x: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0, y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return <canvas aria-label="编辑画布" className="editor-canvas" onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} ref={canvasRef} tabIndex={0} />;
|
||||
}
|
||||
Reference in New Issue
Block a user