feat: complete TASK-WP4-02 canvas element editing

This commit is contained in:
suyx
2026-08-03 10:58:17 +08:00
parent 8d8fbeb816
commit d16802c164
11 changed files with 1314 additions and 67 deletions
+273 -60
View File
@@ -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>