Update
This commit is contained in:
Binary file not shown.
BIN
resources/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
BIN
resources/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
Binary file not shown.
93
resources/fonts/Montserrat/OFL.txt
Normal file
93
resources/fonts/Montserrat/OFL.txt
Normal file
@@ -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.
|
||||||
81
resources/fonts/Montserrat/README.txt
Normal file
81
resources/fonts/Montserrat/README.txt
Normal file
@@ -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.
|
||||||
BIN
resources/fonts/Montserrat/static/Montserrat-Black.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Black.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-BlackItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Bold.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-BoldItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraBold.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraLight.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Italic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Italic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Light.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Light.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-LightItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-LightItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Medium.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Medium.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-MediumItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Regular.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-SemiBold.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-SemiBold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-Thin.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-Thin.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/Montserrat/static/Montserrat-ThinItalic.ttf
Normal file
BIN
resources/fonts/Montserrat/static/Montserrat-ThinItalic.ttf
Normal file
Binary file not shown.
@@ -57,10 +57,12 @@ const sampleTimelineElements = [
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
layer: 2,
|
layer: 2,
|
||||||
duration: 4,
|
duration: 4,
|
||||||
x: 50,
|
x: 90,
|
||||||
y: 600,
|
y: 180,
|
||||||
fontSize: 24,
|
fontSize: 40,
|
||||||
fontWeight: 'bold', // ADD THIS LINE
|
fontFamily: 'Montserrat',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontStyle: 'normal',
|
||||||
fill: 'white',
|
fill: 'white',
|
||||||
stroke: 'black',
|
stroke: 'black',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
@@ -76,7 +78,9 @@ const sampleTimelineElements = [
|
|||||||
x: 50,
|
x: 50,
|
||||||
y: 650,
|
y: 650,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 'bold', // ADD THIS LINE
|
fontFamily: 'Montserrat',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontStyle: 'normal',
|
||||||
fill: 'yellow',
|
fill: 'yellow',
|
||||||
stroke: 'red',
|
stroke: 'red',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// NEW: Handle element transformations (position, scale, rotation)
|
// Handle element transformations (position, scale, rotation)
|
||||||
const handleElementUpdate = useCallback(
|
const handleElementUpdate = useCallback(
|
||||||
(elementId, updates) => {
|
(elementId, updates) => {
|
||||||
setTimelineElements((prev) =>
|
setTimelineElements((prev) =>
|
||||||
@@ -553,7 +553,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
handleSeek={handleSeek}
|
handleSeek={handleSeek}
|
||||||
copyFFmpegCommand={copyFFmpegCommand}
|
copyFFmpegCommand={copyFFmpegCommand}
|
||||||
exportVideo={exportVideo}
|
exportVideo={exportVideo}
|
||||||
onElementUpdate={handleElementUpdate} // NEW: Pass the update handler
|
onElementUpdate={handleElementUpdate}
|
||||||
layerRef={layerRef}
|
layerRef={layerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,16 +98,29 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
|
|
||||||
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
||||||
|
|
||||||
// Process text elements with centering
|
// Process text elements with font family support
|
||||||
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, '\\:');
|
||||||
|
|
||||||
|
// 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
|
// Center the text: x position is the center point, y is adjusted for baseline
|
||||||
const centerX = Math.round(t.x);
|
const centerX = Math.round(t.x);
|
||||||
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
|
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=${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
|
t.stroke
|
||||||
}:text_align=center: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}]`,
|
||||||
);
|
);
|
||||||
@@ -211,9 +224,25 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
showConsoleLogs && console.log('FFmpeg loaded!');
|
showConsoleLogs && console.log('FFmpeg loaded!');
|
||||||
setExportProgress(20);
|
setExportProgress(20);
|
||||||
|
|
||||||
setExportStatus('Loading font...');
|
setExportStatus('Loading fonts...');
|
||||||
await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'));
|
// Load Montserrat font variants
|
||||||
showConsoleLogs && console.log('Font loaded!');
|
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);
|
setExportProgress(30);
|
||||||
|
|
||||||
setExportStatus('Downloading media...');
|
setExportStatus('Downloading media...');
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ const VideoPreview = ({
|
|||||||
// Snap settings
|
// Snap settings
|
||||||
const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
|
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
|
// Function to determine which image source to use for videos
|
||||||
const getImageSource = (element) => {
|
const getImageSource = (element) => {
|
||||||
const isVideoActive = videoStates[element.id] && isPlaying;
|
const isVideoActive = videoStates[element.id] && isPlaying;
|
||||||
@@ -276,7 +280,7 @@ const VideoPreview = ({
|
|||||||
[onElementUpdate, timelineElements],
|
[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(
|
const handleTransform = useCallback(
|
||||||
(elementId) => {
|
(elementId) => {
|
||||||
const node = elementRefs.current[elementId];
|
const node = elementRefs.current[elementId];
|
||||||
@@ -290,12 +294,27 @@ const VideoPreview = ({
|
|||||||
const scaleX = node.scaleX();
|
const scaleX = node.scaleX();
|
||||||
const scaleY = node.scaleY();
|
const scaleY = node.scaleY();
|
||||||
|
|
||||||
let newWidth, newHeight;
|
let newWidth,
|
||||||
|
newHeight,
|
||||||
|
updates = {};
|
||||||
|
|
||||||
if (element.type === 'text') {
|
if (element.type === 'text') {
|
||||||
// For text, allow free scaling
|
// OPTION A: Convert scale change to fontSize change
|
||||||
newWidth = node.width() * scaleX;
|
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
||||||
newHeight = node.height() * 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 {
|
} else {
|
||||||
// For images/videos, maintain aspect ratio by using the larger scale
|
// For images/videos, maintain aspect ratio by using the larger scale
|
||||||
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
||||||
@@ -311,6 +330,9 @@ const VideoPreview = ({
|
|||||||
// Update offset for center rotation
|
// Update offset for center rotation
|
||||||
node.offsetX(newWidth / 2);
|
node.offsetX(newWidth / 2);
|
||||||
node.offsetY(newHeight / 2);
|
node.offsetY(newHeight / 2);
|
||||||
|
|
||||||
|
updates.width = newWidth;
|
||||||
|
updates.height = newHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate position for snapping
|
// Calculate position for snapping
|
||||||
@@ -320,8 +342,10 @@ const VideoPreview = ({
|
|||||||
// Convert center position to top-left for snapping
|
// Convert center position to top-left for snapping
|
||||||
const centerX = node.x();
|
const centerX = node.x();
|
||||||
const centerY = node.y();
|
const centerY = node.y();
|
||||||
topLeftX = centerX - newWidth / 2;
|
const currentWidth = element.type === 'text' ? node.width() : newWidth;
|
||||||
topLeftY = centerY - newHeight / 2;
|
const currentHeight = element.type === 'text' ? node.height() : newHeight;
|
||||||
|
topLeftX = centerX - currentWidth / 2;
|
||||||
|
topLeftY = centerY - currentHeight / 2;
|
||||||
} else {
|
} else {
|
||||||
// Use position directly for text
|
// Use position directly for text
|
||||||
topLeftX = node.x();
|
topLeftX = node.x();
|
||||||
@@ -331,13 +355,15 @@ const VideoPreview = ({
|
|||||||
// Check for position snapping during transform (but be less aggressive during rotation)
|
// Check for position snapping during transform (but be less aggressive during rotation)
|
||||||
const isRotating = Math.abs(rotation % 90) > 5; // Not close to perpendicular
|
const isRotating = Math.abs(rotation % 90) > 5; // Not close to perpendicular
|
||||||
if (!isRotating) {
|
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 (Math.abs(snapResult.x - topLeftX) > 5 || Math.abs(snapResult.y - topLeftY) > 5) {
|
||||||
if (usesCenterPositioning(element.type)) {
|
if (usesCenterPositioning(element.type)) {
|
||||||
// Convert back to center position
|
// Convert back to center position
|
||||||
const newCenterX = snapResult.x + newWidth / 2;
|
const newCenterX = snapResult.x + currentWidth / 2;
|
||||||
const newCenterY = snapResult.y + newHeight / 2;
|
const newCenterY = snapResult.y + currentHeight / 2;
|
||||||
node.x(newCenterX);
|
node.x(newCenterX);
|
||||||
node.y(newCenterY);
|
node.y(newCenterY);
|
||||||
} else {
|
} else {
|
||||||
@@ -359,18 +385,23 @@ const VideoPreview = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state with the final calculated values
|
// Always update position and rotation
|
||||||
const finalTransform = {
|
updates.x = topLeftX;
|
||||||
x: topLeftX,
|
updates.y = topLeftY;
|
||||||
y: topLeftY,
|
updates.rotation = rotation;
|
||||||
width: newWidth,
|
|
||||||
height: newHeight,
|
|
||||||
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
|
// Update transformer when selection changes
|
||||||
@@ -433,6 +464,10 @@ const VideoPreview = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (element.type === 'text') {
|
} 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 (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={element.id}
|
key={element.id}
|
||||||
@@ -445,8 +480,8 @@ const VideoPreview = ({
|
|||||||
x={element.x}
|
x={element.x}
|
||||||
y={element.y}
|
y={element.y}
|
||||||
fontSize={element.fontSize}
|
fontSize={element.fontSize}
|
||||||
fontStyle={element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal'} // ADD THIS LINE
|
fontStyle={`${fontStyle} ${fontWeight}`}
|
||||||
fontFamily="Arial"
|
fontFamily={element.fontFamily || 'Montserrat'}
|
||||||
fill={element.fill}
|
fill={element.fill}
|
||||||
stroke={element.stroke}
|
stroke={element.stroke}
|
||||||
strokeWidth={element.strokeWidth}
|
strokeWidth={element.strokeWidth}
|
||||||
|
|||||||
@@ -1,54 +1,614 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
||||||
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
|
||||||
import { SettingsIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function EditNavSidebar({ isOpen, onClose }) {
|
const VideoPreview = ({
|
||||||
const { getSetting, setSetting } = useLocalSettingsStore();
|
// Dimensions
|
||||||
|
dimensions,
|
||||||
|
|
||||||
|
// Timeline state
|
||||||
|
currentTime,
|
||||||
|
totalDuration,
|
||||||
|
isPlaying,
|
||||||
|
status,
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
isExporting,
|
||||||
|
exportProgress,
|
||||||
|
exportStatus,
|
||||||
|
|
||||||
|
// Data
|
||||||
|
timelineElements,
|
||||||
|
activeElements,
|
||||||
|
videoElements,
|
||||||
|
loadedVideos,
|
||||||
|
videoStates,
|
||||||
|
ffmpegCommand,
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
handlePlay,
|
||||||
|
handlePause,
|
||||||
|
handleReset,
|
||||||
|
handleSeek,
|
||||||
|
copyFFmpegCommand,
|
||||||
|
exportVideo,
|
||||||
|
onElementUpdate, // New prop for updating element properties
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
layerRef,
|
||||||
|
}) => {
|
||||||
|
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 (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<div>
|
||||||
<SheetContent side="left" className="w-50 overflow-y-auto">
|
<Stage width={dimensions.width} height={dimensions.height} ref={stageRef} onClick={handleStageClick} onTap={handleStageClick}>
|
||||||
<SheetHeader>
|
<Layer ref={layerRef}>
|
||||||
<SheetTitle className="flex items-center gap-3">
|
{activeElements.map((element) => {
|
||||||
<div className="font-display ml-0 text-lg tracking-wide md:ml-3 md:text-xl">MEMEAIGEN</div>
|
const isSelected = selectedElementId === element.id;
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
if (element.type === 'video') {
|
||||||
<Dialog>
|
const imageSource = getImageSource(element);
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="link">
|
|
||||||
<SettingsIcon className="h-6 w-6" /> Settings
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Settings</DialogTitle>
|
|
||||||
<DialogDescription>Change your settings here.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
if (!imageSource) {
|
||||||
<Checkbox
|
return null;
|
||||||
id="genAlphaSlang"
|
}
|
||||||
checked={getSetting('genAlphaSlang')}
|
|
||||||
onCheckedChange={() => setSetting('genAlphaSlang', !getSetting('genAlphaSlang'))}
|
return (
|
||||||
|
<Image
|
||||||
|
key={element.id}
|
||||||
|
ref={(node) => {
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
<label
|
);
|
||||||
htmlFor="genAlphaSlang"
|
} else if (element.type === 'text') {
|
||||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
// Build font style string
|
||||||
>
|
const fontWeight = element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal';
|
||||||
Use gen alpha slang
|
const fontStyle = element.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter></DialogFooter>
|
return (
|
||||||
</DialogContent>
|
<Text
|
||||||
</Dialog>
|
key={element.id}
|
||||||
</div>
|
ref={(node) => {
|
||||||
</SheetContent>
|
if (node) {
|
||||||
</Sheet>
|
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 (
|
||||||
|
<Image
|
||||||
|
key={element.id}
|
||||||
|
ref={(node) => {
|
||||||
|
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 && (
|
||||||
|
<Line
|
||||||
|
points={[guideLines.vertical, 0, guideLines.vertical, dimensions.height]}
|
||||||
|
stroke="#0066ff"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={[4, 4]}
|
||||||
|
opacity={0.8}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{guideLines.showHorizontal && (
|
||||||
|
<Line
|
||||||
|
points={[0, guideLines.horizontal, dimensions.width, guideLines.horizontal]}
|
||||||
|
stroke="#0066ff"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={[4, 4]}
|
||||||
|
opacity={0.8}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transformer for selected element */}
|
||||||
|
<Transformer
|
||||||
|
ref={transformerRef}
|
||||||
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Layer>
|
||||||
|
</Stage>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default VideoPreview;
|
||||||
|
|||||||
@@ -1,32 +1,72 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useMitt } from '@/plugins/MittContext';
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
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';
|
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 }) {
|
export default function TextSidebar({ isOpen, onClose }) {
|
||||||
const { selectedTextElement } = useVideoEditorStore();
|
const { selectedTextElement } = useVideoEditorStore();
|
||||||
const emitter = useMitt();
|
const emitter = useMitt();
|
||||||
const [textValue, setTextValue] = useState('');
|
const [textValue, setTextValue] = useState('');
|
||||||
const [fontSize, setFontSize] = useState(24); // Default font size
|
const [fontSize, setFontSize] = useState(24);
|
||||||
const [isBold, setIsBold] = useState(true); // Default to bold
|
const [fontFamily, setFontFamily] = useState(DEFAULT_FONT_FAMILY);
|
||||||
|
const [isBold, setIsBold] = useState(true);
|
||||||
|
const [isItalic, setIsItalic] = useState(false);
|
||||||
|
|
||||||
// Font size constraints
|
// Font size constraints
|
||||||
const MIN_FONT_SIZE = 8;
|
const MIN_FONT_SIZE = 8;
|
||||||
const MAX_FONT_SIZE = 120;
|
const MAX_FONT_SIZE = 120;
|
||||||
const FONT_SIZE_STEP = 2;
|
const FONT_SIZE_STEP = 2;
|
||||||
|
|
||||||
// Update textarea, fontSize, and bold when selected element changes
|
// Update all state when selected element changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTextElement) {
|
if (selectedTextElement) {
|
||||||
setTextValue(selectedTextElement.text || '');
|
setTextValue(selectedTextElement.text || '');
|
||||||
setFontSize(selectedTextElement.fontSize || 24);
|
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]);
|
}, [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
|
// Handle text changes
|
||||||
const handleTextChange = (e) => {
|
const handleTextChange = (e) => {
|
||||||
const newText = e.target.value;
|
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
|
// Handle font size changes
|
||||||
const handleFontSizeChange = (newSize) => {
|
const handleFontSizeChange = (newSize) => {
|
||||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, 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
|
// Increase font size
|
||||||
const increaseFontSize = () => {
|
const increaseFontSize = () => {
|
||||||
handleFontSizeChange(fontSize + FONT_SIZE_STEP);
|
handleFontSizeChange(fontSize + FONT_SIZE_STEP);
|
||||||
@@ -101,6 +166,23 @@ export default function TextSidebar({ isOpen, onClose }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Font Family */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Font Family</label>
|
||||||
|
<Select value={fontFamily} onValueChange={handleFontFamilyChange}>
|
||||||
|
<SelectTrigger className="mt-2">
|
||||||
|
<SelectValue placeholder="Select font" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AVAILABLE_FONTS.map((font) => (
|
||||||
|
<SelectItem key={font.value} value={font.value}>
|
||||||
|
{font.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Font Size Controls */}
|
{/* Font Size Controls */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Font Size</label>
|
<label className="text-sm font-medium">Font Size</label>
|
||||||
@@ -135,20 +217,35 @@ export default function TextSidebar({ isOpen, onClose }) {
|
|||||||
<div className="mt-1 text-center text-xs text-gray-500">
|
<div className="mt-1 text-center text-xs text-gray-500">
|
||||||
Size range: {MIN_FONT_SIZE}px - {MAX_FONT_SIZE}px
|
Size range: {MIN_FONT_SIZE}px - {MAX_FONT_SIZE}px
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visual feedback for canvas scaling */}
|
||||||
|
<div className="mt-1 text-center text-xs text-blue-600">
|
||||||
|
💡 Tip: You can also resize text by dragging the corners on the canvas
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Font Style Controls */}
|
{/* Font Style Controls */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Font Style</label>
|
<label className="text-sm font-medium">Font Style</label>
|
||||||
<div className="mt-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={isBold ? 'default' : 'outline'}
|
variant={isBold ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleBoldToggle}
|
onClick={handleBoldToggle}
|
||||||
className="flex w-full items-center gap-2"
|
className="flex flex-1 items-center gap-2"
|
||||||
>
|
>
|
||||||
<Bold className="h-4 w-4" />
|
<Bold className="h-4 w-4" />
|
||||||
<span className={isBold ? 'font-bold' : 'font-normal'}>{isBold ? 'Bold' : 'Normal'}</span>
|
<span className={isBold ? 'font-bold' : 'font-normal'}>Bold</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isItalic ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleItalicToggle}
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
<span className={isItalic ? 'italic' : 'not-italic'}>Italic</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user