feat: priblizenie platna v editore cenoviek (uloha 9)

- posuvnik a tlacidla +/- (50-400 %), klik na percenta vrati 100 %, Ctrl + koliesko
- tahanie vrstiev prepocitava posun podla priblizenia
- platno sa posuva a vyplna vysku stlpca, vrch cenovky sa uz neoreze

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-19 10:03:01 +02:00
1 parent 61c8c2fb76
commit 48d071d48e
1 file changed
+101 -14
+101 -14
View File
@@ -4,7 +4,12 @@ import React, { useState, useRef, useEffect } from 'react';
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { supabase } from '@/lib/supabase';
import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng } from '@/lib/badgeUtils';
import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight } from 'lucide-react';
import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight, ZoomIn, ZoomOut } from 'lucide-react';
// Priblíženie plátna v editore: rozsah posuvníka a kroky tlačidiel +/−
const CANVAS_ZOOM_MIN = 0.5;
const CANVAS_ZOOM_MAX = 4;
const CANVAS_ZOOM_STEPS = [0.5, 0.75, 1, 1.5, 2, 3, 4];
interface BadgeEditorModalProps {
isOpen: boolean;
@@ -71,6 +76,33 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
const [isExportingPng, setIsExportingPng] = useState(false);
const [isSavingDb, setIsSavingDb] = useState(false);
// Priblíženie plátna (len pre editor, neovplyvňuje výslednú cenovku)
const [canvasZoom, setCanvasZoom] = useState(1);
const canvasAreaRef = useRef<HTMLDivElement>(null);
const hasActiveBadge = !!activeBadge;
const zoomIn = () =>
setCanvasZoom((z) => CANVAS_ZOOM_STEPS.find((s) => s > z) ?? z);
const zoomOut = () =>
setCanvasZoom((z) => [...CANVAS_ZOOM_STEPS].reverse().find((s) => s < z) ?? z);
// Ctrl/Cmd + koliesko myši na plátne = priblíženie / oddialenie
useEffect(() => {
const el = canvasAreaRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setCanvasZoom((z) =>
e.deltaY < 0
? CANVAS_ZOOM_STEPS.find((s) => s > z) ?? z
: [...CANVAS_ZOOM_STEPS].reverse().find((s) => s < z) ?? z
);
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [isOpen, hasActiveBadge]);
// Stav pre priamy Drag & Drop vrstiev na plátne
const [dragState, setDragState] = useState<{
layerId: string;
@@ -111,8 +143,8 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
if (!dragState) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
const deltaX = (e.clientX - dragState.startX) / canvasZoom;
const deltaY = (e.clientY - dragState.startY) / canvasZoom;
const newX = Math.round(dragState.layerStartX + deltaX);
const newY = Math.round(dragState.layerStartY + deltaY);
@@ -130,8 +162,8 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
const handleTouchMove = (e: TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
const deltaX = touch.clientX - dragState.startX;
const deltaY = touch.clientY - dragState.startY;
const deltaX = (touch.clientX - dragState.startX) / canvasZoom;
const deltaY = (touch.clientY - dragState.startY) / canvasZoom;
const newX = Math.round(dragState.layerStartX + deltaX);
const newY = Math.round(dragState.layerStartY + deltaY);
@@ -161,7 +193,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleDragEnd);
};
}, [dragState]);
}, [dragState, canvasZoom]);
// Načítanie písiem pre všetky vrstvy aktívnej cenovky
useEffect(() => {
@@ -645,22 +677,73 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
</div>
{/* 2. Stĺpec: Vektorový Canvas & Náhľad s Drag & Drop */}
<div className="col-span-5 p-6 flex flex-col items-center justify-center bg-slate-950 relative">
<div className="absolute top-4 left-4 flex items-center gap-2">
<div className="col-span-5 min-h-0 flex flex-col bg-slate-950">
{/* Lišta plátna: názov + ovládanie priblíženia */}
<div className="shrink-0 flex items-center justify-between gap-2 px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-400 flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5" /> Náhľad na plátne
</span>
<span className="text-[10px] text-blue-400 bg-blue-950/60 border border-blue-800/60 px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Move className="w-3 h-3" /> Drag & Drop vrstiev
<Move className="w-3 h-3" /> Drag & Drop
</span>
</div>
{activeBadge && (
<div className="flex items-center gap-1 bg-slate-900/90 border border-slate-700 rounded-full px-1.5 py-1 text-slate-300">
<button
type="button"
onClick={zoomOut}
disabled={canvasZoom <= CANVAS_ZOOM_MIN}
className="p-1 rounded-full hover:bg-slate-700 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent"
title="Oddialiť (Ctrl + koliesko)"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<input
type="range"
min={CANVAS_ZOOM_MIN}
max={CANVAS_ZOOM_MAX}
step={0.05}
value={canvasZoom}
onChange={(e) => setCanvasZoom(Number(e.target.value))}
className="w-24 h-1 accent-blue-400 cursor-pointer"
title="Priblíženie plátna"
/>
<button
type="button"
onClick={zoomIn}
disabled={canvasZoom >= CANVAS_ZOOM_MAX}
className="p-1 rounded-full hover:bg-slate-700 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent"
title="Priblížiť (Ctrl + koliesko)"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setCanvasZoom(1)}
className="min-w-[44px] text-[10px] font-mono font-bold hover:text-white"
title="Obnoviť na 100 %"
>
{Math.round(canvasZoom * 100)} %
</button>
</div>
)}
</div>
{activeBadge ? (
<div className="flex flex-col items-center justify-center w-full">
<>
{/* Priblížiteľná a posúvateľná oblasť plátna (vyplní zvyšnú výšku stĺpca) */}
<div ref={canvasAreaRef} className="flex-1 min-h-0 overflow-auto flex">
{/* Rezerva okolo cenovky, aby vrstvy a popisky presahujúce podklad neboli orezané */}
<div className="m-auto shrink-0 p-10">
{/* Obal simulujúci veľkosť, zarovnanie a odsadenie */}
<div
className="w-full max-w-xs flex"
className="flex"
style={{
zoom: canvasZoom,
// Obal má na obrazovke stále ~320 px (simulácia zarovnania), pri veľkom priblížení sa rozšíri na celú cenovku
width: `${Math.max(activeBadge.width || 110, 320 / canvasZoom)}px`,
justifyContent:
activeBadge.align === 'left'
? 'flex-start'
@@ -672,7 +755,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
}}
>
<div
className="relative select-none transition-all"
className="relative select-none"
style={{
width: `${activeBadge.width || 110}px`
}}
@@ -730,9 +813,12 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
})}
</div>
</div>
</div>
</div>
<div className="shrink-0 flex justify-center px-4 pb-4 pt-2">
{/* Info o premenných a centoch */}
<div className="mt-6 bg-slate-900/80 border border-slate-800 rounded-xl p-3 text-[11px] text-slate-300 space-y-1.5 max-w-sm">
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-3 text-[11px] text-slate-300 space-y-1.5 max-w-sm">
<span className="font-bold text-gray-400 block mb-0.5">Dostupné dynamické značky:</span>
<div className="flex flex-wrap gap-1.5 text-[10px]">
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-amber-300">
@@ -748,8 +834,9 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-slate-500">Vyberte alebo vytvorte novú šablónu cenovky.</p>
<p className="m-auto text-sm text-slate-500">Vyberte alebo vytvorte novú šablónu cenovky.</p>
)}
</div>