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"; import type { Canvas as FabricCanvas } from "fabric";
const props = defineProps<{ const props = defineProps<{
canvasId?: string;
size: number; size: number;
registerCanvas: (payload: { registerCanvas: (payload: {
canvas: FabricCanvas; canvas: FabricCanvas;
fabric: typeof import("fabric"); fabric: typeof import("fabric");
canvasId?: string;
}) => void; }) => void;
unregisterCanvas: () => void; unregisterCanvas: (canvasId?: string) => void;
backgroundColor: string; backgroundColor: string;
}>(); }>();
@@ -30,28 +32,28 @@ const syncCanvasDomStyles = () => {
if (!element) { if (!element) {
return; return;
} }
element.classList.add("rounded-full"); element.classList.add("rounded-lg");
element.style.borderRadius = "9999px"; element.style.borderRadius = "8px";
element.style.backgroundColor = "transparent"; element.style.backgroundColor = "transparent";
}); });
}; };
const updateCssDimensions = (dimension?: number) => { const updateCssDimensions = (containerWidth?: number) => {
if (!fabricCanvas) { if (!fabricCanvas) {
return; return;
} }
const targetSize = const targetWidth =
dimension ?? containerWidth ??
containerElement.value?.clientWidth ?? containerElement.value?.clientWidth ??
containerElement.value?.clientHeight ??
null; null;
if (!targetSize) { if (!targetWidth) {
return; return;
} }
const targetHeight = Math.round(targetWidth * 0.67); // 3:2 aspect ratio
fabricCanvas.setDimensions( fabricCanvas.setDimensions(
{ {
width: targetSize, width: targetWidth,
height: targetSize, height: targetHeight,
}, },
{ cssOnly: true } { cssOnly: true }
); );
@@ -70,8 +72,8 @@ const observeContainer = () => {
if (!entry) { if (!entry) {
return; return;
} }
const dimension = Math.min(entry.contentRect.width, entry.contentRect.height); const containerWidth = entry.contentRect.width;
updateCssDimensions(dimension); updateCssDimensions(containerWidth);
}); });
resizeObserver.observe(containerElement.value); resizeObserver.observe(containerElement.value);
updateCssDimensions(); updateCssDimensions();
@@ -91,9 +93,14 @@ const setupCanvas = async () => {
preserveObjectStacking: true, 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(); observeContainer();
syncCanvasDomStyles(); syncCanvasDomStyles();
isReady.value = true; isReady.value = true;
@@ -104,7 +111,7 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
props.unregisterCanvas(); props.unregisterCanvas(props.canvasId);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
resizeObserver = null; resizeObserver = null;
fabricCanvas = null; fabricCanvas = null;
@@ -118,7 +125,8 @@ watch(
if (!isReady.value || next === prev || !fabricCanvas) { if (!isReady.value || next === prev || !fabricCanvas) {
return; 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(); updateCssDimensions();
fabricCanvas.renderAll(); fabricCanvas.renderAll();
} }
@@ -127,20 +135,20 @@ watch(
<template> <template>
<div <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 class="absolute inset-4 sm:inset-5 md:inset-6 lg:inset-8">
<div ref="containerElement" class="relative h-full w-full"> <div ref="containerElement" class="relative h-full w-full">
<canvas <canvas
ref="canvasElement" ref="canvasElement"
class="absolute inset-0 h-full w-full rounded-full" class="absolute inset-0 h-full w-full rounded-lg"
:width="size" :width="size"
:height="size" :height="size"
/> />
<div <div
v-if="!isReady" 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> <span class="text-sm font-medium text-slate-400">Loading canvas</span>
</div> </div>

View File

@@ -18,6 +18,7 @@ const {
previewUrl, previewUrl,
registerCanvas, registerCanvas,
unregisterCanvas, unregisterCanvas,
setActiveCanvas,
addTextbox, addTextbox,
addShape, addShape,
addImageFromFile, addImageFromFile,
@@ -41,6 +42,14 @@ const {
resetZoom, resetZoom,
} = useSlipmatDesigner(); } = 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 DESIGN_PRICE_USD = 39.99;
const { user, backendUser, initAuth, isLoading } = useAuth(); 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"> <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) --> <!-- Left Sidebar - Template Picker and Preview (together on desktop, separate on mobile) -->
<div class="contents lg:block lg:space-y-6"> <div class="contents lg:block lg:space-y-6">
<div class="order-1"> <!-- <div class="order-1">
<TemplatePicker <TemplatePicker
:templates="templates" :templates="templates"
:selected-template-id="selectedTemplate.id" :selected-template-id="selectedTemplate.id"
@select="handleTemplateSelect" @select="handleTemplateSelect"
/> />
</div> </div> -->
<div class="order-3"> <div class="order-3">
<DesignerPreview <DesignerPreview
@@ -396,6 +405,23 @@ const handleCheckout = async () => {
<!-- Designer Canvas - Second on mobile, right column on desktop --> <!-- Designer Canvas - Second on mobile, right column on desktop -->
<div class="order-2 flex flex-col gap-6 lg:order-0"> <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 <div
class="rounded-3xl border border-slate-200 bg-white shadow-xl" class="rounded-3xl border border-slate-200 bg-white shadow-xl"
> >
@@ -420,17 +446,55 @@ const handleCheckout = async () => {
:on-zoom-out="zoomOut" :on-zoom-out="zoomOut"
:on-zoom-reset="resetZoom" :on-zoom-reset="resetZoom"
/> />
<div class="p-6"> <div class="p-6 space-y-4">
<DesignerCanvas <!-- Canvas for each view -->
:size="displaySize" <div v-show="activeView === 'front'" class="canvas-container">
:background-color="selectedTemplate.backgroundColor" <h3 class="mb-2 text-sm font-semibold text-slate-700">Front View</h3>
:register-canvas="registerCanvas" <DesignerCanvas
:unregister-canvas="unregisterCanvas" 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"> <p class="mt-4 text-sm text-slate-600">
Safe zone and bleed guides update automatically when you switch Design each view of your table jersey separately. Switch between views using the tabs above.
templates. Use the toolbar to layer text, shapes, and imagery Safe zone and bleed guides update automatically when you switch templates.
inside the design area.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,58 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue";
const router = useRouter(); const router = useRouter();
const startDesigning = () => { const startDesigning = () => {
router.push('/designer'); router.push("/designer");
}; };
onMounted(() => {
// Import model-viewer on client-side only
if (process.client) {
import("@google/model-viewer");
}
});
</script> </script>
<template> <template>
<div class="relative min-h-screen overflow-hidden bg-white"> <div class="relative min-h-screen overflow-hidden bg-white">
<!-- Subtle Background Pattern --> <!-- Subtle Background Pattern -->
<div class="absolute inset-0 opacity-5"> <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> </div>
<!-- Main Content Grid --> <!-- 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 --> <!-- 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 --> <!-- Left Side - Title & Description -->
<div class="w-full space-y-8 text-center lg:w-1/2 lg:text-left"> <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 TableJerseys
</h1> </h1>
<div class="space-y-4"> <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 Design custom jerseys for your table
</p> </p>
<!-- Simple preview for mobile view --> <!-- Simple preview for mobile view -->
<div class="relative mx-auto my-8 block lg:hidden"> <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="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 --> <!-- 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"> <div class="relative">
<!-- Table with fitted cover --> <!-- Table with fitted cover -->
<div class="relative"> <div class="relative">
<!-- Table Top --> <!-- 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 --> <!-- 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-center">
<div class="text-2xl font-bold text-slate-800">23</div> <div class="text-2xl font-bold text-slate-800">
<div class="text-[7px] font-semibold text-slate-600 tracking-wider">CUSTOM</div> 23
</div>
<div
class="text-[7px] font-semibold text-slate-600 tracking-wider"
>
CUSTOM
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Fitted Cover Skirt (draping down) --> <!-- 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 --> <!-- 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 --> <!-- 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> </div>
</div> </div>
@@ -75,69 +132,67 @@ const startDesigning = () => {
> >
<span class="relative z-10 flex items-center gap-3"> <span class="relative z-10 flex items-center gap-3">
Start Designing Start Designing
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" /> 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> </svg>
</span> </span>
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Stats Section - Now on Left Side --> <!-- 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="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 class="text-sm text-slate-600">Any Size</div>
</div> </div>
<div class="space-y-2 text-center lg:text-left"> <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 class="text-sm text-slate-600">Print Quality</div>
</div> </div>
<div class="space-y-2 text-center lg:text-left"> <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 class="text-sm text-slate-600">Per Design</div>
</div> </div>
</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 hidden w-full lg:block lg:w-1/2">
<div class="relative mx-auto max-w-4xl"> <div class="relative mx-auto max-w-4xl">
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]"> <div
<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"> class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]"
<!-- Rotating Table with Fitted Cover --> >
<div class=" relative" style="transform-style: preserve-3d;"> <div
<div class="relative"> 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"
<!-- Table with fitted cover --> >
<div class="relative"> <model-viewer
<!-- Table Top with jersey on cover --> src="/LAMESA.glb"
<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"> alt="Table with Jersey"
<!-- Jersey design on top of cover --> auto-rotate
<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"> rotation-per-second="15deg"
<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"> camera-controls
<div class="text-center"> shadow-intensity="1"
<div class="text-5xl sm:text-7xl font-bold text-slate-800">23</div> class="w-full h-full"
<div class="text-sm sm:text-xl font-semibold text-slate-600 tracking-wider">CUSTOM</div> style="--poster-color: transparent"
</div> ></model-viewer>
</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> </div>
</div> </div>
</div> </div>

View File

@@ -86,9 +86,11 @@ export const useSlipmatDesigner = () => {
const selectedTemplate = ref<SlipmatTemplate>(FALLBACK_TEMPLATE); const selectedTemplate = ref<SlipmatTemplate>(FALLBACK_TEMPLATE);
const fabricApi = shallowRef<FabricModule | null>(null); const fabricApi = shallowRef<FabricModule | null>(null);
const canvas = shallowRef<FabricCanvas | null>(null); const canvases = shallowRef<Record<string, FabricCanvas>>({});
const backgroundCircle = shallowRef<FabricCircle | null>(null); const activeCanvasId = ref<string>('front');
const safeZoneCircle = shallowRef<FabricCircle | null>(null); 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 previewUrl = ref<string | null>(null);
const previewBlob = shallowRef<Blob | null>(null); const previewBlob = shallowRef<Blob | null>(null);
@@ -135,20 +137,32 @@ export const useSlipmatDesigner = () => {
if (!currentCanvas) { if (!currentCanvas) {
return; return;
} }
if (backgroundCircle.value) { const bgCircle = backgroundCircle.value[activeCanvasId.value];
backgroundCircle.value.set({ radius: currentCanvas.getWidth() / 2 }); 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) { if (bgCircle) {
currentCanvas.sendObjectToBack(backgroundCircle.value); currentCanvas.sendObjectToBack(bgCircle);
} }
if (safeZoneCircle.value) { if (safeCircle) {
currentCanvas.bringObjectToFront(safeZoneCircle.value); currentCanvas.bringObjectToFront(safeCircle);
} }
currentCanvas.requestRenderAll(); currentCanvas.requestRenderAll();
}; };
const isStaticObject = (object: FabricObject | null) => const isStaticObject = (object: FabricObject | null) => {
object === backgroundCircle.value || object === safeZoneCircle.value; const bgCircle = backgroundCircle.value[activeCanvasId.value];
const safeCircle = safeZoneCircle.value[activeCanvasId.value];
return object === bgCircle || object === safeCircle;
};
const getSelectedObjects = (): FabricObject[] => { const getSelectedObjects = (): FabricObject[] => {
const currentCanvas = canvas.value; const currentCanvas = canvas.value;
@@ -222,30 +236,35 @@ export const useSlipmatDesigner = () => {
return; return;
} }
const size = currentCanvas.getWidth(); const width = currentCanvas.getWidth();
const height = currentCanvas.getHeight();
const { backgroundColor, safeZoneInches = 0, diameterInches } = const { backgroundColor, safeZoneInches = 0, diameterInches } =
selectedTemplate.value; selectedTemplate.value;
const bgCircle = const canvasId = activeCanvasId.value;
backgroundCircle.value ?? const bgRect =
new fabric.Circle({ backgroundCircle.value[canvasId] as any ??
left: size / 2, new fabric.Rect({
top: size / 2, left: 0,
radius: size / 2, top: 0,
originX: "center", width: width,
originY: "center", height: height,
originX: "left",
originY: "top",
selectable: false, selectable: false,
evented: false, evented: false,
rx: 8,
ry: 8,
}); });
bgCircle.set({ fill: backgroundColor }); bgRect.set({ fill: backgroundColor });
bgCircle.set({ radius: size / 2 }); bgRect.set({ width: width, height: height });
if (!backgroundCircle.value) { if (!backgroundCircle.value[canvasId]) {
backgroundCircle.value = bgCircle; backgroundCircle.value[canvasId] = bgRect as any;
currentCanvas.add(bgCircle); currentCanvas.add(bgRect);
} }
currentCanvas.sendObjectToBack(bgCircle); currentCanvas.sendObjectToBack(bgRect);
currentCanvas.requestRenderAll(); currentCanvas.requestRenderAll();
const safeRatio = Math.max( const safeRatio = Math.max(
@@ -253,16 +272,17 @@ export const useSlipmatDesigner = () => {
(diameterInches - safeZoneInches * 2) / diameterInches (diameterInches - safeZoneInches * 2) / diameterInches
); );
const safeCircleRadius = (size / 2) * safeRatio; const safeMargin = ((1 - safeRatio) * Math.min(width, height)) / 2;
const safeCircle = const safeRect =
safeZoneCircle.value ?? safeZoneCircle.value[canvasId] as any ??
new fabric.Circle({ new fabric.Rect({
left: size / 2, left: safeMargin,
top: size / 2, top: safeMargin,
radius: safeCircleRadius, width: width - safeMargin * 2,
originX: "center", height: height - safeMargin * 2,
originY: "center", originX: "left",
originY: "top",
fill: "rgba(0,0,0,0)", fill: "rgba(0,0,0,0)",
stroke: "#4b5563", stroke: "#4b5563",
strokeDashArray: [8, 8], strokeDashArray: [8, 8],
@@ -270,36 +290,52 @@ export const useSlipmatDesigner = () => {
selectable: false, selectable: false,
evented: false, evented: false,
hoverCursor: "default", 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) { if (!safeZoneCircle.value[canvasId]) {
safeZoneCircle.value = safeCircle; safeZoneCircle.value[canvasId] = safeRect as any;
currentCanvas.add(safeCircle); currentCanvas.add(safeRect);
} }
maintainStaticLayerOrder(); maintainStaticLayerOrder();
currentCanvas.requestRenderAll(); currentCanvas.requestRenderAll();
currentCanvas.clipPath = new fabric.Circle({ currentCanvas.clipPath = new fabric.Rect({
left: size / 2, left: 0,
top: size / 2, top: 0,
radius: size / 2, width: width,
originX: "center", height: height,
originY: "center", originX: "left",
originY: "top",
absolutePositioned: true, absolutePositioned: true,
rx: 8,
ry: 8,
}); });
currentCanvas.renderAll(); currentCanvas.renderAll();
schedulePreviewRefresh(); schedulePreviewRefresh();
}; };
const registerCanvas = ({ canvas: instance, fabric }: CanvasReadyPayload) => { const registerCanvas = ({ canvas: instance, fabric, canvasId = 'front' }: CanvasReadyPayload & { canvasId?: string }) => {
fabricApi.value = fabric; 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; instance.preserveObjectStacking = true;
const refreshEvents = [ const refreshEvents = [
@@ -352,18 +388,41 @@ export const useSlipmatDesigner = () => {
updateSelectedStyleState(); updateSelectedStyleState();
setZoom(zoomLevel.value); setZoom(zoomLevel.value);
schedulePreviewRefresh(); 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 = () => { const unregisterCanvas = (canvasId?: string) => {
canvas.value?.dispose(); if (canvasId && canvases.value[canvasId]) {
canvas.value = null; canvases.value[canvasId]?.dispose();
fabricApi.value = null; delete canvases.value[canvasId];
backgroundCircle.value = null; delete backgroundCircle.value[canvasId];
safeZoneCircle.value = null; delete safeZoneCircle.value[canvasId];
activeFillColor.value = null; } else {
activeStrokeColor.value = null; // Unregister all
hasStyleableSelection.value = false; Object.values(canvases.value).forEach(c => c?.dispose());
zoomLevel.value = 1; 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 centerPoint = () => {
@@ -795,6 +854,7 @@ export const useSlipmatDesigner = () => {
maxZoom: MAX_ZOOM, maxZoom: MAX_ZOOM,
registerCanvas, registerCanvas,
unregisterCanvas, unregisterCanvas,
setActiveCanvas,
addTextbox, addTextbox,
addShape, addShape,
addImageFromFile, addImageFromFile,

119
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"name": "tablejerseys-web", "name": "tablejerseys-web",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@google/model-viewer": "^4.1.0",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"fabric": "^6.0.2", "fabric": "^6.0.2",
@@ -1513,6 +1514,22 @@
"integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==",
"license": "Apache-2.0" "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": { "node_modules/@grpc/grpc-js": {
"version": "1.9.15", "version": "1.9.15",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz",
@@ -1699,6 +1716,21 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT" "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": { "node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", "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-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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", "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==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT" "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": { "node_modules/@unhead/vue": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz",
@@ -7294,6 +7344,12 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==", "integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT" "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": { "node_modules/impound": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/impound/-/impound-1.0.0.tgz", "resolved": "https://registry.npmjs.org/impound/-/impound-1.0.0.tgz",
@@ -7495,6 +7551,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/is-reference": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -7758,6 +7820,15 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -8055,6 +8126,37 @@
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"license": "MIT" "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": { "node_modules/local-pkg": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "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==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "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": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -11057,6 +11169,13 @@
"b4a": "^1.6.4" "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": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

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

BIN
public/LAMESA.glb Normal file

Binary file not shown.