Some checks failed
Deploy Production / deploy (push) Failing after 1m11s
- Added canvasId prop to DesignerCanvas for identifying multiple canvases. - Implemented active view selection (front, top, left, right) in designer page. - Updated DesignerCanvas to maintain aspect ratio and dimensions based on view. - Integrated @google/model-viewer for 3D model rendering on the index page. - Refactored useSlipmatDesigner to manage multiple canvases and their states. - Added LAMESA.glb model file for 3D representation. - Updated package.json and package-lock.json to include @google/model-viewer dependency.
902 lines
24 KiB
TypeScript
902 lines
24 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;
|
|
}
|
|
|
|
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 canvases = shallowRef<Record<string, FabricCanvas>>({});
|
|
const activeCanvasId = ref<string>('front');
|
|
const canvas = computed(() => canvases.value[activeCanvasId.value] || null);
|
|
const backgroundCircle = shallowRef<Record<string, FabricCircle | null>>({});
|
|
const safeZoneCircle = shallowRef<Record<string, FabricCircle | 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;
|
|
}
|
|
const bgCircle = backgroundCircle.value[activeCanvasId.value];
|
|
const safeCircle = safeZoneCircle.value[activeCanvasId.value];
|
|
|
|
if (bgCircle) {
|
|
const bgRect = bgCircle as any;
|
|
if (bgRect.set) {
|
|
bgRect.set({
|
|
width: currentCanvas.getWidth(),
|
|
height: currentCanvas.getHeight()
|
|
});
|
|
}
|
|
}
|
|
if (bgCircle) {
|
|
currentCanvas.sendObjectToBack(bgCircle);
|
|
}
|
|
if (safeCircle) {
|
|
currentCanvas.bringObjectToFront(safeCircle);
|
|
}
|
|
currentCanvas.requestRenderAll();
|
|
};
|
|
|
|
const isStaticObject = (object: FabricObject | null) => {
|
|
const bgCircle = backgroundCircle.value[activeCanvasId.value];
|
|
const safeCircle = safeZoneCircle.value[activeCanvasId.value];
|
|
return object === bgCircle || object === safeCircle;
|
|
};
|
|
|
|
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 width = currentCanvas.getWidth();
|
|
const height = currentCanvas.getHeight();
|
|
const { backgroundColor, safeZoneInches = 0, diameterInches } =
|
|
selectedTemplate.value;
|
|
|
|
const canvasId = activeCanvasId.value;
|
|
const bgRect =
|
|
backgroundCircle.value[canvasId] as any ??
|
|
new fabric.Rect({
|
|
left: 0,
|
|
top: 0,
|
|
width: width,
|
|
height: height,
|
|
originX: "left",
|
|
originY: "top",
|
|
selectable: false,
|
|
evented: false,
|
|
rx: 8,
|
|
ry: 8,
|
|
});
|
|
|
|
bgRect.set({ fill: backgroundColor });
|
|
bgRect.set({ width: width, height: height });
|
|
|
|
if (!backgroundCircle.value[canvasId]) {
|
|
backgroundCircle.value[canvasId] = bgRect as any;
|
|
currentCanvas.add(bgRect);
|
|
}
|
|
currentCanvas.sendObjectToBack(bgRect);
|
|
currentCanvas.requestRenderAll();
|
|
|
|
const safeRatio = Math.max(
|
|
0,
|
|
(diameterInches - safeZoneInches * 2) / diameterInches
|
|
);
|
|
|
|
const safeMargin = ((1 - safeRatio) * Math.min(width, height)) / 2;
|
|
|
|
const safeRect =
|
|
safeZoneCircle.value[canvasId] as any ??
|
|
new fabric.Rect({
|
|
left: safeMargin,
|
|
top: safeMargin,
|
|
width: width - safeMargin * 2,
|
|
height: height - safeMargin * 2,
|
|
originX: "left",
|
|
originY: "top",
|
|
fill: "rgba(0,0,0,0)",
|
|
stroke: "#4b5563",
|
|
strokeDashArray: [8, 8],
|
|
strokeWidth: 1,
|
|
selectable: false,
|
|
evented: false,
|
|
hoverCursor: "default",
|
|
rx: 8,
|
|
ry: 8,
|
|
});
|
|
|
|
safeRect.set({
|
|
left: safeMargin,
|
|
top: safeMargin,
|
|
width: width - safeMargin * 2,
|
|
height: height - safeMargin * 2
|
|
});
|
|
|
|
if (!safeZoneCircle.value[canvasId]) {
|
|
safeZoneCircle.value[canvasId] = safeRect as any;
|
|
currentCanvas.add(safeRect);
|
|
}
|
|
|
|
maintainStaticLayerOrder();
|
|
currentCanvas.requestRenderAll();
|
|
|
|
currentCanvas.clipPath = new fabric.Rect({
|
|
left: 0,
|
|
top: 0,
|
|
width: width,
|
|
height: height,
|
|
originX: "left",
|
|
originY: "top",
|
|
absolutePositioned: true,
|
|
rx: 8,
|
|
ry: 8,
|
|
});
|
|
|
|
currentCanvas.renderAll();
|
|
schedulePreviewRefresh();
|
|
};
|
|
|
|
const registerCanvas = ({ canvas: instance, fabric, canvasId = 'front' }: CanvasReadyPayload & { canvasId?: string }) => {
|
|
fabricApi.value = fabric;
|
|
canvases.value[canvasId] = instance;
|
|
|
|
const previousActiveId = activeCanvasId.value;
|
|
// Temporarily set this canvas as active to initialize it
|
|
activeCanvasId.value = canvasId;
|
|
|
|
const canvasWidth = DISPLAY_SIZE;
|
|
const canvasHeight = Math.round(DISPLAY_SIZE * 0.67); // 3:2 aspect ratio for table
|
|
instance.setDimensions({ width: canvasWidth, height: canvasHeight });
|
|
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();
|
|
|
|
// Restore the active canvas to 'front' if this wasn't the front canvas
|
|
if (canvasId !== 'front' && previousActiveId) {
|
|
activeCanvasId.value = previousActiveId;
|
|
} else if (canvasId === 'front') {
|
|
// Keep 'front' as active
|
|
activeCanvasId.value = 'front';
|
|
}
|
|
};
|
|
|
|
const unregisterCanvas = (canvasId?: string) => {
|
|
if (canvasId && canvases.value[canvasId]) {
|
|
canvases.value[canvasId]?.dispose();
|
|
delete canvases.value[canvasId];
|
|
delete backgroundCircle.value[canvasId];
|
|
delete safeZoneCircle.value[canvasId];
|
|
} else {
|
|
// Unregister all
|
|
Object.values(canvases.value).forEach(c => c?.dispose());
|
|
canvases.value = {};
|
|
backgroundCircle.value = {};
|
|
safeZoneCircle.value = {};
|
|
fabricApi.value = null;
|
|
activeFillColor.value = null;
|
|
activeStrokeColor.value = null;
|
|
hasStyleableSelection.value = false;
|
|
zoomLevel.value = 1;
|
|
}
|
|
};
|
|
|
|
const setActiveCanvas = (canvasId: string) => {
|
|
if (canvases.value[canvasId]) {
|
|
activeCanvasId.value = canvasId;
|
|
updateSelectedStyleState();
|
|
}
|
|
};
|
|
|
|
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,
|
|
setActiveCanvas,
|
|
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();
|
|
};
|