first commit

This commit is contained in:
Frank John Begornia
2025-11-02 00:23:22 +08:00
commit e2955debb7
18 changed files with 13383 additions and 0 deletions

View File

@@ -0,0 +1,751 @@
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;
}
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 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);
return {
previewUrl: previewDataUrl,
previewBlob: previewDataBlob,
productionUrl: productionDataUrl,
productionBlob: productionDataBlob,
templateId: selectedTemplate.value.id,
createdAt: new Date().toISOString(),
};
} 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`
);
};
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,
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();
};