diff --git a/src/App.tsx b/src/App.tsx index e4f428d..2dc02fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,13 +10,14 @@ import { StudioTopBar } from './components/studio/StudioTopBar'; import { EditorProvider } from './context/EditorContext'; import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults'; import { useCustomTooltips } from './hooks/useCustomTooltips'; -import { ToastProvider } from './components/ui/ToastProvider'; +import { useToast } from './components/ui/ToastProvider'; import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence'; import { ContentGridView } from './components/content-grid/ContentGridView'; import { TemplateBuilder } from './components/express/builder/TemplateBuilder'; import { EXPRESS_TEMPLATES } from './config/expressTemplates'; import { compileExpressToTimeline } from './utils/expressCompiler'; import { FullscreenToggle } from './components/ui/FullscreenToggle'; +import { GlobalExportWidget } from './components/export/GlobalExportWidget'; import { detectMediaDimensionsAndAspect } from './utils/mediaDimensions'; type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form'; @@ -34,6 +35,7 @@ function saveContentData(data: Record(() => { return loadCompanies() ?? PREDEFINED_COMPANIES; }); @@ -150,7 +152,7 @@ export default function App() { x: 50, y: 50, width: 100, height: 100, objectFit: 'cover', - opacity: 1, + opacity: 100, isBrandElement: false, isLocked: true, } : { @@ -163,7 +165,7 @@ export default function App() { x: 50, y: 50, width: 100, height: 100, objectFit: 'cover', - opacity: 1, + opacity: 100, isBrandElement: false, isLocked: true, }; @@ -182,8 +184,9 @@ export default function App() { handleDesignChange(editingBrandAsset.type, url); setEditingBrandAsset(null); setCurrentStep('brand'); + showToast('Activo de marca guardado y aplicado correctamente', 'success'); } - }, [editingBrandAsset]); + }, [editingBrandAsset, showToast]); const handleStartExpressBlank = useCallback(() => { setCurrentCompanyId(null); @@ -244,7 +247,6 @@ export default function App() { }, [productionTemplate, productionBrand]); return ( -
{currentStep !== 'studio' && ( )}
+ -
); } diff --git a/src/components/BrandComposition.tsx b/src/components/BrandComposition.tsx index 1874b67..9c3029b 100644 --- a/src/components/BrandComposition.tsx +++ b/src/components/BrandComposition.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { AbsoluteFill } from '../engine/components'; import { useCurrentFrame } from '../engine/player'; import { RenderProps } from '../types'; -import { useCanvasDrag } from './composition/useCanvasDrag'; import { BackgroundLayer } from './composition/BackgroundLayer'; import { BrandOverlay } from './composition/BrandOverlay'; import { CompositionElement } from './composition/CompositionElement'; @@ -25,27 +24,23 @@ export const BrandComposition: React.FC = ({ activeLayerId, activeAction, brandVisibility, - outputFormat + outputFormat, + onDragStart, + startResize, + startRotate, + snapGuides }) => { const frame = useCurrentFrame(); - - const { - containerRef, - dragState, - setDragState, - transformDragState, - setTransformDragState, - tempPositions, - guides - } = useCanvasDrag(timelineElements, onElementPositionChange, onElementTransformChange); // Separate brand fullscreen videos from other elements for correct z-order const brandFullscreenEls = timelineElements.filter(el => el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video' ); - const otherElements = timelineElements.filter(el => - !(el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video') - ); + const otherElements = timelineElements.filter(el => { + const isBrandFullscreen = el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video'; + const isBackground = layers?.find(l => l.id === el.layerId)?.type === 'background'; + return !isBrandFullscreen && !isBackground; + }); const renderElement = (el: typeof timelineElements[0]) => { let layer = layers.find(l => l.id === el.layerId); @@ -69,23 +64,15 @@ export const BrandComposition: React.FC = ({ activeLayerId={activeLayerId ?? null} activeAction={activeAction ?? 'move'} isImageMode={outputFormat === 'image'} - tempPositions={tempPositions} - dragStateId={dragState?.id ?? null} - containerRef={containerRef} onElementClick={onElementClick} onElementDoubleClick={onElementDoubleClick} onElementContextMenu={onElementContextMenu} onElementDuplicate={onElementDuplicate} onElementDelete={onElementDelete} onElementLock={onElementLock} - onDragStart={(id, startX, startY, initialElX, initialElY) => { - if (onElementPositionChange) { - setDragState({ id, startX, startY, initialElX, initialElY }); - } - }} - onTransformStart={(id, type, startX, startY, initialScale, initialRot, centerX, centerY) => { - setTransformDragState({ id, type, startX, startY, initialScale, initialRot, centerX, centerY }); - }} + onDragStart={onDragStart} + startResize={startResize} + startRotate={startRotate} /> ); }; @@ -93,7 +80,7 @@ export const BrandComposition: React.FC = ({ const showBackground = brandVisibility?.background ?? true; return ( - + {/* Layer 1: Background media (user-uploaded backgrounds) */} @@ -107,7 +94,7 @@ export const BrandComposition: React.FC = ({ {otherElements.map(renderElement)} {/* Smart Guides Overlay */} - + ); }; diff --git a/src/components/StudioProperties.tsx b/src/components/StudioProperties.tsx index 09bd24e..3d25081 100644 --- a/src/components/StudioProperties.tsx +++ b/src/components/StudioProperties.tsx @@ -3,7 +3,7 @@ import type { BradlyPlayerRef } from '../engine/player'; import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react'; import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types'; import { AudioLayerPanel } from './properties/AudioLayerPanel'; -import { GraphicLayerPanel } from './properties/GraphicLayerPanel'; + import { TransitionsPanel } from './properties/TransitionsPanel'; import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel'; import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel'; @@ -116,8 +116,6 @@ export const StudioProperties: React.FC = ({ playerRef={playerRef} endFrameLimit={timelineElements.find(el => layers.find(l => l.id === el.layerId)?.type === 'background')?.endFrame || 150} /> - ) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'visual' ? ( - ) : ( = ({ const { activeAction, setActiveAction } = useEditor(); + const compositionContainerRef = React.useRef(null); + + const { + startDrag, + startResize, + startRotate, + handlePointerMove: handleDragMove, + handlePointerUp: handleDragUp, + isDragging, + activeId: dragFieldId, + snapGuides, + } = useDragResize({ + containerRef: compositionContainerRef, + onMove: useCallback((id: string, x: number, y: number) => { + setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, x, y } : el)); + }, [setTimelineElements]), + onResize: useCallback((id: string, w: number, h: number) => { + setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, width: w, height: h, scale: 1 } : el)); + }, [setTimelineElements]), + onRotate: useCallback((id: string, rotation: number) => { + setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, rotation } : el)); + }, [setTimelineElements]), + snapLines: [50], + }); + // Keyboard shortcuts for action modes (M/S/R) and element actions (D/Delete) useEffect(() => { if (!selectedElementId) return; @@ -123,11 +149,14 @@ export const StudioWorkspace: React.FC = ({ const dy = e.clientY - lastPanPos.current.y; setPan(prev => ({ x: prev.x + dx, y: prev.y + dy })); lastPanPos.current = { x: e.clientX, y: e.clientY }; + } else { + handleDragMove(e); } }; const handlePointerUp = () => { setIsPanning(false); + handleDragUp(); }; // Ref for native wheel handler (React onWheel is passive, can't preventDefault) @@ -169,11 +198,18 @@ export const StudioWorkspace: React.FC = ({ }; const { layers, setLayers, setActiveLayerId } = useEditor(); + const lastDropTime = useRef(0); // ═══ Drop handler for canvas ═══ const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); + + // Prevent duplicate drops (event bubbling/double firing) within 300ms + const now = Date.now(); + if (now - lastDropTime.current < 300) return; + lastDropTime.current = now; + const data = e.dataTransfer.getData('application/json'); if (!data || !setTimelineElements) return; @@ -230,18 +266,26 @@ export const StudioWorkspace: React.FC = ({ } } - setTimelineElements(prev => [...prev, { - id: 'el-' + Date.now(), - layerId: targetLayerId, - type: elementType, - content: parsed.src, - startFrame: currentFrame, - endFrame: Math.min(durationInFrames, currentFrame + 100), - x: Math.max(5, Math.min(95, x)), - y: Math.max(5, Math.min(95, y)), - scale: 1, - originalFileName: parsed.fileName, - }]); + const newId = 'el-' + Date.now(); + console.log('[handleDrop] Adding new element:', newId, parsed.src); + + setTimelineElements(prev => { + // Prevent duplicate drops in the same millisecond or duplicate event bubbling + if (prev.some(el => el.id === newId)) return prev; + + return [...prev, { + id: newId, + layerId: targetLayerId, + type: elementType, + content: parsed.src, + startFrame: currentFrame, + endFrame: Math.min(durationInFrames, currentFrame + 100), + x: Math.max(5, Math.min(95, x)), + y: Math.max(5, Math.min(95, y)), + scale: 1, + originalFileName: parsed.fileName, + }]; + }); // Auto-detect audio duration and update endFrame if (elementType === 'audio') { @@ -438,6 +482,7 @@ export const StudioWorkspace: React.FC = ({ isEditing={!!selectedElementId} className="" canvasClassName="rounded" + canvasRef={compositionContainerRef} overlay={selectedElementId ? ( <> {/* Text editor overlay */} @@ -595,7 +640,12 @@ export const StudioWorkspace: React.FC = ({ setEditingTextId(id); setShowContextMenu(false); } - } + }, + onDragStart: startDrag, + onTransformStart: undefined, + startResize, + startRotate, + snapGuides, }} durationInFrames={durationInFrames} compositionWidth={dimensions.width} diff --git a/src/components/composition/BackgroundLayer.tsx b/src/components/composition/BackgroundLayer.tsx index 4d32816..922aaac 100644 --- a/src/components/composition/BackgroundLayer.tsx +++ b/src/components/composition/BackgroundLayer.tsx @@ -28,10 +28,15 @@ export const BackgroundLayer: React.FC = ({ timelineElemen return ( <> {backgroundElements.map((el) => { + if (el.isHidden) return null; const filterStyle = getFilterStyle(el.filter || 'none'); + // Calculate base opacity + const layerOpacity = layers.find(l => l.id === el.layerId)?.opacity ?? 1; + const opacity = ((el.opacity ?? 100) / 100) * layerOpacity; + return ( - + {el.type === 'color' && (
; - dragStateId: string | null; - containerRef: RefObject; onElementClick?: (id: string) => void; onElementDoubleClick?: (id: string) => void; onElementContextMenu?: (id: string, e: React.MouseEvent) => void; - onDragStart: (id: string, startX: number, startY: number, initialElX: number, initialElY: number) => void; - onTransformStart: (id: string, type: 'scale' | 'rotate', startX: number, startY: number, initialScale: number, initialRot: number, centerX: number, centerY: number) => void; + onDragStart?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void; + startResize?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void; + startRotate?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void; onElementDuplicate?: (id: string) => void; onElementDelete?: (id: string) => void; onElementLock?: (id: string) => void; @@ -40,14 +40,12 @@ export const CompositionElement: React.FC = ({ activeLayerId, activeAction, isImageMode = false, - tempPositions, - dragStateId, - containerRef, onElementClick, onElementDoubleClick, onElementContextMenu, onDragStart, - onTransformStart, + startResize, + startRotate, onElementDuplicate, onElementDelete, onElementLock, @@ -66,24 +64,37 @@ export const CompositionElement: React.FC = ({ // Skip hidden elements (after all hooks to satisfy Rules of Hooks) if (el.isHidden) return null; + if (el.type === 'text') { + console.log('[DEBUG TEXT]', { + id: el.id, + content: el.content, + x: el.x, y: el.y, + opacity: el.opacity, + layerOpacity: layer?.opacity, + color: el.color, + fontSize: el.fontSize, + fontFamily: fontFamily, + startFrame: el.startFrame, + endFrame: el.endFrame, + currentFrame: frame + }); + } const isSelected = selectedElementId === el.id; const layerOpacity = layer?.opacity ?? 1; const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity; - const currentScale = tempPositions[el.id]?.scale ?? el.scale ?? 1; - const currentRot = tempPositions[el.id]?.rotation ?? el.rotation ?? 0; - const tempX = tempPositions[el.id]?.x; - const tempY = tempPositions[el.id]?.y; + const currentScale = el.scale ?? 1; + const currentRot = el.rotation ?? 0; const { opacity, transformStr, displayContent } = calculateElementTransitions( - el, frame, baseOpacity, currentScale, currentRot, tempX, tempY + el, frame, baseOpacity, currentScale, currentRot, undefined, undefined ); // Resolve position — multi-keyframes take priority over legacy animEnd* - let currentX = tempX ?? el.x; - let currentY = tempY ?? el.y; + let currentX = el.x; + let currentY = el.y; - if (el.keyframes && el.keyframes.length >= 2 && !tempPositions[el.id]) { + if (el.keyframes && el.keyframes.length >= 2) { // Multi-keyframe: resolve x/y from keyframe engine const resolved = resolveKeyframes(el.keyframes, frame, { x: el.x, y: el.y, @@ -94,10 +105,10 @@ export const CompositionElement: React.FC = ({ } else if (!el.keyframes) { // Legacy 2-point keyframes if (el.animEndX !== undefined) { - currentX = interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); + currentX = interpolate(frame, [el.startFrame, el.endFrame], [el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); } if (el.animEndY !== undefined) { - currentY = interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); + currentY = interpolate(frame, [el.startFrame, el.endFrame], [el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); } } @@ -131,48 +142,43 @@ export const CompositionElement: React.FC = ({ overflow: 'hidden', } : undefined; - // ── Transform helpers ── - const startScaleDrag = (e: React.PointerEvent) => { - e.stopPropagation(); - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - onTransformStart( - el.id, 'scale', e.clientX, e.clientY, - currentScale, currentRot, - rect.left + (currentX / 100) * rect.width, - rect.top + (currentY / 100) * rect.height - ); - }; + // Adapters for the drag hooks + const getOrigPos = () => ({ + x: currentX, + y: currentY, + w: el.width ?? (el.type === 'text' ? undefined : 25) ?? 25, + h: el.height ?? 25, + rotation: currentRot, + }); - const startRotateDrag = (e: React.PointerEvent) => { - e.stopPropagation(); - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - onTransformStart( - el.id, 'rotate', e.clientX, e.clientY, - currentScale, currentRot, - rect.left + (currentX / 100) * rect.width, - rect.top + (currentY / 100) * rect.height - ); - }; - - const startDrag = (e: React.PointerEvent) => { + const handleStartDrag = (e: React.PointerEvent) => { if (!isInteractive) return; e.stopPropagation(); if (e.button === 2) return; if (onElementClick) onElementClick(el.id); - - // In move mode: drag moves. In scale/rotate: start respective transform. - if (activeAction === 'move') { - onDragStart(el.id, e.clientX, e.clientY, currentX, currentY); - } else if (activeAction === 'scale') { - startScaleDrag(e); - } else if (activeAction === 'rotate') { - startRotateDrag(e); + + if (activeAction === 'move' && onDragStart) { + onDragStart(e, el.id, getOrigPos()); + } else if (activeAction === 'scale' && startResize) { + startResize(e, el.id, getOrigPos()); + } else if (activeAction === 'rotate' && startRotate) { + startRotate(e, el.id, getOrigPos()); } }; + const handleScaleDrag = (e: React.PointerEvent) => { + if (!isInteractive || !startResize) return; + e.stopPropagation(); + startResize(e, el.id, getOrigPos()); + }; + + const handleRotateDrag = (e: React.PointerEvent) => { + if (!isInteractive || !startRotate) return; + e.stopPropagation(); + startRotate(e, el.id, getOrigPos()); + }; + // ── Selection outline color ── const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6'; @@ -325,7 +331,7 @@ export const CompositionElement: React.FC = ({ opacity: opacity, cursor: isInteractive ? (activeAction === 'move' - ? (dragStateId === el.id ? 'grabbing' : 'grab') + ? 'grab' : activeAction === 'scale' ? 'nwse-resize' : activeAction === 'rotate' ? 'alias' : 'grab') @@ -351,276 +357,29 @@ export const CompositionElement: React.FC = ({ e.stopPropagation(); if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent); }} - onPointerDown={startDrag} + onPointerDown={handleStartDrag} > - {/* ── Content ── */} - {el.type === 'text' ? ( -
- {displayContent} -
- ) : el.type === 'video' ? ( - (() => { - const videoContent = el.chromaKeyEnabled ? ( - - ) : ( -