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:
1 parent
61c8c2fb76
commit
48d071d48e
1 file changed
+186
-99
@@ -4,7 +4,12 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng } from '@/lib/badgeUtils';
|
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 {
|
interface BadgeEditorModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -71,6 +76,33 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
const [isExportingPng, setIsExportingPng] = useState(false);
|
const [isExportingPng, setIsExportingPng] = useState(false);
|
||||||
const [isSavingDb, setIsSavingDb] = 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
|
// Stav pre priamy Drag & Drop vrstiev na plátne
|
||||||
const [dragState, setDragState] = useState<{
|
const [dragState, setDragState] = useState<{
|
||||||
layerId: string;
|
layerId: string;
|
||||||
@@ -111,8 +143,8 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const deltaX = e.clientX - dragState.startX;
|
const deltaX = (e.clientX - dragState.startX) / canvasZoom;
|
||||||
const deltaY = e.clientY - dragState.startY;
|
const deltaY = (e.clientY - dragState.startY) / canvasZoom;
|
||||||
const newX = Math.round(dragState.layerStartX + deltaX);
|
const newX = Math.round(dragState.layerStartX + deltaX);
|
||||||
const newY = Math.round(dragState.layerStartY + deltaY);
|
const newY = Math.round(dragState.layerStartY + deltaY);
|
||||||
|
|
||||||
@@ -130,8 +162,8 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
const handleTouchMove = (e: TouchEvent) => {
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
if (!touch) return;
|
if (!touch) return;
|
||||||
const deltaX = touch.clientX - dragState.startX;
|
const deltaX = (touch.clientX - dragState.startX) / canvasZoom;
|
||||||
const deltaY = touch.clientY - dragState.startY;
|
const deltaY = (touch.clientY - dragState.startY) / canvasZoom;
|
||||||
const newX = Math.round(dragState.layerStartX + deltaX);
|
const newX = Math.round(dragState.layerStartX + deltaX);
|
||||||
const newY = Math.round(dragState.layerStartY + deltaY);
|
const newY = Math.round(dragState.layerStartY + deltaY);
|
||||||
|
|
||||||
@@ -161,7 +193,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
window.removeEventListener('touchend', handleDragEnd);
|
window.removeEventListener('touchend', handleDragEnd);
|
||||||
};
|
};
|
||||||
}, [dragState]);
|
}, [dragState, canvasZoom]);
|
||||||
|
|
||||||
// Načítanie písiem pre všetky vrstvy aktívnej cenovky
|
// Načítanie písiem pre všetky vrstvy aktívnej cenovky
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -645,111 +677,166 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Stĺpec: Vektorový Canvas & Náhľad s Drag & Drop */}
|
{/* 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="col-span-5 min-h-0 flex flex-col bg-slate-950">
|
||||||
<div className="absolute top-4 left-4 flex items-center gap-2">
|
{/* Lišta plátna: názov + ovládanie priblíženia */}
|
||||||
<span className="text-xs font-semibold text-slate-400 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center justify-between gap-2 px-4 pt-4 pb-2">
|
||||||
<Eye className="w-3.5 h-3.5" /> Náhľad na plátne
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="text-xs font-semibold text-slate-400 flex items-center gap-1.5">
|
||||||
<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">
|
<Eye className="w-3.5 h-3.5" /> Náhľad na plátne
|
||||||
<Move className="w-3 h-3" /> Drag & Drop vrstiev
|
</span>
|
||||||
</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
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{activeBadge ? (
|
{activeBadge ? (
|
||||||
<div className="flex flex-col items-center justify-center w-full">
|
<>
|
||||||
{/* Obal simulujúci veľkosť, zarovnanie a odsadenie */}
|
{/* Priblížiteľná a posúvateľná oblasť plátna (vyplní zvyšnú výšku stĺpca) */}
|
||||||
<div
|
<div ref={canvasAreaRef} className="flex-1 min-h-0 overflow-auto flex">
|
||||||
className="w-full max-w-xs flex"
|
{/* Rezerva okolo cenovky, aby vrstvy a popisky presahujúce podklad neboli orezané */}
|
||||||
style={{
|
<div className="m-auto shrink-0 p-10">
|
||||||
justifyContent:
|
{/* Obal simulujúci veľkosť, zarovnanie a odsadenie */}
|
||||||
activeBadge.align === 'left'
|
|
||||||
? 'flex-start'
|
|
||||||
: activeBadge.align === 'right'
|
|
||||||
? 'flex-end'
|
|
||||||
: 'center',
|
|
||||||
paddingTop: `${activeBadge.paddingTop || 0}px`,
|
|
||||||
paddingBottom: `${activeBadge.paddingBottom || 0}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative select-none transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${activeBadge.width || 110}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* SVG Podklad */}
|
|
||||||
<div
|
<div
|
||||||
className="block pointer-events-none w-full"
|
className="flex"
|
||||||
dangerouslySetInnerHTML={{ __html: activeBadge.svgRaw || '' }}
|
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
|
||||||
{/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */}
|
width: `${Math.max(activeBadge.width || 110, 320 / canvasZoom)}px`,
|
||||||
{activeBadge.layersConfig.map((layer) => {
|
justifyContent:
|
||||||
const isSelected = layer.id === selectedLayerId;
|
activeBadge.align === 'left'
|
||||||
const isDraggingThis = dragState?.layerId === layer.id;
|
? 'flex-start'
|
||||||
return (
|
: activeBadge.align === 'right'
|
||||||
|
? 'flex-end'
|
||||||
|
: 'center',
|
||||||
|
paddingTop: `${activeBadge.paddingTop || 0}px`,
|
||||||
|
paddingBottom: `${activeBadge.paddingBottom || 0}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative select-none"
|
||||||
|
style={{
|
||||||
|
width: `${activeBadge.width || 110}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* SVG Podklad */}
|
||||||
<div
|
<div
|
||||||
key={layer.id}
|
className="block pointer-events-none w-full"
|
||||||
onMouseDown={(e) => handleLayerMouseDown(e, layer)}
|
dangerouslySetInnerHTML={{ __html: activeBadge.svgRaw || '' }}
|
||||||
onTouchStart={(e) => handleLayerTouchStart(e, layer)}
|
/>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedLayerId(layer.id);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: `${layer.x}px`,
|
|
||||||
top: `${layer.y}px`,
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
fontSize: `${layer.fontSize}px`,
|
|
||||||
fontWeight: (layer.fontWeight || '700') as any,
|
|
||||||
color: layer.fill,
|
|
||||||
fontFamily: layer.fontFamily
|
|
||||||
? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
|
|
||||||
: 'Poppins, Arial, sans-serif',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
cursor: isDraggingThis ? 'grabbing' : 'grab',
|
|
||||||
userSelect: 'none'
|
|
||||||
}}
|
|
||||||
className={`px-1.5 py-0.5 rounded transition-all select-none ${
|
|
||||||
isSelected
|
|
||||||
? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg'
|
|
||||||
: 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
title="Ťahajte myšou pre zmenu pozície"
|
|
||||||
>
|
|
||||||
{renderLayerVisualContent(layer)}
|
|
||||||
|
|
||||||
{isSelected && (
|
{/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */}
|
||||||
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold bg-slate-900 text-blue-300 px-1.5 py-0.5 rounded shadow border border-slate-700 pointer-events-none whitespace-nowrap flex items-center gap-1 z-20">
|
{activeBadge.layersConfig.map((layer) => {
|
||||||
<Move className="w-2.5 h-2.5" /> {layer.x}px, {layer.y}px
|
const isSelected = layer.id === selectedLayerId;
|
||||||
</span>
|
const isDraggingThis = dragState?.layerId === layer.id;
|
||||||
)}
|
return (
|
||||||
</div>
|
<div
|
||||||
);
|
key={layer.id}
|
||||||
})}
|
onMouseDown={(e) => handleLayerMouseDown(e, layer)}
|
||||||
|
onTouchStart={(e) => handleLayerTouchStart(e, layer)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedLayerId(layer.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${layer.x}px`,
|
||||||
|
top: `${layer.y}px`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: `${layer.fontSize}px`,
|
||||||
|
fontWeight: (layer.fontWeight || '700') as any,
|
||||||
|
color: layer.fill,
|
||||||
|
fontFamily: layer.fontFamily
|
||||||
|
? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
|
||||||
|
: 'Poppins, Arial, sans-serif',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
cursor: isDraggingThis ? 'grabbing' : 'grab',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
className={`px-1.5 py-0.5 rounded transition-all select-none ${
|
||||||
|
isSelected
|
||||||
|
? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg'
|
||||||
|
: 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
title="Ťahajte myšou pre zmenu pozície"
|
||||||
|
>
|
||||||
|
{renderLayerVisualContent(layer)}
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold bg-slate-900 text-blue-300 px-1.5 py-0.5 rounded shadow border border-slate-700 pointer-events-none whitespace-nowrap flex items-center gap-1 z-20">
|
||||||
|
<Move className="w-2.5 h-2.5" /> {layer.x}px, {layer.y}px
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info o premenných a centoch */}
|
<div className="shrink-0 flex justify-center px-4 pb-4 pt-2">
|
||||||
<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">
|
{/* Info o premenných a centoch */}
|
||||||
<span className="font-bold text-gray-400 block mb-0.5">Dostupné dynamické značky:</span>
|
<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">
|
||||||
<div className="flex flex-wrap gap-1.5 text-[10px]">
|
<span className="font-bold text-gray-400 block mb-0.5">Dostupné dynamické značky:</span>
|
||||||
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-amber-300">
|
<div className="flex flex-wrap gap-1.5 text-[10px]">
|
||||||
{'{cena}'} → {currentPriceMain}
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-amber-300">
|
||||||
<sup>{currentPriceSub}</sup> (bez €)
|
{'{cena}'} → {currentPriceMain}
|
||||||
</span>
|
<sup>{currentPriceSub}</sup> (bez €)
|
||||||
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
|
</span>
|
||||||
{'{zlava}'} → {currentDiscount.replace('-', '')}
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
|
||||||
</span>
|
{'{zlava}'} → {currentDiscount.replace('-', '')}
|
||||||
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-blue-300">
|
</span>
|
||||||
{'{mena}'} → €
|
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-blue-300">
|
||||||
</span>
|
{'{mena}'} → €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user