feat: complete TASK-WP4-01 editor shell and background
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
|
||||
|
||||
const elementKeys = new Set([
|
||||
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
|
||||
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
|
||||
"template_or_asset_id", "type", "z_index",
|
||||
]);
|
||||
|
||||
export function defaultBackgroundAdjustments(): BackgroundAdjustments {
|
||||
return { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 };
|
||||
}
|
||||
|
||||
function pixelsForRatio(ratio: CanvasState["ratio"]) {
|
||||
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||
}
|
||||
|
||||
export function createEditorCanvasState(input: { assetId: string | null; ratio: CanvasState["ratio"] }): CanvasState {
|
||||
const pixels = pixelsForRatio(input.ratio);
|
||||
return {
|
||||
background: { adjustments: defaultBackgroundAdjustments(), asset_id: input.assetId },
|
||||
elements: [],
|
||||
pixel_height: pixels.pixelHeight,
|
||||
pixel_width: pixels.pixelWidth,
|
||||
ratio: input.ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function checked(state: CanvasState): CanvasState {
|
||||
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
export function updateBackgroundAdjustments(state: CanvasState, patch: Partial<BackgroundAdjustments>): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: { ...state.background.adjustments, ...patch } } });
|
||||
}
|
||||
|
||||
export function resetBackgroundAdjustments(state: CanvasState): CanvasState {
|
||||
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: defaultBackgroundAdjustments() } });
|
||||
}
|
||||
|
||||
export function switchBackground(state: CanvasState, assetId: string, palette: readonly string[]): CanvasState {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(assetId)) throw new Error("canvas_background_asset_invalid");
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
const next = structuredClone(state);
|
||||
next.background = { asset_id: assetId, adjustments: defaultBackgroundAdjustments() };
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...palette] } : element);
|
||||
return checked(next);
|
||||
}
|
||||
|
||||
export class CanvasEditHistory {
|
||||
private current: CanvasState;
|
||||
private readonly past: CanvasState[] = [];
|
||||
private readonly future: CanvasState[] = [];
|
||||
|
||||
constructor(initial: CanvasState) { this.current = checked(initial); }
|
||||
get value() { return structuredClone(this.current); }
|
||||
get canUndo() { return this.past.length > 0; }
|
||||
get canRedo() { return this.future.length > 0; }
|
||||
|
||||
commit(next: CanvasState) {
|
||||
this.past.push(this.value);
|
||||
this.current = checked(next);
|
||||
this.future.length = 0;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const previous = this.past.pop();
|
||||
if (!previous) return undefined;
|
||||
this.future.push(this.value);
|
||||
this.current = previous;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
redo() {
|
||||
const next = this.future.pop();
|
||||
if (!next) return undefined;
|
||||
this.past.push(this.value);
|
||||
this.current = next;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeFabricCanvas(input: unknown): CanvasState | undefined {
|
||||
if (!input || typeof input !== "object") return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
if (!value.background || typeof value.background !== "object" || !Array.isArray(value.elements)) return undefined;
|
||||
const elements = value.elements.map((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Object.keys(entry).some((key) => !elementKeys.has(key))) return undefined;
|
||||
return structuredClone(entry);
|
||||
});
|
||||
if (elements.some((entry) => entry === undefined)) return undefined;
|
||||
const candidate = {
|
||||
background: structuredClone(value.background),
|
||||
elements,
|
||||
pixel_height: value.pixel_height,
|
||||
pixel_width: value.pixel_width,
|
||||
ratio: value.ratio,
|
||||
schema_version: value.schema_version,
|
||||
};
|
||||
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
|
||||
}
|
||||
|
||||
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
|
||||
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
|
||||
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
.editor-page-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||
background: #e8e8e5;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(160px, 1fr) auto minmax(128px, 160px) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.editor-toolbar button,
|
||||
.editor-back,
|
||||
.editor-inspector button {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.editor-toolbar button:disabled,
|
||||
.editor-inspector button:disabled {
|
||||
border-color: #b9b9b3;
|
||||
color: #62625d;
|
||||
background: #e8e8e5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editor-back {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-title strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-title span,
|
||||
.editor-statusbar,
|
||||
.editor-inspector header p,
|
||||
.editor-confirm p:first-child {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-history-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-history-actions button {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.editor-save-status {
|
||||
display: inline-flex;
|
||||
min-width: 128px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-left: 3px solid #16794b;
|
||||
background: #f7f7f5;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-save-status.dirty,
|
||||
.editor-save-status.saving { border-color: #1769aa; }
|
||||
.editor-save-status.failed,
|
||||
.editor-save-status.conflicted { border-color: #c92a24; color: #8f1d14; }
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-assets-panel,
|
||||
.editor-inspector {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: #f7f7f5;
|
||||
}
|
||||
|
||||
.editor-assets-panel { border-right: 1px solid #b9b9b3; }
|
||||
.editor-inspector { border-left: 1px solid #b9b9b3; padding: 20px; }
|
||||
|
||||
.editor-asset-tabs {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #b9b9b3;
|
||||
}
|
||||
|
||||
.editor-asset-tabs button {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
color: #111111;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-asset-tabs button[aria-current="page"] { border-left-color: #111111; background: #ffffff; }
|
||||
.editor-asset-tabs button:disabled { color: #62625d; cursor: not-allowed; }
|
||||
|
||||
.editor-assets-panel section { padding: 18px 16px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-assets-panel h2 { margin: 0 0 10px; font-size: 16px; }
|
||||
|
||||
.editor-source {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid #b9b9b3;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-source + .editor-source { margin-top: 8px; }
|
||||
.editor-source.active { border: 2px solid #111111; padding: 7px; }
|
||||
.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 {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background: #e8e8e5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-canvas-frame {
|
||||
display: grid;
|
||||
width: min(100%, 720px);
|
||||
max-height: calc(100vh - 128px);
|
||||
place-items: center;
|
||||
border: 1px solid #62625d;
|
||||
background: #ffffff;
|
||||
box-shadow: 8px 8px 0 #b9b9b3;
|
||||
}
|
||||
|
||||
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
|
||||
.editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; }
|
||||
|
||||
.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-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%; }
|
||||
.editor-inspector select { min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; font: inherit; }
|
||||
.editor-range > span { display: flex; justify-content: space-between; }
|
||||
.editor-range output { font-family: Consolas, monospace; }
|
||||
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
|
||||
.editor-primary { background: #f2f400 !important; }
|
||||
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
color: #62625d;
|
||||
}
|
||||
|
||||
.editor-notice {
|
||||
position: fixed;
|
||||
z-index: 4;
|
||||
top: 68px;
|
||||
left: 50%;
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #1769aa;
|
||||
background: #ffffff;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-dialog-backdrop { position: fixed; z-index: 5; inset: 0; display: grid; place-items: center; padding: 24px; background: rgb(17 17 17 / 60%); }
|
||||
.editor-confirm { width: min(480px, 100%); padding: 24px; border: 2px solid #111111; background: #ffffff; box-shadow: 8px 8px 0 #111111; }
|
||||
.editor-confirm h2 { margin: 4px 0 12px; font-size: 22px; }
|
||||
.editor-confirm p { margin: 0; }
|
||||
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
|
||||
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
|
||||
|
||||
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.editor-layout { grid-template-columns: 220px minmax(0, 1fr) 280px; }
|
||||
.editor-workspace { padding: 20px; }
|
||||
.editor-toolbar { grid-template-columns: 40px minmax(120px, 1fr) auto minmax(110px, 128px) auto auto; gap: 8px; padding: 0 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||
.editor-toolbar { grid-template-columns: 40px minmax(0, 1fr) auto; min-height: 56px; }
|
||||
.editor-toolbar > button, .editor-save-status { display: none; }
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||
.editor-assets-panel { order: 2; }
|
||||
.editor-inspector { order: 3; }
|
||||
.editor-workspace { min-height: 55vh; order: 1; padding: 16px; }
|
||||
.editor-canvas-frame { width: min(100%, 480px); }
|
||||
.editor-statusbar { flex-wrap: wrap; min-height: 32px; gap: 8px 16px; padding: 8px 12px; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AdminUsersPage } from "./admin-users.js";
|
||||
import { AdminModelsPage } from "./admin-models.js";
|
||||
import { CreditsPage } from "./credits-page.js";
|
||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||
import { EditorPage } from "./editor-page.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -24,10 +25,12 @@ let authRevision = 0;
|
||||
|
||||
function renderAuthenticationEntry() {
|
||||
authRevision += 1;
|
||||
const editorProject = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})\/editor$/i);
|
||||
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
|
||||
let authenticationPage;
|
||||
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
|
||||
else if (editorProject?.[1]) authenticationPage = <EditorPage key={authRevision} projectId={editorProject[1]} />;
|
||||
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
|
||||
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
|
||||
|
||||
Reference in New Issue
Block a user