improve realtime canvas project saving
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-08 14:47:56 +08:00
parent 5ca33daa75
commit b177f8085c

View File

@ -97,6 +97,7 @@ type SavedRealtimeProject = {
canvasBackground: string;
imageLayers: ImageLayer[];
drawingLayerDataUrl: string;
coverImageUrl?: string;
previewUrl?: string;
};
@ -123,6 +124,19 @@ function getResizeCursor(handle: LayerResizeHandle) {
return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
}
function buildSuggestedProjectName(prompt: string) {
const trimmed = prompt.trim();
if (trimmed) {
return trimmed.slice(0, 24);
}
return `实时项目 ${new Date().toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`;
}
function readRealtimeProjectsFromStorage() {
if (typeof window === "undefined") return [] as SavedRealtimeProject[];
try {
@ -710,6 +724,8 @@ export function RealtimeCanvasDialog({
const [brushSize, setBrushSize] = useState(6);
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>([]);
const [projectPanelOpen, setProjectPanelOpen] = useState(false);
const [saveProjectDialogOpen, setSaveProjectDialogOpen] = useState(false);
const [pendingProjectName, setPendingProjectName] = useState("");
const [isDragImportActive, setIsDragImportActive] = useState(false);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
@ -1508,25 +1524,34 @@ export function RealtimeCanvasDialog({
toast.success("项目已删除");
}, []);
const saveCurrentProject = useCallback(async () => {
const saveCurrentProject = useCallback(() => {
if (!hasRenderableInput(true)) {
toast.error("空白画布无需保存");
return;
}
setPendingProjectName(buildSuggestedProjectName(prompt));
setSaveProjectDialogOpen(true);
}, [hasRenderableInput, prompt]);
const confirmSaveCurrentProject = useCallback(async () => {
const normalizedProjectName = pendingProjectName.trim();
if (!normalizedProjectName) {
toast.error("请输入项目名称");
return;
}
const existingProjects = readRealtimeProjectsFromStorage();
const projectId = `rtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const compositeCanvas = await buildCompositeCanvas();
const coverImageUrl =
imageLayersRef.current[0]?.src ||
previewUrl ||
compositeCanvas?.toDataURL("image/jpeg", 0.72) ||
"";
const project: SavedRealtimeProject = {
id: projectId,
name:
prompt.trim().slice(0, 24) ||
`实时项目 ${new Date().toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`,
name: normalizedProjectName,
updatedAt: new Date().toISOString(),
prompt: prompt.trim() || FALLBACK_PROMPT,
brushColor,
@ -1534,11 +1559,13 @@ export function RealtimeCanvasDialog({
canvasBackground: canvasBackgroundRef.current,
imageLayers: cloneImageLayers(imageLayersRef.current),
drawingLayerDataUrl: exportDrawingLayerDataUrl(),
coverImageUrl,
previewUrl: previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "",
};
const nextProjects = [project, ...existingProjects].slice(0, 30);
writeRealtimeProjectsToStorage(nextProjects);
setSavedProjects(nextProjects);
setSaveProjectDialogOpen(false);
setProjectPanelOpen(true);
toast.success("项目已保存");
}, [
@ -1546,7 +1573,7 @@ export function RealtimeCanvasDialog({
brushSize,
buildCompositeCanvas,
exportDrawingLayerDataUrl,
hasRenderableInput,
pendingProjectName,
previewUrl,
prompt,
]);
@ -2669,6 +2696,55 @@ export function RealtimeCanvasDialog({
</div>
</div>
</div>
{saveProjectDialogOpen ? (
<div className="absolute left-1/2 top-24 z-40 w-[22rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-[24px] border border-black/10 bg-white/95 p-4 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-zinc-900">保存项目</div>
<div className="text-xs text-zinc-400">请输入项目名称后保存当前画布</div>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full"
onClick={() => setSaveProjectDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<Input
value={pendingProjectName}
onChange={(event) => setPendingProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void confirmSaveCurrentProject();
}
}}
placeholder="请输入项目名称"
className="mt-4 h-11 rounded-2xl border-black/10"
autoFocus
/>
<div className="mt-4 flex gap-2">
<Button
type="button"
variant="outline"
className="h-10 flex-1 rounded-2xl"
onClick={() => setSaveProjectDialogOpen(false)}
>
取消
</Button>
<Button
type="button"
className="h-10 flex-1 rounded-2xl bg-black text-white hover:bg-black/90"
onClick={() => void confirmSaveCurrentProject()}
>
保存
</Button>
</div>
</div>
) : null}
{projectPanelOpen ? (
<div className="absolute left-4 top-20 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] border border-black/10 bg-white/95 p-4 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between">
@ -2686,7 +2762,7 @@ export function RealtimeCanvasDialog({
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-4 max-h-[55vh] space-y-2 overflow-y-auto pr-1">
<div className="mt-4 max-h-[55vh] min-h-0 space-y-2 overflow-y-auto overscroll-contain pr-1">
{savedProjects.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 px-4 py-8 text-center text-sm text-zinc-400">
暂无保存的项目
@ -2696,22 +2772,24 @@ export function RealtimeCanvasDialog({
<div key={project.id} className="rounded-2xl border border-black/10 bg-white p-2.5 shadow-sm">
<div className="flex gap-3">
<div className="h-16 w-16 overflow-hidden rounded-xl border border-black/10 bg-zinc-100">
{project.previewUrl ? (
<img src={project.previewUrl} alt={project.name} className="h-full w-full object-cover" />
{project.coverImageUrl || project.previewUrl ? (
<img
src={
/^https?:\/\//i.test(project.coverImageUrl || project.previewUrl || "")
? buildImageProxyUrl(project.coverImageUrl || project.previewUrl || "")
: project.coverImageUrl || project.previewUrl || ""
}
alt={project.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-[11px] text-zinc-400">
无预览
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 pt-1">
<div className="truncate text-sm font-medium text-zinc-900">{project.name}</div>
<div className="mt-1 line-clamp-2 text-[11px] text-zinc-400">
{project.prompt || "未填写提示词"}
</div>
<div className="mt-2 text-[11px] text-zinc-400">
{new Date(project.updatedAt).toLocaleString("zh-CN")}
</div>
</div>
</div>
<div className="mt-3 flex gap-2">
@ -2841,12 +2919,12 @@ export function RealtimeCanvasDialog({
</div>
</div>
<div className="absolute left-4 top-28 bottom-4 z-10 flex w-16 max-w-[18vw] flex-col rounded-[24px] border border-black/10 bg-white/96 p-2 shadow-[0_12px_36px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-center border-b border-black/10 pb-2">
<div className="flex items-center justify-center border-b border-black/10 pb-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-50 text-zinc-900">
<Layers3 className="h-4 w-4" />
</div>
</div>
<div className="mt-2 flex-1 space-y-1.5 overflow-y-auto pr-0.5">
<div className="mt-2 flex-1 space-y-2 overflow-y-auto px-1 py-1">
{imageLayers.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 px-1 py-4 text-center text-[10px] leading-4 text-zinc-400">
暂无图层
@ -2858,9 +2936,9 @@ export function RealtimeCanvasDialog({
type="button"
draggable
className={cn(
"flex w-full items-center justify-center rounded-[16px] p-1 transition hover:bg-zinc-50",
"mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] p-1.5 transition hover:bg-zinc-50",
selectedLayerId === layer.id
? "bg-zinc-100 ring-2 ring-black/6"
? "bg-zinc-100 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]"
: ""
)}
onClick={() => setSelectedLayerId(layer.id)}
@ -2887,7 +2965,7 @@ export function RealtimeCanvasDialog({
<img
src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src}
alt={layer.name}
className="h-11 w-11 object-contain"
className="h-full w-full rounded-[14px] object-contain"
/>
</button>
))