diff --git a/resources/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf b/resources/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..9f89c9d Binary files /dev/null and b/resources/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf differ diff --git a/resources/fonts/Montserrat/Montserrat-VariableFont_wght.ttf b/resources/fonts/Montserrat/Montserrat-VariableFont_wght.ttf new file mode 100644 index 0000000..df7379c Binary files /dev/null and b/resources/fonts/Montserrat/Montserrat-VariableFont_wght.ttf differ diff --git a/resources/fonts/Montserrat/OFL.txt b/resources/fonts/Montserrat/OFL.txt new file mode 100644 index 0000000..4515774 --- /dev/null +++ b/resources/fonts/Montserrat/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/fonts/Montserrat/README.txt b/resources/fonts/Montserrat/README.txt new file mode 100644 index 0000000..526747d --- /dev/null +++ b/resources/fonts/Montserrat/README.txt @@ -0,0 +1,81 @@ +Montserrat Variable Font +======================== + +This download contains Montserrat as both variable fonts and static fonts. + +Montserrat is a variable font with this axis: + wght + +This means all the styles are contained in these files: + Montserrat-VariableFont_wght.ttf + Montserrat-Italic-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Montserrat: + static/Montserrat-Thin.ttf + static/Montserrat-ExtraLight.ttf + static/Montserrat-Light.ttf + static/Montserrat-Regular.ttf + static/Montserrat-Medium.ttf + static/Montserrat-SemiBold.ttf + static/Montserrat-Bold.ttf + static/Montserrat-ExtraBold.ttf + static/Montserrat-Black.ttf + static/Montserrat-ThinItalic.ttf + static/Montserrat-ExtraLightItalic.ttf + static/Montserrat-LightItalic.ttf + static/Montserrat-Italic.ttf + static/Montserrat-MediumItalic.ttf + static/Montserrat-SemiBoldItalic.ttf + static/Montserrat-BoldItalic.ttf + static/Montserrat-ExtraBoldItalic.ttf + static/Montserrat-BlackItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/resources/fonts/Montserrat/static/Montserrat-Black.ttf b/resources/fonts/Montserrat/static/Montserrat-Black.ttf new file mode 100644 index 0000000..2d31930 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Black.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-BlackItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-BlackItalic.ttf new file mode 100644 index 0000000..40c6e1e Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-BlackItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Bold.ttf b/resources/fonts/Montserrat/static/Montserrat-Bold.ttf new file mode 100644 index 0000000..02ff6ff Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Bold.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-BoldItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-BoldItalic.ttf new file mode 100644 index 0000000..998ed88 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-BoldItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-ExtraBold.ttf b/resources/fonts/Montserrat/static/Montserrat-ExtraBold.ttf new file mode 100644 index 0000000..5922551 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-ExtraBold.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf new file mode 100644 index 0000000..7415509 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-ExtraLight.ttf b/resources/fonts/Montserrat/static/Montserrat-ExtraLight.ttf new file mode 100644 index 0000000..491c6ec Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-ExtraLight.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf new file mode 100644 index 0000000..23dd23f Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Italic.ttf b/resources/fonts/Montserrat/static/Montserrat-Italic.ttf new file mode 100644 index 0000000..84bc0f4 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Italic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Light.ttf b/resources/fonts/Montserrat/static/Montserrat-Light.ttf new file mode 100644 index 0000000..9d89492 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Light.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-LightItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-LightItalic.ttf new file mode 100644 index 0000000..bf854d4 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-LightItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Medium.ttf b/resources/fonts/Montserrat/static/Montserrat-Medium.ttf new file mode 100644 index 0000000..dfbcfe4 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Medium.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-MediumItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-MediumItalic.ttf new file mode 100644 index 0000000..1e67477 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-MediumItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Regular.ttf b/resources/fonts/Montserrat/static/Montserrat-Regular.ttf new file mode 100644 index 0000000..48ba65e Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Regular.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-SemiBold.ttf b/resources/fonts/Montserrat/static/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..8dbcdb3 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-SemiBold.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf new file mode 100644 index 0000000..8604419 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-Thin.ttf b/resources/fonts/Montserrat/static/Montserrat-Thin.ttf new file mode 100644 index 0000000..2a85a52 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-Thin.ttf differ diff --git a/resources/fonts/Montserrat/static/Montserrat-ThinItalic.ttf b/resources/fonts/Montserrat/static/Montserrat-ThinItalic.ttf new file mode 100644 index 0000000..5dc7160 Binary files /dev/null and b/resources/fonts/Montserrat/static/Montserrat-ThinItalic.ttf differ diff --git a/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx index 5eeec54..098da47 100644 --- a/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx +++ b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx @@ -57,10 +57,12 @@ const sampleTimelineElements = [ startTime: 0, layer: 2, duration: 4, - x: 50, - y: 600, - fontSize: 24, - fontWeight: 'bold', // ADD THIS LINE + x: 90, + y: 180, + fontSize: 40, + fontFamily: 'Montserrat', + fontWeight: 'bold', + fontStyle: 'normal', fill: 'white', stroke: 'black', strokeWidth: 1, @@ -76,7 +78,9 @@ const sampleTimelineElements = [ x: 50, y: 650, fontSize: 20, - fontWeight: 'bold', // ADD THIS LINE + fontFamily: 'Montserrat', + fontWeight: 'bold', + fontStyle: 'normal', fill: 'yellow', stroke: 'red', strokeWidth: 2, diff --git a/resources/js/modules/editor/partials/canvas/video-editor.jsx b/resources/js/modules/editor/partials/canvas/video-editor.jsx index 2fa8754..435a562 100644 --- a/resources/js/modules/editor/partials/canvas/video-editor.jsx +++ b/resources/js/modules/editor/partials/canvas/video-editor.jsx @@ -70,7 +70,7 @@ const VideoEditor = ({ width, height }) => { }); }; - // NEW: Handle element transformations (position, scale, rotation) + // Handle element transformations (position, scale, rotation) const handleElementUpdate = useCallback( (elementId, updates) => { setTimelineElements((prev) => @@ -553,7 +553,7 @@ const VideoEditor = ({ width, height }) => { handleSeek={handleSeek} copyFFmpegCommand={copyFFmpegCommand} exportVideo={exportVideo} - onElementUpdate={handleElementUpdate} // NEW: Pass the update handler + onElementUpdate={handleElementUpdate} layerRef={layerRef} /> diff --git a/resources/js/modules/editor/partials/canvas/video-export.jsx b/resources/js/modules/editor/partials/canvas/video-export.jsx index e6b9ae9..f143071 100644 --- a/resources/js/modules/editor/partials/canvas/video-export.jsx +++ b/resources/js/modules/editor/partials/canvas/video-export.jsx @@ -98,16 +98,29 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { showConsoleLogs && console.log('🎵 Audio args:', audioArgs); - // Process text elements with centering + // Process text elements with font family support texts.forEach((t, i) => { const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:'); + // Determine font file based on weight and style + let fontFile = 'Montserrat-Regular.ttf'; // default + const isBold = t.fontWeight === 'bold' || t.fontWeight === 700; + const isItalic = t.fontStyle === 'italic'; + + if (isBold && isItalic) { + fontFile = 'Montserrat-BoldItalic.ttf'; + } else if (isBold) { + fontFile = 'Montserrat-Bold.ttf'; + } else if (isItalic) { + fontFile = 'Montserrat-Italic.ttf'; + } + // 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=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${ + `[${videoLayer}]drawtext=fontfile=/${fontFile}:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${ t.stroke }:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`, ); @@ -211,9 +224,25 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { showConsoleLogs && console.log('FFmpeg loaded!'); setExportProgress(20); - setExportStatus('Loading font...'); - await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf')); - showConsoleLogs && console.log('Font loaded!'); + setExportStatus('Loading fonts...'); + // Load Montserrat font variants + await ffmpeg.writeFile( + 'Montserrat-Regular.ttf', + await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459Wlhyw.ttf'), + ); + await ffmpeg.writeFile( + 'Montserrat-Bold.ttf', + await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459W1hyw.ttf'), + ); + await ffmpeg.writeFile( + 'Montserrat-Italic.ttf', + await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459WxhywMDPA.ttf'), + ); + await ffmpeg.writeFile( + 'Montserrat-BoldItalic.ttf', + await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459W1hywMDPA.ttf'), + ); + showConsoleLogs && console.log('Fonts loaded!'); setExportProgress(30); setExportStatus('Downloading media...'); diff --git a/resources/js/modules/editor/partials/canvas/video-preview.jsx b/resources/js/modules/editor/partials/canvas/video-preview.jsx index 56f1f6a..ff26ffa 100644 --- a/resources/js/modules/editor/partials/canvas/video-preview.jsx +++ b/resources/js/modules/editor/partials/canvas/video-preview.jsx @@ -58,6 +58,10 @@ const VideoPreview = ({ // Snap settings const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center + // Font size constraints (same as in text-sidebar.jsx) + const MIN_FONT_SIZE = 8; + const MAX_FONT_SIZE = 120; + // Function to determine which image source to use for videos const getImageSource = (element) => { const isVideoActive = videoStates[element.id] && isPlaying; @@ -276,7 +280,7 @@ const VideoPreview = ({ [onElementUpdate, timelineElements], ); - // Handle transform events (scale, rotate) with snapping - USES NATIVE KONVA ROTATION SNAPPING + // Handle transform events (scale, rotate) with fontSize conversion for text const handleTransform = useCallback( (elementId) => { const node = elementRefs.current[elementId]; @@ -290,12 +294,27 @@ const VideoPreview = ({ const scaleX = node.scaleX(); const scaleY = node.scaleY(); - let newWidth, newHeight; + let newWidth, + newHeight, + updates = {}; if (element.type === 'text') { - // For text, allow free scaling - newWidth = node.width() * scaleX; - newHeight = node.height() * scaleY; + // OPTION A: Convert scale change to fontSize change + const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY)); + const newFontSize = Math.round(element.fontSize * scale); + + // Clamp fontSize to valid range + const clampedFontSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newFontSize)); + + // Reset scale to 1 since we're converting to fontSize + node.scaleX(1); + node.scaleY(1); + + // The width/height will be automatically calculated by Konva based on fontSize + // For text elements, we let Konva handle the natural dimensions + updates.fontSize = clampedFontSize; + + console.log(`Text transform: scale=${scale.toFixed(2)}, oldFontSize=${element.fontSize}, newFontSize=${clampedFontSize}`); } else { // For images/videos, maintain aspect ratio by using the larger scale const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY)); @@ -311,6 +330,9 @@ const VideoPreview = ({ // Update offset for center rotation node.offsetX(newWidth / 2); node.offsetY(newHeight / 2); + + updates.width = newWidth; + updates.height = newHeight; } // Calculate position for snapping @@ -320,8 +342,10 @@ const VideoPreview = ({ // Convert center position to top-left for snapping const centerX = node.x(); const centerY = node.y(); - topLeftX = centerX - newWidth / 2; - topLeftY = centerY - newHeight / 2; + const currentWidth = element.type === 'text' ? node.width() : newWidth; + const currentHeight = element.type === 'text' ? node.height() : newHeight; + topLeftX = centerX - currentWidth / 2; + topLeftY = centerY - currentHeight / 2; } else { // Use position directly for text topLeftX = node.x(); @@ -331,13 +355,15 @@ const VideoPreview = ({ // Check for position snapping during transform (but be less aggressive during rotation) const isRotating = Math.abs(rotation % 90) > 5; // Not close to perpendicular if (!isRotating) { - const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, newWidth, newHeight); + const currentWidth = element.type === 'text' ? node.width() : newWidth; + const currentHeight = element.type === 'text' ? node.height() : newHeight; + const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, currentWidth, currentHeight); if (Math.abs(snapResult.x - topLeftX) > 5 || Math.abs(snapResult.y - topLeftY) > 5) { if (usesCenterPositioning(element.type)) { // Convert back to center position - const newCenterX = snapResult.x + newWidth / 2; - const newCenterY = snapResult.y + newHeight / 2; + const newCenterX = snapResult.x + currentWidth / 2; + const newCenterY = snapResult.y + currentHeight / 2; node.x(newCenterX); node.y(newCenterY); } else { @@ -359,18 +385,23 @@ const VideoPreview = ({ }); } - // Update state with the final calculated values - const finalTransform = { - x: topLeftX, - y: topLeftY, - width: newWidth, - height: newHeight, - rotation: rotation, - }; + // Always update position and rotation + updates.x = topLeftX; + updates.y = topLeftY; + updates.rotation = rotation; - onElementUpdate(elementId, finalTransform); + // Update state with the calculated values + onElementUpdate(elementId, updates); + + // If this is a text element and fontSize changed, emit update for sidebar (without opening it) + if (element.type === 'text' && updates.fontSize && updates.fontSize !== element.fontSize) { + // Small delay to ensure state is updated first + setTimeout(() => { + emitter.emit('text-element-updated', { ...element, ...updates }); + }, 50); + } }, - [onElementUpdate, dimensions.width, dimensions.height, timelineElements], + [onElementUpdate, dimensions.width, dimensions.height, timelineElements, emitter], ); // Update transformer when selection changes @@ -433,6 +464,10 @@ const VideoPreview = ({ /> ); } else if (element.type === 'text') { + // Build font style string + const fontWeight = element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal'; + const fontStyle = element.fontStyle === 'italic' ? 'italic' : 'normal'; + return ( { + const emitter = useMitt(); + + // Selection state + const [selectedElementId, setSelectedElementId] = useState(null); + const transformerRef = useRef(null); + const stageRef = useRef(null); + + // Refs for each element to connect with transformer + const elementRefs = useRef({}); + + // Guide lines state + const [guideLines, setGuideLines] = useState({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + + // Snap settings + const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center + + // Font size constraints (same as in text-sidebar.jsx) + const MIN_FONT_SIZE = 8; + const MAX_FONT_SIZE = 120; + + // Function to determine which image source to use for videos + const getImageSource = (element) => { + const isVideoActive = videoStates[element.id] && isPlaying; + + if (isVideoActive && element.videoElement && element.isVideoReady) { + return element.videoElement; + } else if (element.posterImage && element.isVideoPoster) { + return element.posterImage; + } + + return null; + }; + + // Check if element uses center-offset positioning + const usesCenterPositioning = (elementType) => { + return elementType === 'video' || elementType === 'image'; + }; + + // Check if position should snap to center and calculate guide lines + const calculateSnapAndGuides = (elementId, newX, newY, width, height) => { + const centerX = dimensions.width / 2; + const centerY = dimensions.height / 2; + + // Calculate element center + const elementCenterX = newX + width / 2; + const elementCenterY = newY + height / 2; + + let snapX = newX; + let snapY = newY; + let showVertical = false; + let showHorizontal = false; + let verticalLine = null; + let horizontalLine = null; + + // Check vertical center snap + if (Math.abs(elementCenterX - centerX) < POSITION_SNAP_THRESHOLD) { + snapX = centerX - width / 2; + showVertical = true; + verticalLine = centerX; + } + + // Check horizontal center snap + if (Math.abs(elementCenterY - centerY) < POSITION_SNAP_THRESHOLD) { + snapY = centerY - height / 2; + showHorizontal = true; + horizontalLine = centerY; + } + + return { + x: snapX, + y: snapY, + guideLines: { + vertical: verticalLine, + horizontal: horizontalLine, + showVertical, + showHorizontal, + }, + }; + }; + + // Handle element selection + 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, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + }, + [emitter, timelineElements], + ); + + // Handle clicking on empty space to deselect + const handleStageClick = useCallback((e) => { + // If clicking on stage (not on an element), deselect + if (e.target === e.target.getStage()) { + setSelectedElementId(null); + setGuideLines({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + } + }, []); + + // Handle drag events with snapping + const handleDragMove = useCallback( + (elementId, e) => { + const node = e.target; + const element = timelineElements.find((el) => el.id === elementId); + if (!element) return; + + const width = node.width() * node.scaleX(); + const height = node.height() * node.scaleY(); + + let topLeftX, topLeftY; + + if (usesCenterPositioning(element.type)) { + // For center-positioned elements (video/image), convert center to top-left + const elementCenterX = node.x(); + const elementCenterY = node.y(); + topLeftX = elementCenterX - width / 2; + topLeftY = elementCenterY - height / 2; + } else { + // For top-left positioned elements (text) + topLeftX = node.x(); + topLeftY = node.y(); + } + + const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, width, height); + + // Update guide lines + setGuideLines(snapResult.guideLines); + + // Update state during drag + if (onElementUpdate) { + onElementUpdate(elementId, { + x: snapResult.x, + y: snapResult.y, + }); + } + }, + [onElementUpdate, dimensions.width, dimensions.height, timelineElements], + ); + + // Create drag bound function for real-time snapping + const createDragBoundFunc = useCallback( + (elementId) => { + return (pos) => { + const element = timelineElements.find((el) => el.id === elementId); + if (!element) return pos; + + const node = elementRefs.current[elementId]; + if (!node) return pos; + + const width = node.width() * node.scaleX(); + const height = node.height() * node.scaleY(); + + let topLeftX, topLeftY; + + if (usesCenterPositioning(element.type)) { + // Convert center position to top-left for snapping calculations + topLeftX = pos.x - width / 2; + topLeftY = pos.y - height / 2; + } else { + topLeftX = pos.x; + topLeftY = pos.y; + } + + const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, width, height); + + if (usesCenterPositioning(element.type)) { + // Convert back to center position + return { + x: snapResult.x + width / 2, + y: snapResult.y + height / 2, + }; + } else { + return { + x: snapResult.x, + y: snapResult.y, + }; + } + }; + }, + [timelineElements, dimensions.width, dimensions.height], + ); + + const handleDragEnd = useCallback( + (elementId, e) => { + const node = e.target; + const element = timelineElements.find((el) => el.id === elementId); + if (!element) return; + + // Clear guide lines when drag ends + setGuideLines({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + + // Final position update + const width = node.width() * node.scaleX(); + const height = node.height() * node.scaleY(); + + let finalX, finalY; + if (usesCenterPositioning(element.type)) { + finalX = node.x() - width / 2; + finalY = node.y() - height / 2; + } else { + finalX = node.x(); + finalY = node.y(); + } + + if (onElementUpdate) { + onElementUpdate(elementId, { + x: finalX, + y: finalY, + }); + } + }, + [onElementUpdate, timelineElements], + ); + + // Handle transform events (scale, rotate) with fontSize conversion for text + const handleTransform = useCallback( + (elementId) => { + const node = elementRefs.current[elementId]; + const element = timelineElements.find((el) => el.id === elementId); + if (!node || !onElementUpdate || !element) return; + + // Get rotation - Konva handles snapping automatically with rotationSnaps + const rotation = node.rotation(); + + // Get the scale values from Konva + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + let newWidth, + newHeight, + updates = {}; + + if (element.type === 'text') { + // OPTION A: Convert scale change to fontSize change + const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY)); + const newFontSize = Math.round(element.fontSize * scale); + + // Clamp fontSize to valid range + const clampedFontSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newFontSize)); + + // Reset scale to 1 since we're converting to fontSize + node.scaleX(1); + node.scaleY(1); + + // The width/height will be automatically calculated by Konva based on fontSize + // For text elements, we let Konva handle the natural dimensions + updates.fontSize = clampedFontSize; + + console.log(`Text transform: scale=${scale.toFixed(2)}, oldFontSize=${element.fontSize}, newFontSize=${clampedFontSize}`); + } else { + // For images/videos, maintain aspect ratio by using the larger scale + const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY)); + newWidth = node.width() * scale; + newHeight = node.height() * scale; + + // Reset scale to 1 and update dimensions to maintain aspect ratio + node.scaleX(1); + node.scaleY(1); + node.width(newWidth); + node.height(newHeight); + + // Update offset for center rotation + node.offsetX(newWidth / 2); + node.offsetY(newHeight / 2); + + updates.width = newWidth; + updates.height = newHeight; + } + + // Calculate position for snapping + let topLeftX, topLeftY; + + if (usesCenterPositioning(element.type)) { + // Convert center position to top-left for snapping + const centerX = node.x(); + const centerY = node.y(); + const currentWidth = element.type === 'text' ? node.width() : newWidth; + const currentHeight = element.type === 'text' ? node.height() : newHeight; + topLeftX = centerX - currentWidth / 2; + topLeftY = centerY - currentHeight / 2; + } else { + // Use position directly for text + topLeftX = node.x(); + topLeftY = node.y(); + } + + // Check for position snapping during transform (but be less aggressive during rotation) + const isRotating = Math.abs(rotation % 90) > 5; // Not close to perpendicular + if (!isRotating) { + const currentWidth = element.type === 'text' ? node.width() : newWidth; + const currentHeight = element.type === 'text' ? node.height() : newHeight; + const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, currentWidth, currentHeight); + + if (Math.abs(snapResult.x - topLeftX) > 5 || Math.abs(snapResult.y - topLeftY) > 5) { + if (usesCenterPositioning(element.type)) { + // Convert back to center position + const newCenterX = snapResult.x + currentWidth / 2; + const newCenterY = snapResult.y + currentHeight / 2; + node.x(newCenterX); + node.y(newCenterY); + } else { + // Apply directly for text + node.x(snapResult.x); + node.y(snapResult.y); + } + setGuideLines(snapResult.guideLines); + topLeftX = snapResult.x; + topLeftY = snapResult.y; + } + } else { + // Clear guide lines during rotation + setGuideLines({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + } + + // Always update position and rotation + updates.x = topLeftX; + updates.y = topLeftY; + updates.rotation = rotation; + + // Update state with the calculated values + onElementUpdate(elementId, updates); + + // If this is a text element and fontSize changed, emit update for sidebar + if (element.type === 'text' && updates.fontSize && updates.fontSize !== element.fontSize) { + // Small delay to ensure state is updated first + setTimeout(() => { + emitter.emit('text-element-selected', { ...element, ...updates }); + }, 50); + } + }, + [onElementUpdate, dimensions.width, dimensions.height, timelineElements, emitter], + ); + + // Update transformer when selection changes + useEffect(() => { + if (transformerRef.current) { + const selectedNode = selectedElementId ? elementRefs.current[selectedElementId] : null; + + if (selectedNode) { + transformerRef.current.nodes([selectedNode]); + transformerRef.current.getLayer().batchDraw(); + } else { + transformerRef.current.nodes([]); + } + } + }, [selectedElementId, activeElements]); return ( - !open && onClose()}> - - - -
MEMEAIGEN
-
-
+
+ + + {activeElements.map((element) => { + const isSelected = selectedElementId === element.id; -
- - - - - - - Settings - Change your settings here. - + if (element.type === 'video') { + const imageSource = getImageSource(element); -
- setSetting('genAlphaSlang', !getSetting('genAlphaSlang'))} + if (!imageSource) { + return null; + } + + return ( + { + if (node) { + elementRefs.current[element.id] = node; + } + }} + image={imageSource} + // Use center position for x,y when offset is set + x={element.x + element.width / 2} + y={element.y + element.height / 2} + width={element.width} + height={element.height} + // Set offset to center for proper rotation + offsetX={element.width / 2} + offsetY={element.height / 2} + rotation={element.rotation || 0} + draggable + dragBoundFunc={createDragBoundFunc(element.id)} + onClick={() => handleElementSelect(element.id)} + onTap={() => handleElementSelect(element.id)} + onDragMove={(e) => handleDragMove(element.id, e)} + onDragEnd={(e) => handleDragEnd(element.id, e)} + onTransform={() => handleTransform(element.id)} + // Visual feedback for selection + stroke={isSelected ? '#0066ff' : undefined} + strokeWidth={isSelected ? 2 : 0} + strokeScaleEnabled={false} /> - -
+ ); + } else if (element.type === 'text') { + // Build font style string + const fontWeight = element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal'; + const fontStyle = element.fontStyle === 'italic' ? 'italic' : 'normal'; - -
-
-
- - + return ( + { + if (node) { + elementRefs.current[element.id] = node; + } + }} + text={element.text} + x={element.x} + y={element.y} + fontSize={element.fontSize} + fontStyle={`${fontStyle} ${fontWeight}`} + fontFamily={element.fontFamily || 'Montserrat'} + fill={element.fill} + 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)} + onTap={() => handleElementSelect(element.id)} + onDragMove={(e) => handleDragMove(element.id, e)} + onDragEnd={(e) => handleDragEnd(element.id, e)} + onTransform={() => handleTransform(element.id)} + // Visual feedback for selection + shadowColor={isSelected ? '#0066ff' : undefined} + shadowBlur={isSelected ? 4 : 0} + shadowOpacity={isSelected ? 0.3 : 0} + /> + ); + } else if (element.type === 'image' && element.imageElement && element.isImageReady) { + return ( + { + if (node) { + elementRefs.current[element.id] = node; + } + }} + image={element.imageElement} + // Use center position for x,y when offset is set + x={element.x + element.width / 2} + y={element.y + element.height / 2} + width={element.width} + height={element.height} + // Set offset to center for proper rotation + offsetX={element.width / 2} + offsetY={element.height / 2} + rotation={element.rotation || 0} + draggable + dragBoundFunc={createDragBoundFunc(element.id)} + onClick={() => handleElementSelect(element.id)} + onTap={() => handleElementSelect(element.id)} + onDragMove={(e) => handleDragMove(element.id, e)} + onDragEnd={(e) => handleDragEnd(element.id, e)} + onTransform={() => handleTransform(element.id)} + // Visual feedback for selection + stroke={isSelected ? '#0066ff' : undefined} + strokeWidth={isSelected ? 2 : 0} + strokeScaleEnabled={false} + /> + ); + } + return null; + })} + + {/* Guide Lines Layer */} + {guideLines.showVertical && ( + + )} + {guideLines.showHorizontal && ( + + )} + + {/* Transformer for selected element */} + { + // Limit resize to prevent elements from becoming too small + if (newBox.width < 20 || newBox.height < 20) { + return oldBox; + } + return newBox; + }} + // Transformer styling - Figma-like appearance + borderStroke="#0066ff" + borderStrokeWidth={2} + anchorStroke="#0066ff" + anchorFill="white" + anchorSize={14} + anchorCornerRadius={2} + // Enable only corner anchors for aspect ratio + enabledAnchors={['top-left', 'top-right', 'bottom-right', 'bottom-left']} + // Rotation handle + rotateAnchorOffset={30} + // Built-in Konva rotation snapping + rotationSnaps={[0, 90, 180, 270]} + rotationSnapTolerance={8} + // Clear guide lines when transform ends + onTransformEnd={() => { + setGuideLines({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + }} + // Style the rotation anchor to be circular + anchorStyleFunc={(anchor) => { + if (anchor.hasName('.rotater')) { + // Make it circular by setting corner radius to half the width + anchor.cornerRadius(anchor.width() / 2); + anchor.fill('#0066ff'); + anchor.stroke('white'); + anchor.strokeWidth(1); + } + }} + /> +
+
+
); -} +}; + +export default VideoPreview; diff --git a/resources/js/modules/editor/partials/text-sidebar.jsx b/resources/js/modules/editor/partials/text-sidebar.jsx index 443b55c..4c27067 100644 --- a/resources/js/modules/editor/partials/text-sidebar.jsx +++ b/resources/js/modules/editor/partials/text-sidebar.jsx @@ -1,32 +1,72 @@ import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { Bold, Minus, Plus, Type } from 'lucide-react'; +import { Bold, Italic, Minus, Plus, Type } from 'lucide-react'; import { useEffect, useState } from 'react'; +// Font configuration +const DEFAULT_FONT_FAMILY = 'Montserrat'; +const AVAILABLE_FONTS = [{ value: 'Montserrat', label: 'Montserrat' }]; + export default function TextSidebar({ isOpen, onClose }) { const { selectedTextElement } = useVideoEditorStore(); const emitter = useMitt(); const [textValue, setTextValue] = useState(''); - const [fontSize, setFontSize] = useState(24); // Default font size - const [isBold, setIsBold] = useState(true); // Default to bold + const [fontSize, setFontSize] = useState(24); + const [fontFamily, setFontFamily] = useState(DEFAULT_FONT_FAMILY); + const [isBold, setIsBold] = useState(true); + const [isItalic, setIsItalic] = useState(false); // Font size constraints const MIN_FONT_SIZE = 8; const MAX_FONT_SIZE = 120; const FONT_SIZE_STEP = 2; - // Update textarea, fontSize, and bold when selected element changes + // Update all state when selected element changes useEffect(() => { if (selectedTextElement) { setTextValue(selectedTextElement.text || ''); setFontSize(selectedTextElement.fontSize || 24); - setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true); // Default to bold if not set + setFontFamily(selectedTextElement.fontFamily || DEFAULT_FONT_FAMILY); + setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true); + setIsItalic(selectedTextElement.fontStyle === 'italic' || false); } }, [selectedTextElement]); + // Listen for fontSize changes from canvas transformations (separate from selection) + useEffect(() => { + const handleTextElementUpdate = (updatedElement) => { + if (selectedTextElement && updatedElement.id === selectedTextElement.id) { + // Update local state to reflect changes from canvas + if (updatedElement.fontSize !== undefined) { + setFontSize(updatedElement.fontSize); + } + if (updatedElement.text !== undefined) { + setTextValue(updatedElement.text); + } + if (updatedElement.fontFamily !== undefined) { + setFontFamily(updatedElement.fontFamily); + } + if (updatedElement.fontWeight !== undefined) { + setIsBold(updatedElement.fontWeight === 'bold' || updatedElement.fontWeight === 700); + } + if (updatedElement.fontStyle !== undefined) { + setIsItalic(updatedElement.fontStyle === 'italic'); + } + } + }; + + // Listen for updates from canvas transforms (doesn't open sidebar) + emitter.on('text-element-updated', handleTextElementUpdate); + + return () => { + emitter.off('text-element-updated', handleTextElementUpdate); + }; + }, [emitter, selectedTextElement]); + // Handle text changes const handleTextChange = (e) => { const newText = e.target.value; @@ -40,6 +80,18 @@ export default function TextSidebar({ isOpen, onClose }) { } }; + // Handle font family changes + const handleFontFamilyChange = (newFontFamily) => { + setFontFamily(newFontFamily); + + if (selectedTextElement) { + emitter.emit('text-update', { + elementId: selectedTextElement.id, + updates: { fontFamily: newFontFamily }, + }); + } + }; + // Handle font size changes const handleFontSizeChange = (newSize) => { const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newSize)); @@ -66,6 +118,19 @@ export default function TextSidebar({ isOpen, onClose }) { } }; + // Handle italic toggle + const handleItalicToggle = () => { + const newItalicState = !isItalic; + setIsItalic(newItalicState); + + if (selectedTextElement) { + emitter.emit('text-update', { + elementId: selectedTextElement.id, + updates: { fontStyle: newItalicState ? 'italic' : 'normal' }, + }); + } + }; + // Increase font size const increaseFontSize = () => { handleFontSizeChange(fontSize + FONT_SIZE_STEP); @@ -101,6 +166,23 @@ export default function TextSidebar({ isOpen, onClose }) { /> + {/* Font Family */} +
+ + +
+ {/* Font Size Controls */}
@@ -135,20 +217,35 @@ export default function TextSidebar({ isOpen, onClose }) {
Size range: {MIN_FONT_SIZE}px - {MAX_FONT_SIZE}px
+ + {/* Visual feedback for canvas scaling */} +
+ 💡 Tip: You can also resize text by dragging the corners on the canvas +
{/* Font Style Controls */}
-
+
+ +