diff --git a/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx b/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx index dd6b4e9..fe08b29 100644 --- a/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx +++ b/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react'; +import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, ChevronRight, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react'; import { useLang } from '../../../../core/infrastructure/i18n'; import { ChatApi } from '../../../chats/infrastructure/chatApi'; import { StoryApi } from '../../infrastructure/storyApi'; @@ -51,18 +51,21 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) const [videoDuration, setVideoDuration] = useState(0); const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text' | 'trim'>('none'); const [paused, setPaused] = useState(false); - const videoRef = useRef(null); + const [thumbnails, setThumbnails] = useState([]); + const [zoom, setZoom] = useState(1); + const editorVideoRef = useRef(null); + const trimVideoRef = useRef(null); const [textObjects, setTextObjects] = useState([]); const [selectedTextId, setSelectedTextId] = useState(null); const [stickers, setStickers] = useState>([]); const [selectedStickerId, setSelectedStickerId] = useState(null); + const [currentPlayTime, setCurrentPlayTime] = useState(0); const [brushColor, setBrushColor] = useState('#ffffff'); const [brushSize, setBrushSize] = useState(8); const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); const [adjustments, setAdjustments] = useState({ brightness: 100, contrast: 100, saturate: 100, sepia: 0, grayscale: 0 }); @@ -70,8 +73,24 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) const fileInputRef = useRef(null); const stageRef = useRef(null); const canvasRef = useRef(null); + const timelineRef = useRef(null); const isDrawingRef = useRef(false); + // Wheel Zoom Implementation + useEffect(() => { + const el = timelineRef.current; + if (!el) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; // Smaller increments + setZoom(prev => Math.max(1, Math.min(20, prev + delta))); + }; + + el.addEventListener('wheel', handleWheel, { passive: false }); + return () => el.removeEventListener('wheel', handleWheel); + }, [tool]); + // Scroll Lock implementation useEffect(() => { const originalStyle = window.getComputedStyle(document.body).overflow; @@ -81,6 +100,32 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) }; }, []); + // Smooth Playback Loop for Timeline + useEffect(() => { + let rafId: number; + const update = () => { + const v = trimVideoRef.current; + if (v && !v.paused && tool === 'trim') { + const cur = v.currentTime; + setCurrentPlayTime(cur); + + // Correct boundary check: only pause at the end. + if (cur >= endTime) { + v.pause(); + v.currentTime = startTime; + setPaused(true); + setCurrentPlayTime(startTime); + } else if (cur < startTime - 0.1) { + v.currentTime = startTime; + setCurrentPlayTime(startTime); + } + } + rafId = requestAnimationFrame(update); + }; + rafId = requestAnimationFrame(update); + return () => cancelAnimationFrame(rafId); + }, [tool, startTime, endTime]); + const TARGET_W = 1080; const TARGET_H = 1920; const SCALE_FACTOR = TARGET_W / 400; @@ -98,14 +143,50 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) if (file.type.startsWith('video/')) { const v = document.createElement('video'); v.src = url; - v.onloadedmetadata = () => { + v.onloadedmetadata = async () => { setVideoDuration(v.duration); + setStartTime(0); setEndTime(v.duration); + setZoom(1); + setCurrentPlayTime(0); + setThumbnails([]); + if (v.duration > 120) { setTool('trim'); } else { setTool('none'); } + + // Generate thumbnails for storyboard + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 160; + canvas.height = 90; + const thumbs: string[] = []; + const count = v.duration > 1800 ? 5 : (v.duration > 300 ? 8 : 10); + for (let i = 0; i < count; i++) { + v.currentTime = (v.duration / count) * i; + await new Promise(r => { + const onSeek = () => { + v.removeEventListener('seeked', onSeek); + r(null); + }; + v.addEventListener('seeked', onSeek); + setTimeout(() => { + v.removeEventListener('seeked', onSeek); + r(null); + }, 1200); // Increased timeout for long videos + }); + ctx?.drawImage(v, 0, 0, 160, 90); + thumbs.push(canvas.toDataURL('image/jpeg', 0.4)); // Lower JPEG quality to save memory + } + setThumbnails(thumbs); + setPaused(true); // Don't auto-play + setCurrentPlayTime(0); + if (v.duration > 120) { + if (editorVideoRef.current) editorVideoRef.current.currentTime = 0; + if (trimVideoRef.current) trimVideoRef.current.currentTime = 0; + } }; } else { setTool('crop'); @@ -118,6 +199,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) setCroppedPreview(null); setCroppedAreaPixels(null); setTool('none'); + setStartTime(0); + setEndTime(0); + setVideoDuration(0); + setCurrentPlayTime(0); + setZoom(1); + setThumbnails([]); }; const addText = () => { @@ -275,6 +362,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
@@ -311,25 +400,83 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {(croppedPreview || mediaPreview) && (
{mediaFile?.type.startsWith('video/') ? ( -
+
) : ( base @@ -366,12 +513,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) )}
- {tool === 'crop' && mediaPreview && ( -
- setCroppedAreaPixels(pixels)} /> - -
- )} + {/* Mobile interface components */}
@@ -380,13 +522,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
{[ - { id: 'media', icon: , label: 'Медиа', action: () => fileInputRef.current?.click() }, { id: 'text', icon: , label: 'Текст', action: addText }, { id: 'stickers', icon: , label: 'Стикеры', active: tool === 'stickers' }, { id: 'brush', icon: , label: 'Кисти', active: tool === 'brush' }, { id: 'filters', icon: , label: 'Коррекция', active: tool === 'filters' }, ].map(item => ( - @@ -464,105 +605,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) )} - {tool === 'trim' && ( - -
-

- - Обрезка видео -

-
- {(endTime - startTime).toFixed(1)}с / {videoDuration.toFixed(1)}с -
-
- - {videoDuration > 120 && ( -
- warning - Видео слишком длинное! Обязательно обрежьте до 2 минут (120с). -
- )} - -
-
-
- Начало - {startTime.toFixed(1)}с -
- { - const val = parseFloat(e.target.value); - setStartTime(val); - if (videoRef.current) videoRef.current.currentTime = val; - }} - className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer" - /> -
- -
-
- Конец - {endTime.toFixed(1)}с -
- { - const val = parseFloat(e.target.value); - setEndTime(val); - if (videoRef.current) videoRef.current.currentTime = val; - }} - className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer" - /> -
- -
-
- - -
- -
-
-
- )} - {!mediaPreview && tool === 'none' && (
@@ -577,11 +619,346 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) )} -
- + + + {tool === 'trim' && mediaPreview && ( + + {/* Header */} +
+ +

Обрезка видео

+ +
+ +
+ {/* Floating Timer - Moved Above */} +
+ + {new Date(currentPlayTime * 1000).toISOString().substr(14, 5)} / {new Date(videoDuration * 1000).toISOString().substr(14, 5)} + +
+ + {/* Video Preview */} +
+
+ +
+
+
+
+ Колесико мыши для зума ({zoom.toFixed(1)}x) +
+
+ {new Date(0 * 1000).toISOString().substr(14, 5)} +
+ {(endTime - startTime).toFixed(1)} сек выбрано +
+ {new Date(videoDuration * 1000).toISOString().substr(14, 5)} +
+
+ +
+ {/* Overview Track (Mini-map) */} +
+
+ {thumbnails.map((t, i) => ( + mini + ))} +
+ {/* Selection UI on Mini-map */} +
{ + e.stopPropagation(); + const rect = e.currentTarget.parentElement?.getBoundingClientRect(); + if (!rect) return; + const x = (e.clientX - rect.left) / rect.width; + const time = x * videoDuration; + (timelineRef.current as any)._dragging = 'range'; + (timelineRef.current as any)._offsetTime = time - startTime; + }} + style={{ + left: `${(startTime / videoDuration) * 100}%`, + width: `${((endTime - startTime) / videoDuration) * 100}%` + }} + /> + {/* Mini Playhead */} +
+ {/* Interaction Layer */} +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const time = x * videoDuration; + if (trimVideoRef.current) trimVideoRef.current.currentTime = time; + setCurrentPlayTime(time); + }} + /> +
+ + {/* Time Ruler Placeholder */} +
+ {[0, 0.25, 0.5, 0.75, 1].map(p => ( + {new Date(videoDuration * p * 1000).toISOString().substr(14, 5)} + ))} +
+ + {/* Precision Track (Zoomable) */} +
+
{ + const rect = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const time = x * videoDuration; + + const startX = (startTime / videoDuration) * rect.width; + const endX = (endTime / videoDuration) * rect.width; + const mouseX = e.clientX - rect.left; + + if (Math.abs(mouseX - startX) < 20) { + (e.currentTarget as any)._dragging = 'start'; + } else if (Math.abs(mouseX - endX) < 20) { + (e.currentTarget as any)._dragging = 'end'; + } else if (mouseX > startX && mouseX < endX) { + (e.currentTarget as any)._dragging = 'range'; + (e.currentTarget as any)._offsetTime = time - startTime; + } else { + (e.currentTarget as any)._dragging = 'scrub'; + if (trimVideoRef.current) { + trimVideoRef.current.currentTime = time; + setCurrentPlayTime(time); + } + } + }} + onMouseMove={(e) => { + const dragging = (e.currentTarget as any)._dragging; + if (!dragging) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const time = Math.max(0, Math.min(videoDuration, x * videoDuration)); + + if (dragging === 'start') { + if (time < endTime - 0.2) { + setStartTime(time); + if (trimVideoRef.current) { + trimVideoRef.current.currentTime = time; + setCurrentPlayTime(time); + } + } + } else if (dragging === 'end') { + if (time > startTime + 0.2) { + setEndTime(time); + if (trimVideoRef.current) { + trimVideoRef.current.currentTime = time; + setCurrentPlayTime(time); + } + } + } else if (dragging === 'range') { + const offset = (e.currentTarget as any)._offsetTime; + const duration = endTime - startTime; + let newStart = time - offset; + if (newStart < 0) newStart = 0; + if (newStart + duration > videoDuration) newStart = videoDuration - duration; + setStartTime(newStart); + setEndTime(newStart + duration); + if (trimVideoRef.current) { + trimVideoRef.current.currentTime = newStart; + setCurrentPlayTime(newStart); + } + } else if (dragging === 'scrub') { + if (trimVideoRef.current) { + trimVideoRef.current.currentTime = time; + setCurrentPlayTime(time); + } + } + }} + onMouseUp={(e) => (e.currentTarget as any)._dragging = null} + onMouseLeave={(e) => (e.currentTarget as any)._dragging = null} + > + {/* Thumbnails Track */} +
+ {thumbnails.map((t, i) => ( + frame + ))} +
+ + {/* Selection Window */} +
+
+
+
+
+
+
+
+ + {/* Playback Cursor */} +
+
+
+
+ + {/* Main Player Controls */} +
+ +
+ +
+
+ Начало +
+ + {new Date(startTime * 1000).toISOString().substr(14, 5)}.{(startTime % 1).toFixed(1).substr(2)} + + +
+
+
+ Конец +
+ + {new Date(endTime * 1000).toISOString().substr(14, 5)}.{(endTime % 1).toFixed(1).substr(2)} + + +
+
+
+ +
+ +
+
+
+ + )} + + ); }