Files
hexin-codex-bot 7e7e26d869
Verify and deploy production / verify-and-deploy (push) Successful in 2m50s
ui: center canvas and refine project layout
2026-10-05 15:36:39 +08:00

166 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
type Settings = {
fit: "contain" | "cover";
scale: number;
offsetX: number;
offsetY: number;
rotation: number;
brightness: number;
contrast: number;
saturation: number;
borderRadius: number;
};
type Slide = { id: string; fileName: string; originalName: string; mimeType: string; settings: Settings };
type Project = { id: string; name: string; canvas: { width: number; height: number; background: string }; slides: Slide[]; createdAt: string; updatedAt: string };
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
return data;
}
function imageUrl(projectId: string, fileName: string) { return `/api/image-projects/${projectId}/images/${fileName}`; }
export default function ImageEditor({ projectId }: { projectId: string }) {
const [project, setProject] = useState<Project | null>(null);
const [selectedId, setSelectedId] = useState("");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("正在载入项目…");
const fileInput = useRef<HTMLInputElement>(null);
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const load = useCallback(async () => {
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${projectId}`));
setProject(data.project);
setSelectedId((current) => current || data.project.slides[0]?.id || "");
setMessage("项目已载入");
} catch (error) { setMessage((error as Error).message); }
}, [projectId]);
useEffect(() => { void load(); }, [load]);
const selected = project?.slides.find((slide) => slide.id === selectedId) || project?.slides[0];
function updateSettings(patch: Partial<Settings>) {
if (!project || !selected) return;
setProject({ ...project, slides: project.slides.map((slide) => slide.id === selected.id ? { ...slide, settings: { ...slide.settings, ...patch } } : slide) });
}
async function save() {
if (!project) return;
setBusy("save");
try {
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(project) }));
setProject(data.project);
setMessage("所有修改已保存");
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function addImages(files: FileList | null) {
if (!project || !files?.length) return;
setBusy("upload");
try {
const form = new FormData();
Array.from(files).forEach((file) => form.append("images", file));
const data = await readJson<{ project: Project }>(await fetch(`/api/image-projects/${project.id}/images`, { method: "POST", body: form }));
setProject(data.project);
setSelectedId(data.project.slides.at(-1)?.id || selectedId);
setMessage(`已添加 ${files.length} 张图片`);
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); if (fileInput.current) fileInput.current.value = ""; }
}
function setCanvas(width: number, height: number) {
if (project) setProject({ ...project, canvas: { ...project.canvas, width, height } });
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (!selected) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { x: event.clientX, y: event.clientY, offsetX: selected.settings.offsetX, offsetY: selected.settings.offsetY };
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!drag.current || !selected) return;
const rect = event.currentTarget.getBoundingClientRect();
updateSettings({ offsetX: Math.max(-100, Math.min(100, drag.current.offsetX + (event.clientX - drag.current.x) / rect.width * 100)), offsetY: Math.max(-100, Math.min(100, drag.current.offsetY + (event.clientY - drag.current.y) / rect.height * 100)) });
}
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
drag.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!project) return <div className="editor-loading"><span className="editor-spinner" /><p>{message}</p><a href="/">返回首页</a></div>;
if (!selected) return <div className="editor-loading"><p>项目中没有图片</p><a href="/">返回首页</a></div>;
const style = {
transform: `translate(${selected.settings.offsetX}%, ${selected.settings.offsetY}%) scale(${selected.settings.scale / 100}) rotate(${selected.settings.rotation}deg)`,
filter: `brightness(${selected.settings.brightness}%) contrast(${selected.settings.contrast}%) saturate(${selected.settings.saturation}%)`,
objectFit: selected.settings.fit,
borderRadius: `${selected.settings.borderRadius}px`,
} as CSSProperties;
const ratioStyle = { "--canvas-ratio": `${project.canvas.width} / ${project.canvas.height}`, background: project.canvas.background } as CSSProperties;
const selectedIndex = project.slides.findIndex((item) => item.id === selected.id);
return <main className="image-editor-shell">
<header className="editor-topbar">
<div className="editor-brand"><a href="/" className="editor-back">←</a><span className="editor-logo">小红书</span><i /><input value={project.name} onChange={(event) => setProject({ ...project, name: event.target.value })} /></div>
<div className="editor-status"><span className="status-dot" />{message}</div>
<div className="editor-actions"><button className="ghost-control" onClick={() => fileInput.current?.click()}>+ 添加图片</button><button className="save-control" onClick={save} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目"}</button></div>
<input ref={fileInput} hidden type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => void addImages(event.target.files)} />
</header>
<section className="editor-workspace">
<div className="canvas-viewport">
<div className="canvas-page" style={ratioStyle} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
<img draggable={false} src={imageUrl(project.id, selected.fileName)} alt={selected.originalName} style={style} />
<div className="canvas-selection"><i className="handle nw"/><i className="handle ne"/><i className="handle sw"/><i className="handle se"/></div>
</div>
</div>
<div className="editor-carousel">
<button className="carousel-arrow" disabled={selectedIndex === 0} onClick={() => setSelectedId(project.slides[Math.max(0, selectedIndex - 1)].id)}>‹</button>
<div className="carousel-track">{project.slides.map((slide, index) => {
const offset = index - selectedIndex;
if (Math.abs(offset) > 2) return null;
return <button key={slide.id} className={`carousel-slide ${offset === 0 ? "active" : offset < 0 ? "left" : "right"} ${Math.abs(offset) === 2 ? "far" : ""}`} onClick={() => setSelectedId(slide.id)}><img src={imageUrl(project.id, slide.fileName)} alt={slide.originalName} /><span>{index + 1}</span></button>;
})}<button className="carousel-add" onClick={() => fileInput.current?.click()}>+</button></div>
<button className="carousel-arrow" disabled={selectedIndex === project.slides.length - 1} onClick={() => setSelectedId(project.slides[Math.min(project.slides.length - 1, selectedIndex + 1)].id)}>›</button>
</div>
</section>
<aside className="editor-inspector">
<div className="inspector-tabs"><button className="active">画面</button><button>文字</button><button>素材</button></div>
<InspectorSection title="画布">
<div className="ratio-buttons"><button className={project.canvas.width === project.canvas.height ? "active" : ""} onClick={() => setCanvas(1080, 1080)}>1:1</button><button className={project.canvas.width / project.canvas.height === .75 ? "active" : ""} onClick={() => setCanvas(1080, 1440)}>3:4</button><button className={project.canvas.width / project.canvas.height === .8 ? "active" : ""} onClick={() => setCanvas(1080, 1350)}>4:5</button></div>
<label className="color-field"><span>背景</span><input type="color" value={project.canvas.background} onChange={(event) => setProject({ ...project, canvas: { ...project.canvas, background: event.target.value } })} /><code>{project.canvas.background.toUpperCase()}</code></label>
</InspectorSection>
<InspectorSection title="图片适配">
<div className="segmented-control"><button className={selected.settings.fit === "cover" ? "active" : ""} onClick={() => updateSettings({ fit: "cover" })}>填充</button><button className={selected.settings.fit === "contain" ? "active" : ""} onClick={() => updateSettings({ fit: "contain" })}>完整</button></div>
<Range label="缩放" value={selected.settings.scale} min={20} max={300} suffix="%" onChange={(scale) => updateSettings({ scale })} />
<Range label="旋转" value={selected.settings.rotation} min={-180} max={180} suffix="°" onChange={(rotation) => updateSettings({ rotation })} />
<Range label="圆角" value={selected.settings.borderRadius} min={0} max={200} suffix="" onChange={(borderRadius) => updateSettings({ borderRadius })} />
</InspectorSection>
<InspectorSection title="画面调整">
<Range label="亮度" value={selected.settings.brightness} min={0} max={200} suffix="%" onChange={(brightness) => updateSettings({ brightness })} />
<Range label="对比度" value={selected.settings.contrast} min={0} max={200} suffix="%" onChange={(contrast) => updateSettings({ contrast })} />
<Range label="饱和度" value={selected.settings.saturation} min={0} max={200} suffix="%" onChange={(saturation) => updateSettings({ saturation })} />
</InspectorSection>
</aside>
</main>;
}
function InspectorSection({ title, children }: { title: string; children: React.ReactNode }) {
return <section className="inspector-section"><h3>{title}<button>⌃</button></h3>{children}</section>;
}
function Range({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
return <label className="range-field"><div><span>{label}</span><code>{Math.round(value)}{suffix}</code></div><input type="range" min={min} max={max} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}