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; 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(TEMPLATE_PRESETS); const selectedTemplate = ref(FALLBACK_TEMPLATE); const fabricApi = shallowRef(null); const canvas = shallowRef(null); const backgroundCircle = shallowRef(null); const safeZoneCircle = shallowRef(null); const previewUrl = ref(null); const previewBlob = shallowRef(null); const productionBlob = shallowRef(null); const productionObjectUrl = ref(null); const isExporting = ref(false); const activeFillColor = ref(null); const activeStrokeColor = ref(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); }); instance.on("after:render", () => schedulePreviewRefresh()); 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([ 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 => { if (canvas.value) { return canvas.value; } await new Promise((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((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 => { 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 => { 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 => { 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(); };