mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Uses XMLHttpRequest upload progress to track image upload (0-80%), then shows "Processing collage..." for the server-side compositing phase (80-100%). Progress bar with percentage shown in the preview area.
303 lines
8.8 KiB
TypeScript
303 lines
8.8 KiB
TypeScript
import { create } from "zustand";
|
|
import { COLLAGE_TEMPLATES, getDefaultTemplate } from "@/lib/collage-templates";
|
|
import { fetchDecodedPreview, needsServerPreview, revokePreviewUrl } from "@/lib/image-preview";
|
|
|
|
export type AspectRatio = "free" | "1:1" | "4:3" | "3:2" | "16:9" | "9:16" | "4:5";
|
|
export type OutputFormat = "png" | "jpeg" | "webp";
|
|
export type Phase = "upload" | "editing" | "processing" | "result";
|
|
|
|
export interface CollageImage {
|
|
id: string;
|
|
file: File;
|
|
blobUrl: string;
|
|
previewBlobUrl?: string;
|
|
previewLoading: boolean;
|
|
}
|
|
|
|
export type ObjectFit = "cover" | "contain";
|
|
|
|
export interface CellTransform {
|
|
panX: number; // percentage -100..100
|
|
panY: number; // percentage -100..100
|
|
zoom: number; // 1.0 to 3.0
|
|
objectFit: ObjectFit;
|
|
}
|
|
|
|
interface CollageState {
|
|
// Images
|
|
images: CollageImage[];
|
|
|
|
// Layout
|
|
templateId: string;
|
|
cellAssignments: number[]; // maps cellIndex -> imageIndex (-1 if empty)
|
|
|
|
// Per-cell transforms
|
|
cellTransforms: Record<number, CellTransform>;
|
|
|
|
// Style
|
|
gap: number;
|
|
cornerRadius: number;
|
|
backgroundColor: string;
|
|
bgPreset: "white" | "black" | "transparent" | "custom";
|
|
|
|
// Canvas
|
|
aspectRatio: AspectRatio;
|
|
|
|
// Output
|
|
outputFormat: OutputFormat;
|
|
quality: number;
|
|
|
|
// UI state
|
|
selectedCell: number | null;
|
|
phase: Phase;
|
|
progress: number;
|
|
resultUrl: string | null;
|
|
resultSize: number | null;
|
|
originalSize: number | null;
|
|
error: string | null;
|
|
jobId: string | null;
|
|
|
|
// Actions
|
|
addImages: (files: File[]) => void;
|
|
removeImage: (index: number) => void;
|
|
clearImages: () => void;
|
|
setTemplateId: (id: string) => void;
|
|
setCellAssignment: (cellIndex: number, imageIndex: number) => void;
|
|
swapCells: (a: number, b: number) => void;
|
|
setCellTransform: (cellIndex: number, transform: Partial<CellTransform>) => void;
|
|
resetCellTransform: (cellIndex: number) => void;
|
|
setGap: (v: number) => void;
|
|
setCornerRadius: (v: number) => void;
|
|
setBackgroundColor: (color: string) => void;
|
|
setBgPreset: (preset: "white" | "black" | "transparent" | "custom") => void;
|
|
setAspectRatio: (v: AspectRatio) => void;
|
|
setOutputFormat: (v: OutputFormat) => void;
|
|
setQuality: (v: number) => void;
|
|
setSelectedCell: (v: number | null) => void;
|
|
setPhase: (v: Phase) => void;
|
|
setProgress: (v: number) => void;
|
|
setResult: (url: string, size: number, originalSize: number, jobId: string) => void;
|
|
setError: (e: string | null) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
const DEFAULT_TRANSFORM: CellTransform = { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
|
|
|
|
let nextImageId = 0;
|
|
|
|
function buildDefaultAssignments(imageCount: number, cellCount: number): number[] {
|
|
const assignments: number[] = [];
|
|
for (let i = 0; i < cellCount; i++) {
|
|
assignments.push(i < imageCount ? i : -1);
|
|
}
|
|
return assignments;
|
|
}
|
|
|
|
export const useCollageStore = create<CollageState>((set, get) => ({
|
|
images: [],
|
|
templateId: "2-h-equal",
|
|
cellAssignments: [],
|
|
cellTransforms: {},
|
|
gap: 8,
|
|
cornerRadius: 0,
|
|
backgroundColor: "#FFFFFF",
|
|
bgPreset: "white",
|
|
aspectRatio: "free",
|
|
outputFormat: "png",
|
|
quality: 90,
|
|
selectedCell: null,
|
|
phase: "upload",
|
|
progress: 0,
|
|
resultUrl: null,
|
|
resultSize: null,
|
|
originalSize: null,
|
|
error: null,
|
|
jobId: null,
|
|
|
|
addImages: (files) => {
|
|
const newImages: CollageImage[] = files.map((f) => ({
|
|
id: `img-${++nextImageId}`,
|
|
file: f,
|
|
blobUrl: URL.createObjectURL(f),
|
|
previewLoading: needsServerPreview(f),
|
|
}));
|
|
const state = get();
|
|
const allImages = [...state.images, ...newImages];
|
|
const template = getDefaultTemplate(allImages.length);
|
|
const cellCount = template.cells.length;
|
|
const assignments = buildDefaultAssignments(allImages.length, cellCount);
|
|
|
|
set({
|
|
images: allImages,
|
|
templateId: template.id,
|
|
cellAssignments: assignments,
|
|
cellTransforms: {},
|
|
phase: "editing",
|
|
resultUrl: null,
|
|
resultSize: null,
|
|
error: null,
|
|
});
|
|
|
|
for (const img of newImages) {
|
|
if (img.previewLoading) {
|
|
const imgId = img.id;
|
|
fetchDecodedPreview(img.file).then((url) => {
|
|
const current = get();
|
|
const idx = current.images.findIndex((i) => i.id === imgId);
|
|
if (idx === -1) return;
|
|
const updated = [...current.images];
|
|
updated[idx] = {
|
|
...updated[idx],
|
|
previewLoading: false,
|
|
...(url ? { previewBlobUrl: url } : {}),
|
|
};
|
|
set({ images: updated });
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
removeImage: (index) => {
|
|
const state = get();
|
|
const img = state.images[index];
|
|
if (img) {
|
|
URL.revokeObjectURL(img.blobUrl);
|
|
if (img.previewBlobUrl) revokePreviewUrl(img.previewBlobUrl);
|
|
}
|
|
const newImages = state.images.filter((_, i) => i !== index);
|
|
if (newImages.length === 0) {
|
|
get().reset();
|
|
return;
|
|
}
|
|
const template = getDefaultTemplate(newImages.length);
|
|
const assignments = buildDefaultAssignments(newImages.length, template.cells.length);
|
|
set({
|
|
images: newImages,
|
|
templateId: template.id,
|
|
cellAssignments: assignments,
|
|
cellTransforms: {},
|
|
phase: "editing",
|
|
resultUrl: null,
|
|
});
|
|
},
|
|
|
|
clearImages: () => {
|
|
const state = get();
|
|
for (const img of state.images) {
|
|
URL.revokeObjectURL(img.blobUrl);
|
|
if (img.previewBlobUrl) revokePreviewUrl(img.previewBlobUrl);
|
|
}
|
|
set({
|
|
images: [],
|
|
cellAssignments: [],
|
|
cellTransforms: {},
|
|
phase: "upload",
|
|
resultUrl: null,
|
|
resultSize: null,
|
|
error: null,
|
|
selectedCell: null,
|
|
});
|
|
},
|
|
|
|
setTemplateId: (id) => {
|
|
const state = get();
|
|
const template = COLLAGE_TEMPLATES.find((t) => t.id === id);
|
|
if (!template) return;
|
|
const assignments = buildDefaultAssignments(state.images.length, template.cells.length);
|
|
set({ templateId: id, cellAssignments: assignments, cellTransforms: {}, resultUrl: null });
|
|
},
|
|
|
|
setCellAssignment: (cellIndex, imageIndex) => {
|
|
const state = get();
|
|
const newAssignments = [...state.cellAssignments];
|
|
newAssignments[cellIndex] = imageIndex;
|
|
set({ cellAssignments: newAssignments, resultUrl: null });
|
|
},
|
|
|
|
swapCells: (a, b) => {
|
|
const state = get();
|
|
const newAssignments = [...state.cellAssignments];
|
|
const newTransforms = { ...state.cellTransforms };
|
|
// Swap assignments
|
|
[newAssignments[a], newAssignments[b]] = [newAssignments[b], newAssignments[a]];
|
|
// Swap transforms
|
|
const tmpT = newTransforms[a];
|
|
newTransforms[a] = newTransforms[b];
|
|
newTransforms[b] = tmpT;
|
|
set({ cellAssignments: newAssignments, cellTransforms: newTransforms, resultUrl: null });
|
|
},
|
|
|
|
setCellTransform: (cellIndex, transform) => {
|
|
const state = get();
|
|
const current = state.cellTransforms[cellIndex] ?? { ...DEFAULT_TRANSFORM };
|
|
set({
|
|
cellTransforms: {
|
|
...state.cellTransforms,
|
|
[cellIndex]: { ...current, ...transform },
|
|
},
|
|
resultUrl: null,
|
|
});
|
|
},
|
|
|
|
resetCellTransform: (cellIndex) => {
|
|
const state = get();
|
|
const newTransforms = { ...state.cellTransforms };
|
|
delete newTransforms[cellIndex];
|
|
set({ cellTransforms: newTransforms, resultUrl: null });
|
|
},
|
|
|
|
setGap: (v) => set({ gap: v, resultUrl: null }),
|
|
setCornerRadius: (v) => set({ cornerRadius: v, resultUrl: null }),
|
|
setBackgroundColor: (color) => set({ backgroundColor: color, resultUrl: null }),
|
|
setBgPreset: (preset) => {
|
|
const colors: Record<string, string> = {
|
|
white: "#FFFFFF",
|
|
black: "#000000",
|
|
transparent: "transparent",
|
|
};
|
|
if (preset === "custom") {
|
|
set({ bgPreset: preset });
|
|
} else {
|
|
set({ bgPreset: preset, backgroundColor: colors[preset], resultUrl: null });
|
|
}
|
|
},
|
|
setAspectRatio: (v) => set({ aspectRatio: v, resultUrl: null }),
|
|
setOutputFormat: (v) => set({ outputFormat: v, resultUrl: null }),
|
|
setQuality: (v) => set({ quality: v, resultUrl: null }),
|
|
setSelectedCell: (v) => set({ selectedCell: v }),
|
|
setPhase: (v) => set({ phase: v }),
|
|
setProgress: (v) => set({ progress: v }),
|
|
setResult: (url, size, originalSize, jobId) =>
|
|
set({ resultUrl: url, resultSize: size, originalSize, jobId, phase: "result", error: null }),
|
|
setError: (e) => set({ error: e, phase: "editing" }),
|
|
reset: () => {
|
|
const state = get();
|
|
for (const img of state.images) {
|
|
URL.revokeObjectURL(img.blobUrl);
|
|
if (img.previewBlobUrl) revokePreviewUrl(img.previewBlobUrl);
|
|
}
|
|
nextImageId = 0;
|
|
set({
|
|
images: [],
|
|
templateId: "2-h-equal",
|
|
cellAssignments: [],
|
|
cellTransforms: {},
|
|
gap: 8,
|
|
cornerRadius: 0,
|
|
backgroundColor: "#FFFFFF",
|
|
bgPreset: "white",
|
|
aspectRatio: "free",
|
|
outputFormat: "png",
|
|
quality: 90,
|
|
selectedCell: null,
|
|
phase: "upload",
|
|
progress: 0,
|
|
resultUrl: null,
|
|
resultSize: null,
|
|
originalSize: null,
|
|
error: null,
|
|
jobId: null,
|
|
});
|
|
},
|
|
}));
|