feat: enhance designer canvas with multi-view support and model viewer integration
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.
This commit is contained in:
Frank John Begornia
2026-01-12 23:00:32 +08:00
parent 3ba0b250ed
commit 27dabed2d2
7 changed files with 457 additions and 150 deletions

View File

@@ -4,12 +4,14 @@ import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { Canvas as FabricCanvas } from "fabric";
const props = defineProps<{
canvasId?: string;
size: number;
registerCanvas: (payload: {
canvas: FabricCanvas;
fabric: typeof import("fabric");
canvasId?: string;
}) => void;
unregisterCanvas: () => void;
unregisterCanvas: (canvasId?: string) => void;
backgroundColor: string;
}>();
@@ -30,28 +32,28 @@ const syncCanvasDomStyles = () => {
if (!element) {
return;
}
element.classList.add("rounded-full");
element.style.borderRadius = "9999px";
element.classList.add("rounded-lg");
element.style.borderRadius = "8px";
element.style.backgroundColor = "transparent";
});
};
const updateCssDimensions = (dimension?: number) => {
const updateCssDimensions = (containerWidth?: number) => {
if (!fabricCanvas) {
return;
}
const targetSize =
dimension ??
const targetWidth =
containerWidth ??
containerElement.value?.clientWidth ??
containerElement.value?.clientHeight ??
null;
if (!targetSize) {
if (!targetWidth) {
return;
}
const targetHeight = Math.round(targetWidth * 0.67); // 3:2 aspect ratio
fabricCanvas.setDimensions(
{
width: targetSize,
height: targetSize,
width: targetWidth,
height: targetHeight,
},
{ cssOnly: true }
);
@@ -70,8 +72,8 @@ const observeContainer = () => {
if (!entry) {
return;
}
const dimension = Math.min(entry.contentRect.width, entry.contentRect.height);
updateCssDimensions(dimension);
const containerWidth = entry.contentRect.width;
updateCssDimensions(containerWidth);
});
resizeObserver.observe(containerElement.value);
updateCssDimensions();
@@ -91,9 +93,14 @@ const setupCanvas = async () => {
preserveObjectStacking: true,
});
fabricCanvas.setDimensions({ width: props.size, height: props.size });
const canvasHeight = Math.round(props.size * 0.67); // 3:2 aspect ratio
fabricCanvas.setDimensions({ width: props.size, height: canvasHeight });
props.registerCanvas({ canvas: fabricCanvas, fabric: fabricModule });
props.registerCanvas({
canvas: fabricCanvas,
fabric: fabricModule,
canvasId: props.canvasId
});
observeContainer();
syncCanvasDomStyles();
isReady.value = true;
@@ -104,7 +111,7 @@ onMounted(() => {
});
onBeforeUnmount(() => {
props.unregisterCanvas();
props.unregisterCanvas(props.canvasId);
resizeObserver?.disconnect();
resizeObserver = null;
fabricCanvas = null;
@@ -118,7 +125,8 @@ watch(
if (!isReady.value || next === prev || !fabricCanvas) {
return;
}
fabricCanvas.setDimensions({ width: next, height: next });
const canvasHeight = Math.round(next * 0.67); // 3:2 aspect ratio
fabricCanvas.setDimensions({ width: next, height: canvasHeight });
updateCssDimensions();
fabricCanvas.renderAll();
}
@@ -127,20 +135,20 @@ watch(
<template>
<div
class="relative mx-auto flex max-w-[min(720px,100%)] items-center justify-center overflow-hidden rounded-3xl border border-slate-700/60 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/40"
class="relative mx-auto flex max-w-[min(900px,100%)] items-center justify-center overflow-hidden rounded-3xl border border-slate-700/60 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/40"
>
<div class="relative aspect-square w-full">
<div class="relative aspect-[3/2] w-full">
<div class="absolute inset-4 sm:inset-5 md:inset-6 lg:inset-8">
<div ref="containerElement" class="relative h-full w-full">
<canvas
ref="canvasElement"
class="absolute inset-0 h-full w-full rounded-full"
class="absolute inset-0 h-full w-full rounded-lg"
:width="size"
:height="size"
/>
<div
v-if="!isReady"
class="absolute inset-0 grid place-items-center rounded-full bg-slate-900/70"
class="absolute inset-0 grid place-items-center rounded-lg bg-slate-900/70"
>
<span class="text-sm font-medium text-slate-400">Loading canvas</span>
</div>

View File

@@ -18,6 +18,7 @@ const {
previewUrl,
registerCanvas,
unregisterCanvas,
setActiveCanvas,
addTextbox,
addShape,
addImageFromFile,
@@ -41,6 +42,14 @@ const {
resetZoom,
} = useSlipmatDesigner();
// Active view selector for multi-canvas design
const activeView = ref<'front' | 'top' | 'left' | 'right'>('front');
const setActiveView = (view: 'front' | 'top' | 'left' | 'right') => {
activeView.value = view;
setActiveCanvas(view);
};
const DESIGN_PRICE_USD = 39.99;
const { user, backendUser, initAuth, isLoading } = useAuth();
@@ -376,13 +385,13 @@ const handleCheckout = async () => {
<section class="mt-10 flex flex-col gap-8 lg:grid lg:grid-cols-[320px_minmax(0,1fr)] lg:gap-6">
<!-- Left Sidebar - Template Picker and Preview (together on desktop, separate on mobile) -->
<div class="contents lg:block lg:space-y-6">
<div class="order-1">
<!-- <div class="order-1">
<TemplatePicker
:templates="templates"
:selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect"
/>
</div>
</div> -->
<div class="order-3">
<DesignerPreview
@@ -396,6 +405,23 @@ const handleCheckout = async () => {
<!-- Designer Canvas - Second on mobile, right column on desktop -->
<div class="order-2 flex flex-col gap-6 lg:order-0">
<!-- View Selector Tabs -->
<div class="flex gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2">
<button
v-for="view in ['front', 'top', 'left', 'right']"
:key="view"
@click="setActiveView(view as 'front' | 'top' | 'left' | 'right')"
:class="[
'flex-1 rounded-md px-4 py-2 text-sm font-semibold capitalize transition-all',
activeView === view
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
]"
>
{{ view }} View
</button>
</div>
<div
class="rounded-3xl border border-slate-200 bg-white shadow-xl"
>
@@ -420,17 +446,55 @@ const handleCheckout = async () => {
:on-zoom-out="zoomOut"
:on-zoom-reset="resetZoom"
/>
<div class="p-6">
<DesignerCanvas
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
<div class="p-6 space-y-4">
<!-- Canvas for each view -->
<div v-show="activeView === 'front'" class="canvas-container">
<h3 class="mb-2 text-sm font-semibold text-slate-700">Front View</h3>
<DesignerCanvas
canvas-id="front"
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
</div>
<div v-show="activeView === 'top'" class="canvas-container">
<h3 class="mb-2 text-sm font-semibold text-slate-700">Top View</h3>
<DesignerCanvas
canvas-id="top"
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
</div>
<div v-show="activeView === 'left'" class="canvas-container">
<h3 class="mb-2 text-sm font-semibold text-slate-700">Left View</h3>
<DesignerCanvas
canvas-id="left"
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
</div>
<div v-show="activeView === 'right'" class="canvas-container">
<h3 class="mb-2 text-sm font-semibold text-slate-700">Right View</h3>
<DesignerCanvas
canvas-id="right"
:size="displaySize"
:background-color="selectedTemplate.backgroundColor"
:register-canvas="registerCanvas"
:unregister-canvas="unregisterCanvas"
/>
</div>
<p class="mt-4 text-sm text-slate-600">
Safe zone and bleed guides update automatically when you switch
templates. Use the toolbar to layer text, shapes, and imagery
inside the design area.
Design each view of your table jersey separately. Switch between views using the tabs above.
Safe zone and bleed guides update automatically when you switch templates.
</p>
</div>
</div>

View File

@@ -1,58 +1,115 @@
<script setup lang="ts">
import { onMounted } from "vue";
const router = useRouter();
const startDesigning = () => {
router.push('/designer');
router.push("/designer");
};
onMounted(() => {
// Import model-viewer on client-side only
if (process.client) {
import("@google/model-viewer");
}
});
</script>
<template>
<div class="relative min-h-screen overflow-hidden bg-white">
<!-- Subtle Background Pattern -->
<div class="absolute inset-0 opacity-5">
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(156 163 175) 1px, transparent 0); background-size: 40px 40px;"></div>
<div
class="absolute inset-0"
style="
background-image: radial-gradient(
circle at 1px 1px,
rgb(156 163 175) 1px,
transparent 0
);
background-size: 40px 40px;
"
></div>
</div>
<!-- Main Content Grid -->
<div class="relative z-20 flex min-h-screen flex-col px-4 py-12 md:px-8 lg:px-16">
<div
class="relative z-20 flex min-h-screen flex-col px-4 py-12 md:px-8 lg:px-16"
>
<!-- Top Section - Split Layout -->
<div class="flex flex-1 flex-col items-center justify-between gap-8 lg:flex-row lg:gap-16">
<div
class="flex flex-1 flex-col items-center justify-between gap-8 lg:flex-row lg:gap-16"
>
<!-- Left Side - Title & Description -->
<div class="w-full space-y-8 text-center lg:w-1/2 lg:text-left">
<h1 class="text-6xl font-bold tracking-tight text-slate-900 sm:text-7xl md:text-8xl">
<h1
class="text-6xl font-bold tracking-tight text-slate-900 sm:text-7xl md:text-8xl"
>
TableJerseys
</h1>
<div class="space-y-4">
<p class="text-2xl font-semibold text-slate-700 sm:text-3xl md:text-4xl">
<p
class="text-2xl font-semibold text-slate-700 sm:text-3xl md:text-4xl"
>
Design custom jerseys for your table
</p>
<!-- Simple preview for mobile view -->
<div class="relative mx-auto my-8 block lg:hidden">
<div class="relative mx-auto h-[280px] w-[280px] sm:h-80 sm:w-80">
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-lg overflow-hidden">
<div
class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-lg overflow-hidden"
>
<!-- Rotating Table with Fitted Cover -->
<div class="animate-spin-slow relative" style="transform-style: preserve-3d;">
<div
class="animate-spin-slow relative"
style="transform-style: preserve-3d"
>
<div class="relative">
<!-- Table with fitted cover -->
<div class="relative">
<!-- Table Top -->
<div class="relative h-24 w-40 rounded-t-sm bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-xl">
<div
class="relative h-24 w-40 rounded-t-sm bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-xl"
>
<!-- Jersey design on top of cover -->
<div class="absolute inset-4 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-lg border-2 border-slate-300">
<div
class="absolute inset-4 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-lg border-2 border-slate-300"
>
<div class="text-center">
<div class="text-2xl font-bold text-slate-800">23</div>
<div class="text-[7px] font-semibold text-slate-600 tracking-wider">CUSTOM</div>
<div class="text-2xl font-bold text-slate-800">
23
</div>
<div
class="text-[7px] font-semibold text-slate-600 tracking-wider"
>
CUSTOM
</div>
</div>
</div>
</div>
<!-- Fitted Cover Skirt (draping down) -->
<div class="absolute top-24 left-0 right-0 h-16 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-sm shadow-2xl">
<div
class="absolute top-24 left-0 right-0 h-16 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-sm shadow-2xl"
>
<!-- Fabric folds/creases -->
<div class="absolute inset-0 opacity-30" style="background: repeating-linear-gradient(90deg, transparent, transparent 8px, rgba(0,0,0,0.3) 8px, rgba(0,0,0,0.3) 9px);"></div>
<div
class="absolute inset-0 opacity-30"
style="
background: repeating-linear-gradient(
90deg,
transparent,
transparent 8px,
rgba(0, 0, 0, 0.3) 8px,
rgba(0, 0, 0, 0.3) 9px
);
"
></div>
<!-- Shadow at bottom -->
<div class="absolute bottom-0 left-0 right-0 h-2 bg-black/40"></div>
<div
class="absolute bottom-0 left-0 right-0 h-2 bg-black/40"
></div>
</div>
</div>
</div>
@@ -75,69 +132,67 @@ const startDesigning = () => {
>
<span class="relative z-10 flex items-center gap-3">
Start Designing
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
<svg
class="h-6 w-6 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</span>
</NuxtLink>
</div>
<!-- Stats Section - Now on Left Side -->
<div class="mx-auto grid max-w-4xl gap-8 pt-12 grid-cols-3 sm:grid-cols-3 lg:mx-0">
<div
class="mx-auto grid max-w-4xl gap-8 pt-12 grid-cols-3 sm:grid-cols-3 lg:mx-0"
>
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">Custom</div>
<div class="text-3xl font-bold text-slate-900 md:text-4xl">
Custom
</div>
<div class="text-sm text-slate-600">Any Size</div>
</div>
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">300 DPI</div>
<div class="text-3xl font-bold text-slate-900 md:text-4xl">
300 DPI
</div>
<div class="text-sm text-slate-600">Print Quality</div>
</div>
<div class="space-y-2 text-center lg:text-left">
<div class="text-3xl font-bold text-slate-900 md:text-4xl">$39.99</div>
<div class="text-3xl font-bold text-slate-900 md:text-4xl">
$39.99
</div>
<div class="text-sm text-slate-600">Per Design</div>
</div>
</div>
</div>
<!-- Right Side - Simple Image -->
<!-- Right Side - 3D Model -->
<div class="relative hidden w-full lg:block lg:w-1/2">
<div class="relative mx-auto max-w-4xl">
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]">
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-xl overflow-hidden">
<!-- Rotating Table with Fitted Cover -->
<div class=" relative" style="transform-style: preserve-3d;">
<div class="relative">
<!-- Table with fitted cover -->
<div class="relative">
<!-- Table Top with jersey on cover -->
<div class="relative h-48 w-96 sm:h-64 sm:w-[500px] rounded-t-lg bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-2xl">
<!-- Jersey design on top of cover -->
<div class="absolute inset-8 sm:inset-12 flex items-center justify-center rounded-xl bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-2xl border-4 border-slate-300">
<div class="absolute inset-4 sm:inset-6 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-100 via-white to-red-100 border-2 border-slate-200">
<div class="text-center">
<div class="text-5xl sm:text-7xl font-bold text-slate-800">23</div>
<div class="text-sm sm:text-xl font-semibold text-slate-600 tracking-wider">CUSTOM</div>
</div>
</div>
</div>
<!-- Subtle highlights on cover top -->
<div class="absolute top-4 left-8 h-6 w-12 rounded-full bg-white/10 blur-lg"></div>
</div>
<!-- Fitted Cover Skirt (draping down like trade show table) -->
<div class="absolute top-48 sm:top-64 left-0 right-0 h-32 sm:h-40 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-lg shadow-2xl">
<!-- Fabric folds/pleats -->
<div class="absolute inset-0 opacity-40" style="background: repeating-linear-gradient(90deg, transparent, transparent 16px, rgba(0,0,0,0.4) 16px, rgba(0,0,0,0.4) 18px);"></div>
<!-- More realistic vertical folds -->
<div class="absolute inset-0 opacity-20" style="background: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, transparent 2px, transparent 14px, rgba(0,0,0,0.3) 16px);"></div>
<!-- Shadow at bottom edge -->
<div class="absolute bottom-0 left-0 right-0 h-4 bg-black/50 rounded-b-lg"></div>
<!-- Ground shadow -->
<div class="absolute -bottom-2 left-4 right-4 h-2 bg-black/30 blur-sm rounded-full"></div>
</div>
</div>
</div>
</div>
<div
class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]"
>
<div
class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-300 bg-linear-to-br from-slate-700 to-slate-900 shadow-xl overflow-hidden"
>
<model-viewer
src="/LAMESA.glb"
alt="Table with Jersey"
auto-rotate
rotation-per-second="15deg"
camera-controls
shadow-intensity="1"
class="w-full h-full"
style="--poster-color: transparent"
></model-viewer>
</div>
</div>
</div>

View File

@@ -86,9 +86,11 @@ export const useSlipmatDesigner = () => {
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 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);
@@ -135,20 +137,32 @@ export const useSlipmatDesigner = () => {
if (!currentCanvas) {
return;
}
if (backgroundCircle.value) {
backgroundCircle.value.set({ radius: currentCanvas.getWidth() / 2 });
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 (backgroundCircle.value) {
currentCanvas.sendObjectToBack(backgroundCircle.value);
if (bgCircle) {
currentCanvas.sendObjectToBack(bgCircle);
}
if (safeZoneCircle.value) {
currentCanvas.bringObjectToFront(safeZoneCircle.value);
if (safeCircle) {
currentCanvas.bringObjectToFront(safeCircle);
}
currentCanvas.requestRenderAll();
};
const isStaticObject = (object: FabricObject | null) =>
object === backgroundCircle.value || object === safeZoneCircle.value;
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;
@@ -222,30 +236,35 @@ export const useSlipmatDesigner = () => {
return;
}
const size = currentCanvas.getWidth();
const width = currentCanvas.getWidth();
const height = currentCanvas.getHeight();
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",
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,
});
bgCircle.set({ fill: backgroundColor });
bgCircle.set({ radius: size / 2 });
bgRect.set({ fill: backgroundColor });
bgRect.set({ width: width, height: height });
if (!backgroundCircle.value) {
backgroundCircle.value = bgCircle;
currentCanvas.add(bgCircle);
if (!backgroundCircle.value[canvasId]) {
backgroundCircle.value[canvasId] = bgRect as any;
currentCanvas.add(bgRect);
}
currentCanvas.sendObjectToBack(bgCircle);
currentCanvas.sendObjectToBack(bgRect);
currentCanvas.requestRenderAll();
const safeRatio = Math.max(
@@ -253,16 +272,17 @@ export const useSlipmatDesigner = () => {
(diameterInches - safeZoneInches * 2) / diameterInches
);
const safeCircleRadius = (size / 2) * safeRatio;
const safeMargin = ((1 - safeRatio) * Math.min(width, height)) / 2;
const safeCircle =
safeZoneCircle.value ??
new fabric.Circle({
left: size / 2,
top: size / 2,
radius: safeCircleRadius,
originX: "center",
originY: "center",
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],
@@ -270,36 +290,52 @@ export const useSlipmatDesigner = () => {
selectable: false,
evented: false,
hoverCursor: "default",
rx: 8,
ry: 8,
});
safeCircle.set({ radius: safeCircleRadius });
safeRect.set({
left: safeMargin,
top: safeMargin,
width: width - safeMargin * 2,
height: height - safeMargin * 2
});
if (!safeZoneCircle.value) {
safeZoneCircle.value = safeCircle;
currentCanvas.add(safeCircle);
if (!safeZoneCircle.value[canvasId]) {
safeZoneCircle.value[canvasId] = safeRect as any;
currentCanvas.add(safeRect);
}
maintainStaticLayerOrder();
currentCanvas.requestRenderAll();
currentCanvas.clipPath = new fabric.Circle({
left: size / 2,
top: size / 2,
radius: size / 2,
originX: "center",
originY: "center",
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 }: CanvasReadyPayload) => {
const registerCanvas = ({ canvas: instance, fabric, canvasId = 'front' }: CanvasReadyPayload & { canvasId?: string }) => {
fabricApi.value = fabric;
canvas.value = instance;
canvases.value[canvasId] = instance;
const previousActiveId = activeCanvasId.value;
// Temporarily set this canvas as active to initialize it
activeCanvasId.value = canvasId;
instance.setDimensions({ width: DISPLAY_SIZE, height: DISPLAY_SIZE });
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 = [
@@ -352,18 +388,41 @@ export const useSlipmatDesigner = () => {
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 = () => {
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 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 = () => {
@@ -795,6 +854,7 @@ export const useSlipmatDesigner = () => {
maxZoom: MAX_ZOOM,
registerCanvas,
unregisterCanvas,
setActiveCanvas,
addTextbox,
addShape,
addImageFromFile,

119
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"name": "tablejerseys-web",
"hasInstallScript": true,
"dependencies": {
"@google/model-viewer": "^4.1.0",
"@nuxtjs/color-mode": "^3.5.2",
"@tailwindcss/vite": "^4.1.16",
"fabric": "^6.0.2",
@@ -1513,6 +1514,22 @@
"integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==",
"license": "Apache-2.0"
},
"node_modules/@google/model-viewer": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.1.0.tgz",
"integrity": "sha512-7WB/jS6wfBfRl/tWhsUUvDMKFE1KlKME97coDLlZQfvJD0nCwjhES1lJ+k7wnmf7T3zMvCfn9mIjM/mgZapuig==",
"license": "Apache-2.0",
"dependencies": {
"@monogrid/gainmap-js": "^3.1.0",
"lit": "^3.2.1"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"three": "^0.172.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.9.15",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz",
@@ -1699,6 +1716,21 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
"integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -1720,6 +1752,18 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
@@ -4183,6 +4227,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@unhead/vue": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz",
@@ -7294,6 +7344,12 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/impound": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/impound/-/impound-1.0.0.tgz",
@@ -7495,6 +7551,12 @@
"license": "MIT",
"optional": true
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -7758,6 +7820,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -8055,6 +8126,37 @@
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"license": "MIT"
},
"node_modules/lit": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
"integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -9816,6 +9918,16 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -11057,6 +11169,13 @@
"b4a": "^1.6.4"
}
},
"node_modules/three": {
"version": "0.172.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
"integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
"license": "MIT",
"peer": true
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@@ -10,7 +10,8 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/color-mode": "^3.5.2",
"@google/model-viewer": "^4.1.0",
"@nuxtjs/color-mode": "^3.5.2",
"@tailwindcss/vite": "^4.1.16",
"fabric": "^6.0.2",
"firebase": "^12.5.0",

BIN
public/LAMESA.glb Normal file

Binary file not shown.