Update
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
||||||
import useMediaStore from '@/stores/MediaStore';
|
import useMediaStore from '@/stores/MediaStore';
|
||||||
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
import EditNavSidebar from './partials/edit-nav-sidebar';
|
import EditNavSidebar from './partials/edit-nav-sidebar';
|
||||||
import EditSidebar from './partials/edit-sidebar';
|
import EditSidebar from './partials/edit-sidebar';
|
||||||
import EditorCanvas from './partials/editor-canvas';
|
import EditorCanvas from './partials/editor-canvas';
|
||||||
import EditorControls from './partials/editor-controls';
|
import EditorControls from './partials/editor-controls';
|
||||||
import EditorHeader from './partials/editor-header';
|
import EditorHeader from './partials/editor-header';
|
||||||
|
import TextSidebar from './partials/text-sidebar';
|
||||||
import { calculateOptimalMaxWidth, calculateResponsiveWidth } from './utils/layout-constants';
|
import { calculateOptimalMaxWidth, calculateResponsiveWidth } from './utils/layout-constants';
|
||||||
|
|
||||||
// Hook to detect if viewport is below minimum width
|
// Hook to detect if viewport is below minimum width
|
||||||
@@ -101,11 +104,13 @@ const useResponsiveDimensions = () => {
|
|||||||
|
|
||||||
const Editor = () => {
|
const Editor = () => {
|
||||||
const { init } = useMediaStore();
|
const { init } = useMediaStore();
|
||||||
|
|
||||||
const { getSetting } = useLocalSettingsStore();
|
const { getSetting } = useLocalSettingsStore();
|
||||||
|
const { setSelectedTextElement } = useVideoEditorStore();
|
||||||
|
const emitter = useMitt();
|
||||||
|
|
||||||
const [isEditNavSidebarOpen, setIsEditNavSidebarOpen] = useState(false);
|
const [isEditNavSidebarOpen, setIsEditNavSidebarOpen] = useState(false);
|
||||||
const [isEditSidebarOpen, setIsEditSidebarOpen] = useState(false);
|
const [isEditSidebarOpen, setIsEditSidebarOpen] = useState(false);
|
||||||
|
const [isTextSidebarOpen, setIsTextSidebarOpen] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(true); // Video starts muted by default
|
const [isMuted, setIsMuted] = useState(true); // Video starts muted by default
|
||||||
const { maxWidth, responsiveWidth } = useResponsiveDimensions();
|
const { maxWidth, responsiveWidth } = useResponsiveDimensions();
|
||||||
const isBelowMinWidth = useViewportDetection(320);
|
const isBelowMinWidth = useViewportDetection(320);
|
||||||
@@ -114,6 +119,23 @@ const Editor = () => {
|
|||||||
init();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Listen for text element selection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTextElementSelected = (textElement) => {
|
||||||
|
setSelectedTextElement(textElement);
|
||||||
|
setIsTextSidebarOpen(true);
|
||||||
|
// Close other sidebars when text sidebar opens
|
||||||
|
setIsEditSidebarOpen(false);
|
||||||
|
setIsEditNavSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.on('text-element-selected', handleTextElementSelected);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('text-element-selected', handleTextElementSelected);
|
||||||
|
};
|
||||||
|
}, [emitter, setSelectedTextElement]);
|
||||||
|
|
||||||
const handleEditNavClick = () => {
|
const handleEditNavClick = () => {
|
||||||
setIsEditNavSidebarOpen(!isEditNavSidebarOpen);
|
setIsEditNavSidebarOpen(!isEditNavSidebarOpen);
|
||||||
};
|
};
|
||||||
@@ -130,6 +152,11 @@ const Editor = () => {
|
|||||||
setIsEditSidebarOpen(false);
|
setIsEditSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTextSidebarClose = () => {
|
||||||
|
setIsTextSidebarOpen(false);
|
||||||
|
setSelectedTextElement(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle mute functionality
|
// Toggle mute functionality
|
||||||
const handleToggleMute = () => {
|
const handleToggleMute = () => {
|
||||||
setIsMuted(!isMuted);
|
setIsMuted(!isMuted);
|
||||||
@@ -139,6 +166,7 @@ const Editor = () => {
|
|||||||
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
|
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
|
||||||
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
|
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
|
||||||
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
|
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
|
||||||
|
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
|
||||||
|
|
||||||
<EditorHeader
|
<EditorHeader
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
|
|||||||
@@ -518,13 +518,17 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
emitter.on('video-play', handlePlay);
|
emitter.on('video-play', handlePlay);
|
||||||
emitter.on('video-reset', handleReset);
|
emitter.on('video-reset', handleReset);
|
||||||
emitter.on('video-seek', handleSeek);
|
emitter.on('video-seek', handleSeek);
|
||||||
|
emitter.on('text-update', ({ elementId, updates }) => {
|
||||||
|
handleElementUpdate(elementId, updates);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('video-play', handlePlay);
|
emitter.off('video-play', handlePlay);
|
||||||
emitter.off('video-reset', handleReset);
|
emitter.off('video-reset', handleReset);
|
||||||
emitter.off('video-seek', handleSeek);
|
emitter.off('video-seek', handleSeek);
|
||||||
|
emitter.off('text-update');
|
||||||
};
|
};
|
||||||
}, [emitter, handlePlay, handleReset, handleSeek]);
|
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
||||||
|
|||||||
@@ -98,15 +98,18 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
|
|
||||||
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
||||||
|
|
||||||
|
// Process text elements with centering
|
||||||
texts.forEach((t, i) => {
|
texts.forEach((t, i) => {
|
||||||
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
||||||
|
|
||||||
|
// Center the text: x position is the center point, y is adjusted for baseline
|
||||||
|
const centerX = Math.round(t.x);
|
||||||
|
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
|
||||||
|
|
||||||
filters.push(
|
filters.push(
|
||||||
`[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${Math.round(
|
`[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
|
||||||
t.x,
|
|
||||||
)}:y=${Math.round(t.y)}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
|
|
||||||
t.stroke
|
t.stroke
|
||||||
}:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`,
|
}:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`,
|
||||||
);
|
);
|
||||||
videoLayer = `t${i}`;
|
videoLayer = `t${i}`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ const VideoPreview = ({
|
|||||||
// Refs
|
// Refs
|
||||||
layerRef,
|
layerRef,
|
||||||
}) => {
|
}) => {
|
||||||
|
const emitter = useMitt();
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const [selectedElementId, setSelectedElementId] = useState(null);
|
const [selectedElementId, setSelectedElementId] = useState(null);
|
||||||
const transformerRef = useRef(null);
|
const transformerRef = useRef(null);
|
||||||
@@ -116,16 +119,28 @@ const VideoPreview = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle element selection
|
// Handle element selection
|
||||||
const handleElementSelect = useCallback((elementId) => {
|
const handleElementSelect = useCallback(
|
||||||
setSelectedElementId(elementId);
|
(elementId) => {
|
||||||
// Clear guide lines when selecting
|
setSelectedElementId(elementId);
|
||||||
setGuideLines({
|
|
||||||
vertical: null,
|
// Find the selected element
|
||||||
horizontal: null,
|
const element = timelineElements.find((el) => el.id === elementId);
|
||||||
showVertical: false,
|
|
||||||
showHorizontal: false,
|
// If it's a text element, emit text-element-selected event
|
||||||
});
|
if (element && element.type === 'text') {
|
||||||
}, []);
|
emitter.emit('text-element-selected', element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear guide lines when selecting
|
||||||
|
setGuideLines({
|
||||||
|
vertical: null,
|
||||||
|
horizontal: null,
|
||||||
|
showVertical: false,
|
||||||
|
showHorizontal: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[emitter, timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle clicking on empty space to deselect
|
// Handle clicking on empty space to deselect
|
||||||
const handleStageClick = useCallback((e) => {
|
const handleStageClick = useCallback((e) => {
|
||||||
@@ -434,6 +449,10 @@ const VideoPreview = ({
|
|||||||
stroke={element.stroke}
|
stroke={element.stroke}
|
||||||
strokeWidth={element.strokeWidth}
|
strokeWidth={element.strokeWidth}
|
||||||
rotation={element.rotation || 0}
|
rotation={element.rotation || 0}
|
||||||
|
// Center the text horizontally
|
||||||
|
align="center"
|
||||||
|
// Let text have natural width and height for multiline support
|
||||||
|
wrap="word"
|
||||||
draggable
|
draggable
|
||||||
dragBoundFunc={createDragBoundFunc(element.id)}
|
dragBoundFunc={createDragBoundFunc(element.id)}
|
||||||
onClick={() => handleElementSelect(element.id)}
|
onClick={() => handleElementSelect(element.id)}
|
||||||
|
|||||||
67
resources/js/modules/editor/partials/text-sidebar.jsx
Normal file
67
resources/js/modules/editor/partials/text-sidebar.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
|
import { Type } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function TextSidebar({ isOpen, onClose }) {
|
||||||
|
const { selectedTextElement } = useVideoEditorStore();
|
||||||
|
const emitter = useMitt();
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
|
|
||||||
|
// Update textarea when selected element changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextElement) {
|
||||||
|
setTextValue(selectedTextElement.text || '');
|
||||||
|
}
|
||||||
|
}, [selectedTextElement]);
|
||||||
|
|
||||||
|
// Handle text changes
|
||||||
|
const handleTextChange = (e) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
setTextValue(newText);
|
||||||
|
|
||||||
|
if (selectedTextElement) {
|
||||||
|
emitter.emit('text-update', {
|
||||||
|
elementId: selectedTextElement.id,
|
||||||
|
updates: { text: newText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<SheetContent side="right" className="w-80 overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-3">
|
||||||
|
<Type className="h-6 w-6" />
|
||||||
|
Edit Text
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4 px-2">
|
||||||
|
{selectedTextElement ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Edit your text here</label>
|
||||||
|
<Textarea
|
||||||
|
value={textValue}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
placeholder="Enter your text..."
|
||||||
|
className="mt-2 text-center"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<Type className="mx-auto h-12 w-12 text-gray-300" />
|
||||||
|
<p className="mt-2">Select a text element to edit</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import axiosInstance from '@/plugins/axios-plugin';
|
|
||||||
import { mountStoreDevtool } from 'simple-zustand-devtools';
|
import { mountStoreDevtool } from 'simple-zustand-devtools';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { route } from 'ziggy-js';
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
|
|
||||||
const useVideoEditorStore = create(
|
const useVideoEditorStore = create(
|
||||||
devtools((set, get) => ({
|
devtools((set, get) => ({
|
||||||
videoIsPlaying: false,
|
videoIsPlaying: false,
|
||||||
|
selectedTextElement: null,
|
||||||
|
|
||||||
setVideoIsPlaying: (isPlaying) => {
|
setVideoIsPlaying: (isPlaying) => {
|
||||||
set({ videoIsPlaying: isPlaying });
|
set({ videoIsPlaying: isPlaying });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSelectedTextElement: (element) => {
|
||||||
|
set({ selectedTextElement: element });
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user