This commit is contained in:
ct
2025-06-16 23:16:10 +08:00
parent 4220709b57
commit ef2871a983
6 changed files with 142 additions and 19 deletions

View File

@@ -1,12 +1,15 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useMitt } from '@/plugins/MittContext';
import useLocalSettingsStore from '@/stores/localSettingsStore';
import useMediaStore from '@/stores/MediaStore';
import useVideoEditorStore from '@/stores/VideoEditorStore';
import EditNavSidebar from './partials/edit-nav-sidebar';
import EditSidebar from './partials/edit-sidebar';
import EditorCanvas from './partials/editor-canvas';
import EditorControls from './partials/editor-controls';
import EditorHeader from './partials/editor-header';
import TextSidebar from './partials/text-sidebar';
import { calculateOptimalMaxWidth, calculateResponsiveWidth } from './utils/layout-constants';
// Hook to detect if viewport is below minimum width
@@ -101,11 +104,13 @@ const useResponsiveDimensions = () => {
const Editor = () => {
const { init } = useMediaStore();
const { getSetting } = useLocalSettingsStore();
const { setSelectedTextElement } = useVideoEditorStore();
const emitter = useMitt();
const [isEditNavSidebarOpen, setIsEditNavSidebarOpen] = useState(false);
const [isEditSidebarOpen, setIsEditSidebarOpen] = useState(false);
const [isTextSidebarOpen, setIsTextSidebarOpen] = useState(false);
const [isMuted, setIsMuted] = useState(true); // Video starts muted by default
const { maxWidth, responsiveWidth } = useResponsiveDimensions();
const isBelowMinWidth = useViewportDetection(320);
@@ -114,6 +119,23 @@ const Editor = () => {
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 = () => {
setIsEditNavSidebarOpen(!isEditNavSidebarOpen);
};
@@ -130,6 +152,11 @@ const Editor = () => {
setIsEditSidebarOpen(false);
};
const handleTextSidebarClose = () => {
setIsTextSidebarOpen(false);
setSelectedTextElement(null);
};
// Toggle mute functionality
const handleToggleMute = () => {
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` }}>
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
<EditorHeader
className="mx-auto"

View File

@@ -518,13 +518,17 @@ const VideoEditor = ({ width, height }) => {
emitter.on('video-play', handlePlay);
emitter.on('video-reset', handleReset);
emitter.on('video-seek', handleSeek);
emitter.on('text-update', ({ elementId, updates }) => {
handleElementUpdate(elementId, updates);
});
return () => {
emitter.off('video-play', handlePlay);
emitter.off('video-reset', handleReset);
emitter.off('video-seek', handleSeek);
emitter.off('text-update');
};
}, [emitter, handlePlay, handleReset, handleSeek]);
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
return (
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">

View File

@@ -98,15 +98,18 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
// Process text elements with centering
texts.forEach((t, i) => {
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(
`[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${Math.round(
t.x,
)}:y=${Math.round(t.y)}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
`[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
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}`;
});

View File

@@ -1,3 +1,4 @@
import { useMitt } from '@/plugins/MittContext';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
@@ -36,6 +37,8 @@ const VideoPreview = ({
// Refs
layerRef,
}) => {
const emitter = useMitt();
// Selection state
const [selectedElementId, setSelectedElementId] = useState(null);
const transformerRef = useRef(null);
@@ -116,8 +119,18 @@ const VideoPreview = ({
};
// Handle element selection
const handleElementSelect = useCallback((elementId) => {
const handleElementSelect = useCallback(
(elementId) => {
setSelectedElementId(elementId);
// Find the selected element
const element = timelineElements.find((el) => el.id === elementId);
// 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,
@@ -125,7 +138,9 @@ const VideoPreview = ({
showVertical: false,
showHorizontal: false,
});
}, []);
},
[emitter, timelineElements],
);
// Handle clicking on empty space to deselect
const handleStageClick = useCallback((e) => {
@@ -434,6 +449,10 @@ const VideoPreview = ({
stroke={element.stroke}
strokeWidth={element.strokeWidth}
rotation={element.rotation || 0}
// Center the text horizontally
align="center"
// Let text have natural width and height for multiline support
wrap="word"
draggable
dragBoundFunc={createDragBoundFunc(element.id)}
onClick={() => handleElementSelect(element.id)}

View 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>
);
}

View File

@@ -1,17 +1,19 @@
import axiosInstance from '@/plugins/axios-plugin';
import { mountStoreDevtool } from 'simple-zustand-devtools';
import { toast } from 'sonner';
import { route } from 'ziggy-js';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useVideoEditorStore = create(
devtools((set, get) => ({
videoIsPlaying: false,
selectedTextElement: null,
setVideoIsPlaying: (isPlaying) => {
set({ videoIsPlaying: isPlaying });
},
setSelectedTextElement: (element) => {
set({ selectedTextElement: element });
},
})),
{