feat: complete TASK-WP4-01 editor shell and background
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
|
||||
interface EditorSession {
|
||||
csrf_token: string;
|
||||
user: { creator_name: string };
|
||||
}
|
||||
|
||||
interface EditorProject {
|
||||
canvas_state?: CanvasState;
|
||||
created_at: string;
|
||||
current_image_id: string | null;
|
||||
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
|
||||
name: string;
|
||||
pixel_height?: number;
|
||||
pixel_width?: number;
|
||||
project_id: string;
|
||||
ratio: Ratio;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
async function readEditorJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
throw new Error("session_invalid");
|
||||
}
|
||||
if (!response.ok) throw new Error("editor_request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function paletteForAsset(assetId: string) {
|
||||
const seed = assetId.replaceAll("-", "");
|
||||
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} />;
|
||||
}
|
||||
|
||||
function saveStatusLabel(status: ProjectSaveStatus) {
|
||||
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
|
||||
}
|
||||
|
||||
export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [session, setSession] = useState<EditorSession>();
|
||||
const [project, setProject] = useState<EditorProject>();
|
||||
const [canvasState, setCanvasState] = useState<CanvasState>();
|
||||
const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments);
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||
const [pendingBackground, setPendingBackground] = useState<string>();
|
||||
const [notice, setNotice] = useState("");
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([
|
||||
readEditorJson<EditorSession>("/api/v1/auth/session"),
|
||||
readEditorJson<EditorProject>(`/api/v1/projects/${projectId}`),
|
||||
]).then(([nextSession, nextProject]) => {
|
||||
if (!active) return;
|
||||
const initial = nextProject.canvas_state ?? createEditorCanvasState({ assetId: nextProject.current_image_id, ratio: nextProject.ratio });
|
||||
setSession(nextSession);
|
||||
setProject(nextProject);
|
||||
setCanvasState(initial);
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !session || !canvasState) return undefined;
|
||||
const queue = new ProjectAutoSaveQueue({
|
||||
initialState: { canvas_state: canvasState, name: project.name },
|
||||
initialVersion: project.state_version,
|
||||
onStatus: setSaveStatus,
|
||||
onSaved: (snapshot, stateVersion) => {
|
||||
setCanvasState(snapshot.canvas_state);
|
||||
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
|
||||
},
|
||||
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
|
||||
body: JSON.stringify(snapshot),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "Idempotency-Key": operationId, "If-Match": String(stateVersion), "X-CSRF-Token": session.csrf_token },
|
||||
method: "PUT",
|
||||
});
|
||||
if (response.status === 412) throw new Error("project_state_conflict");
|
||||
if (!response.ok) throw new Error("project_save_failed");
|
||||
const saved = await response.json() as { state_version: number };
|
||||
return { stateVersion: saved.state_version };
|
||||
},
|
||||
});
|
||||
queueRef.current = queue;
|
||||
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
||||
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
|
||||
|
||||
function commitCanvas(next: CanvasState) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
historyRef.current?.commit(next);
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
if (!canvasState) return;
|
||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||
setNotice("底图调整已提交");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const previous = historyRef.current?.undo();
|
||||
if (previous) {
|
||||
setCanvasState(previous);
|
||||
setDraftAdjustments(previous.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||
}
|
||||
}
|
||||
|
||||
function redo() {
|
||||
const next = historyRef.current?.redo();
|
||||
if (next) {
|
||||
setCanvasState(next);
|
||||
setDraftAdjustments(next.background.adjustments);
|
||||
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBackground() {
|
||||
if (!canvasState || !pendingBackground) return;
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, paletteForAsset(pendingBackground)));
|
||||
setPendingBackground(undefined);
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
}
|
||||
|
||||
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";
|
||||
return (
|
||||
<div className="editor-page-shell">
|
||||
<header className="editor-toolbar">
|
||||
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}>←</a>
|
||||
<div className="editor-title"><strong>{project.name}</strong><span>{project.ratio}</span></div>
|
||||
<div className="editor-history-actions">
|
||||
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button">↶</button>
|
||||
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button">↷</button>
|
||||
</div>
|
||||
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
|
||||
<button disabled type="button">预览</button>
|
||||
<button disabled type="button">导出</button>
|
||||
</header>
|
||||
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
|
||||
<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>)}
|
||||
</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">
|
||||
{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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" onClick={confirmBackground} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user