- Implemented `useDesignPersistence` composable for managing design records. - Enhanced `useSlipmatDesigner` to support loading designs from JSON. - Created global authentication middleware for route protection. - Added Supabase client plugin for database interactions. - Developed API endpoints for fetching, saving, and retrieving designs. - Introduced utility functions for Auth0 token verification and Supabase client retrieval. - Updated Nuxt configuration to include Auth0 and Supabase environment variables. - Added necessary dependencies for Auth0 and Supabase. - Enhanced TypeScript configuration for improved type support.
775 lines
20 KiB
TypeScript
775 lines
20 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface ExportedDesign {
|
|
previewUrl: string;
|
|
previewBlob: Blob;
|
|
productionUrl: string;
|
|
productionBlob: Blob;
|
|
templateId: string;
|
|
createdAt: string;
|
|
canvasJson: FabricCanvasJSON;
|
|
}
|
|
|
|
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 FabricCircle = FabricNamespace.Circle;
|
|
type FabricRect = FabricNamespace.Rect;
|
|
type FabricTextbox = FabricNamespace.Textbox;
|
|
type FabricObject = FabricNamespace.Object;
|
|
type FabricCanvasJSON = ReturnType<FabricCanvas["toJSON"]>;
|
|
|
|
type CanvasReadyPayload = {
|
|
canvas: FabricCanvas;
|
|
fabric: FabricModule;
|
|
};
|
|
|
|
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);
|
|
});
|
|
|
|
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<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 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);
|
|
|
|
previewUrl.value = previewDataUrl;
|
|
previewBlob.value = previewDataBlob;
|
|
productionBlob.value = productionDataBlob;
|
|
|
|
if (productionObjectUrl.value) {
|
|
URL.revokeObjectURL(productionObjectUrl.value);
|
|
}
|
|
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
|
|
|
const canvasJson = currentCanvas.toJSON() as FabricCanvasJSON;
|
|
|
|
return {
|
|
previewUrl: previewDataUrl,
|
|
previewBlob: previewDataBlob,
|
|
productionUrl: productionDataUrl,
|
|
productionBlob: productionDataBlob,
|
|
templateId: selectedTemplate.value.id,
|
|
createdAt: new Date().toISOString(),
|
|
canvasJson,
|
|
};
|
|
} finally {
|
|
isExporting.value = false;
|
|
}
|
|
};
|
|
|
|
const loadDesignFromJson = async (designJson: FabricCanvasJSON) => {
|
|
const fabric = fabricApi.value;
|
|
const currentCanvas = canvas.value;
|
|
if (!fabric || !currentCanvas) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
currentCanvas.loadFromJSON(designJson, () => {
|
|
currentCanvas.renderAll();
|
|
maintainStaticLayerOrder();
|
|
schedulePreviewRefresh();
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
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`
|
|
);
|
|
};
|
|
|
|
watch(selectedTemplate, () => {
|
|
resetZoom();
|
|
applyTemplateToCanvas();
|
|
maintainStaticLayerOrder();
|
|
updateSelectedStyleState();
|
|
schedulePreviewRefresh();
|
|
});
|
|
|
|
return {
|
|
templates,
|
|
selectedTemplate,
|
|
selectTemplate,
|
|
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,
|
|
setZoom,
|
|
zoomIn,
|
|
zoomOut,
|
|
resetZoom,
|
|
clearDesign,
|
|
exportDesign,
|
|
loadDesignFromJson,
|
|
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();
|
|
};
|