166 lines
11 KiB
TypeScript
166 lines
11 KiB
TypeScript
"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>;
|
||
}
|