This commit is contained in:
841
composables/useSlipmatDesigner.ts
Normal file
841
composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
import { computed, ref, shallowRef, watch } from "vue";
|
||||
|
||||
import type * as FabricNamespace from "fabric";
|
||||
type FabricModule = typeof import("fabric");
|
||||
|
||||
export interface SlipmatTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
diameterInches: number;
|
||||
dpi: number;
|
||||
bleedInches?: number;
|
||||
safeZoneInches?: number;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
const DISPLAY_SIZE = 720;
|
||||
const PREVIEW_SIZE = 1024;
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 2;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
const TEMPLATE_PRESETS: SlipmatTemplate[] = [
|
||||
{
|
||||
id: "lp-12",
|
||||
name: "12\" LP",
|
||||
diameterInches: 12,
|
||||
dpi: 300,
|
||||
bleedInches: 0.125,
|
||||
safeZoneInches: 0.25,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "ep-10",
|
||||
name: "10\" EP",
|
||||
diameterInches: 10,
|
||||
dpi: 300,
|
||||
bleedInches: 0.125,
|
||||
safeZoneInches: 0.25,
|
||||
backgroundColor: "#f7f7f7",
|
||||
},
|
||||
{
|
||||
id: "single-7",
|
||||
name: "7\" Single",
|
||||
diameterInches: 7,
|
||||
dpi: 300,
|
||||
bleedInches: 0.1,
|
||||
safeZoneInches: 0.2,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
];
|
||||
|
||||
type FabricCanvas = FabricNamespace.Canvas;
|
||||
type FabricSerializedCanvas = ReturnType<FabricCanvas["toJSON"]>;
|
||||
type FabricCircle = FabricNamespace.Circle;
|
||||
type FabricRect = FabricNamespace.Rect;
|
||||
type FabricTextbox = FabricNamespace.Textbox;
|
||||
type FabricObject = FabricNamespace.Object;
|
||||
|
||||
type CanvasReadyPayload = {
|
||||
canvas: FabricCanvas;
|
||||
fabric: FabricModule;
|
||||
};
|
||||
|
||||
export interface ExportedDesign {
|
||||
previewUrl: string;
|
||||
previewBlob: Blob;
|
||||
productionUrl: string;
|
||||
productionBlob: Blob;
|
||||
templateId: string;
|
||||
createdAt: string;
|
||||
canvasJson: FabricSerializedCanvas;
|
||||
}
|
||||
|
||||
const FALLBACK_TEMPLATE: SlipmatTemplate = TEMPLATE_PRESETS[0] ?? {
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
diameterInches: 12,
|
||||
dpi: 300,
|
||||
bleedInches: 0,
|
||||
safeZoneInches: 0,
|
||||
backgroundColor: "#ffffff",
|
||||
};
|
||||
|
||||
export const useSlipmatDesigner = () => {
|
||||
const templates = ref<SlipmatTemplate[]>(TEMPLATE_PRESETS);
|
||||
const selectedTemplate = ref<SlipmatTemplate>(FALLBACK_TEMPLATE);
|
||||
|
||||
const fabricApi = shallowRef<FabricModule | null>(null);
|
||||
const canvas = shallowRef<FabricCanvas | null>(null);
|
||||
const backgroundCircle = shallowRef<FabricCircle | null>(null);
|
||||
const safeZoneCircle = shallowRef<FabricCircle | null>(null);
|
||||
|
||||
const previewUrl = ref<string | null>(null);
|
||||
const previewBlob = shallowRef<Blob | null>(null);
|
||||
const productionBlob = shallowRef<Blob | null>(null);
|
||||
const productionObjectUrl = ref<string | null>(null);
|
||||
const isExporting = ref(false);
|
||||
const activeFillColor = ref<string | null>(null);
|
||||
const activeStrokeColor = ref<string | null>(null);
|
||||
const hasStyleableSelection = ref(false);
|
||||
const zoomLevel = ref(1);
|
||||
|
||||
let previewRefreshScheduled = false;
|
||||
|
||||
const displaySize = computed(() => DISPLAY_SIZE);
|
||||
|
||||
const productionPixelSize = computed(() =>
|
||||
Math.round(selectedTemplate.value.diameterInches * selectedTemplate.value.dpi)
|
||||
);
|
||||
|
||||
const templateLabel = computed(
|
||||
() =>
|
||||
`${selectedTemplate.value.name} (${selectedTemplate.value.diameterInches}\" @ ${selectedTemplate.value.dpi} DPI)`
|
||||
);
|
||||
|
||||
const zoomPercent = computed(() => Math.round(zoomLevel.value * 100));
|
||||
|
||||
const clampZoom = (value: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
|
||||
|
||||
const schedulePreviewRefresh = () => {
|
||||
if (previewRefreshScheduled) {
|
||||
return;
|
||||
}
|
||||
previewRefreshScheduled = true;
|
||||
if (typeof window !== "undefined") {
|
||||
requestAnimationFrame(async () => {
|
||||
previewRefreshScheduled = false;
|
||||
await refreshPreview();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const maintainStaticLayerOrder = () => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return;
|
||||
}
|
||||
if (backgroundCircle.value) {
|
||||
backgroundCircle.value.set({ radius: currentCanvas.getWidth() / 2 });
|
||||
}
|
||||
if (backgroundCircle.value) {
|
||||
currentCanvas.sendObjectToBack(backgroundCircle.value);
|
||||
}
|
||||
if (safeZoneCircle.value) {
|
||||
currentCanvas.bringObjectToFront(safeZoneCircle.value);
|
||||
}
|
||||
currentCanvas.requestRenderAll();
|
||||
};
|
||||
|
||||
const isStaticObject = (object: FabricObject | null) =>
|
||||
object === backgroundCircle.value || object === safeZoneCircle.value;
|
||||
|
||||
const getSelectedObjects = (): FabricObject[] => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return [];
|
||||
}
|
||||
const activeObjects = currentCanvas.getActiveObjects() as FabricObject[];
|
||||
if (activeObjects && activeObjects.length) {
|
||||
return activeObjects;
|
||||
}
|
||||
const activeObject = currentCanvas.getActiveObject() as FabricObject | null;
|
||||
return activeObject ? [activeObject] : [];
|
||||
};
|
||||
|
||||
const getStyleableSelection = (): FabricObject[] =>
|
||||
getSelectedObjects().filter((object) => {
|
||||
if (!object || isStaticObject(object)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = object as FabricObject & {
|
||||
set: (key: string, value: unknown) => void;
|
||||
fill?: unknown;
|
||||
stroke?: unknown;
|
||||
};
|
||||
return typeof candidate.set === "function" && ("fill" in candidate || "stroke" in candidate);
|
||||
});
|
||||
|
||||
const updateSelectedStyleState = () => {
|
||||
const styleable = getStyleableSelection();
|
||||
hasStyleableSelection.value = styleable.length > 0;
|
||||
if (!styleable.length) {
|
||||
activeFillColor.value = null;
|
||||
activeStrokeColor.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const primary = styleable[0] as FabricObject & {
|
||||
fill?: unknown;
|
||||
stroke?: unknown;
|
||||
};
|
||||
|
||||
activeFillColor.value =
|
||||
primary && typeof primary.fill === "string" ? primary.fill : null;
|
||||
activeStrokeColor.value =
|
||||
primary && typeof primary.stroke === "string" ? primary.stroke : null;
|
||||
|
||||
canvas.value?.requestRenderAll();
|
||||
};
|
||||
|
||||
const canStyleSelection = computed(() => hasStyleableSelection.value);
|
||||
|
||||
const refreshPreview = async () => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const multiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||
const url = currentCanvas.toDataURL({
|
||||
format: "png",
|
||||
multiplier,
|
||||
enableRetinaScaling: true,
|
||||
});
|
||||
previewUrl.value = url;
|
||||
previewBlob.value = await dataUrlToBlob(url);
|
||||
};
|
||||
|
||||
const applyTemplateToCanvas = () => {
|
||||
const currentCanvas = canvas.value;
|
||||
const fabric = fabricApi.value;
|
||||
if (!currentCanvas || !fabric) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = currentCanvas.getWidth();
|
||||
const { backgroundColor, safeZoneInches = 0, diameterInches } =
|
||||
selectedTemplate.value;
|
||||
|
||||
const bgCircle =
|
||||
backgroundCircle.value ??
|
||||
new fabric.Circle({
|
||||
left: size / 2,
|
||||
top: size / 2,
|
||||
radius: size / 2,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
bgCircle.set({ fill: backgroundColor });
|
||||
bgCircle.set({ radius: size / 2 });
|
||||
|
||||
if (!backgroundCircle.value) {
|
||||
backgroundCircle.value = bgCircle;
|
||||
currentCanvas.add(bgCircle);
|
||||
}
|
||||
currentCanvas.sendObjectToBack(bgCircle);
|
||||
currentCanvas.requestRenderAll();
|
||||
|
||||
const safeRatio = Math.max(
|
||||
0,
|
||||
(diameterInches - safeZoneInches * 2) / diameterInches
|
||||
);
|
||||
|
||||
const safeCircleRadius = (size / 2) * safeRatio;
|
||||
|
||||
const safeCircle =
|
||||
safeZoneCircle.value ??
|
||||
new fabric.Circle({
|
||||
left: size / 2,
|
||||
top: size / 2,
|
||||
radius: safeCircleRadius,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
fill: "rgba(0,0,0,0)",
|
||||
stroke: "#4b5563",
|
||||
strokeDashArray: [8, 8],
|
||||
strokeWidth: 1,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hoverCursor: "default",
|
||||
});
|
||||
|
||||
safeCircle.set({ radius: safeCircleRadius });
|
||||
|
||||
if (!safeZoneCircle.value) {
|
||||
safeZoneCircle.value = safeCircle;
|
||||
currentCanvas.add(safeCircle);
|
||||
}
|
||||
|
||||
maintainStaticLayerOrder();
|
||||
currentCanvas.requestRenderAll();
|
||||
|
||||
currentCanvas.clipPath = new fabric.Circle({
|
||||
left: size / 2,
|
||||
top: size / 2,
|
||||
radius: size / 2,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
absolutePositioned: true,
|
||||
});
|
||||
|
||||
currentCanvas.renderAll();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const registerCanvas = ({ canvas: instance, fabric }: CanvasReadyPayload) => {
|
||||
fabricApi.value = fabric;
|
||||
canvas.value = instance;
|
||||
|
||||
instance.setDimensions({ width: DISPLAY_SIZE, height: DISPLAY_SIZE });
|
||||
instance.preserveObjectStacking = true;
|
||||
|
||||
const refreshEvents = [
|
||||
"object:added",
|
||||
"object:modified",
|
||||
"object:removed",
|
||||
"object:skewing",
|
||||
"object:scaling",
|
||||
"object:rotating",
|
||||
"object:moving",
|
||||
] as const;
|
||||
|
||||
const handleMutation = () => {
|
||||
maintainStaticLayerOrder();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
refreshEvents.forEach((eventName) => {
|
||||
instance.on(eventName, handleMutation);
|
||||
});
|
||||
|
||||
const selectionEvents = [
|
||||
"selection:created",
|
||||
"selection:updated",
|
||||
"selection:cleared",
|
||||
] as const;
|
||||
|
||||
selectionEvents.forEach((eventName) => {
|
||||
instance.on(eventName, () => updateSelectedStyleState());
|
||||
});
|
||||
|
||||
instance.on("mouse:wheel", (opt) => {
|
||||
const { e } = opt;
|
||||
const delta = e.deltaY;
|
||||
const direction = delta > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
const nextZoom = zoomLevel.value + direction;
|
||||
const pointer = instance.getPointer(e);
|
||||
const fabric = fabricApi.value;
|
||||
if (!fabric) {
|
||||
return;
|
||||
}
|
||||
const point = new fabric.Point(pointer.x, pointer.y);
|
||||
setZoom(nextZoom, { point });
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
applyTemplateToCanvas();
|
||||
updateSelectedStyleState();
|
||||
setZoom(zoomLevel.value);
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const unregisterCanvas = () => {
|
||||
canvas.value?.dispose();
|
||||
canvas.value = null;
|
||||
fabricApi.value = null;
|
||||
backgroundCircle.value = null;
|
||||
safeZoneCircle.value = null;
|
||||
activeFillColor.value = null;
|
||||
activeStrokeColor.value = null;
|
||||
hasStyleableSelection.value = false;
|
||||
zoomLevel.value = 1;
|
||||
};
|
||||
|
||||
const centerPoint = () => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return { x: DISPLAY_SIZE / 2, y: DISPLAY_SIZE / 2 };
|
||||
}
|
||||
return {
|
||||
x: currentCanvas.getWidth() / 2,
|
||||
y: currentCanvas.getHeight() / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const addTextbox = () => {
|
||||
const fabric = fabricApi.value;
|
||||
const currentCanvas = canvas.value;
|
||||
if (!fabric || !currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const { x, y } = centerPoint();
|
||||
const textbox: FabricTextbox = new fabric.Textbox("Your Text", {
|
||||
left: x,
|
||||
top: y,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
fill: "#111827",
|
||||
fontSize: 36,
|
||||
fontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif",
|
||||
editable: true,
|
||||
width: DISPLAY_SIZE * 0.5,
|
||||
textAlign: "center",
|
||||
});
|
||||
currentCanvas.add(textbox);
|
||||
currentCanvas.setActiveObject(textbox);
|
||||
maintainStaticLayerOrder();
|
||||
currentCanvas.requestRenderAll();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const addShape = (shape: "circle" | "rect") => {
|
||||
const fabric = fabricApi.value;
|
||||
const currentCanvas = canvas.value;
|
||||
if (!fabric || !currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const { x, y } = centerPoint();
|
||||
let object: FabricCircle | FabricRect;
|
||||
|
||||
if (shape === "circle") {
|
||||
object = new fabric.Circle({
|
||||
left: x,
|
||||
top: y,
|
||||
radius: DISPLAY_SIZE * 0.18,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
fill: "rgba(59, 130, 246, 0.4)",
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
} else {
|
||||
object = new fabric.Rect({
|
||||
left: x,
|
||||
top: y,
|
||||
width: DISPLAY_SIZE * 0.3,
|
||||
height: DISPLAY_SIZE * 0.18,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
rx: 12,
|
||||
ry: 12,
|
||||
fill: "rgba(16, 185, 129, 0.35)",
|
||||
stroke: "#10b981",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
currentCanvas.add(object);
|
||||
currentCanvas.setActiveObject(object);
|
||||
maintainStaticLayerOrder();
|
||||
currentCanvas.requestRenderAll();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const addImageFromFile = async (file: File) => {
|
||||
const fabric = fabricApi.value;
|
||||
const currentCanvas = canvas.value;
|
||||
if (!fabric || !currentCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
|
||||
const img = await fabric.Image.fromURL(dataUrl, {
|
||||
crossOrigin: "anonymous",
|
||||
});
|
||||
|
||||
const { x, y } = centerPoint();
|
||||
img.set({
|
||||
left: x,
|
||||
top: y,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: true,
|
||||
clipPath: undefined,
|
||||
});
|
||||
|
||||
const maxSize = DISPLAY_SIZE * 0.8;
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxSize / Math.max(img.width ?? maxSize, img.height ?? maxSize)
|
||||
);
|
||||
img.scale(scale);
|
||||
currentCanvas.add(img);
|
||||
currentCanvas.setActiveObject(img);
|
||||
maintainStaticLayerOrder();
|
||||
currentCanvas.requestRenderAll();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const setActiveFillColor = (color: string) => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const targets = getStyleableSelection();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
targets.forEach((object) => {
|
||||
(object as FabricObject & { set: (key: string, value: unknown) => void }).set(
|
||||
"fill",
|
||||
color
|
||||
);
|
||||
});
|
||||
activeFillColor.value = color;
|
||||
currentCanvas.requestRenderAll();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const setActiveStrokeColor = (color: string) => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const targets = getStyleableSelection();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
targets.forEach((object) => {
|
||||
const target = object as FabricObject & {
|
||||
set: (key: string, value: unknown) => void;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
target.set("stroke", color);
|
||||
if (target.strokeWidth === undefined || target.strokeWidth === 0) {
|
||||
target.set("strokeWidth", 2);
|
||||
}
|
||||
});
|
||||
activeStrokeColor.value = color;
|
||||
currentCanvas.requestRenderAll();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const setZoom = (value: number, options?: { point?: FabricNamespace.Point }) => {
|
||||
const currentCanvas = canvas.value;
|
||||
const fabric = fabricApi.value;
|
||||
if (!currentCanvas || !fabric) {
|
||||
return;
|
||||
}
|
||||
const clamped = clampZoom(value);
|
||||
|
||||
const targetPoint =
|
||||
options?.point ??
|
||||
new fabric.Point(
|
||||
currentCanvas.getWidth() / 2,
|
||||
currentCanvas.getHeight() / 2
|
||||
);
|
||||
|
||||
currentCanvas.zoomToPoint(targetPoint, clamped);
|
||||
|
||||
if (!options?.point) {
|
||||
const viewport = currentCanvas.viewportTransform;
|
||||
if (viewport) {
|
||||
const width = currentCanvas.getWidth();
|
||||
const height = currentCanvas.getHeight();
|
||||
viewport[4] = (width - width * clamped) / 2;
|
||||
viewport[5] = (height - height * clamped) / 2;
|
||||
currentCanvas.setViewportTransform(viewport);
|
||||
}
|
||||
}
|
||||
|
||||
zoomLevel.value = clamped;
|
||||
maintainStaticLayerOrder();
|
||||
currentCanvas.requestRenderAll();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const zoomIn = () => setZoom(zoomLevel.value + ZOOM_STEP);
|
||||
|
||||
const zoomOut = () => setZoom(zoomLevel.value - ZOOM_STEP);
|
||||
|
||||
const resetZoom = () => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (currentCanvas) {
|
||||
currentCanvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
}
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
const clearDesign = () => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return;
|
||||
}
|
||||
const staticObjects = new Set<FabricObject | null>([
|
||||
backgroundCircle.value,
|
||||
safeZoneCircle.value,
|
||||
]);
|
||||
currentCanvas
|
||||
.getObjects()
|
||||
.filter(
|
||||
(object: FabricObject | null): object is FabricObject =>
|
||||
!staticObjects.has(object)
|
||||
)
|
||||
.forEach((object: FabricObject) => currentCanvas.remove(object));
|
||||
currentCanvas.discardActiveObject();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
const selectTemplate = (templateId: string) => {
|
||||
const template = templates.value.find((entry) => entry.id === templateId);
|
||||
if (template) {
|
||||
selectedTemplate.value = template;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForCanvasReady = async (): Promise<FabricCanvas> => {
|
||||
if (canvas.value) {
|
||||
return canvas.value;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(
|
||||
canvas,
|
||||
(value) => {
|
||||
if (value) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
});
|
||||
|
||||
if (!canvas.value) {
|
||||
throw new Error("Canvas not ready");
|
||||
}
|
||||
|
||||
return canvas.value;
|
||||
};
|
||||
|
||||
const loadDesign = async (payload: {
|
||||
templateId?: string | null;
|
||||
canvasJson: string | FabricSerializedCanvas;
|
||||
previewUrl?: string | null;
|
||||
}) => {
|
||||
if (payload.templateId) {
|
||||
selectTemplate(payload.templateId);
|
||||
}
|
||||
|
||||
const currentCanvas = await waitForCanvasReady();
|
||||
if (!fabricApi.value) {
|
||||
throw new Error("Fabric API not ready");
|
||||
}
|
||||
|
||||
const parsedJson =
|
||||
typeof payload.canvasJson === "string"
|
||||
? (JSON.parse(payload.canvasJson) as FabricSerializedCanvas)
|
||||
: payload.canvasJson;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentCanvas.loadFromJSON(parsedJson, () => {
|
||||
maintainStaticLayerOrder();
|
||||
updateSelectedStyleState();
|
||||
currentCanvas.renderAll();
|
||||
schedulePreviewRefresh();
|
||||
resolve();
|
||||
});
|
||||
}).catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Reset cached assets; caller can provide preview if available.
|
||||
previewBlob.value = null;
|
||||
productionBlob.value = null;
|
||||
if (productionObjectUrl.value) {
|
||||
URL.revokeObjectURL(productionObjectUrl.value);
|
||||
productionObjectUrl.value = null;
|
||||
}
|
||||
|
||||
if (payload.previewUrl) {
|
||||
previewUrl.value = payload.previewUrl;
|
||||
} else {
|
||||
await refreshPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const exportDesign = async (): Promise<ExportedDesign | null> => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
return null;
|
||||
}
|
||||
isExporting.value = true;
|
||||
try {
|
||||
const previewMultiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||
const previewDataUrl = currentCanvas.toDataURL({
|
||||
format: "png",
|
||||
multiplier: previewMultiplier,
|
||||
enableRetinaScaling: true,
|
||||
});
|
||||
const previewDataBlob = await dataUrlToBlob(previewDataUrl);
|
||||
|
||||
const productionSize = productionPixelSize.value;
|
||||
const productionMultiplier = productionSize / currentCanvas.getWidth();
|
||||
const productionDataUrl = currentCanvas.toDataURL({
|
||||
format: "png",
|
||||
multiplier: productionMultiplier,
|
||||
enableRetinaScaling: true,
|
||||
});
|
||||
const productionDataBlob = await dataUrlToBlob(productionDataUrl);
|
||||
|
||||
const canvasJson = currentCanvas.toJSON();
|
||||
|
||||
previewUrl.value = previewDataUrl;
|
||||
previewBlob.value = previewDataBlob;
|
||||
productionBlob.value = productionDataBlob;
|
||||
|
||||
if (productionObjectUrl.value) {
|
||||
URL.revokeObjectURL(productionObjectUrl.value);
|
||||
}
|
||||
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
||||
|
||||
return {
|
||||
previewUrl: previewDataUrl,
|
||||
previewBlob: previewDataBlob,
|
||||
productionUrl: productionDataUrl,
|
||||
productionBlob: productionDataBlob,
|
||||
templateId: selectedTemplate.value.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
canvasJson,
|
||||
};
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPreview = async () => {
|
||||
if (!previewUrl.value) {
|
||||
await refreshPreview();
|
||||
}
|
||||
if (!previewUrl.value) {
|
||||
return;
|
||||
}
|
||||
triggerDownload(previewUrl.value, `${selectedTemplate.value.id}-preview.png`);
|
||||
};
|
||||
|
||||
const downloadProduction = async () => {
|
||||
if (!productionObjectUrl.value) {
|
||||
const exportResult = await exportDesign();
|
||||
if (!exportResult) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!productionObjectUrl.value) {
|
||||
return;
|
||||
}
|
||||
triggerDownload(
|
||||
productionObjectUrl.value,
|
||||
`${selectedTemplate.value.id}-production.png`
|
||||
);
|
||||
};
|
||||
|
||||
const setBackgroundColor = (color: string) => {
|
||||
const bgCircle = backgroundCircle.value;
|
||||
if (!bgCircle || !canvas.value) {
|
||||
return;
|
||||
}
|
||||
bgCircle.set({ fill: color });
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
backgroundColor: color,
|
||||
};
|
||||
canvas.value.requestRenderAll();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
watch(selectedTemplate, () => {
|
||||
resetZoom();
|
||||
applyTemplateToCanvas();
|
||||
maintainStaticLayerOrder();
|
||||
updateSelectedStyleState();
|
||||
schedulePreviewRefresh();
|
||||
});
|
||||
|
||||
return {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
loadDesign,
|
||||
displaySize,
|
||||
productionPixelSize,
|
||||
templateLabel,
|
||||
previewUrl,
|
||||
previewBlob,
|
||||
productionBlob,
|
||||
productionObjectUrl,
|
||||
isExporting,
|
||||
activeFillColor,
|
||||
activeStrokeColor,
|
||||
canStyleSelection,
|
||||
zoomLevel,
|
||||
zoomPercent,
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
registerCanvas,
|
||||
unregisterCanvas,
|
||||
addTextbox,
|
||||
addShape,
|
||||
addImageFromFile,
|
||||
setActiveFillColor,
|
||||
setActiveStrokeColor,
|
||||
setBackgroundColor,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
clearDesign,
|
||||
exportDesign,
|
||||
downloadPreview,
|
||||
downloadProduction,
|
||||
refreshPreview,
|
||||
schedulePreviewRefresh,
|
||||
applyTemplateToCanvas,
|
||||
};
|
||||
};
|
||||
|
||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const dataUrlToBlob = async (dataUrl: string): Promise<Blob> => {
|
||||
const response = await fetch(dataUrl);
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
const triggerDownload = (url: string, filename: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
anchor.click();
|
||||
};
|
||||
Reference in New Issue
Block a user