diff --git a/src/components/builder/components/ContextMenu.tsx b/src/components/builder/components/ContextMenu.tsx index 92bdd58b..1961a328 100644 --- a/src/components/builder/components/ContextMenu.tsx +++ b/src/components/builder/components/ContextMenu.tsx @@ -52,6 +52,7 @@ interface Props { onDuplicate?: (id: RatingType) => void; onResetBadge: (id: LayerTargetId) => void; onDelete: (id: LayerTargetId) => void; + isMobile?: boolean; } const ContextMenu: React.FC = memo( @@ -72,6 +73,7 @@ const ContextMenu: React.FC = memo( onDuplicate, onResetBadge, onDelete, + isMobile = false, }) => { const menuRef = useRef(null); @@ -145,11 +147,22 @@ const ContextMenu: React.FC = memo( }, { items: [ - { id: 'hide', label: isLogo ? 'Hide Layer' : 'Hide Badge', icon: , shortcut: 'H' }, + { + id: 'hide', + label: isLogo ? 'Hide Layer' : 'Hide Badge', + icon: , + shortcut: 'H', + }, { id: 'showall', label: 'Show All Badges', icon: }, { id: 'select', - label: isSelected ? (isLogo ? 'Deselect Layer' : 'Deselect Badge') : isLogo ? 'Select Layer' : 'Select Badge', + label: isSelected + ? isLogo + ? 'Deselect Layer' + : 'Deselect Badge' + : isLogo + ? 'Select Layer' + : 'Select Badge', icon: isSelected ? : , }, { id: 'selectall', label: 'Select All', icon: }, @@ -163,9 +176,16 @@ const ContextMenu: React.FC = memo( }, { items: [ - ...(!isLogo && onDuplicate ? [{ id: 'dup', label: 'Duplicate', icon: }] : []), + ...(!isLogo && onDuplicate + ? [{ id: 'dup', label: 'Duplicate', icon: }] + : []), { id: 'reset', label: 'Reset to Defaults', icon: }, - { id: 'delete', label: isLogo ? 'Hide Logo Layer' : 'Delete Badge', icon: , danger: true }, + { + id: 'delete', + label: isLogo ? 'Hide Logo Layer' : 'Delete Badge', + icon: , + danger: true, + }, ], }, ]; @@ -200,7 +220,7 @@ const ContextMenu: React.FC = memo( exec(() => onDeselectAll()); break; case 'dup': - exec(() => onDuplicate?.(id)); + if (id !== 'logo') exec(() => onDuplicate?.(id)); break; case 'reset': exec(() => onResetBadge(id)); @@ -212,146 +232,164 @@ const ContextMenu: React.FC = memo( }; return ( -
- - {/* Badge label header */} -
+ {/* Badge label header */}
- - {isLogo ? 'logo' : id} - - {isSelected && ( +
- selected + {isLogo ? 'logo' : id} - )} -
- - {groups.map((group, gi) => ( - - {gi > 0 && ( -
- )} - {group.items.map((item) => ( - - ))} - - ))} -
+ selected + + )} +
+ + {groups.map((group, gi) => ( + + {gi > 0 && ( +
+ )} + {group.items.map((item) => ( + + ))} + + ))} +
+ ); } ); diff --git a/src/components/builder/components/PreviewCanvas.tsx b/src/components/builder/components/PreviewCanvas.tsx index eaa14129..d41e2954 100644 --- a/src/components/builder/components/PreviewCanvas.tsx +++ b/src/components/builder/components/PreviewCanvas.tsx @@ -16,12 +16,7 @@ import type { PosterConfig, RatingType } from '../types'; import { CANVAS_WIDTH, CANVAS_HEIGHT, BASE_BADGE_W, BASE_BADGE_H } from '../types'; import DraggableBadge from './DraggableBadge'; import DraggableLogo from './DraggableLogo'; -import { - calculateAutoPosition, - DEFAULT_API_BASE, - getScale, - snapToGridSize, -} from '../utils'; +import { calculateAutoPosition, DEFAULT_API_BASE, getScale, snapToGridSize } from '../utils'; import { Loader2, AlertCircle, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'; import { useEditor } from '../context/EditorContext'; import clsx from 'clsx'; @@ -59,7 +54,6 @@ const PreviewCanvas: React.FC = ({ }) => { const { viewOptions, - mobileSheetMode, clearSelection, liveRatings, liveTitle, @@ -83,12 +77,12 @@ const PreviewCanvas: React.FC = ({ null ); const [isDraggingMinimalText, setIsDraggingMinimalText] = useState(false); - const [minimalTextOffset, setMinimalTextOffset] = useState({ dx: 0, dy: 0 }); + const [, setMinimalTextOffset] = useState({ dx: 0, dy: 0 }); const minimalTextStartRef = useRef<{ mouseX: number; mouseY: number } | null>(null); const [draggingMinimalRatingIndex, setDraggingMinimalRatingIndex] = useState(null); const [draggingMinimalYear, setDraggingMinimalYear] = useState(false); const [draggingMinimalDuration, setDraggingMinimalDuration] = useState(false); - const [minimalRatingOffset, setMinimalRatingOffset] = useState({ dx: 0, dy: 0 }); + const [, setMinimalRatingOffset] = useState({ dx: 0, dy: 0 }); const [minimalMetaOffset, setMinimalMetaOffset] = useState({ dx: 0, dy: 0 }); const minimalRatingStartRef = useRef<{ mouseX: number; mouseY: number } | null>(null); const minimalMetaStartRef = useRef<{ mouseX: number; mouseY: number } | null>(null); @@ -143,10 +137,15 @@ const PreviewCanvas: React.FC = ({ legacyWidthPx && legacyWidthPx > 120 ? Math.max(4, Math.round((legacyWidthPx - 16 * scale) / approxCharPx)) : undefined; - const charWidth = Math.max(4, Math.min(80, Math.round(itemCfg?.textCharWidth ?? legacyFromPx ?? 24))); + const charWidth = Math.max( + 4, + Math.min(80, Math.round(itemCfg?.textCharWidth ?? legacyFromPx ?? 24)) + ); const legacyHeightPx = itemCfg?.textBoxHeight; const legacyHeightLines = - legacyHeightPx && legacyHeightPx > 16 ? Math.max(1, Math.round(legacyHeightPx / 36)) : undefined; + legacyHeightPx && legacyHeightPx > 16 + ? Math.max(1, Math.round(legacyHeightPx / 36)) + : undefined; const charHeight = Math.max( 1, Math.min( @@ -158,15 +157,11 @@ const PreviewCanvas: React.FC = ({ ) ) ); - const wrapEnabled = itemCfg?.textWrapEnabled ?? true; const w = Math.max(120, Math.round(charWidth * approxCharPx + 16 * scale)); - const charsPerLine = Math.max( - 1, - Math.floor((Math.max(w, 1) - 16 * scale) / approxCharPx) + const titleHeight = Math.max( + 32, + Math.round(charHeight * textSize * textLineHeight + 16 * scale) ); - const estimatedLines = Math.max(1, Math.ceil(Math.max(shown.length, 1) / charsPerLine)); - const renderedLines = wrapEnabled ? Math.min(estimatedLines, charHeight) : 1; - const titleHeight = Math.max(32, Math.round(charHeight * textSize * textLineHeight + 16 * scale)); return { w, h: titleHeight }; } const w = Math.max( @@ -180,12 +175,10 @@ const PreviewCanvas: React.FC = ({ const getBadgeRect = (id: RatingType, index: number) => { const itemConfig = config.items[id]; - const auto = calculateAutoPosition( - id, - index, - previewRatings.length, - { ...config, ratings: previewRatings } - ); + const auto = calculateAutoPosition(id, index, previewRatings.length, { + ...config, + ratings: previewRatings, + }); const x = itemConfig?.x ?? auto.x; const y = itemConfig?.y ?? auto.y; const { w, h } = getBadgeSize(id, itemConfig); @@ -202,18 +195,25 @@ const PreviewCanvas: React.FC = ({ const lastPan = useRef<{ x: number; y: number } | null>(null); useEffect(() => { + let raf: number | null = null; const handleResize = () => { - if (!containerRef.current) return; - const padding = 40; - const scaleX = (containerRef.current.clientWidth - padding) / CANVAS_WIDTH; - const scaleY = (containerRef.current.clientHeight - padding) / CANVAS_HEIGHT; - setAutoScale(Math.min(scaleX, scaleY, 1)); + if (raf !== null) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + if (!containerRef.current) return; + const padding = 40; + const scaleX = (containerRef.current.clientWidth - padding) / CANVAS_WIDTH; + const scaleY = (containerRef.current.clientHeight - padding) / CANVAS_HEIGHT; + setAutoScale(Math.min(scaleX, scaleY, 1)); + }); }; handleResize(); const observer = new ResizeObserver(handleResize); if (containerRef.current) observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [mobileSheetMode]); + return () => { + if (raf !== null) cancelAnimationFrame(raf); + observer.disconnect(); + }; + }, []); useEffect(() => { const container = containerRef.current; @@ -226,48 +226,28 @@ const PreviewCanvas: React.FC = ({ }, []); const currentScale = autoScale * zoom; + + useEffect(() => { + window.dispatchEvent( + new CustomEvent('canvas-view-change', { detail: { zoom, panX: pan.x, panY: pan.y } }) + ); + }, [zoom, pan.x, pan.y]); const toRgba = useCallback((hex: string | undefined, opacity: number) => { const c = (hex || '#000000').replace('#', ''); const valid = c.length === 3 || c.length === 6; if (!valid) return `rgba(0,0,0,${opacity})`; - const expanded = c.length === 3 ? c.split('').map((ch) => ch + ch).join('') : c; + const expanded = + c.length === 3 + ? c + .split('') + .map((ch) => ch + ch) + .join('') + : c; const r = parseInt(expanded.slice(0, 2), 16); const g = parseInt(expanded.slice(2, 4), 16); const b = parseInt(expanded.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${opacity})`; }, []); - const minimalRatings = useMemo(() => { - const raw = config.minimalRatings?.slice(0, 3) ?? []; - if (raw.length > 0) return raw; - return [ - { - provider: 'imdb' as RatingType, - enabled: true, - x: 140, - y: 672, - size: 26, - color: '#facc15', - opacity: 1, - iconMode: 'star' as const, - symbol: '★', - bgEnabled: false, - bgColor: '#000000', - bgOpacity: 0, - borderW: 0, - borderColor: '#ffffff', - borderOpacity: 0.7, - radius: 0, - paddingX: 0, - paddingY: 0, - shadowEnabled: false, - shadowX: 0, - shadowY: 0, - shadowBlur: 0, - shadowColor: '#000000', - }, - ]; - }, [config.minimalRatings]); - const clampPan = useCallback( (newX: number, newY: number) => { const container = containerRef.current; @@ -453,7 +433,10 @@ const PreviewCanvas: React.FC = ({ setConfig((prev) => { const boxW = Math.max(120, prev.minimalTitleWidth ?? 420); const boxH = Math.max(36, (prev.minimalTextSize ?? 42) * 1.5); - const nextX = Math.max(0, Math.min(CANVAS_WIDTH - boxW, Math.round(prev.minimalTextX + dx))); + const nextX = Math.max( + 0, + Math.min(CANVAS_WIDTH - boxW, Math.round(prev.minimalTextX + dx)) + ); const flow = prev.minimalTitleFlow ?? 'up'; const nextY = flow === 'up' @@ -485,8 +468,14 @@ const PreviewCanvas: React.FC = ({ const handleMinimalYearDragEnd = useCallback( (dx: number, dy: number) => { setConfig((prev) => { - const nextX = Math.max(0, Math.min(CANVAS_WIDTH - 120, Math.round((prev.minimalMetaX ?? 26) + dx))); - const nextY = Math.max(0, Math.min(CANVAS_HEIGHT - 40, Math.round((prev.minimalMetaY ?? 672) + dy))); + const nextX = Math.max( + 0, + Math.min(CANVAS_WIDTH - 120, Math.round((prev.minimalMetaX ?? 26) + dx)) + ); + const nextY = Math.max( + 0, + Math.min(CANVAS_HEIGHT - 40, Math.round((prev.minimalMetaY ?? 672) + dy)) + ); return { ...prev, minimalMetaX: nextX, minimalMetaY: nextY }; }); }, @@ -540,7 +529,8 @@ const PreviewCanvas: React.FC = ({ minimalDragRafRef.current = null; if (!minimalPendingOffsetRef.current) return; if (isDraggingMinimalText) setMinimalTextOffset(minimalPendingOffsetRef.current); - if (draggingMinimalRatingIndex !== null) setMinimalRatingOffset(minimalPendingOffsetRef.current); + if (draggingMinimalRatingIndex !== null) + setMinimalRatingOffset(minimalPendingOffsetRef.current); if (draggingMinimalYear || draggingMinimalDuration) setMinimalMetaOffset(minimalPendingOffsetRef.current); }); @@ -689,12 +679,10 @@ const PreviewCanvas: React.FC = ({ const index = previewRatings.indexOf(targetId); if (index === -1) return null; - const auto = calculateAutoPosition( - targetId, - index, - previewRatings.length, - { ...config, ratings: previewRatings } - ); + const auto = calculateAutoPosition(targetId, index, previewRatings.length, { + ...config, + ratings: previewRatings, + }); const iCfg = config.items[targetId]; let x = iCfg?.x !== undefined ? iCfg.x : auto.x; let y = iCfg?.y !== undefined ? iCfg.y : auto.y; @@ -816,73 +804,70 @@ const PreviewCanvas: React.FC = ({ {/* Badge overlays */} {previewRatings.map((id: RatingType, index: number) => { - const auto = calculateAutoPosition( - id, - index, - previewRatings.length, - { ...config, ratings: previewRatings } - ); - const iCfg = config.items[id]; - let x = iCfg?.x !== undefined ? iCfg.x : auto.x; - let y = iCfg?.y !== undefined ? iCfg.y : auto.y; - if (!isFinite(x)) x = auto.x; - if (!isFinite(y)) y = auto.y; - - let isObscuring = false; - if (hoveredBadgeId && hoveredBadgeId !== id) { - const hoveredIdx = previewRatings.indexOf(hoveredBadgeId); - if (hoveredIdx !== -1) isObscuring = checkOverlap(id, index, hoveredBadgeId, hoveredIdx); - } + const auto = calculateAutoPosition(id, index, previewRatings.length, { + ...config, + ratings: previewRatings, + }); + const iCfg = config.items[id]; + let x = iCfg?.x !== undefined ? iCfg.x : auto.x; + let y = iCfg?.y !== undefined ? iCfg.y : auto.y; + if (!isFinite(x)) x = auto.x; + if (!isFinite(y)) y = auto.y; + + let isObscuring = false; + if (hoveredBadgeId && hoveredBadgeId !== id) { + const hoveredIdx = previewRatings.indexOf(hoveredBadgeId); + if (hoveredIdx !== -1) + isObscuring = checkOverlap(id, index, hoveredBadgeId, hoveredIdx); + } - if (dragSession) { - const isTarget = dragSession.id === id; - const isGroup = selectedIds.has(dragSession.id) && selectedIds.has(id); - if (isTarget || isGroup) { - const { w: bW, h: bH } = getBadgeSize(id, iCfg); - // Preview clamp: at least 1px inside poster - let nextX = x + dragSession.dx; - let nextY = y + dragSession.dy; - if (viewOptions?.snapToGrid) { - const centerX = nextX + bW / 2; - const centerY = nextY + bH / 2; - const middleX = CANVAS_WIDTH / 2; - const middleY = CANVAS_HEIGHT / 2; - if (Math.abs(centerX - middleX) <= SNAP_CENTER_TOLERANCE) - nextX = middleX - bW / 2; - if (Math.abs(centerY - middleY) <= SNAP_CENTER_TOLERANCE) - nextY = middleY - bH / 2; - } - x = Math.max(1 - bW, Math.min(nextX, CANVAS_WIDTH - 1)); - y = Math.max(1 - bH, Math.min(nextY, CANVAS_HEIGHT - 1)); + if (dragSession) { + const isTarget = dragSession.id === id; + const isGroup = selectedIds.has(dragSession.id) && selectedIds.has(id); + if (isTarget || isGroup) { + const { w: bW, h: bH } = getBadgeSize(id, iCfg); + // Preview clamp: at least 1px inside poster + let nextX = x + dragSession.dx; + let nextY = y + dragSession.dy; + if (viewOptions?.snapToGrid) { + const centerX = nextX + bW / 2; + const centerY = nextY + bH / 2; + const middleX = CANVAS_WIDTH / 2; + const middleY = CANVAS_HEIGHT / 2; + if (Math.abs(centerX - middleX) <= SNAP_CENTER_TOLERANCE) nextX = middleX - bW / 2; + if (Math.abs(centerY - middleY) <= SNAP_CENTER_TOLERANCE) nextY = middleY - bH / 2; } + x = Math.max(1 - bW, Math.min(nextX, CANVAS_WIDTH - 1)); + y = Math.max(1 - bH, Math.min(nextY, CANVAS_HEIGHT - 1)); } + } - return ( - setHoveredBadgeId(hovered ? id : null)} - zIndex={100 + index} - /> - ); - })} + return ( + setHoveredBadgeId(hovered ? id : null)} + zIndex={100 + index} + /> + ); + })} {config.logo && ( = ({ className="absolute z-40 select-none" style={{ left: - (config.minimalDurationX ?? 90) + (draggingMinimalDuration ? minimalMetaOffset.dx : 0), + (config.minimalDurationX ?? 90) + + (draggingMinimalDuration ? minimalMetaOffset.dx : 0), top: - (config.minimalDurationY ?? 672) + (draggingMinimalDuration ? minimalMetaOffset.dy : 0), + (config.minimalDurationY ?? 672) + + (draggingMinimalDuration ? minimalMetaOffset.dy : 0), fontSize: `${Math.round((config.minimalMetaSize ?? 50) * 0.8)}px`, color: toRgba(config.minimalMetaColor, config.minimalMetaOpacity ?? 0.92), fontWeight: config.minimalMetaWeight ?? 600, letterSpacing: `${config.minimalMetaLetterSpacing ?? 0}px`, lineHeight: 1, cursor: draggingMinimalDuration ? 'grabbing' : 'grab', - outline: - selectedMinimalElements.has('minimal-duration') - ? '1px dashed rgba(196,124,46,0.8)' - : 'none', + outline: selectedMinimalElements.has('minimal-duration') + ? '1px dashed rgba(196,124,46,0.8)' + : 'none', }} onMouseDown={(e) => { e.preventDefault(); diff --git a/src/components/builder/components/layout/MobileDock.tsx b/src/components/builder/components/layout/MobileDock.tsx deleted file mode 100644 index 82019b3e..00000000 --- a/src/components/builder/components/layout/MobileDock.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { memo } from 'react'; -import { Film, Layers, Monitor, Sliders, MousePointer2 } from 'lucide-react'; -import { useEditor } from '../../context/EditorContext'; - -type TabId = 'source' | 'layers' | 'poster' | 'badges' | 'selection'; - -const MobileDock: React.FC<{ - hasBadges: boolean; - hasLogo: boolean; - isMinimalPreset: boolean; - selectedCount: number; -}> = memo(({ hasBadges, hasLogo, isMinimalPreset, selectedCount }) => { - const { activeTab, setActiveTab, setMobileSheetMode, mobileSheetMode } = useEditor(); - const badgesTabLabel = isMinimalPreset - ? hasLogo - ? 'Title/Logo' - : 'Title' - : hasBadges && hasLogo - ? 'Badges/Logo' - : hasLogo - ? 'Logo' - : 'Badges'; - const tabs: { id: TabId; Icon: React.ElementType; label: string; visible: boolean }[] = [ - { id: 'source', Icon: Film, label: 'Media', visible: true }, - { id: 'layers', Icon: Layers, label: 'Layers', visible: !isMinimalPreset }, - { id: 'poster', Icon: Monitor, label: 'Canvas', visible: true }, - { id: 'badges', Icon: Sliders, label: badgesTabLabel, visible: hasBadges || hasLogo || isMinimalPreset }, - { - id: 'selection', - Icon: MousePointer2, - label: selectedCount > 0 ? `${selectedCount} selected` : 'Selection', - visible: true, - }, - ]; - const visibleTabs = tabs.filter((tab) => tab.visible); - - const handleTab = (id: TabId) => { - if (id === activeTab && mobileSheetMode !== 'hidden') { - setMobileSheetMode('hidden'); - return; - } - setActiveTab(id); - setMobileSheetMode('full'); - }; - - return ( -
- {visibleTabs.map(({ id, Icon, label }) => { - const isActive = activeTab === id && mobileSheetMode !== 'hidden'; - return ( - - ); - })} -
- ); - } -); - -MobileDock.displayName = 'MobileDock'; -export default MobileDock; diff --git a/src/components/builder/components/mobile/CanvasHandles.tsx b/src/components/builder/components/mobile/CanvasHandles.tsx new file mode 100644 index 00000000..41f442d3 --- /dev/null +++ b/src/components/builder/components/mobile/CanvasHandles.tsx @@ -0,0 +1,99 @@ +import React, { memo, useRef, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface Props { + leftOpen: boolean; + rightOpen: boolean; + onToggleLeft: () => void; + onToggleRight: () => void; + onCloseLeft: () => void; + onCloseRight: () => void; +} + +const CanvasHandles: React.FC = memo( + ({ leftOpen, rightOpen, onToggleLeft, onToggleRight, onCloseLeft, onCloseRight }) => ( + <> + + + + ) +); + +const Handle: React.FC<{ + side: 'left' | 'right'; + open: boolean; + onTap: () => void; + onSwipeClose: () => void; +}> = ({ side, open, onTap, onSwipeClose }) => { + const [pressed, setPressed] = useState(false); + const touch = useRef({ x: 0, y: 0, t: 0 }); + const Icon = + side === 'left' ? (open ? ChevronLeft : ChevronRight) : open ? ChevronRight : ChevronLeft; + const isLeft = side === 'left'; + return ( + + ); +}; + +CanvasHandles.displayName = 'CanvasHandles'; +export default CanvasHandles; diff --git a/src/components/builder/components/mobile/LongPressMenu.tsx b/src/components/builder/components/mobile/LongPressMenu.tsx new file mode 100644 index 00000000..e966d093 --- /dev/null +++ b/src/components/builder/components/mobile/LongPressMenu.tsx @@ -0,0 +1,96 @@ +import React, { memo, useEffect } from 'react'; +import { Grid3x3, RotateCcw, ShieldCheck } from 'lucide-react'; + +interface Props { + open: boolean; + onClose: () => void; + onResetView: () => void; + onToggleGrid: () => void; + onToggleSafeArea: () => void; +} + +const LongPressMenu: React.FC = memo( + ({ open, onClose, onResetView, onToggleGrid, onToggleSafeArea }) => { + useEffect(() => { + if (!open) return; + const t = window.setTimeout(onClose, 3000); + return () => window.clearTimeout(t); + }, [onClose, open]); + if (!open) return null; + return ( + <> + +); +LongPressMenu.displayName = 'LongPressMenu'; +export default LongPressMenu; diff --git a/src/components/builder/components/mobile/MobileDock.tsx b/src/components/builder/components/mobile/MobileDock.tsx new file mode 100644 index 00000000..f7ceb3e4 --- /dev/null +++ b/src/components/builder/components/mobile/MobileDock.tsx @@ -0,0 +1,241 @@ +import React, { memo, useEffect, useRef, useState } from 'react'; +import { Film, Layers, Monitor, MousePointer2, Sliders } from 'lucide-react'; +import LongPressMenu from './LongPressMenu'; + +export type DockAction = 'source' | 'layers' | 'canvas' | 'badges' | 'selection'; + +interface Props { + active: DockAction; + selectedCount: number; + onSource: () => void; + onLayers: () => void; + onCanvas: () => void; + onBadges: () => void; + onSelection: () => void; + onResetView: () => void; + onToggleGrid: () => void; + onToggleSafeArea: () => void; +} + +const items: Array<{ id: DockAction; label: string; Icon: React.ElementType; size?: number }> = [ + { id: 'source', label: 'SOURCE', Icon: Film }, + { id: 'layers', label: 'LAYERS', Icon: Layers }, + { id: 'canvas', label: 'CANVAS', Icon: Monitor, size: 22 }, + { id: 'badges', label: 'BADGES', Icon: Sliders }, + { id: 'selection', label: 'SELECT', Icon: MousePointer2 }, +]; + +const MobileDock: React.FC = memo( + ({ + active, + selectedCount, + onSource, + onLayers, + onCanvas, + onBadges, + onSelection, + onResetView, + onToggleGrid, + onToggleSafeArea, + }) => { + const ref = useRef(null); + const [width, setWidth] = useState(0); + const [pressed, setPressed] = useState(null); + const [quickOpen, setQuickOpen] = useState(false); + const longPress = useRef(null); + const touch = useRef({ x: 0, y: 0 }); + useEffect(() => { + if (!ref.current) return; + const obs = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + obs.observe(ref.current); + return () => obs.disconnect(); + }, []); + const activeIndex = Math.max( + 0, + items.findIndex((item) => item.id === active) + ); + const actions: Record void> = { + source: onSource, + layers: onLayers, + canvas: onCanvas, + badges: onBadges, + selection: onSelection, + }; + return ( + <> +
+
+ {items.map(({ id, label, Icon, size }) => { + const isActive = active === id; + return ( + + ); + })} +
+ setQuickOpen(false)} + onResetView={onResetView} + onToggleGrid={onToggleGrid} + onToggleSafeArea={onToggleSafeArea} + /> + + ); + } +); +MobileDock.displayName = 'MobileDock'; +export default MobileDock; diff --git a/src/components/builder/components/mobile/MobileDrawer.tsx b/src/components/builder/components/mobile/MobileDrawer.tsx new file mode 100644 index 00000000..27c43ae3 --- /dev/null +++ b/src/components/builder/components/mobile/MobileDrawer.tsx @@ -0,0 +1,452 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronDown, + ChevronsDown, + ChevronsUp, + Film, + Layers, + MousePointer2, + Sliders, +} from 'lucide-react'; + +export type DrawerSide = 'left' | 'right'; +export type DrawerTab = 'source' | 'layers' | 'badges' | 'selection'; + +export interface DrawerSnapPoints { + compact: number; + half: number; + full: number; +} + +interface TabConfig { + id: DrawerTab; + label: string; + Icon: React.ElementType; +} + +interface Props { + side: DrawerSide; + isOpen: boolean; + drawerHeight: number; + snapPoints: DrawerSnapPoints; + onHeightChange: (h: number, animate?: boolean) => void; + onClose: () => void; + activeTab: DrawerTab; + onTabChange: (tab: DrawerTab) => void; + selectedCount?: number; + children: React.ReactNode; +} + +const vibrate = (ms: number) => { + if (typeof navigator !== 'undefined' && 'vibrate' in navigator) navigator.vibrate(ms); +}; + +const MobileDrawer: React.FC = memo( + ({ + side, + isOpen, + drawerHeight, + snapPoints, + onHeightChange, + onClose, + activeTab, + onTabChange, + selectedCount = 0, + children, + }) => { + const contentRef = useRef(null); + const rafRef = useRef(null); + const dragRef = useRef({ + active: false, + startY: 0, + startHeight: 0, + lastY: 0, + lastT: 0, + velocity: 0, + pinch: false, + spread: 0, + }); + const [dragging, setDragging] = useState(false); + const [renderKey, setRenderKey] = useState(activeTab); + const [fading, setFading] = useState(false); + + useEffect(() => { + setFading(true); + contentRef.current && (contentRef.current.scrollTop = 0); + const t = window.setTimeout(() => { + setRenderKey(activeTab); + setFading(false); + }, 80); + return () => window.clearTimeout(t); + }, [activeTab]); + + useEffect( + () => () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }, + [] + ); + + const tabs = useMemo( + () => + side === 'left' + ? [ + { id: 'source', label: 'Source', Icon: Film }, + { id: 'layers', label: 'Layers', Icon: Layers }, + ] + : [ + { id: 'badges', label: 'Badges', Icon: Sliders }, + { id: 'selection', label: 'Select', Icon: MousePointer2 }, + ], + [side] + ); + + const currentLevel = useMemo(() => { + const entries = Object.entries(snapPoints) as Array<[keyof DrawerSnapPoints, number]>; + return entries.reduce( + (best, entry) => + Math.abs(entry[1] - drawerHeight) < Math.abs(best[1] - drawerHeight) ? entry : best, + entries[0] + )[0]; + }, [drawerHeight, snapPoints]); + + const setHeightRaf = useCallback( + (height: number) => { + const clamped = Math.max(0, Math.min(height, snapPoints.full)); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => onHeightChange(clamped, false)); + }, + [onHeightChange, snapPoints.full] + ); + + const beginDrag = useCallback( + (e: React.TouchEvent) => { + e.stopPropagation(); + e.preventDefault(); + const touches = Array.from(e.touches); + dragRef.current.active = true; + dragRef.current.pinch = touches.length >= 2; + dragRef.current.startY = touches[0].clientY; + dragRef.current.startHeight = drawerHeight; + dragRef.current.lastY = touches[0].clientY; + dragRef.current.lastT = performance.now(); + dragRef.current.velocity = 0; + dragRef.current.spread = + touches.length >= 2 ? Math.abs(touches[0].clientY - touches[1].clientY) : 0; + setDragging(true); + }, + [drawerHeight] + ); + + const nearestSnap = useCallback( + (height: number, velocity: number) => { + const points = [snapPoints.compact, snapPoints.half, snapPoints.full]; + const currentIndex = points.reduce( + (best, p, i) => (Math.abs(p - height) < Math.abs(points[best] - height) ? i : best), + 0 + ); + if (velocity > 400) return points[Math.max(0, currentIndex - 1)]; + if (velocity < -400) return points[Math.min(points.length - 1, currentIndex + 1)]; + return points[currentIndex]; + }, + [snapPoints] + ); + + const handleMove = useCallback( + (e: React.TouchEvent) => { + if (!dragRef.current.active) return; + e.stopPropagation(); + e.preventDefault(); + const now = performance.now(); + const touches = Array.from(e.touches); + let nextHeight = dragRef.current.startHeight; + if (dragRef.current.pinch && touches.length >= 2) { + const spread = Math.abs(touches[0].clientY - touches[1].clientY); + nextHeight = dragRef.current.startHeight + (spread - dragRef.current.spread); + } else { + const y = touches[0].clientY; + nextHeight = dragRef.current.startHeight - (y - dragRef.current.startY); + const dt = Math.max(1, now - dragRef.current.lastT); + dragRef.current.velocity = ((y - dragRef.current.lastY) / dt) * 1000; + dragRef.current.lastY = y; + dragRef.current.lastT = now; + } + if (nextHeight < 60) { + onHeightChange(0, true); + onClose(); + return; + } + setHeightRaf(nextHeight); + }, + [onClose, onHeightChange, setHeightRaf] + ); + + const endDrag = useCallback( + (e: React.TouchEvent) => { + if (!dragRef.current.active) return; + e.stopPropagation(); + const target = nearestSnap(drawerHeight, dragRef.current.velocity); + dragRef.current.active = false; + setDragging(false); + onHeightChange(target, true); + vibrate(6); + }, + [drawerHeight, nearestSnap, onHeightChange] + ); + + const snapTo = (height: number) => { + onHeightChange(height, true); + vibrate(8); + }; + + if (!isOpen && drawerHeight <= 0) return
; + + return ( +
e.stopPropagation()} + style={{ + position: 'relative', + width: '100%', + height: '100%', + overflow: 'hidden', + background: 'rgba(9,8,7,0.97)', + backdropFilter: 'blur(28px) saturate(1.6)', + WebkitBackdropFilter: 'blur(28px) saturate(1.6)', + borderTop: '1px solid rgba(196,124,46,0.22)', + borderRadius: '18px 18px 0 0', + boxShadow: '0 -12px 60px rgba(0,0,0,0.7), 0 -1px 0 rgba(196,124,46,0.08)', + willChange: 'height', + display: 'flex', + flexDirection: 'column', + }} + > +
+
+
+
+
+ {tabs.map(({ id, label, Icon }) => { + const active = activeTab === id; + return ( + + ); + })} +
+
+ {(['compact', 'half', 'full'] as const).map((level) => ( + + ))} +
+ + +
+
{ + const el = e.currentTarget; + if (el.scrollTop === 0 && e.touches.length === 1 && dragRef.current.active) + handleMove(e); + }} + style={{ + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + WebkitOverflowScrolling: 'touch', + overscrollBehaviorY: 'contain', + scrollbarWidth: 'thin', + scrollbarColor: 'rgba(196,124,46,0.15) transparent', + paddingBottom: 24, + touchAction: 'pan-y', + opacity: fading ? 0 : 1, + transition: fading ? 'opacity 0.08s ease' : 'opacity 0.12s ease', + }} + > +
{children}
+
+
+ ); + } +); + +const smallButtonStyle: React.CSSProperties = { + width: 28, + height: 28, + borderRadius: 8, + border: '1px solid rgba(255,255,255,0.06)', + background: 'rgba(255,255,255,0.03)', + color: 'rgba(140,130,112,0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, +}; + +MobileDrawer.displayName = 'MobileDrawer'; +export default MobileDrawer; diff --git a/src/components/builder/components/mobile/MobileToolbar.tsx b/src/components/builder/components/mobile/MobileToolbar.tsx new file mode 100644 index 00000000..184f89a3 --- /dev/null +++ b/src/components/builder/components/mobile/MobileToolbar.tsx @@ -0,0 +1,266 @@ +import React, { memo, useMemo, useState } from 'react'; +import { Download, Redo2, Undo2 } from 'lucide-react'; +import type { RatingType } from '../../types'; + +export type MobileDrawerSide = 'left' | 'right' | null; +export type MobileDrawerTab = 'source' | 'layers' | 'badges' | 'selection'; + +interface Props { + drawerSide: MobileDrawerSide; + activeTab: MobileDrawerTab; + selectedIds: Set; + selectedLogo: boolean; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + onExport: () => void; + exportButtonRef: React.RefObject; +} + +const labelForBadge = (id: string) => + id + .split(/[-_]/g) + .map((part) => part.toUpperCase()) + .join(' '); + +const MobileToolbar: React.FC = memo( + ({ + drawerSide, + activeTab, + selectedIds, + selectedLogo, + canUndo, + canRedo, + onUndo, + onRedo, + onExport, + exportButtonRef, + }) => { + const [brandPressed, setBrandPressed] = useState(false); + const selectedCount = selectedIds.size + (selectedLogo ? 1 : 0); + const titleKey = `${drawerSide ?? 'canvas'}-${activeTab}-${selectedCount}-${Array.from(selectedIds).join(',')}-${selectedLogo}`; + + const { title, subtitle } = useMemo(() => { + if (!drawerSide) return { title: 'CANVAS', subtitle: 'TAP POSTER TO SELECT LAYERS' }; + if (drawerSide === 'left') { + return activeTab === 'layers' + ? { title: 'LAYERS', subtitle: 'VISIBILITY & ORDER' } + : { title: 'SOURCE', subtitle: 'MEDIA & POSTER SOURCE' }; + } + if (activeTab === 'badges') return { title: 'BADGES', subtitle: 'GLOBAL BADGE STYLE' }; + if (selectedCount === 0) + return { title: 'SELECTION', subtitle: 'CLICK A LAYER ON THE CANVAS' }; + if (selectedCount > 1) + return { title: `${selectedCount} LAYERS`, subtitle: 'LAYER-SPECIFIC CONFIGURATION' }; + return { + title: selectedLogo ? 'LOGO' : labelForBadge(Array.from(selectedIds)[0] ?? 'LAYER'), + subtitle: 'LAYER-SPECIFIC CONFIGURATION', + }; + }, [activeTab, drawerSide, selectedCount, selectedIds, selectedLogo]); + + return ( +
+
+
+ +
+
+
+ {title} +
+
+ {subtitle} +
+
+
+
+ + + + + + +
+ +
+
+
+ ); + } +); + +const ActionButton: React.FC<{ + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}> = ({ label, disabled, onClick, children }) => ( + +); + +MobileToolbar.displayName = 'MobileToolbar'; +export default MobileToolbar; diff --git a/src/components/builder/components/mobile/ZoomPill.tsx b/src/components/builder/components/mobile/ZoomPill.tsx new file mode 100644 index 00000000..746fc9c4 --- /dev/null +++ b/src/components/builder/components/mobile/ZoomPill.tsx @@ -0,0 +1,66 @@ +import React, { memo, useEffect, useState } from 'react'; +import { ZoomIn } from 'lucide-react'; + +interface Props { + onResetView: () => void; +} + +const ZoomPill: React.FC = memo(({ onResetView }) => { + const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }); + const [pulse, setPulse] = useState(false); + useEffect(() => { + const handler = (e: Event) => setView((e as CustomEvent).detail); + window.addEventListener('canvas-view-change', handler); + return () => window.removeEventListener('canvas-view-change', handler); + }, []); + const visible = + Math.abs(view.zoom - 1) > 0.001 || Math.abs(view.panX) > 0.5 || Math.abs(view.panY) > 0.5; + return ( + + ); +}); +ZoomPill.displayName = 'ZoomPill'; +export default ZoomPill; diff --git a/src/components/builder/context/EditorContext.tsx b/src/components/builder/context/EditorContext.tsx index bbe3cf7e..956ef7e5 100644 --- a/src/components/builder/context/EditorContext.tsx +++ b/src/components/builder/context/EditorContext.tsx @@ -3,7 +3,6 @@ import React, { createContext, useContext, useState, useCallback } from 'react'; import type { RatingType } from '../types'; type TabType = 'source' | 'layers' | 'poster' | 'badges' | 'logo' | 'selection'; -export type SheetMode = 'hidden' | 'half' | 'full'; export interface ViewOptions { showSafeArea: boolean; @@ -16,8 +15,6 @@ export type LiveRatings = Partial>; interface EditorContextType { activeTab: TabType; setActiveTab: (tab: TabType) => void; - mobileSheetMode: SheetMode; - setMobileSheetMode: (mode: SheetMode) => void; selectedIds: Set; selectedLogo: boolean; selectedMinimalElements: Set; @@ -48,7 +45,6 @@ const EditorContext = createContext(undefined); export const EditorProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [activeTab, setActiveTabState] = useState('source'); - const [mobileSheetMode, setMobileSheetMode] = useState('hidden'); const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectedLogo, setSelectedLogo] = useState(false); const [selectedMinimalElements, setSelectedMinimalElements] = useState>(new Set()); @@ -66,11 +62,6 @@ export const EditorProvider: React.FC<{ children: React.ReactNode }> = ({ childr const setActiveTab = useCallback((tab: TabType) => { setActiveTabState(tab); - setMobileSheetMode((prev) => { - if (typeof window !== 'undefined' && window.innerWidth < 1024 && prev === 'hidden') - return 'half'; - return prev; - }); }, []); const handleSelection = useCallback( @@ -173,8 +164,6 @@ export const EditorProvider: React.FC<{ children: React.ReactNode }> = ({ childr value={{ activeTab, setActiveTab, - mobileSheetMode, - setMobileSheetMode, selectedIds, selectedLogo, selectedMinimalElements, diff --git a/src/components/builder/index.tsx b/src/components/builder/index.tsx index 6dc99d42..bbeeab16 100644 --- a/src/components/builder/index.tsx +++ b/src/components/builder/index.tsx @@ -1,6 +1,5 @@ // src/components/builder/index.tsx import React, { Suspense, lazy, useState, useEffect, useRef, useCallback } from 'react'; -import clsx from 'clsx'; import type { PosterConfig, ExtensionType, ApiKeys, RatingType } from './types'; import { DEFAULT_CONFIG, @@ -29,7 +28,11 @@ import { BadgesPanel, SelectionPanel, } from './components/panels'; -import MobileDock from './components/layout/MobileDock'; +import MobileToolbar, { type MobileDrawerTab } from './components/mobile/MobileToolbar'; +import MobileDrawer, { type DrawerSnapPoints } from './components/mobile/MobileDrawer'; +import MobileDock, { type DockAction } from './components/mobile/MobileDock'; +import CanvasHandles from './components/mobile/CanvasHandles'; +import ZoomPill from './components/mobile/ZoomPill'; import ToolbarBtn from './components/toolbar/ToolbarButton'; import ZoomOverlay from './components/canvas/ZoomOverlay'; import { EditorProvider, useEditor } from './context/EditorContext'; @@ -98,8 +101,6 @@ const StudioLayout: React.FC<{ const { activeTab, setActiveTab, - mobileSheetMode, - setMobileSheetMode, selectedIds, selectedLogo, selectedMinimalElements, @@ -124,6 +125,134 @@ const StudioLayout: React.FC<{ const exportBtnRef = useRef(null); const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []); + const mobileBodyRef = useRef(null); + const drawerHeightRef = useRef(0); + const [drawerHeight, setDrawerHeight] = useState(0); + const [leftDrawerOpen, setLeftDrawerOpen] = useState(false); + const [rightDrawerOpen, setRightDrawerOpen] = useState(false); + const [leftDrawerTab, setLeftDrawerTab] = useState('source'); + const [rightDrawerTab, setRightDrawerTab] = useState('badges'); + const [drawerTransitionLock, setDrawerTransitionLock] = useState(false); + const selectedCount = selectedIds.size + (selectedLogo ? 1 : 0); + + const vibrate = useCallback((ms: number) => { + if (typeof navigator !== 'undefined' && 'vibrate' in navigator) navigator.vibrate(ms); + }, []); + + const getDrawerSnapPoints = useCallback((): DrawerSnapPoints => { + if (typeof window === 'undefined') return { compact: 180, half: 320, full: 520 }; + const safeBottom = 0; + const full = Math.max(180, window.innerHeight - 48 - 64 - safeBottom - 80); + const half = Math.max(180, Math.min(full, window.innerHeight * 0.52 - 64)); + return { compact: 180, half, full }; + }, []); + + const [drawerSnapPoints, setDrawerSnapPoints] = useState(() => + getDrawerSnapPoints() + ); + + useEffect(() => { + const update = () => setDrawerSnapPoints(getDrawerSnapPoints()); + update(); + window.addEventListener('resize', update); + window.addEventListener('orientationchange', update); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('orientationchange', update); + }; + }, [getDrawerSnapPoints]); + + const setMobileDrawerHeight = useCallback( + (height: number, animate = true) => { + const clamped = Math.max(0, Math.min(height, getDrawerSnapPoints().full)); + drawerHeightRef.current = clamped; + const el = mobileBodyRef.current; + if (el) { + el.style.transition = animate + ? 'grid-template-rows 0.38s cubic-bezier(0.16,1,0.3,1)' + : 'none'; + el.style.setProperty('--drawer-height', `${clamped}px`); + if (animate) + window.setTimeout(() => { + if (mobileBodyRef.current) mobileBodyRef.current.style.transition = 'none'; + }, 390); + } + setDrawerHeight(clamped); + }, + [getDrawerSnapPoints] + ); + + const closeMobileDrawers = useCallback(() => { + setMobileDrawerHeight(0, true); + setLeftDrawerOpen(false); + setRightDrawerOpen(false); + setDrawerTransitionLock(true); + window.setTimeout(() => { + setDrawerTransitionLock(false); + vibrate(4); + }, 200); + }, [setMobileDrawerHeight, vibrate]); + + const openLeftDrawer = useCallback( + (tab: MobileDrawerTab = 'source') => { + if (drawerTransitionLock) return; + const open = () => { + setLeftDrawerTab(tab); + setActiveTab(tab); + setRightDrawerOpen(false); + setLeftDrawerOpen(true); + setMobileDrawerHeight(getDrawerSnapPoints().half, true); + vibrate(8); + }; + if (rightDrawerOpen) { + setDrawerTransitionLock(true); + setMobileDrawerHeight(0, true); + setRightDrawerOpen(false); + window.setTimeout(() => { + setDrawerTransitionLock(false); + open(); + }, 120); + } else open(); + }, + [ + drawerTransitionLock, + getDrawerSnapPoints, + rightDrawerOpen, + setActiveTab, + setMobileDrawerHeight, + vibrate, + ] + ); + + const openRightDrawer = useCallback( + (tab: MobileDrawerTab = 'badges') => { + if (drawerTransitionLock) return; + setRightDrawerTab(tab); + setActiveTab(tab); + setRightDrawerOpen(true); + setLeftDrawerOpen(false); + setMobileDrawerHeight(getDrawerSnapPoints().half, true); + vibrate(8); + }, + [drawerTransitionLock, getDrawerSnapPoints, setActiveTab, setMobileDrawerHeight, vibrate] + ); + + const activeMobileSide = rightDrawerOpen ? 'right' : leftDrawerOpen ? 'left' : null; + const activeMobileTab = rightDrawerOpen + ? rightDrawerTab + : leftDrawerOpen + ? leftDrawerTab + : 'source'; + const activeDockItem: DockAction = !activeMobileSide + ? 'canvas' + : activeMobileSide === 'left' + ? leftDrawerTab === 'layers' + ? 'layers' + : 'source' + : rightDrawerTab === 'selection' + ? 'selection' + : 'badges'; + useEffect(() => { if (['source', 'layers', 'poster', 'badges', 'selection'].includes(activeTab)) { setAdvancedPanel(activeTab as BuilderPanelId); @@ -143,10 +272,17 @@ const StudioLayout: React.FC<{ ); useEffect(() => { const mq = window.matchMedia('(min-width: 1024px)'); - const handle = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + const handle = (e: MediaQueryListEvent) => { + setIsDesktop(e.matches); + if (e.matches) { + setLeftDrawerOpen(false); + setRightDrawerOpen(false); + setMobileDrawerHeight(0, false); + } + }; mq.addEventListener('change', handle); return () => mq.removeEventListener('change', handle); - }, []); + }, [setMobileDrawerHeight]); const [ctxMenu, setCtxMenu] = useState({ visible: false, @@ -272,12 +408,27 @@ const StudioLayout: React.FC<{ [setConfig] ); - // Auto-open Edit panel on mobile when badge is tapped — DISABLED (drawer requires deliberate open) const handleSelectionOverride = useCallback( (id: RatingType, multi: boolean) => { handleSelection(id, multi); + vibrate(10); + if (typeof window !== 'undefined' && window.innerWidth < 1024) { + setRightDrawerTab('selection'); + setActiveTab('selection'); + if (!rightDrawerOpen) { + setRightDrawerOpen(true); + setMobileDrawerHeight(getDrawerSnapPoints().half, true); + } + } }, - [handleSelection] + [ + getDrawerSnapPoints, + handleSelection, + rightDrawerOpen, + setActiveTab, + setMobileDrawerHeight, + vibrate, + ] ); const moveLayer = useCallback( @@ -931,20 +1082,51 @@ const StudioLayout: React.FC<{ } .sidebar-transition { transition: width 0.25s cubic-bezier(0.4,0,0.2,1), opacity 0.2s ease; } .sidebar-resizing .sidebar-transition { transition: opacity 0.2s ease !important; } + @keyframes panel-title-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + .mobile-panel-title { animation: panel-title-in 0.12s ease forwards; } + @keyframes zoom-pill-tap { 0% { transform: translateX(-50%) scale(1); } 50% { transform: translateX(-50%) scale(0.88); } 100% { transform: translateX(-50%) scale(1); } } + .zoom-pill-tap { animation: zoom-pill-tap 0.2s ease; } + @keyframes dock-badge-in { from { transform: scale(0); } to { transform: scale(1); } } + @keyframes quick-menu-in { from { opacity: 0; transform: translateX(-50%) scale(0.8) translateY(8px); } to { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); } } + .mobile-longpress-menu { animation: quick-menu-in 0.2s cubic-bezier(0.16,1,0.3,1); } + .mobile-toolbar-action:active span, .mobile-toolbar-export:active span { transform: scale(0.88); background: rgba(255,255,255,0.06); transition: transform 0.08s ease, background 0.08s ease; } + .mobile-toolbar-export:active span { background: rgba(196,124,46,0.2); } `}

Posterium Poster Builder

- {(isResetOpen || isImportOpen || shortcutsOpen || ctxMenu.visible || paletteOpen || exportOpen) && ( + {!isFullscreen && ( + setExportOpen((v) => !v)} + exportButtonRef={exportBtnRef} + /> + )} + + {(isResetOpen || + isImportOpen || + shortcutsOpen || + ctxMenu.visible || + paletteOpen || + exportOpen) && ( {isResetOpen && ( )} {paletteOpen && ( @@ -1021,7 +1204,7 @@ const StudioLayout: React.FC<{ {/* ── HEADER ── */} {!isFullscreen && (
+
{/* Left sidebar */} {!isFullscreen && (
0 ? '2px solid rgba(196,124,46,0.3)' : '2px solid transparent', + outlineOffset: -2, + transition: 'outline-color 0.2s ease', + }} onClick={(e) => { - if (e.target === e.currentTarget) clearSelection(); - if (mobileSheetMode !== 'hidden') setMobileSheetMode('hidden'); + if (e.target === e.currentTarget) { + clearSelection(); + vibrate(5); + } }} >
- {/* Sidebar Canvas Toggles removed — now in navbar */} - openCtxMenu('logo', e)} /> - {/* Film corner accents */} {(['tl', 'tr', 'bl', 'br'] as const).map((c) => (
))} + {!isFullscreen && ( +
+ + leftDrawerOpen ? closeMobileDrawers() : openLeftDrawer(leftDrawerTab) + } + onToggleRight={() => + rightDrawerOpen ? closeMobileDrawers() : openRightDrawer(rightDrawerTab) + } + onCloseLeft={() => leftDrawerOpen && closeMobileDrawers()} + onCloseRight={() => rightDrawerOpen && closeMobileDrawers()} + /> + +
+ )} {/* Right sidebar */} @@ -1389,63 +1606,81 @@ const StudioLayout: React.FC<{
)} - {/* Mobile Panel (In-flow flex item) */} {!isFullscreen && (
- {/* Swipe-down dismiss handle */} -
setMobileSheetMode('hidden')} - onTouchStart={(e) => { - const startY = e.touches[0].clientY; - const onMove = (ev: TouchEvent) => { - if (ev.touches[0].clientY - startY > 40) { - setMobileSheetMode('hidden'); - window.removeEventListener('touchmove', onMove); - } - }; - window.addEventListener('touchmove', onMove, { passive: true }); - const cleanup = () => { - window.removeEventListener('touchmove', onMove); - }; - window.addEventListener('touchend', cleanup, { once: true }); + { + if (rightDrawerOpen) { + setRightDrawerTab(tab); + setActiveTab(tab); + } else { + setLeftDrawerTab(tab); + setActiveTab(tab); + } }} + selectedCount={selectedCount} > -
-
- -
- {(activeTab === 'source' || activeTab === 'layers' || activeTab === 'poster') && ( - + {leftDrawerOpen && leftDrawerTab === 'source' && ( + )} - {(activeTab === 'badges' || activeTab === 'selection') && ( - + {leftDrawerOpen && leftDrawerTab === 'layers' && ( + )} -
+ {rightDrawerOpen && rightDrawerTab === 'badges' && ( + + )} + {rightDrawerOpen && rightDrawerTab === 'selection' && ( + + )} +
)}
{/* Mobile dock */} - 0} - hasLogo={config.logo} - isMinimalPreset={(config.uiPreset ?? 'b') === 'm'} - selectedCount={selectedIds.size + (selectedLogo ? 1 : 0)} - /> + {!isFullscreen && ( + + leftDrawerOpen && leftDrawerTab === 'source' + ? closeMobileDrawers() + : openLeftDrawer('source') + } + onLayers={() => + leftDrawerOpen && leftDrawerTab === 'layers' + ? closeMobileDrawers() + : openLeftDrawer('layers') + } + onCanvas={() => { + closeMobileDrawers(); + clearSelection(); + }} + onBadges={() => + rightDrawerOpen && rightDrawerTab === 'badges' + ? closeMobileDrawers() + : openRightDrawer('badges') + } + onSelection={() => + rightDrawerOpen && rightDrawerTab === 'selection' + ? closeMobileDrawers() + : openRightDrawer('selection') + } + onResetView={dispatchResetView} + onToggleGrid={() => toggleViewOption('showGrid')} + onToggleSafeArea={() => toggleViewOption('showSafeArea')} + /> + )} {/* Zoom + fullscreen overlay — always visible */}