Implement Media Editor (#6658)
Co-authored-by: Shahaf Antwarg <santwarg@gmail.com>
This commit is contained in:
parent
340e842a20
commit
02a5a2a44f
1
src/assets/font-icons/brush.svg
Normal file
1
src/assets/font-icons/brush.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#aaa" d="M6.75 15c.872 0 1.653.3 2.284.9.632.602.966 1.369.966 2.242 0 1.07-.413 2.001-1.191 2.742A3.96 3.96 0 0 1 6 22c-.723 0-1.425-.17-2.098-.49a4.66 4.66 0 0 1-1.687-1.32A1 1 0 0 1 3 18.571c.063 0 .194-.022.409-.177.044-.032.091-.067.091-.252 0-.873.335-1.64.967-2.242.63-.6 1.412-.9 2.283-.9m0 2c-.377 0-.66.117-.904.35-.242.23-.345.476-.346.792 0 .613-.199 1.178-.622 1.614.386.166.757.244 1.122.244.573 0 1.031-.185 1.43-.564.395-.377.57-.792.57-1.294 0-.316-.103-.562-.345-.793-.245-.233-.527-.35-.905-.35M16.88 3.707a3 3 0 0 1 4.242 0l.171.172a3 3 0 0 1 0 4.242l-7.171 7.172a3 3 0 0 1-4.243 0l-.172-.172a3 3 0 0 1 0-4.242zm2.828 1.414a1 1 0 0 0-1.415 0l-7.171 7.172a1 1 0 0 0 0 1.414l.171.172a1 1 0 0 0 1.415 0l7.171-7.172a1 1 0 0 0 0-1.414z"/></svg>
|
||||
|
After Width: | Height: | Size: 854 B |
1
src/assets/font-icons/crop.svg
Normal file
1
src/assets/font-icons/crop.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#aaa" d="M6 1a1 1 0 0 1 1 1v14a1 1 0 0 0 1 1h14a1 1 0 1 1 0 2h-3v3a1 1 0 1 1-2 0v-3H8a3 3 0 0 1-3-3V7H2a1 1 0 0 1 0-2h3V2a1 1 0 0 1 1-1m10 4a3 3 0 0 1 3 3v6a1 1 0 1 1-2 0V8a1 1 0 0 0-1-1h-6a1 1 0 0 1 0-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 320 B |
1
src/assets/font-icons/flip.svg
Normal file
1
src/assets/font-icons/flip.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M12 1.17a.83.83 0 0 1 .83.83v20a.83.83 0 0 1-1.66 0V2a.83.83 0 0 1 .83-.83M16 19a1 1 0 1 1 0 2 1 1 0 0 1 0-2m4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2M8 3.17a.83.83 0 0 1 0 1.66H5a.17.17 0 0 0-.17.17v14c0 .094.076.17.17.17h3a.83.83 0 0 1 0 1.66H5A1.83 1.83 0 0 1 3.17 19V5c0-1.01.82-1.83 1.83-1.83zM20 15a1 1 0 1 1 0 2 1 1 0 0 1 0-2m0-4a1 1 0 1 1 0 2 1 1 0 0 1 0-2m0-4a1 1 0 1 1 0 2 1 1 0 0 1 0-2m-4-4a1 1 0 1 1 0 2 1 1 0 0 1 0-2m4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/></svg>
|
||||
|
After Width: | Height: | Size: 576 B |
1
src/assets/font-icons/redo.svg
Normal file
1
src/assets/font-icons/redo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#aaa" d="M15.707 3.293a1 1 0 1 0-1.414 1.414L16.586 7H10a6 6 0 1 0 0 12h6a1 1 0 1 0 0-2h-6a4 4 0 1 1 0-8h6.586l-2.293 2.293a1 1 0 1 0 1.414 1.414l4-4a1 1 0 0 0 0-1.414z"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
1
src/assets/font-icons/rotate.svg
Normal file
1
src/assets/font-icons/rotate.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#fff" fill-rule="evenodd" d="M12.413 1.413a.83.83 0 0 1 1.174 1.174L12.004 4.17H13a8.83 8.83 0 1 1-3.312 17.018.83.83 0 0 1 .623-1.538c.83.335 1.736.52 2.689.52a7.17 7.17 0 0 0 0-14.34h-.996l1.583 1.583a.83.83 0 0 1-1.174 1.174l-3-3a.83.83 0 0 1 0-1.174zm-5.84 6.722c.327-.079.67-.065.992.039.26.084.462.229.629.37.16.135.336.313.524.501l2.237 2.236c.188.189.365.365.5.525.107.125.215.27.298.444l.073.185.039.138c.078.327.065.67-.04.992-.084.26-.228.463-.37.63-.135.159-.312.336-.5.524l-2.237 2.236c-.188.188-.365.366-.524.501-.167.142-.37.286-.629.37-.367.12-.763.12-1.13 0a1.9 1.9 0 0 1-.63-.37c-.159-.135-.336-.313-.524-.5l-2.236-2.237c-.189-.188-.366-.365-.501-.525a1.9 1.9 0 0 1-.37-.629 1.83 1.83 0 0 1 0-1.13l.073-.185c.082-.174.19-.32.297-.444.135-.16.312-.336.5-.525l2.237-2.236c.188-.188.365-.366.524-.5.167-.142.37-.287.63-.371zm.382 1.616a1 1 0 0 0-.075.059 8 8 0 0 0-.425.409l-2.237 2.236a8 8 0 0 0-.409.425 1 1 0 0 0-.058.075.2.2 0 0 0 0 .09 1 1 0 0 0 .058.075c.081.096.202.218.41.425l2.236 2.236c.207.208.329.328.425.41a1 1 0 0 0 .075.057.2.2 0 0 0 .089 0 1 1 0 0 0 .076-.058c.095-.08.217-.201.425-.409l2.236-2.236c.207-.207.328-.33.41-.425a1 1 0 0 0 .057-.076.17.17 0 0 0 0-.089 1 1 0 0 0-.058-.075 8 8 0 0 0-.41-.425L7.546 10.22a8 8 0 0 0-.425-.41 1 1 0 0 0-.076-.058.2.2 0 0 0-.09 0" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/font-icons/undo.svg
Normal file
1
src/assets/font-icons/undo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#aaa" d="M8.293 3.293a1 1 0 1 1 1.414 1.414L7.414 7H14a6 6 0 0 1 0 12H8a1 1 0 1 1 0-2h6a4 4 0 1 0 0-8H7.414l2.293 2.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414z"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@ -2413,6 +2413,29 @@
|
||||
"GiftAuctionForSaleOnFragment" = "{count} for sale on Fragment >";
|
||||
"GiftAuctionForSaleOnTelegram" = "{count} for sale on Telegram >";
|
||||
"EmbeddedMessageNoCaption" = "Caption removed";
|
||||
"EditMedia" = "Edit Media";
|
||||
"Draw" = "Draw";
|
||||
"Crop" = "Crop";
|
||||
"Clear" = "Clear";
|
||||
"Undo" = "Undo";
|
||||
"Redo" = "Redo";
|
||||
"ResetCrop" = "Reset";
|
||||
"CustomColor" = "Custom Color";
|
||||
"Size" = "Size";
|
||||
"Tool" = "Tool";
|
||||
"Pen" = "Pen";
|
||||
"Arrow" = "Arrow";
|
||||
"Brush" = "Brush";
|
||||
"Neon" = "Neon";
|
||||
"Eraser" = "Eraser";
|
||||
"AspectRatio" = "Aspect ratio";
|
||||
"Free" = "Free";
|
||||
"Original" = "Original";
|
||||
"Square" = "Square";
|
||||
"HEX" = "HEX";
|
||||
"RGB" = "RGB";
|
||||
"Adjust" = "Adjust";
|
||||
"Text" = "Text";
|
||||
"ConfirmBuyGiftForTonDescription" = "The seller only accepts TON as payment.";
|
||||
"TitleGiftLocked" = "Gift Locked";
|
||||
"GiftLockedMessage" = "This gift is currently only available to earlier Telegram users. It will unlock for your account in about **{relativeDate}**.";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC, TeactNode } from '../../lib/teact/teact';
|
||||
import type { TeactNode } from '../../lib/teact/teact';
|
||||
import { memo, useEffect, useMemo, useRef, useSignal, useState } from '../../lib/teact/teact';
|
||||
import { getActions, getGlobal, withGlobal } from '../../global';
|
||||
|
||||
@ -39,7 +39,7 @@ import type {
|
||||
ThemeKey,
|
||||
ThreadId,
|
||||
} from '../../types';
|
||||
import { MAIN_THREAD_ID } from '../../api/types';
|
||||
import { ApiMediaFormat, MAIN_THREAD_ID } from '../../api/types';
|
||||
|
||||
import {
|
||||
BASE_EMOJI_KEYWORD_LANG,
|
||||
@ -56,6 +56,10 @@ import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterd
|
||||
import {
|
||||
canEditMedia,
|
||||
getAllowedAttachmentOptions,
|
||||
getMediaFilename,
|
||||
getMediaHash,
|
||||
getMessageDocumentPhoto,
|
||||
getMessagePhoto,
|
||||
getReactionKey,
|
||||
getStoryKey,
|
||||
isChatAdmin,
|
||||
@ -117,8 +121,10 @@ import { tryParseDeepLink } from '../../util/deepLinkParser';
|
||||
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
|
||||
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
|
||||
import { isUserId } from '../../util/entities/ids';
|
||||
import { fetchBlob } from '../../util/files';
|
||||
import focusEditableElement from '../../util/focusEditableElement';
|
||||
import { formatStarsAsIcon } from '../../util/localization/format';
|
||||
import { fetch } from '../../util/mediaLoader';
|
||||
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
|
||||
import parseHtmlAsFormattedText from '../../util/parseHtmlAsFormattedText';
|
||||
import { insertHtmlInSelection } from '../../util/selection';
|
||||
@ -311,6 +317,8 @@ type StateProps = {
|
||||
isAppConfigLoaded?: boolean;
|
||||
insertingPeerIdMention?: string;
|
||||
pollMaxAnswers?: number;
|
||||
replyToMessage?: ApiMessage;
|
||||
shouldOpenMessageMediaEditor?: TabState['shouldOpenMessageMediaEditor'];
|
||||
};
|
||||
|
||||
enum MainButtonState {
|
||||
@ -335,7 +343,7 @@ const SELECT_MODE_TRANSITION_MS = 200;
|
||||
const SENDING_ANIMATION_DURATION = 350;
|
||||
const MOUNT_ANIMATION_DURATION = 430;
|
||||
|
||||
const Composer: FC<OwnProps & StateProps> = ({
|
||||
const Composer = ({
|
||||
type,
|
||||
isOnActiveTab,
|
||||
dropAreaState,
|
||||
@ -436,11 +444,13 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
pollMaxAnswers,
|
||||
replyToMessage,
|
||||
shouldOpenMessageMediaEditor,
|
||||
onDropHide,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onForward,
|
||||
}) => {
|
||||
}: OwnProps & StateProps) => {
|
||||
const {
|
||||
sendMessage,
|
||||
clearDraft,
|
||||
@ -695,6 +705,24 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
shouldSendInHighQuality: attachmentSettings.shouldSendInHighQuality,
|
||||
});
|
||||
|
||||
const mediaEditRequestRef = useRef(Date.now());
|
||||
useEffect(() => {
|
||||
if (!shouldOpenMessageMediaEditor) return;
|
||||
const targetMessage = editingMessage || replyToMessage;
|
||||
const media = targetMessage && (getMessagePhoto(targetMessage) || getMessageDocumentPhoto(targetMessage));
|
||||
if (!media) return;
|
||||
const mediaHash = getMediaHash(media, 'full');
|
||||
if (!mediaHash) return;
|
||||
const now = Date.now();
|
||||
mediaEditRequestRef.current = now;
|
||||
fetch(mediaHash, ApiMediaFormat.BlobUrl).then(async (blobUrl) => {
|
||||
if (mediaEditRequestRef.current !== now) return;
|
||||
const blob = await fetchBlob(blobUrl);
|
||||
const attachment = await buildAttachment(getMediaFilename(media), blob);
|
||||
handleSetAttachments([attachment]);
|
||||
});
|
||||
}, [editingMessage, replyToMessage, shouldOpenMessageMediaEditor, handleSetAttachments]);
|
||||
|
||||
const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag();
|
||||
const [isBotCommandMenuOpen, openBotCommandMenu, closeBotCommandMenu] = useFlag();
|
||||
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
|
||||
@ -2563,6 +2591,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
const { language, shouldCollectDebugLogs } = selectSharedSettings(global);
|
||||
const {
|
||||
forwardMessages: { messageIds: forwardMessageIds },
|
||||
shouldOpenMessageMediaEditor,
|
||||
} = selectTabState(global);
|
||||
const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
|
||||
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
|
||||
@ -2725,6 +2754,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
pollMaxAnswers: appConfig.pollMaxAnswers,
|
||||
shouldOpenMessageMediaEditor,
|
||||
replyToMessage,
|
||||
};
|
||||
},
|
||||
)(Composer));
|
||||
|
||||
@ -242,6 +242,32 @@
|
||||
&.round {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&.with-action-icon {
|
||||
&::after {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .pictogram-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pictogram {
|
||||
@ -250,6 +276,22 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pictogram-action-icon {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
color: white;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&.inside-input {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import type React from '../../../lib/teact/teact';
|
||||
import { useMemo, useRef } from '../../../lib/teact/teact';
|
||||
|
||||
import type {
|
||||
@ -31,7 +29,6 @@ import { renderTextWithEntities } from '../helpers/renderTextWithEntities';
|
||||
|
||||
import useMessageMediaHash from '../../../hooks/media/useMessageMediaHash';
|
||||
import useThumbnail from '../../../hooks/media/useThumbnail';
|
||||
import { useFastClick } from '../../../hooks/useFastClick';
|
||||
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useMedia from '../../../hooks/useMedia';
|
||||
@ -65,15 +62,17 @@ type OwnProps = {
|
||||
isOpen?: boolean;
|
||||
isMediaNsfw?: boolean;
|
||||
noCaptions?: boolean;
|
||||
pictogramActionIcon?: IconName;
|
||||
observeIntersectionForLoading?: ObserveFn;
|
||||
observeIntersectionForPlaying?: ObserveFn;
|
||||
onClick: ((e: React.MouseEvent) => void);
|
||||
onPictogramClick?: ((e: React.MouseEvent) => void);
|
||||
};
|
||||
|
||||
const NBSP = '\u00A0';
|
||||
const EMOJI_SIZE = 17;
|
||||
|
||||
const EmbeddedMessage: FC<OwnProps> = ({
|
||||
const EmbeddedMessage = ({
|
||||
className,
|
||||
message,
|
||||
replyInfo,
|
||||
@ -91,10 +90,12 @@ const EmbeddedMessage: FC<OwnProps> = ({
|
||||
requestedChatTranslationLanguage,
|
||||
isMediaNsfw,
|
||||
noCaptions,
|
||||
pictogramActionIcon,
|
||||
observeIntersectionForLoading,
|
||||
observeIntersectionForPlaying,
|
||||
onClick,
|
||||
}) => {
|
||||
onPictogramClick,
|
||||
}: OwnProps) => {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading);
|
||||
|
||||
@ -144,8 +145,6 @@ const EmbeddedMessage: FC<OwnProps> = ({
|
||||
: message?.forwardInfo?.hiddenUserName;
|
||||
const areSendersSame = sender && sender.id === forwardSender?.id;
|
||||
|
||||
const { handleClick, handleMouseDown } = useFastClick(onClick);
|
||||
|
||||
function renderTextContent() {
|
||||
const isFree = !(suggestedPostInfo?.price?.amount);
|
||||
if (suggestedPostInfo) {
|
||||
@ -316,14 +315,20 @@ const EmbeddedMessage: FC<OwnProps> = ({
|
||||
suggestedPostInfo && 'is-suggested-post',
|
||||
)}
|
||||
dir={lang.isRtl ? 'rtl' : undefined}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="hover-effect" />
|
||||
<RippleEffect />
|
||||
{mediaThumbnail && renderPictogram(
|
||||
mediaThumbnail, mediaBlobUrl, isVideoThumbnail, isRoundVideo, isProtected, isSpoiler,
|
||||
)}
|
||||
{mediaThumbnail && renderPictogram({
|
||||
thumbDataUri: mediaThumbnail,
|
||||
blobUrl: mediaBlobUrl,
|
||||
isFullVideo: isVideoThumbnail,
|
||||
isRoundVideo,
|
||||
isProtected,
|
||||
isSpoiler,
|
||||
pictogramActionIcon,
|
||||
onPictogramClick,
|
||||
})}
|
||||
<div className="message-text">
|
||||
<p className={buildClassName('embedded-text-wrapper', isQuote && 'multiline')}>
|
||||
{renderTextContent()}
|
||||
@ -337,21 +342,35 @@ const EmbeddedMessage: FC<OwnProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function renderPictogram(
|
||||
thumbDataUri: string,
|
||||
blobUrl?: string,
|
||||
isFullVideo?: boolean,
|
||||
isRoundVideo?: boolean,
|
||||
isProtected?: boolean,
|
||||
isSpoiler?: boolean,
|
||||
) {
|
||||
function renderPictogram({
|
||||
thumbDataUri,
|
||||
blobUrl,
|
||||
isFullVideo,
|
||||
isRoundVideo,
|
||||
isProtected,
|
||||
isSpoiler,
|
||||
pictogramActionIcon,
|
||||
onPictogramClick,
|
||||
}: {
|
||||
thumbDataUri: string;
|
||||
blobUrl?: string;
|
||||
isFullVideo?: boolean;
|
||||
isRoundVideo?: boolean;
|
||||
isProtected?: boolean;
|
||||
isSpoiler?: boolean;
|
||||
pictogramActionIcon?: IconName;
|
||||
onPictogramClick?: ((e: React.MouseEvent) => void);
|
||||
}) {
|
||||
const { width, height } = getPictogramDimensions();
|
||||
|
||||
const srcUrl = blobUrl || thumbDataUri;
|
||||
const shouldRenderVideo = isFullVideo && blobUrl;
|
||||
|
||||
return (
|
||||
<div className={buildClassName('embedded-thumb', isRoundVideo && 'round')}>
|
||||
<div
|
||||
className={buildClassName('embedded-thumb', isRoundVideo && 'round', pictogramActionIcon && 'with-action-icon')}
|
||||
onClick={onPictogramClick}
|
||||
>
|
||||
{!isSpoiler && !shouldRenderVideo && (
|
||||
<img
|
||||
src={srcUrl}
|
||||
@ -379,6 +398,7 @@ function renderPictogram(
|
||||
height={height}
|
||||
/>
|
||||
{isProtected && <span className="protector" />}
|
||||
{pictogramActionIcon && <Icon name={pictogramActionIcon} className="pictogram-action-icon" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import { validateFiles } from '../../../util/files';
|
||||
import { formatStarsAsIcon } from '../../../util/localization/format';
|
||||
import { removeAllSelections } from '../../../util/selection';
|
||||
import { openSystemFilesDialog } from '../../../util/systemFilesDialog';
|
||||
import buildAttachment from './helpers/buildAttachment';
|
||||
import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems';
|
||||
import { getHtmlTextLength } from './helpers/getHtmlTextLength';
|
||||
|
||||
@ -44,6 +45,7 @@ import useMentionTooltip from './hooks/useMentionTooltip';
|
||||
|
||||
import Button from '../../ui/Button';
|
||||
import DropdownMenu from '../../ui/DropdownMenu';
|
||||
import MediaEditor from '../../ui/mediaEditor/MediaEditor';
|
||||
import MenuItem from '../../ui/MenuItem';
|
||||
import Modal from '../../ui/Modal';
|
||||
import AttachmentModalItem from './AttachmentModalItem';
|
||||
@ -99,6 +101,7 @@ type StateProps = {
|
||||
captionLimit: number;
|
||||
attachmentSettings: GlobalState['attachmentSettings'];
|
||||
shouldSaveAttachmentsCompression?: boolean;
|
||||
shouldOpenMessageMediaEditor?: boolean;
|
||||
};
|
||||
|
||||
const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text';
|
||||
@ -134,6 +137,7 @@ const AttachmentModal = ({
|
||||
canScheduleUntilOnline,
|
||||
canSchedule,
|
||||
paidMessagesStars,
|
||||
shouldOpenMessageMediaEditor,
|
||||
onAttachmentsUpdate,
|
||||
onCaptionUpdate,
|
||||
onSend,
|
||||
@ -148,7 +152,9 @@ const AttachmentModal = ({
|
||||
}: OwnProps & StateProps) => {
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
const svgRef = useRef<SVGSVGElement>();
|
||||
const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions();
|
||||
const {
|
||||
addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
@ -166,6 +172,16 @@ const AttachmentModal = ({
|
||||
const notEditingFile = isEditingMessageFile !== 'file';
|
||||
|
||||
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
|
||||
const [editingAttachmentIndex, setEditingAttachmentIndex] = useState<number | undefined>(undefined);
|
||||
const editingAttachment = editingAttachmentIndex !== undefined
|
||||
? attachments[editingAttachmentIndex] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenMessageMediaEditor && attachments.length) {
|
||||
setEditingAttachmentIndex(0);
|
||||
resetMessageMediaEditorRequest();
|
||||
}
|
||||
}, [shouldOpenMessageMediaEditor, attachments.length]);
|
||||
|
||||
const shouldSendCompressed = attachmentSettings.shouldCompress;
|
||||
const isSendingCompressed = Boolean(
|
||||
@ -413,6 +429,33 @@ const AttachmentModal = ({
|
||||
}));
|
||||
});
|
||||
|
||||
const handleEdit = useLastCallback((index: number) => {
|
||||
setEditingAttachmentIndex(index);
|
||||
});
|
||||
|
||||
const handleCloseEditor = useLastCallback(() => {
|
||||
setEditingAttachmentIndex(undefined);
|
||||
});
|
||||
|
||||
const handleSaveEdit = useLastCallback(async (file: File) => {
|
||||
if (editingAttachmentIndex === undefined) return;
|
||||
|
||||
const newAttachment = await buildAttachment(file.name, file, {
|
||||
shouldSendAsFile: attachments[editingAttachmentIndex].shouldSendAsFile,
|
||||
shouldSendAsSpoiler: attachments[editingAttachmentIndex].shouldSendAsSpoiler,
|
||||
shouldSendInHighQuality: attachments[editingAttachmentIndex].shouldSendInHighQuality,
|
||||
});
|
||||
|
||||
onAttachmentsUpdate(attachments.map((attachment, i) => {
|
||||
if (i === editingAttachmentIndex) {
|
||||
return newAttachment;
|
||||
}
|
||||
return attachment;
|
||||
}));
|
||||
|
||||
setEditingAttachmentIndex(undefined);
|
||||
});
|
||||
|
||||
const handleResize = useLastCallback(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) {
|
||||
@ -690,6 +733,7 @@ const AttachmentModal = ({
|
||||
key={attachment.uniqueId || i}
|
||||
onDelete={handleDelete}
|
||||
onToggleSpoiler={handleToggleSpoiler}
|
||||
onEdit={!isMobile ? handleEdit : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -789,6 +833,14 @@ const AttachmentModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MediaEditor
|
||||
isOpen={Boolean(editingAttachment)}
|
||||
imageUrl={editingAttachment?.blobUrl}
|
||||
mimeType={editingAttachment?.mimeType}
|
||||
filename={editingAttachment?.filename}
|
||||
onClose={handleCloseEditor}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -802,7 +854,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
attachmentSettings,
|
||||
} = global;
|
||||
|
||||
const { shouldSaveAttachmentsCompression } = selectTabState(global);
|
||||
const { shouldSaveAttachmentsCompression, shouldOpenMessageMediaEditor } = selectTabState(global);
|
||||
const chatFullInfo = selectChatFullInfo(global, chatId);
|
||||
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
|
||||
const { shouldSuggestCustomEmoji } = global.settings.byKey;
|
||||
@ -822,6 +874,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
captionLimit: selectCurrentLimit(global, 'captionLength'),
|
||||
attachmentSettings,
|
||||
shouldSaveAttachmentsCompression,
|
||||
shouldOpenMessageMediaEditor,
|
||||
};
|
||||
},
|
||||
)(AttachmentModal));
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import { memo, useMemo } from '../../../lib/teact/teact';
|
||||
|
||||
import type { ApiAttachment } from '../../../api/types';
|
||||
@ -9,6 +8,7 @@ import { formatMediaDuration } from '../../../util/dates/dateFormat';
|
||||
import { getFileExtension } from '../../common/helpers/documentInfo';
|
||||
import { REM } from '../../common/helpers/mediaDimensions';
|
||||
|
||||
import useAppLayout from '../../../hooks/useAppLayout';
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import File from '../../common/File';
|
||||
@ -26,11 +26,12 @@ type OwnProps = {
|
||||
index: number;
|
||||
onDelete?: (index: number) => void;
|
||||
onToggleSpoiler?: (index: number) => void;
|
||||
onEdit?: (index: number) => void;
|
||||
};
|
||||
|
||||
const BLUR_CANVAS_SIZE = 15 * REM;
|
||||
|
||||
const AttachmentModalItem: FC<OwnProps> = ({
|
||||
const AttachmentModalItem = ({
|
||||
attachment,
|
||||
className,
|
||||
isSingle,
|
||||
@ -39,13 +40,19 @@ const AttachmentModalItem: FC<OwnProps> = ({
|
||||
index,
|
||||
onDelete,
|
||||
onToggleSpoiler,
|
||||
}) => {
|
||||
onEdit,
|
||||
}: OwnProps) => {
|
||||
const { isMobile } = useAppLayout();
|
||||
const displayType = getDisplayType(attachment, shouldDisplayCompressed);
|
||||
|
||||
const handleSpoilerClick = useLastCallback(() => {
|
||||
onToggleSpoiler?.(index);
|
||||
});
|
||||
|
||||
const handleEditClick = useLastCallback(() => {
|
||||
onEdit?.(index);
|
||||
});
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (displayType) {
|
||||
case 'photo':
|
||||
@ -73,7 +80,8 @@ const AttachmentModalItem: FC<OwnProps> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
default: {
|
||||
const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile;
|
||||
return (
|
||||
<>
|
||||
<File
|
||||
@ -83,6 +91,8 @@ const AttachmentModalItem: FC<OwnProps> = ({
|
||||
previewData={attachment.previewBlobUrl}
|
||||
size={attachment.size}
|
||||
smaller
|
||||
onClick={canEdit ? handleEditClick : undefined}
|
||||
actionIcon={canEdit ? 'edit' : undefined}
|
||||
/>
|
||||
{onDelete && (
|
||||
<Icon
|
||||
@ -94,8 +104,9 @@ const AttachmentModalItem: FC<OwnProps> = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [attachment, displayType, index, onDelete]);
|
||||
}, [attachment, displayType, index, onDelete, isMobile]);
|
||||
|
||||
const shouldSkipGrouping = displayType === 'file' || !shouldDisplayGrouped;
|
||||
const shouldDisplaySpoiler = Boolean(displayType !== 'file' && attachment.shouldSendAsSpoiler);
|
||||
@ -116,6 +127,13 @@ const AttachmentModalItem: FC<OwnProps> = ({
|
||||
/>
|
||||
{shouldRenderOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{displayType === 'photo' && onEdit && (
|
||||
<Icon
|
||||
name="edit"
|
||||
className={styles.actionItem}
|
||||
onClick={handleEditClick}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
name={attachment.shouldSendAsSpoiler ? 'spoiler-disable' : 'spoiler'}
|
||||
className={styles.actionItem}
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
} from '../../../api/types';
|
||||
import type { MessageListType, ThemeKey, ThreadId } from '../../../types/index';
|
||||
|
||||
import { isChatChannel, stripCustomEmoji } from '../../../global/helpers';
|
||||
import { canEditMediaInEditor, isChatChannel, stripCustomEmoji } from '../../../global/helpers';
|
||||
import {
|
||||
selectCanAnimateInterface,
|
||||
selectChat,
|
||||
@ -27,6 +27,7 @@ import buildClassName from '../../../util/buildClassName';
|
||||
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
||||
import { unique } from '../../../util/iteratees';
|
||||
|
||||
import useAppLayout from '../../../hooks/useAppLayout';
|
||||
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
|
||||
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
|
||||
import useFrozenProps from '../../../hooks/useFrozenProps';
|
||||
@ -75,7 +76,7 @@ type OwnProps = {
|
||||
chatId: string;
|
||||
threadId: ThreadId;
|
||||
messageListType: MessageListType;
|
||||
onClear?: () => void;
|
||||
onClear?: NoneToVoidFunction;
|
||||
};
|
||||
|
||||
const CLOSE_DURATION = 350;
|
||||
@ -108,10 +109,12 @@ const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => {
|
||||
exitForwardMode,
|
||||
setShouldPreventComposerAnimation,
|
||||
openSuggestMessageModal,
|
||||
requestMessageMediaEditor,
|
||||
} = getActions();
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
const oldLang = useOldLang();
|
||||
const lang = useLang();
|
||||
const { isMobile } = useAppLayout();
|
||||
|
||||
const isReplyToTopicStart = message?.content.action?.type === 'topicCreate';
|
||||
const isShowingSuggestedPost = Boolean(suggestedPostInfo) && !shouldForceShowEditing;
|
||||
@ -182,6 +185,8 @@ const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => {
|
||||
const isReplyWithQuoteRendering = Boolean(frozenReplyInfo?.quoteText);
|
||||
const isShowingSuggestedPostRendering = Boolean(frozenSuggestedPostInfo) && !frozenShouldForceShowEditing;
|
||||
|
||||
const canMediaBeEdited = frozenMessage && canEditMediaInEditor(frozenMessage) && !isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldPreventComposerAnimation) {
|
||||
setShouldPreventComposerAnimation({ shouldPreventComposerAnimation: false });
|
||||
@ -220,6 +225,14 @@ const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => {
|
||||
handleContextMenu(e);
|
||||
});
|
||||
|
||||
const handlePictogramClick = useLastCallback((e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
if ((frozenEditingId || frozenReplyInfo?.type === 'message') && canMediaBeEdited) {
|
||||
requestMessageMediaEditor();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const handleClearClick = useLastCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e.stopPropagation();
|
||||
clearEmbedded();
|
||||
@ -344,6 +357,8 @@ const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => {
|
||||
title={(frozenEditingId && !isShowingReplyRendering) ? oldLang('EditMessage')
|
||||
: noAuthors ? oldLang('HiddenSendersNameDescription') : undefined}
|
||||
onClick={handleMessageClick}
|
||||
onPictogramClick={canMediaBeEdited ? handlePictogramClick : undefined}
|
||||
pictogramActionIcon={canMediaBeEdited ? 'edit' : undefined}
|
||||
senderChat={senderChat}
|
||||
/>
|
||||
<Button
|
||||
|
||||
87
src/components/ui/mediaEditor/CropOverlay.tsx
Normal file
87
src/components/ui/mediaEditor/CropOverlay.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { memo } from '@teact';
|
||||
|
||||
import type { CropState, ResizeHandle } from './hooks/useCropper';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import buildStyle from '../../../util/buildStyle';
|
||||
|
||||
import styles from './MediaEditor.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
cropState: CropState;
|
||||
displaySize: { width: number; height: number };
|
||||
scale: number;
|
||||
isFadingOut: boolean;
|
||||
onCropperDragStart: (e: React.MouseEvent | React.TouchEvent) => void;
|
||||
onCornerResizeStart: (e: React.MouseEvent | React.TouchEvent, handle: ResizeHandle) => void;
|
||||
};
|
||||
|
||||
const CORNERS: ResizeHandle[] = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
|
||||
|
||||
function CropOverlay({
|
||||
cropState,
|
||||
displaySize,
|
||||
scale,
|
||||
isFadingOut,
|
||||
onCropperDragStart,
|
||||
onCornerResizeStart,
|
||||
}: OwnProps) {
|
||||
const { cropperX, cropperY, cropperWidth, cropperHeight } = cropState;
|
||||
|
||||
const frameX = cropperX * scale;
|
||||
const frameY = cropperY * scale;
|
||||
const frameWidth = cropperWidth * scale;
|
||||
const frameHeight = cropperHeight * scale;
|
||||
|
||||
if (frameWidth === 0 || frameHeight === 0) return undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={buildClassName(styles.cropWrapper, isFadingOut && styles.fadingOut)}
|
||||
style={`width: ${displaySize.width}px; height: ${displaySize.height}px`}
|
||||
>
|
||||
<div
|
||||
className={styles.cropDarkOverlay}
|
||||
style={`clip-path: polygon(
|
||||
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
|
||||
${frameX}px ${frameY}px,
|
||||
${frameX}px ${frameY + frameHeight}px,
|
||||
${frameX + frameWidth}px ${frameY + frameHeight}px,
|
||||
${frameX + frameWidth}px ${frameY}px,
|
||||
${frameX}px ${frameY}px
|
||||
)`}
|
||||
/>
|
||||
<div
|
||||
className={styles.cropRegion}
|
||||
style={buildStyle(
|
||||
`left: ${frameX}px`,
|
||||
`top: ${frameY}px`,
|
||||
`width: ${frameWidth}px`,
|
||||
`height: ${frameHeight}px`,
|
||||
)}
|
||||
onMouseDown={onCropperDragStart}
|
||||
onTouchStart={onCropperDragStart}
|
||||
>
|
||||
<div className={styles.cropGrid} />
|
||||
</div>
|
||||
{CORNERS.map((corner) => {
|
||||
const isTop = corner === 'topLeft' || corner === 'topRight';
|
||||
const isLeft = corner === 'topLeft' || corner === 'bottomLeft';
|
||||
const x = isLeft ? frameX : frameX + frameWidth;
|
||||
const y = isTop ? frameY : frameY + frameHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={corner}
|
||||
className={buildClassName(styles.cropCorner, styles[corner])}
|
||||
style={buildStyle(`left: ${x}px`, `top: ${y}px`)}
|
||||
onMouseDown={(e) => onCornerResizeStart(e, corner)}
|
||||
onTouchStart={(e) => onCornerResizeStart(e, corner)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CropOverlay);
|
||||
109
src/components/ui/mediaEditor/CropPanel.tsx
Normal file
109
src/components/ui/mediaEditor/CropPanel.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { memo } from '@teact';
|
||||
|
||||
import type { AspectRatio } from './hooks/useCropper';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import { ASPECT_RATIOS } from './hooks/useCropper';
|
||||
|
||||
import Icon from '../../common/icons/Icon';
|
||||
import ListItem from '../ListItem';
|
||||
|
||||
import styles from './MediaEditor.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
currentRatio: AspectRatio;
|
||||
onRatioChange: (ratio: AspectRatio) => void;
|
||||
};
|
||||
|
||||
const RATIO_ICON_CLASSES: Partial<Record<AspectRatio, string>> = {
|
||||
square: styles.ratio1x1,
|
||||
'3:2': styles.ratio3x2,
|
||||
'2:3': styles.ratio2x3,
|
||||
'4:3': styles.ratio4x3,
|
||||
'3:4': styles.ratio3x4,
|
||||
'5:4': styles.ratio5x4,
|
||||
'4:5': styles.ratio4x5,
|
||||
'16:9': styles.ratio16x9,
|
||||
'9:16': styles.ratio9x16,
|
||||
};
|
||||
|
||||
// First 3 ratios are displayed as full-width items
|
||||
const FULL_WIDTH_RATIOS = ASPECT_RATIOS.slice(0, 3);
|
||||
|
||||
// Remaining ratios are displayed in pairs
|
||||
const PAIRED_RATIOS = ASPECT_RATIOS.slice(3);
|
||||
|
||||
function CropPanel({ currentRatio, onRatioChange }: OwnProps) {
|
||||
const lang = useLang();
|
||||
|
||||
const renderRatioIcon = (value: AspectRatio) => {
|
||||
if (value === 'free') {
|
||||
return <Icon name="fullscreen" className="ListItem-main-icon" />;
|
||||
}
|
||||
if (value === 'original') {
|
||||
return <Icon name="photo" className="ListItem-main-icon" />;
|
||||
}
|
||||
return <div className={buildClassName('ListItem-main-icon', styles.ratioBox, RATIO_ICON_CLASSES[value])} />;
|
||||
};
|
||||
|
||||
const renderRatioLabel = (option: typeof ASPECT_RATIOS[number]) => {
|
||||
if (option.labelKey) {
|
||||
return lang(option.labelKey);
|
||||
}
|
||||
return option.label;
|
||||
};
|
||||
|
||||
const renderPairedRows = () => {
|
||||
// Generate row indices for paired ratios (0, 2, 4, ...)
|
||||
const rowIndices = Array.from({ length: Math.ceil(PAIRED_RATIOS.length / 2) }, (_, i) => i * 2);
|
||||
|
||||
return rowIndices.map((i) => {
|
||||
const leftRatio = PAIRED_RATIOS[i];
|
||||
const rightRatio = PAIRED_RATIOS[i + 1];
|
||||
|
||||
return (
|
||||
<div key={i} className={styles.aspectRatioRow}>
|
||||
<ListItem
|
||||
focus={currentRatio === leftRatio.value}
|
||||
onClick={() => onRatioChange(leftRatio.value)}
|
||||
>
|
||||
{renderRatioIcon(leftRatio.value)}
|
||||
{leftRatio.label}
|
||||
</ListItem>
|
||||
{rightRatio && (
|
||||
<ListItem
|
||||
focus={currentRatio === rightRatio.value}
|
||||
onClick={() => onRatioChange(rightRatio.value)}
|
||||
>
|
||||
{renderRatioIcon(rightRatio.value)}
|
||||
{rightRatio.label}
|
||||
</ListItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>{lang('AspectRatio')}</div>
|
||||
<div className={styles.aspectRatioList}>
|
||||
{FULL_WIDTH_RATIOS.map((option) => (
|
||||
<ListItem
|
||||
key={option.value}
|
||||
focus={currentRatio === option.value}
|
||||
onClick={() => onRatioChange(option.value)}
|
||||
>
|
||||
{renderRatioIcon(option.value)}
|
||||
{renderRatioLabel(option)}
|
||||
</ListItem>
|
||||
))}
|
||||
{renderPairedRows()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CropPanel);
|
||||
220
src/components/ui/mediaEditor/DrawPanel.tsx
Normal file
220
src/components/ui/mediaEditor/DrawPanel.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import type { ElementRef } from '@teact';
|
||||
import { memo } from '@teact';
|
||||
|
||||
import type { DrawTool } from './canvasUtils';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import buildStyle from '../../../util/buildStyle';
|
||||
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from './hooks/useDrawing';
|
||||
|
||||
import InputText from '../InputText';
|
||||
import ListItem from '../ListItem';
|
||||
import RangeSlider from '../RangeSlider';
|
||||
import {
|
||||
ArrowSvg, BrushSvg, EraserSvg, NeonSvg, PenSvg,
|
||||
} from './DrawToolSvgs';
|
||||
|
||||
import styles from './MediaEditor.module.scss';
|
||||
|
||||
interface ToolOption {
|
||||
id: DrawTool;
|
||||
labelKey: 'Pen' | 'Arrow' | 'Brush' | 'Neon' | 'Eraser';
|
||||
Icon: typeof PenSvg;
|
||||
}
|
||||
|
||||
const DRAW_TOOLS: ToolOption[] = [
|
||||
{ id: 'pen', labelKey: 'Pen', Icon: PenSvg },
|
||||
{ id: 'arrow', labelKey: 'Arrow', Icon: ArrowSvg },
|
||||
{ id: 'brush', labelKey: 'Brush', Icon: BrushSvg },
|
||||
{ id: 'neon', labelKey: 'Neon', Icon: NeonSvg },
|
||||
{ id: 'eraser', labelKey: 'Eraser', Icon: EraserSvg },
|
||||
];
|
||||
|
||||
type OwnProps = {
|
||||
predefinedColors: string[];
|
||||
selectedColor: string;
|
||||
isColorPickerOpen: boolean;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
brightness: number;
|
||||
pickerColor: string;
|
||||
hexInputValue: string;
|
||||
rgbInputValue: string;
|
||||
brushSize: number;
|
||||
drawTool: DrawTool;
|
||||
hueSliderRef: ElementRef<HTMLDivElement>;
|
||||
satBrightRef: ElementRef<HTMLDivElement>;
|
||||
onColorSelect: (color: string) => void;
|
||||
onOpenColorPicker: VoidFunction;
|
||||
onCloseColorPicker: VoidFunction;
|
||||
onHueSliderMouseDown: (e: React.MouseEvent) => void;
|
||||
onHueChange: (e: React.TouchEvent) => void;
|
||||
onSatBrightMouseDown: (e: React.MouseEvent) => void;
|
||||
onSatBrightChange: (e: React.TouchEvent) => void;
|
||||
onHexInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onHexInputBlur: VoidFunction;
|
||||
onRgbInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onRgbInputBlur: VoidFunction;
|
||||
onBrushSizeChange: (size: number) => void;
|
||||
onToolChange: (tool: DrawTool) => void;
|
||||
};
|
||||
|
||||
function DrawPanel({
|
||||
predefinedColors,
|
||||
selectedColor,
|
||||
isColorPickerOpen,
|
||||
hue,
|
||||
saturation,
|
||||
brightness,
|
||||
pickerColor,
|
||||
hexInputValue,
|
||||
rgbInputValue,
|
||||
brushSize,
|
||||
drawTool,
|
||||
hueSliderRef,
|
||||
satBrightRef,
|
||||
onColorSelect,
|
||||
onOpenColorPicker,
|
||||
onCloseColorPicker,
|
||||
onHueSliderMouseDown,
|
||||
onHueChange,
|
||||
onSatBrightMouseDown,
|
||||
onSatBrightChange,
|
||||
onHexInput,
|
||||
onHexInputBlur,
|
||||
onRgbInput,
|
||||
onRgbInputBlur,
|
||||
onBrushSizeChange,
|
||||
onToolChange,
|
||||
}: OwnProps) {
|
||||
const lang = useLang();
|
||||
|
||||
const hueDeg = hue * 360;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.colorRow}>
|
||||
{isColorPickerOpen ? (
|
||||
<div
|
||||
ref={hueSliderRef}
|
||||
className={styles.hueSlider}
|
||||
onMouseDown={onHueSliderMouseDown}
|
||||
onTouchStart={onHueChange}
|
||||
onTouchMove={onHueChange}
|
||||
>
|
||||
<div
|
||||
className={styles.hueHandle}
|
||||
style={`--picker-hue: ${hueDeg}`}
|
||||
/>
|
||||
</div>
|
||||
) : predefinedColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={buildClassName(
|
||||
styles.colorSwatch,
|
||||
selectedColor === color && styles.selected,
|
||||
)}
|
||||
style={`--swatch-color: ${color}; --swatch-outline: ${color}1a`}
|
||||
onClick={() => onColorSelect(color)}
|
||||
aria-label={color}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
className={buildClassName(
|
||||
styles.colorSwatch,
|
||||
styles.customColor,
|
||||
isColorPickerOpen && styles.selected,
|
||||
)}
|
||||
onClick={isColorPickerOpen ? onCloseColorPicker : onOpenColorPicker}
|
||||
aria-label={lang('CustomColor')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isColorPickerOpen && (
|
||||
<div className={styles.colorPickerInline}>
|
||||
<div className={styles.colorPickerRow}>
|
||||
<div
|
||||
ref={satBrightRef}
|
||||
className={styles.saturationBrightness}
|
||||
style={`--picker-hue: ${hueDeg}`}
|
||||
onMouseDown={onSatBrightMouseDown}
|
||||
onTouchStart={onSatBrightChange}
|
||||
onTouchMove={onSatBrightChange}
|
||||
>
|
||||
<div
|
||||
className={styles.satBrightHandle}
|
||||
style={buildStyle(
|
||||
`--picker-sat: ${saturation * 100}%`,
|
||||
`--picker-bright: ${(1 - brightness) * 100}%`,
|
||||
`--picker-color: ${pickerColor}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.colorInputs}>
|
||||
<InputText
|
||||
className={styles.colorInput}
|
||||
label={lang('HEX')}
|
||||
value={hexInputValue}
|
||||
onChange={onHexInput}
|
||||
onBlur={onHexInputBlur}
|
||||
maxLength={7}
|
||||
/>
|
||||
<InputText
|
||||
className={styles.colorInput}
|
||||
label={lang('RGB')}
|
||||
value={rgbInputValue}
|
||||
onChange={onRgbInput}
|
||||
onBlur={onRgbInputBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.sizeRow} style={`--selected-color: ${selectedColor}`}>
|
||||
<span className={styles.sectionLabel}>
|
||||
{lang('Size')}
|
||||
<span className={styles.sizeValue}>{brushSize}</span>
|
||||
</span>
|
||||
<RangeSlider
|
||||
className={styles.sizeSlider}
|
||||
min={MIN_BRUSH_SIZE}
|
||||
max={MAX_BRUSH_SIZE}
|
||||
value={brushSize}
|
||||
onChange={onBrushSizeChange}
|
||||
bold
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionLabel}>{lang('Tool')}</div>
|
||||
<div className={styles.toolList}>
|
||||
{DRAW_TOOLS.map((tool) => {
|
||||
const iconClassName = buildClassName(
|
||||
'ListItem-main-icon',
|
||||
styles.toolIcon,
|
||||
drawTool === tool.id && styles.toolIconActive,
|
||||
);
|
||||
return (
|
||||
<ListItem
|
||||
key={tool.id}
|
||||
focus={drawTool === tool.id}
|
||||
onClick={() => onToolChange(tool.id)}
|
||||
>
|
||||
<span className={iconClassName} style={`color: ${selectedColor}`}>
|
||||
<tool.Icon />
|
||||
</span>
|
||||
<span className={styles.toolLabel}>
|
||||
{lang(tool.labelKey)}
|
||||
</span>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DrawPanel);
|
||||
277
src/components/ui/mediaEditor/DrawToolSvgs.tsx
Normal file
277
src/components/ui/mediaEditor/DrawToolSvgs.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
/* eslint-disable @stylistic/max-len */
|
||||
|
||||
export function PenSvg() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20" className="draw-tool-icon">
|
||||
<g clip-path="url(#clip0_2524_7134)">
|
||||
<g filter="url(#filter0_iiii_2524_7134)">
|
||||
<path d="M0 1H80L110.2 8.44653C112.048 8.90213 112.971 9.12994 113.185 9.49307C113.369 9.80597 113.369 10.194 113.185 10.5069C112.971 10.8701 112.048 11.0979 110.2 11.5535L80 19H0V1Z" fill="#3E3F3F" />
|
||||
</g>
|
||||
<path d="M112.564 10.9709L103.474 13.2132C103.21 13.2782 102.944 13.121 102.883 12.8566C102.736 12.2146 102.5 11.0296 102.5 10C102.5 8.9705 102.736 7.78549 102.883 7.14344C102.944 6.87906 103.21 6.72187 103.474 6.78685L112.564 9.02913C113.578 9.27925 113.578 10.7208 112.564 10.9709Z" fill="currentColor" />
|
||||
<rect x="76" y="1" width="4" height="18" rx="0.5" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iiii_2524_7134" x="0" y="-4" width="116.323" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7134" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="3" dy="-5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2524_7134" result="effect2_innerShadow_2524_7134" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="-1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_2524_7134" result="effect3_innerShadow_2524_7134" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2524_7134" result="effect4_innerShadow_2524_7134" />
|
||||
</filter>
|
||||
<clipPath id="clip0_2524_7134">
|
||||
<rect width="20" height="120" fill="currentColor" transform="matrix(0 1 -1 0 120 0)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowSvg() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20" className="draw-tool-icon">
|
||||
<g clip-path="url(#clip0_2524_7140)">
|
||||
<path d="M94 10H110M110 10L104 4M110 10L104 16" stroke="url(#paint0_linear_2524_7140)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g filter="url(#filter0_iiii_2524_7140)">
|
||||
<path d="M0 1H92C94.2091 1 96 2.79086 96 5V15C96 17.2091 94.2091 19 92 19H0V1Z" fill="#3E3F3F" />
|
||||
</g>
|
||||
<path d="M92 1C94.2091 1 96 2.79086 96 5V15C96 17.2091 94.2091 19 92 19V1Z" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iiii_2524_7140" x="0" y="-4" width="99" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7140" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="3" dy="-5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2524_7140" result="effect2_innerShadow_2524_7140" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="-1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_2524_7140" result="effect3_innerShadow_2524_7140" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2524_7140" result="effect4_innerShadow_2524_7140" />
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2524_7140" x1="110" y1="10" x2="94" y2="10" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.755" stop-color="currentColor" />
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2524_7140">
|
||||
<rect width="20" height="120" fill="currentColor" transform="matrix(0 1 -1 0 120 0)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrushSvg() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20" className="draw-tool-icon">
|
||||
<g clip-path="url(#clip0_2524_7174)">
|
||||
<g filter="url(#filter0_iiii_2524_7174)">
|
||||
<path d="M0 1H82.3579C83.4414 1 84.5135 1.22006 85.5093 1.64684L91 4H101C101.552 4 102 4.44772 102 5V15C102 15.5523 101.552 16 101 16H91L85.5093 18.3532C84.5135 18.7799 83.4414 19 82.3579 19H0V1Z" fill="#3E3F3F" />
|
||||
</g>
|
||||
<rect x="76" y="1" width="4" height="18" rx="0.5" fill="currentColor" />
|
||||
<path d="M102 5H106.434C106.785 5 107.111 5.1843 107.291 5.4855L112.091 13.4855C112.491 14.152 112.011 15 111.234 15H102V5Z" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_iiii_2524_7174" x="0" y="-4" width="105" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7174" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="3" dy="-5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2524_7174" result="effect2_innerShadow_2524_7174" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="-1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_2524_7174" result="effect3_innerShadow_2524_7174" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2524_7174" result="effect4_innerShadow_2524_7174" />
|
||||
</filter>
|
||||
<clipPath id="clip0_2524_7174">
|
||||
<rect width="20" height="120" fill="currentColor" transform="matrix(0 1 -1 0 120 0)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NeonSvg() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20" className="draw-tool-icon">
|
||||
<g clip-path="url(#clip0_2524_7180)">
|
||||
<g filter="url(#filter0_f_2524_7180)">
|
||||
<path d="M102 5H107.146C108.282 5 109.323 5.64872 109.601 6.75061C109.813 7.59297 110 8.70303 110 10C110 11.297 109.813 12.407 109.601 13.2494C109.323 14.3513 108.282 15 107.146 15H102V5Z" fill="currentColor" />
|
||||
</g>
|
||||
<g filter="url(#filter1_f_2524_7180)">
|
||||
<path d="M102 5H107.146C108.282 5 109.323 5.64872 109.601 6.75061C109.813 7.59297 110 8.70303 110 10C110 11.297 109.813 12.407 109.601 13.2494C109.323 14.3513 108.282 15 107.146 15H102V5Z" fill="currentColor" />
|
||||
</g>
|
||||
<g filter="url(#filter2_f_2524_7180)">
|
||||
<path d="M102 5H107.146C108.282 5 109.323 5.64872 109.601 6.75061C109.813 7.59297 110 8.70303 110 10C110 11.297 109.813 12.407 109.601 13.2494C109.323 14.3513 108.282 15 107.146 15H102V5Z" fill="currentColor" />
|
||||
</g>
|
||||
<g filter="url(#filter3_iiii_2524_7180)">
|
||||
<path d="M0 1H82.3579C83.4414 1 84.5135 1.22006 85.5093 1.64684L91 4H101C101.552 4 102 4.44772 102 5V15C102 15.5523 101.552 16 101 16H91L85.5093 18.3532C84.5135 18.7799 83.4414 19 82.3579 19H0V1Z" fill="#3E3F3F" />
|
||||
</g>
|
||||
<rect x="76" y="1" width="4" height="18" rx="0.5" fill="currentColor" />
|
||||
<path d="M102 5H107.146C108.282 5 109.323 5.64872 109.601 6.75061C109.813 7.59297 110 8.70303 110 10C110 11.297 109.813 12.407 109.601 13.2494C109.323 14.3513 108.282 15 107.146 15H102V5Z" fill="currentColor" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_2524_7180" x="96" y="-1" width="20" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_2524_7180" />
|
||||
</filter>
|
||||
<filter id="filter1_f_2524_7180" x="96" y="-1" width="20" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_2524_7180" />
|
||||
</filter>
|
||||
<filter id="filter2_f_2524_7180" x="96" y="-1" width="20" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_2524_7180" />
|
||||
</filter>
|
||||
<filter id="filter3_iiii_2524_7180" x="0" y="-4" width="105" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7180" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="3" dy="-5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2524_7180" result="effect2_innerShadow_2524_7180" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="-1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_2524_7180" result="effect3_innerShadow_2524_7180" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2524_7180" result="effect4_innerShadow_2524_7180" />
|
||||
</filter>
|
||||
<clipPath id="clip0_2524_7180">
|
||||
<rect width="20" height="120" fill="currentColor" transform="matrix(0 1 -1 0 120 0)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EraserSvg() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20" className="draw-tool-icon">
|
||||
<g clip-path="url(#clip0_2524_7149)">
|
||||
<g filter="url(#filter0_i_2524_7149)">
|
||||
<path d="M95 1H108C110.209 1 112 2.79086 112 5V15C112 17.2091 110.209 19 108 19H95V1Z" fill="#D9D9D9" />
|
||||
<path d="M95 1H108C110.209 1 112 2.79086 112 5V15C112 17.2091 110.209 19 108 19H95V1Z" fill="#F09B99" />
|
||||
</g>
|
||||
<g filter="url(#filter1_iiii_2524_7149)">
|
||||
<path d="M0 1H77.6464C77.8728 1 78.0899 0.910072 78.25 0.75C78.4101 0.589928 78.6272 0.5 78.8536 0.5H96C97.1046 0.5 98 1.39543 98 2.5V17.5C98 18.6046 97.1046 19.5 96 19.5H78.8536C78.6272 19.5 78.4101 19.4101 78.25 19.25C78.0899 19.0899 77.8728 19 77.6464 19H0V1Z" fill="#3E3F3F" />
|
||||
</g>
|
||||
<path d="M79 19.5V0.5L78 0.5V19.5H79Z" fill="black" fill-opacity="0.33" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_2524_7149" x="95" y="-1" width="19" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="2" dy="-2" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.33 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7149" />
|
||||
</filter>
|
||||
<filter id="filter1_iiii_2524_7149" x="0" y="-4.5" width="101" height="29" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2524_7149" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="3" dy="-5" />
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.137255 0 0 0 0 0.145098 0 0 0 0 0.14902 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2524_7149" result="effect2_innerShadow_2524_7149" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="-1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_2524_7149" result="effect3_innerShadow_2524_7149" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dx="1" dy="1" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.242217 0 0 0 0 0.247242 0 0 0 0 0.247101 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect3_innerShadow_2524_7149" result="effect4_innerShadow_2524_7149" />
|
||||
</filter>
|
||||
<clipPath id="clip0_2524_7149">
|
||||
<rect width="20" height="120" fill="white" transform="matrix(0 1 -1 0 120 0)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
796
src/components/ui/mediaEditor/MediaEditor.module.scss
Normal file
796
src/components/ui/mediaEditor/MediaEditor.module.scss
Normal file
@ -0,0 +1,796 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
inset: 0;
|
||||
transform: scale(0.95);
|
||||
|
||||
display: flex;
|
||||
|
||||
opacity: 0;
|
||||
background-color: #000;
|
||||
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:global(.open) {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:global(.closing) {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(body.no-page-transitions) & {
|
||||
transform: none !important;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasArea {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 0;
|
||||
padding: 2rem;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasContainer {
|
||||
position: relative;
|
||||
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&.drawMode {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
&.transitioningToDraw {
|
||||
animation: canvasFadeInDelayed 0.3s ease;
|
||||
}
|
||||
|
||||
&.transitioningToCrop {
|
||||
animation: canvasZoomOut 0.3s ease;
|
||||
}
|
||||
|
||||
&.transformAnimating {
|
||||
animation: canvasTransformReveal 0.3s ease forwards;
|
||||
}
|
||||
|
||||
&.flipAnimating {
|
||||
animation: canvasFlipReveal 0.3s ease forwards;
|
||||
}
|
||||
|
||||
:global(body.no-page-transitions) & {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Crop → Draw: canvas stays hidden while snapshot zooms, then fades in
|
||||
@keyframes canvasFadeInDelayed {
|
||||
0% { opacity: 0; }
|
||||
60% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
// Draw → Crop: canvas starts zoomed into crop region, then zooms out to full
|
||||
@keyframes canvasZoomOut {
|
||||
0% {
|
||||
transform: translate(var(--offset-x), var(--offset-y));
|
||||
clip-path:
|
||||
inset(
|
||||
var(--crop-top) var(--crop-right) var(--crop-bottom) var(--crop-left) round 0.25rem
|
||||
);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
clip-path: inset(0 round 0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.canvasSnapshot {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
border-radius: 0.25rem;
|
||||
|
||||
:global(body.no-page-transitions) & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Crop → Draw: clip to crop frame, translate to center, then fade out
|
||||
&.zoomIn {
|
||||
animation: snapshotZoomIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
// Draw → Crop: simple crossfade
|
||||
&.fadeOut {
|
||||
animation: snapshotFadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
&.rotateFade {
|
||||
animation: snapshotRotateFade 0.3s ease forwards;
|
||||
}
|
||||
|
||||
&.flipFade {
|
||||
animation: snapshotFlipFade 0.3s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes snapshotZoomIn {
|
||||
0% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
clip-path: inset(0 round 0.25rem);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform:
|
||||
translate(
|
||||
calc(-50% + var(--offset-x)),
|
||||
calc(-50% + var(--offset-y))
|
||||
);
|
||||
opacity: 1;
|
||||
clip-path:
|
||||
inset(
|
||||
var(--crop-top) var(--crop-right) var(--crop-bottom) var(--crop-left) round 0.25rem
|
||||
);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform:
|
||||
translate(
|
||||
calc(-50% + var(--offset-x)),
|
||||
calc(-50% + var(--offset-y))
|
||||
);
|
||||
opacity: 0;
|
||||
clip-path:
|
||||
inset(
|
||||
var(--crop-top) var(--crop-right) var(--crop-bottom) var(--crop-left) round 0.25rem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes snapshotFadeOut {
|
||||
0% { transform: translate(-50%, -50%); opacity: 1; }
|
||||
25%, 100% { transform: translate(-50%, -50%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes snapshotRotateFade {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(1, 1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(-50%, -50%) rotate(-90deg) scale(var(--end-sx, 1), var(--end-sy, 1));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(-90deg) scale(var(--end-sx, 1), var(--end-sy, 1));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes snapshotFlipFade {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotateX(0deg) rotateY(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-50%, -50%) rotateX(10deg) rotateY(-90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50.01%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes canvasTransformReveal {
|
||||
0%, 65% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes canvasFlipReveal {
|
||||
0%, 45% {
|
||||
transform: rotateX(10deg) rotateY(90deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(0deg) rotateY(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Crop overlay - explicitly sized to match canvas, centered in container
|
||||
.cropWrapper {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
animation: fadeIn 0.3s ease;
|
||||
|
||||
&.fadingOut {
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
:global(body.no-page-transitions) & {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cropDarkOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cropRegion {
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
position: absolute;
|
||||
border: 2px solid var(--color-white);
|
||||
}
|
||||
|
||||
.cropGrid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 33.33%;
|
||||
right: 0;
|
||||
bottom: 33.33%;
|
||||
left: 0;
|
||||
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
right: 33.33%;
|
||||
bottom: 0;
|
||||
left: 33.33%;
|
||||
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.cropCorner {
|
||||
pointer-events: auto;
|
||||
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
|
||||
&.topLeft {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
&.topRight {
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
&.bottomLeft {
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
&.bottomRight {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
}
|
||||
|
||||
// Right panel (bottom on mobile)
|
||||
.editPanel {
|
||||
display: flex;
|
||||
flex: 0 0 var(--right-column-width);
|
||||
flex-direction: column;
|
||||
background-color: var(--color-background);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
max-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
flex: 1;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.panelTabs {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.modeTabs {
|
||||
z-index: 1;
|
||||
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
|
||||
margin-top: 0.125rem;
|
||||
padding: 0 1rem;
|
||||
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
|
||||
}
|
||||
|
||||
.modeTab {
|
||||
flex: 0.125;
|
||||
|
||||
padding: 0.75rem 0.5rem;
|
||||
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
transition: color 0.15s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:global(.Tab--active) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.panelContent {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Draw mode
|
||||
.sectionLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.colorRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin: 0.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--swatch-color);
|
||||
outline: 0.5rem solid transparent !important;
|
||||
|
||||
transition: transform 0.15s ease-in-out, outline-color 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
outline-color: var(--swatch-outline) !important;
|
||||
}
|
||||
|
||||
&.customColor {
|
||||
--swatch-color: transparent;
|
||||
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
#ff0000,
|
||||
#ff8000,
|
||||
#ffff00,
|
||||
#00ff00,
|
||||
#00ffff,
|
||||
#0000ff,
|
||||
#8000ff,
|
||||
#ff0080,
|
||||
#ff0000
|
||||
);
|
||||
|
||||
&.selected {
|
||||
outline-color: #ffffff1a !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sizeRow {
|
||||
--selected-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sizeSlider {
|
||||
padding: 0 0.5rem;
|
||||
|
||||
&:global(.RangeSlider) {
|
||||
--slider-color: var(--selected-color);
|
||||
}
|
||||
}
|
||||
|
||||
.sizeValue {
|
||||
min-width: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.canvasControls {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
width: 25rem;
|
||||
max-width: 100%;
|
||||
padding: 1rem;
|
||||
|
||||
&.hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.fadingOut {
|
||||
pointer-events: none;
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
&.fadingIn {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
:global(body.no-page-transitions) & {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aspectRatioList, .toolList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.toolIcon {
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
width: 7.5rem;
|
||||
height: 1.25rem;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
|
||||
width: 3.5rem;
|
||||
height: 1.25rem;
|
||||
|
||||
background: linear-gradient(90deg, var(--color-background) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
:global(.draw-tool-icon) {
|
||||
transform: translateX(-1.5rem);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.toolLabel {
|
||||
transform: translateX(-1.5rem);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
:global(.ListItem.focus),
|
||||
:global(.ListItem:hover) {
|
||||
.toolIcon {
|
||||
&::after {
|
||||
background: linear-gradient(90deg, var(--background-color) 0%, transparent 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ListItem.focus) {
|
||||
.toolIcon {
|
||||
:global(.draw-tool-icon) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.toolLabel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.aspectRatioRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
// CSS-based ratio icons
|
||||
.ratioBox {
|
||||
|
||||
// Base height for all ratio boxes
|
||||
--ratio-size: 1.25rem;
|
||||
|
||||
margin-inline-start: 0.125rem;
|
||||
border: 0.125rem solid var(--color-text-secondary);
|
||||
border-radius: 0.125rem;
|
||||
|
||||
.selected & {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
// Square (1:1)
|
||||
&.ratio1x1 {
|
||||
width: var(--ratio-size);
|
||||
height: var(--ratio-size);
|
||||
}
|
||||
|
||||
// 3:2 (horizontal)
|
||||
&.ratio3x2 {
|
||||
width: var(--ratio-size);
|
||||
height: calc(var(--ratio-size) * 2 / 3);
|
||||
}
|
||||
|
||||
// 2:3 (vertical)
|
||||
&.ratio2x3 {
|
||||
width: calc(var(--ratio-size) * 2 / 3);
|
||||
height: var(--ratio-size);
|
||||
}
|
||||
|
||||
// 4:3 (horizontal)
|
||||
&.ratio4x3 {
|
||||
width: var(--ratio-size);
|
||||
height: calc(var(--ratio-size) * 3 / 4);
|
||||
}
|
||||
|
||||
// 3:4 (vertical)
|
||||
&.ratio3x4 {
|
||||
width: calc(var(--ratio-size) * 3 / 4);
|
||||
height: var(--ratio-size);
|
||||
}
|
||||
|
||||
// 5:4 (horizontal)
|
||||
&.ratio5x4 {
|
||||
width: var(--ratio-size);
|
||||
height: calc(var(--ratio-size) * 4 / 5);
|
||||
}
|
||||
|
||||
// 4:5 (vertical)
|
||||
&.ratio4x5 {
|
||||
width: calc(var(--ratio-size) * 4 / 5);
|
||||
height: var(--ratio-size);
|
||||
}
|
||||
|
||||
// 16:9 (wide)
|
||||
&.ratio16x9 {
|
||||
width: var(--ratio-size);
|
||||
height: calc(var(--ratio-size) * 9 / 16);
|
||||
}
|
||||
|
||||
// 9:16 (tall)
|
||||
&.ratio9x16 {
|
||||
width: calc(var(--ratio-size) * 9 / 16);
|
||||
height: var(--ratio-size);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline color picker
|
||||
.colorPickerInline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.colorPickerRow {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.saturationBrightness {
|
||||
cursor: crosshair;
|
||||
|
||||
position: relative;
|
||||
|
||||
flex: 1;
|
||||
|
||||
height: 7.5rem;
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
background:
|
||||
linear-gradient(to top, #000, transparent),
|
||||
linear-gradient(to right, #fff, hsl(var(--picker-hue), 100%, 50%));
|
||||
|
||||
@media (max-width: 600px) {
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.colorInputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.colorInput {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.satBrightHandle {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
top: var(--picker-bright);
|
||||
left: var(--picker-sat);
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--color-white);
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--picker-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.hueSlider {
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
flex: 1;
|
||||
|
||||
height: 1.5rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
background:
|
||||
linear-gradient(
|
||||
to right,
|
||||
#ff0000,
|
||||
#ff8000,
|
||||
#ffff00,
|
||||
#80ff00,
|
||||
#00ff00,
|
||||
#00ff80,
|
||||
#00ffff,
|
||||
#0080ff,
|
||||
#0000ff,
|
||||
#8000ff,
|
||||
#ff00ff,
|
||||
#ff0080,
|
||||
#ff0000
|
||||
);
|
||||
}
|
||||
|
||||
.hueHandle {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(var(--picker-hue) / 360 * 100%);
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--color-white);
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: hsl(var(--picker-hue), 100%, 50%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Save button
|
||||
.saveButton {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
right: 1rem;
|
||||
bottom: 17rem;
|
||||
}
|
||||
}
|
||||
836
src/components/ui/mediaEditor/MediaEditor.tsx
Normal file
836
src/components/ui/mediaEditor/MediaEditor.tsx
Normal file
@ -0,0 +1,836 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from '@teact';
|
||||
|
||||
import type { DrawAction } from './canvasUtils';
|
||||
import type { CropAction, CropState } from './hooks/useCropper';
|
||||
|
||||
import { selectTheme } from '../../../global/selectors';
|
||||
import { selectAnimationLevel } from '../../../global/selectors/sharedState';
|
||||
import { IS_WINDOWS } from '../../../util/browser/windowEnvironment';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import buildStyle from '../../../util/buildStyle';
|
||||
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
||||
import getPointerPosition from '../../../util/events/getPointerPosition';
|
||||
import { blobToFile, preloadImage } from '../../../util/files';
|
||||
import { resolveTransitionName } from '../../../util/resolveTransitionName';
|
||||
import { REM } from '../../common/helpers/mediaDimensions';
|
||||
import {
|
||||
applyCanvasTransform, computeRotationZoom, getEffectiveDimensions, renderActionsToCanvas,
|
||||
} from './canvasUtils';
|
||||
|
||||
import useSelector from '../../../hooks/data/useSelector';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
import useCanvasRenderer from './hooks/useCanvasRenderer';
|
||||
import useColorPicker, { getPredefinedColors } from './hooks/useColorPicker';
|
||||
import useCropper, { DEFAULT_CROP_STATE, getTotalRotation } from './hooks/useCropper';
|
||||
import useDisplaySize from './hooks/useDisplaySize';
|
||||
import useDrawing from './hooks/useDrawing';
|
||||
|
||||
import Icon from '../../common/icons/Icon';
|
||||
import Button from '../Button';
|
||||
import FloatingActionButton from '../FloatingActionButton';
|
||||
import Portal from '../Portal';
|
||||
import TabList from '../TabList';
|
||||
import Transition from '../Transition';
|
||||
import CropOverlay from './CropOverlay';
|
||||
import CropPanel from './CropPanel';
|
||||
import DrawPanel from './DrawPanel';
|
||||
import RotationSlider from './RotationSlider';
|
||||
|
||||
import styles from './MediaEditor.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
isOpen: boolean;
|
||||
imageUrl?: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
onClose: VoidFunction;
|
||||
onSave: (file: File) => void;
|
||||
};
|
||||
|
||||
type EditorMode = 'crop' | 'draw';
|
||||
|
||||
type EditorAction = DrawAction | CropAction;
|
||||
|
||||
const EDITOR_TABS = [
|
||||
{ type: 'draw' as const, icon: 'brush' as const },
|
||||
{ type: 'crop' as const, icon: 'crop' as const },
|
||||
];
|
||||
|
||||
const INITIAL_MODE = 'draw';
|
||||
const TABS = EDITOR_TABS.map((tab) => ({
|
||||
title: <Icon name={tab.icon} />,
|
||||
}));
|
||||
|
||||
const TRANSITION_DURATION = 300;
|
||||
|
||||
const MediaEditor = ({
|
||||
isOpen,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
filename,
|
||||
onClose,
|
||||
onSave,
|
||||
}: OwnProps) => {
|
||||
const lang = useLang();
|
||||
const animationLevel = useSelector(selectAnimationLevel);
|
||||
const theme = useSelector(selectTheme);
|
||||
|
||||
const predefinedColors = useMemo(() => getPredefinedColors(theme), [theme]);
|
||||
|
||||
const {
|
||||
ref: rootRef,
|
||||
shouldRender,
|
||||
} = useShowTransition({
|
||||
isOpen,
|
||||
withShouldRender: true,
|
||||
});
|
||||
|
||||
const transitionRef = useRef<HTMLDivElement>();
|
||||
const canvasRef = useRef<HTMLCanvasElement>();
|
||||
const canvasAreaRef = useRef<HTMLDivElement>();
|
||||
const originalImageRef = useRef<HTMLImageElement | undefined>(undefined);
|
||||
|
||||
const [mode, setMode] = useState<EditorMode>(INITIAL_MODE);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [snapshotSrc, setSnapshotSrc] = useState<string | undefined>();
|
||||
const [snapshotStyle, setSnapshotStyle] = useState('');
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
const [cropState, setCropState] = useState<CropState>(DEFAULT_CROP_STATE);
|
||||
|
||||
const effectiveDims = useMemo(() => {
|
||||
if (imageDimensions.width === 0) return { width: 0, height: 0 };
|
||||
return getEffectiveDimensions(imageDimensions.width, imageDimensions.height, cropState.quarterTurns);
|
||||
}, [imageDimensions.width, imageDimensions.height, cropState.quarterTurns]);
|
||||
|
||||
const [transformAnimType, setTransformAnimType] = useState<'rotate' | 'flip' | undefined>();
|
||||
|
||||
const [actions, setActions] = useState<EditorAction[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<EditorAction[]>([]);
|
||||
|
||||
const actionsRef = useRef<EditorAction[]>([]);
|
||||
const redoStackRef = useRef<EditorAction[]>([]);
|
||||
actionsRef.current = actions;
|
||||
redoStackRef.current = redoStack;
|
||||
|
||||
// Display size hook - must be called before useCropper and useCanvasRenderer
|
||||
const {
|
||||
displaySize,
|
||||
getDisplayScale,
|
||||
resetDisplaySize,
|
||||
} = useDisplaySize({
|
||||
canvasAreaRef,
|
||||
imageWidth: effectiveDims.width,
|
||||
imageHeight: effectiveDims.height,
|
||||
reservedHeight: 6.5 * REM,
|
||||
});
|
||||
|
||||
// Color picker hook
|
||||
const {
|
||||
hueSliderRef,
|
||||
satBrightRef,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
isColorPickerOpen,
|
||||
openColorPicker,
|
||||
closeColorPicker,
|
||||
hue,
|
||||
saturation,
|
||||
brightness,
|
||||
pickerColor,
|
||||
hexInputValue,
|
||||
rgbInputValue,
|
||||
handleHueChange,
|
||||
handleSatBrightChange,
|
||||
handleHexInput,
|
||||
handleHexInputBlur,
|
||||
handleRgbInput,
|
||||
handleRgbInputBlur,
|
||||
handleColorSelect,
|
||||
handleHueSliderMouseDown,
|
||||
handleSatBrightMouseDown,
|
||||
} = useColorPicker({ initialColor: predefinedColors[1] });
|
||||
|
||||
// Get display coordinates for cropper
|
||||
const getDisplayCoordinates = useLastCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { x: clientX, y: clientY } = getPointerPosition(e as React.MouseEvent);
|
||||
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top,
|
||||
};
|
||||
});
|
||||
|
||||
// Handle crop actions
|
||||
const handleCropAction = useLastCallback((action: CropAction) => {
|
||||
setActions((prev) => [...prev, action]);
|
||||
setRedoStack([]);
|
||||
});
|
||||
|
||||
// Cropper hook
|
||||
const {
|
||||
getCroppedRegion,
|
||||
initCropState,
|
||||
handleCropperDragStart,
|
||||
handleCornerResizeStart,
|
||||
handleAspectRatioChange,
|
||||
handleRotationChange,
|
||||
handleRotationChangeEnd,
|
||||
handleQuarterRotate,
|
||||
handleFlip,
|
||||
} = useCropper({
|
||||
imageRef: originalImageRef,
|
||||
displaySize,
|
||||
getDisplayScale,
|
||||
getDisplayCoordinates,
|
||||
onAction: handleCropAction,
|
||||
cropState,
|
||||
setCropState,
|
||||
});
|
||||
|
||||
// Memoize drawActions to avoid filtering on every render
|
||||
const drawActions = useMemo(
|
||||
() => actions.filter((a): a is DrawAction => a.type === 'draw'),
|
||||
[actions],
|
||||
);
|
||||
|
||||
// Get canvas coordinates for drawing
|
||||
const getCanvasCoordinates = useLastCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { x: clientX, y: clientY } = getPointerPosition(e as React.MouseEvent);
|
||||
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
};
|
||||
});
|
||||
|
||||
const inverseTransformPoint = useLastCallback((
|
||||
x: number, y: number,
|
||||
effCenterX: number, effCenterY: number,
|
||||
imgCenterX: number, imgCenterY: number,
|
||||
zoom: number,
|
||||
) => {
|
||||
const rotation = getTotalRotation(cropState);
|
||||
const { flipH } = cropState;
|
||||
|
||||
// Translate to effective center
|
||||
let tx = x - effCenterX;
|
||||
let ty = y - effCenterY;
|
||||
|
||||
// Inverse rotation
|
||||
if (rotation !== 0) {
|
||||
const rad = (-rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const newX = tx * cos - ty * sin;
|
||||
const newY = tx * sin + ty * cos;
|
||||
tx = newX;
|
||||
ty = newY;
|
||||
}
|
||||
|
||||
// Divide by zoom
|
||||
tx /= zoom;
|
||||
ty /= zoom;
|
||||
|
||||
// Inverse flip
|
||||
if (flipH) tx = -tx;
|
||||
|
||||
// Translate back to image center
|
||||
return { x: tx + imgCenterX, y: ty + imgCenterY };
|
||||
});
|
||||
|
||||
const canvasToImageCoords = useLastCallback((canvasX: number, canvasY: number) => {
|
||||
const crop = getCroppedRegion();
|
||||
const img = originalImageRef.current;
|
||||
const effectiveX = crop.x + canvasX;
|
||||
const effectiveY = crop.y + canvasY;
|
||||
|
||||
if (!img || mode !== 'draw') return { x: effectiveX, y: effectiveY };
|
||||
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropState.quarterTurns,
|
||||
);
|
||||
const rotation = getTotalRotation(cropState);
|
||||
const { flipH } = cropState;
|
||||
const zoom = computeRotationZoom(effW, effH, cropState.rotation);
|
||||
|
||||
if (rotation === 0 && !flipH && zoom === 1) {
|
||||
return { x: effectiveX, y: effectiveY };
|
||||
}
|
||||
|
||||
return inverseTransformPoint(
|
||||
effectiveX, effectiveY,
|
||||
effW / 2, effH / 2,
|
||||
img.width / 2, img.height / 2,
|
||||
zoom,
|
||||
);
|
||||
});
|
||||
|
||||
// Handle draw action complete
|
||||
const handleDrawActionComplete = useLastCallback((action: DrawAction) => {
|
||||
setActions((prev) => [...prev, action]);
|
||||
setRedoStack([]);
|
||||
});
|
||||
|
||||
// Drawing hook
|
||||
const {
|
||||
drawTool,
|
||||
setDrawTool,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
currentDrawAction,
|
||||
handlePointerDown,
|
||||
resetDrawing,
|
||||
} = useDrawing({
|
||||
getCanvasCoordinates,
|
||||
canvasToImageCoords,
|
||||
selectedColor,
|
||||
onActionComplete: handleDrawActionComplete,
|
||||
});
|
||||
|
||||
// Canvas renderer hook
|
||||
const {
|
||||
canvasSize,
|
||||
renderCanvas,
|
||||
resetCanvasSize,
|
||||
} = useCanvasRenderer({
|
||||
canvasRef,
|
||||
imageRef: originalImageRef,
|
||||
mode,
|
||||
cropState,
|
||||
drawActions,
|
||||
currentDrawAction,
|
||||
});
|
||||
|
||||
// Reset state when editor opens
|
||||
useEffect(() => {
|
||||
if (isOpen && imageUrl) {
|
||||
setActions([]);
|
||||
setRedoStack([]);
|
||||
resetDrawing();
|
||||
setMode(INITIAL_MODE);
|
||||
setSnapshotSrc(undefined);
|
||||
setIsTransitioning(false);
|
||||
setTransformAnimType(undefined);
|
||||
setSelectedColor(predefinedColors[1]);
|
||||
setCropState(DEFAULT_CROP_STATE);
|
||||
resetCanvasSize();
|
||||
resetDisplaySize();
|
||||
setImageDimensions({ width: 0, height: 0 });
|
||||
originalImageRef.current = undefined;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
|
||||
}, [isOpen, imageUrl]);
|
||||
|
||||
// Initialize canvas when image loads
|
||||
useEffect(() => {
|
||||
if (!isOpen || !imageUrl) return;
|
||||
|
||||
const initCanvas = async () => {
|
||||
let image: HTMLImageElement;
|
||||
try {
|
||||
image = await preloadImage(imageUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
originalImageRef.current = image;
|
||||
setImageDimensions({ width: image.width, height: image.height });
|
||||
initCropState(image.width, image.height);
|
||||
renderCanvas();
|
||||
};
|
||||
|
||||
initCanvas();
|
||||
}, [isOpen, imageUrl, renderCanvas, initCropState]);
|
||||
|
||||
// Esc key handler via captureEscKeyListener (participates in shared handler stack)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
|
||||
return captureEscKeyListener(() => {
|
||||
if (isColorPickerOpen) {
|
||||
closeColorPicker();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, [isOpen, isColorPickerOpen, closeColorPicker, onClose]);
|
||||
|
||||
// Keyboard shortcuts (undo/redo)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (isMeta && key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((isMeta && key === 'z' && e.shiftKey) || (IS_WINDOWS && isMeta && key === 'y')) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleUndo = useLastCallback(() => {
|
||||
const actionList = actionsRef.current;
|
||||
if (actionList.length === 0) return;
|
||||
|
||||
const lastAction = actionList[actionList.length - 1];
|
||||
const newActions = actionList.slice(0, -1);
|
||||
|
||||
if (lastAction.type === 'crop') {
|
||||
const currentState = { ...cropState };
|
||||
setCropState(lastAction.previousState);
|
||||
setRedoStack((prev) => [...prev, { type: 'crop', previousState: currentState }]);
|
||||
} else {
|
||||
setRedoStack((prev) => [...prev, lastAction]);
|
||||
}
|
||||
setActions(newActions);
|
||||
});
|
||||
|
||||
const handleRedo = useLastCallback(() => {
|
||||
const redo = redoStackRef.current;
|
||||
if (redo.length === 0) return;
|
||||
|
||||
const actionToRedo = redo[redo.length - 1];
|
||||
const newRedoStack = redo.slice(0, -1);
|
||||
|
||||
if (actionToRedo.type === 'crop') {
|
||||
const currentState = { ...cropState };
|
||||
setCropState(actionToRedo.previousState);
|
||||
setActions((prev) => [...prev, { type: 'crop', previousState: currentState }]);
|
||||
} else {
|
||||
setActions((prev) => [...prev, actionToRedo]);
|
||||
}
|
||||
setRedoStack(newRedoStack);
|
||||
});
|
||||
|
||||
const captureCanvasSnapshot = useLastCallback((
|
||||
computeStyle?: (displayWidth: number, displayHeight: number) => string,
|
||||
) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvas.width === 0 || canvas.height === 0) return;
|
||||
|
||||
try {
|
||||
const displayWidth = canvas.offsetWidth;
|
||||
const displayHeight = canvas.offsetHeight;
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = displayWidth;
|
||||
tempCanvas.height = displayHeight;
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(canvas, 0, 0, displayWidth, displayHeight);
|
||||
setSnapshotSrc(tempCanvas.toDataURL());
|
||||
setSnapshotStyle(
|
||||
computeStyle
|
||||
? computeStyle(displayWidth, displayHeight)
|
||||
: `width: ${displayWidth}px; height: ${displayHeight}px`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Canvas might be tainted
|
||||
}
|
||||
});
|
||||
|
||||
const handleQuarterRotateAnimated = useLastCallback(() => {
|
||||
if (animationLevel > 0) {
|
||||
captureCanvasSnapshot((oldW, oldH) => {
|
||||
// Compute scale factors so the rotated snapshot matches the new canvas size
|
||||
const canvasArea = canvasAreaRef.current;
|
||||
if (!canvasArea) return `width: ${oldW}px; height: ${oldH}px`;
|
||||
|
||||
const newEffDims = getEffectiveDimensions(
|
||||
imageDimensions.width, imageDimensions.height,
|
||||
(cropState.quarterTurns + 1) % 4,
|
||||
);
|
||||
const areaRect = canvasArea.getBoundingClientRect();
|
||||
const areaStyle = getComputedStyle(canvasArea);
|
||||
const padX = parseFloat(areaStyle.paddingLeft) + parseFloat(areaStyle.paddingRight);
|
||||
const padY = parseFloat(areaStyle.paddingTop) + parseFloat(areaStyle.paddingBottom);
|
||||
const scaleToFit = Math.min(
|
||||
(areaRect.width - padX) / newEffDims.width,
|
||||
(areaRect.height - padY - 6.5 * REM) / newEffDims.height,
|
||||
1,
|
||||
);
|
||||
const newW = newEffDims.width * scaleToFit;
|
||||
const newH = newEffDims.height * scaleToFit;
|
||||
|
||||
// After CSS rotate(-90deg) scale(sx, sy), visual bounds = (oldH*sy, oldW*sx)
|
||||
const sx = newH / oldW;
|
||||
const sy = newW / oldH;
|
||||
return `width: ${oldW}px; height: ${oldH}px; --end-sx: ${sx}; --end-sy: ${sy}`;
|
||||
});
|
||||
setTransformAnimType('rotate');
|
||||
}
|
||||
handleQuarterRotate();
|
||||
if (animationLevel > 0) {
|
||||
setTimeout(() => {
|
||||
setTransformAnimType(undefined);
|
||||
setSnapshotSrc(undefined);
|
||||
}, TRANSITION_DURATION);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFlipAnimated = useLastCallback(() => {
|
||||
if (animationLevel > 0) {
|
||||
captureCanvasSnapshot();
|
||||
setTransformAnimType('flip');
|
||||
}
|
||||
handleFlip();
|
||||
if (animationLevel > 0) {
|
||||
setTimeout(() => {
|
||||
setTransformAnimType(undefined);
|
||||
setSnapshotSrc(undefined);
|
||||
}, TRANSITION_DURATION);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useLastCallback(() => {
|
||||
const img = originalImageRef.current;
|
||||
if (!img) return;
|
||||
|
||||
const crop = getCroppedRegion();
|
||||
if (crop.width <= 0 || crop.height <= 0) return;
|
||||
|
||||
const rotation = getTotalRotation(cropState);
|
||||
const { flipH } = cropState;
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropState.quarterTurns,
|
||||
);
|
||||
const zoom = computeRotationZoom(effW, effH, cropState.rotation);
|
||||
const hasTransforms = rotation !== 0 || flipH || cropState.quarterTurns !== 0 || zoom !== 1;
|
||||
|
||||
// Stage 1: Render full image with transforms at effective dims
|
||||
const fullCanvas = document.createElement('canvas');
|
||||
fullCanvas.width = effW;
|
||||
fullCanvas.height = effH;
|
||||
const fullCtx = fullCanvas.getContext('2d');
|
||||
if (!fullCtx) return;
|
||||
|
||||
if (hasTransforms) {
|
||||
fullCtx.save();
|
||||
applyCanvasTransform(fullCtx, img, rotation, flipH, cropState.quarterTurns, zoom);
|
||||
}
|
||||
|
||||
fullCtx.drawImage(img, 0, 0);
|
||||
renderActionsToCanvas(fullCtx, drawActions, 0, 0, undefined, img.width, img.height);
|
||||
|
||||
if (hasTransforms) {
|
||||
fullCtx.restore();
|
||||
}
|
||||
|
||||
// Stage 2: Crop from effective space
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = Math.round(crop.width);
|
||||
finalCanvas.height = Math.round(crop.height);
|
||||
const ctx = finalCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(fullCanvas, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
|
||||
|
||||
const mimeTypeToUse = mimeType || 'image/jpeg';
|
||||
finalCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const resultFilename = filename || `image.${getExtensionFromMimeType(mimeTypeToUse)}`;
|
||||
const file = blobToFile(blob, resultFilename);
|
||||
onSave(file);
|
||||
onClose();
|
||||
}
|
||||
}, mimeTypeToUse);
|
||||
});
|
||||
|
||||
const activeTabIndex = EDITOR_TABS.findIndex((tab) => tab.type === mode);
|
||||
|
||||
const handleTabSwitch = useLastCallback((index: number) => {
|
||||
const tab = EDITOR_TABS[index];
|
||||
if (tab && tab.type !== mode) {
|
||||
if (animationLevel > 0) {
|
||||
if (tab.type === 'draw') {
|
||||
// Crop → Draw: compute crop frame for zoom animation
|
||||
captureCanvasSnapshot((displayWidth, displayHeight) => {
|
||||
const scale = getDisplayScale();
|
||||
const fW = cropState.cropperWidth * scale;
|
||||
const fH = cropState.cropperHeight * scale;
|
||||
const fX = cropState.cropperX * scale;
|
||||
const fY = cropState.cropperY * scale;
|
||||
|
||||
return buildStyle(
|
||||
`width: ${displayWidth}px`,
|
||||
`height: ${displayHeight}px`,
|
||||
`--crop-top: ${fY}px`,
|
||||
`--crop-right: ${displayWidth - (fX + fW)}px`,
|
||||
`--crop-bottom: ${displayHeight - (fY + fH)}px`,
|
||||
`--crop-left: ${fX}px`,
|
||||
`--offset-x: ${(displayWidth / 2) - (fX + fW / 2)}px`,
|
||||
`--offset-y: ${(displayHeight / 2) - (fY + fH / 2)}px`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
captureCanvasSnapshot();
|
||||
}
|
||||
setIsTransitioning(true);
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
setSnapshotSrc(undefined);
|
||||
}, TRANSITION_DURATION);
|
||||
}
|
||||
setMode(tab.type);
|
||||
}
|
||||
});
|
||||
|
||||
const canUndo = actions.length > 0;
|
||||
const canRedo = redoStack.length > 0;
|
||||
|
||||
if (!shouldRender) return undefined;
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (mode) {
|
||||
case 'crop':
|
||||
return (
|
||||
<CropPanel
|
||||
currentRatio={cropState.aspectRatio}
|
||||
onRatioChange={handleAspectRatioChange}
|
||||
/>
|
||||
);
|
||||
case 'draw':
|
||||
return (
|
||||
<DrawPanel
|
||||
predefinedColors={predefinedColors}
|
||||
selectedColor={selectedColor}
|
||||
isColorPickerOpen={isColorPickerOpen}
|
||||
hue={hue}
|
||||
saturation={saturation}
|
||||
brightness={brightness}
|
||||
pickerColor={pickerColor}
|
||||
hexInputValue={hexInputValue}
|
||||
rgbInputValue={rgbInputValue}
|
||||
brushSize={brushSize}
|
||||
drawTool={drawTool}
|
||||
hueSliderRef={hueSliderRef}
|
||||
satBrightRef={satBrightRef}
|
||||
onColorSelect={handleColorSelect}
|
||||
onOpenColorPicker={openColorPicker}
|
||||
onCloseColorPicker={closeColorPicker}
|
||||
onHueSliderMouseDown={handleHueSliderMouseDown}
|
||||
onHueChange={handleHueChange}
|
||||
onSatBrightMouseDown={handleSatBrightMouseDown}
|
||||
onSatBrightChange={handleSatBrightChange}
|
||||
onHexInput={handleHexInput}
|
||||
onHexInputBlur={handleHexInputBlur}
|
||||
onRgbInput={handleRgbInput}
|
||||
onRgbInputBlur={handleRgbInputBlur}
|
||||
onBrushSizeChange={setBrushSize}
|
||||
onToolChange={setDrawTool}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isTransitioningToDraw = isTransitioning && mode === 'draw';
|
||||
const isTransitioningToCrop = isTransitioning && mode === 'crop';
|
||||
const shouldShowCropOverlay = mode === 'crop' || isTransitioningToDraw;
|
||||
const displayScale = getDisplayScale();
|
||||
|
||||
const canvasStyle = useMemo(() => {
|
||||
if (displaySize.width === 0) return '';
|
||||
|
||||
if (mode === 'crop') {
|
||||
const baseStyle = buildStyle(
|
||||
`width: ${displaySize.width}px`,
|
||||
`height: ${displaySize.height}px`,
|
||||
);
|
||||
|
||||
if (isTransitioning) {
|
||||
// Draw → Crop: pass crop frame vars for zoom-out animation
|
||||
const fW = cropState.cropperWidth * displayScale;
|
||||
const fH = cropState.cropperHeight * displayScale;
|
||||
const fX = cropState.cropperX * displayScale;
|
||||
const fY = cropState.cropperY * displayScale;
|
||||
|
||||
return buildStyle(
|
||||
baseStyle,
|
||||
`--crop-top: ${fY}px`,
|
||||
`--crop-right: ${displaySize.width - (fX + fW)}px`,
|
||||
`--crop-bottom: ${displaySize.height - (fY + fH)}px`,
|
||||
`--crop-left: ${fX}px`,
|
||||
`--offset-x: ${(displaySize.width / 2) - (fX + fW / 2)}px`,
|
||||
`--offset-y: ${(displaySize.height / 2) - (fY + fH / 2)}px`,
|
||||
);
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
}
|
||||
|
||||
const frameWidth = cropState.cropperWidth * displayScale;
|
||||
const frameHeight = cropState.cropperHeight * displayScale;
|
||||
|
||||
return buildStyle(
|
||||
`width: ${frameWidth}px`,
|
||||
`height: ${frameHeight}px`,
|
||||
);
|
||||
}, [displaySize, cropState, displayScale, mode, isTransitioning]);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div ref={rootRef} className={styles.root}>
|
||||
<div ref={canvasAreaRef} className={styles.canvasArea}>
|
||||
<div className={styles.canvasContainer}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={buildClassName(
|
||||
styles.canvas,
|
||||
isTransitioningToDraw && styles.transitioningToDraw,
|
||||
isTransitioningToCrop && styles.transitioningToCrop,
|
||||
mode === 'draw' && !isTransitioning && styles.drawMode,
|
||||
transformAnimType === 'rotate' && styles.transformAnimating,
|
||||
transformAnimType === 'flip' && styles.flipAnimating,
|
||||
)}
|
||||
width={canvasSize.width || undefined}
|
||||
height={canvasSize.height || undefined}
|
||||
style={canvasStyle}
|
||||
onMouseDown={mode === 'draw' ? handlePointerDown : undefined}
|
||||
onTouchStart={mode === 'draw' ? handlePointerDown : undefined}
|
||||
/>
|
||||
{snapshotSrc && (
|
||||
<img
|
||||
className={buildClassName(
|
||||
styles.canvasSnapshot,
|
||||
isTransitioningToDraw && styles.zoomIn,
|
||||
isTransitioningToCrop && styles.fadeOut,
|
||||
transformAnimType === 'rotate' && styles.rotateFade,
|
||||
transformAnimType === 'flip' && styles.flipFade,
|
||||
)}
|
||||
src={snapshotSrc}
|
||||
style={snapshotStyle}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{shouldShowCropOverlay && !transformAnimType && displaySize.width > 0 && (
|
||||
<CropOverlay
|
||||
cropState={cropState}
|
||||
displaySize={displaySize}
|
||||
scale={displayScale}
|
||||
isFadingOut={isTransitioningToDraw}
|
||||
onCropperDragStart={handleCropperDragStart}
|
||||
onCornerResizeStart={handleCornerResizeStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={buildClassName(
|
||||
styles.canvasControls,
|
||||
isTransitioningToDraw && styles.fadingOut,
|
||||
isTransitioningToCrop && styles.fadingIn,
|
||||
mode === 'draw' && !isTransitioning && styles.hidden,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
onClick={handleQuarterRotateAnimated}
|
||||
iconName="rotate"
|
||||
/>
|
||||
|
||||
<RotationSlider
|
||||
value={cropState.rotation}
|
||||
onChange={handleRotationChange}
|
||||
onChangeEnd={handleRotationChangeEnd}
|
||||
/>
|
||||
|
||||
<Button
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
onClick={handleFlipAnimated}
|
||||
iconName="flip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.editPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<Button round color="translucent" size="smaller" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
<div className={styles.headerTitle}>{lang('EditMedia')}</div>
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
onClick={handleUndo}
|
||||
disabled={!canUndo}
|
||||
iconName="undo"
|
||||
/>
|
||||
<Button
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
iconName="redo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.panelTabs}>
|
||||
<Transition
|
||||
ref={transitionRef}
|
||||
name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
|
||||
activeKey={activeTabIndex}
|
||||
shouldRestoreHeight
|
||||
className={styles.panelContent}
|
||||
>
|
||||
{renderPanelContent()}
|
||||
</Transition>
|
||||
<TabList
|
||||
tabs={TABS}
|
||||
activeTab={activeTabIndex}
|
||||
onSwitchTab={handleTabSwitch}
|
||||
className={styles.modeTabs}
|
||||
tabClassName={styles.modeTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton
|
||||
isShown={actions.length > 0}
|
||||
iconName="check"
|
||||
className={styles.saveButton}
|
||||
onClick={handleSave}
|
||||
ariaLabel={lang('Save')}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
function getExtensionFromMimeType(mimeType: string): string {
|
||||
return mimeType.split('/')[1];
|
||||
}
|
||||
|
||||
export default memo(MediaEditor);
|
||||
104
src/components/ui/mediaEditor/RotationSlider.module.scss
Normal file
104
src/components/ui/mediaEditor/RotationSlider.module.scss
Normal file
@ -0,0 +1,104 @@
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider {
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
height: 3rem;
|
||||
|
||||
// Dark overlay that dims dots except at center, creating a spotlight effect
|
||||
&::after {
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2rem;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
height: 0.25rem;
|
||||
|
||||
background: radial-gradient(0.75rem 50% at center, transparent, rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
will-change: transform;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.labelsRow {
|
||||
position: relative;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.label,
|
||||
.labelActive {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
|
||||
&::after {
|
||||
content: '°';
|
||||
position: absolute;
|
||||
top: -0.25rem;
|
||||
right: -0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.labelActive {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.centerIndicator {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 1.375rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-right: 0.25rem solid transparent;
|
||||
border-bottom: 0.3125rem solid #fff;
|
||||
border-left: 0.25rem solid transparent;
|
||||
}
|
||||
|
||||
.dotsRow {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
// 91 dots spanning -90° to 90° every 2°, at 5px/degree = 10px spacing
|
||||
// Offset by half a tile so first dot lands at -90° position
|
||||
left: -28.4375rem;
|
||||
|
||||
width: 56.875rem;
|
||||
height: 0.25rem;
|
||||
|
||||
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.8) 0.0625rem, transparent 0.0625rem);
|
||||
background-size: 0.625rem 0.25rem;
|
||||
}
|
||||
84
src/components/ui/mediaEditor/RotationSlider.tsx
Normal file
84
src/components/ui/mediaEditor/RotationSlider.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { memo } from '@teact';
|
||||
|
||||
import getPointerPosition from '../../../util/events/getPointerPosition';
|
||||
import { clamp } from '../../../util/math';
|
||||
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import styles from './RotationSlider.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onChangeEnd?: NoneToVoidFunction;
|
||||
};
|
||||
|
||||
const MIN_ROTATION = -90;
|
||||
const MAX_ROTATION = 90;
|
||||
const LABEL_INTERVAL = 15;
|
||||
const PIXELS_PER_DEGREE = 5;
|
||||
|
||||
function RotationSlider({ value, onChange, onChangeEnd }: OwnProps) {
|
||||
const handlePointerDown = useLastCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const { x: startX } = getPointerPosition(e);
|
||||
const startValue = value;
|
||||
|
||||
const handleMove = (ev: MouseEvent | TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
const { x: clientX } = getPointerPosition(ev);
|
||||
const deltaX = clientX - startX;
|
||||
const newValue = clamp(Math.round(startValue - deltaX / PIXELS_PER_DEGREE), MIN_ROTATION, MAX_ROTATION);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('touchmove', handleMove);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
document.removeEventListener('touchend', handleUp);
|
||||
onChangeEnd?.();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('touchmove', handleMove, { passive: false });
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
document.addEventListener('touchend', handleUp);
|
||||
});
|
||||
|
||||
const nearestLabel = Math.round(value / LABEL_INTERVAL) * LABEL_INTERVAL;
|
||||
const trackOffset = -value * PIXELS_PER_DEGREE;
|
||||
|
||||
const labels = [];
|
||||
for (let deg = MIN_ROTATION; deg <= MAX_ROTATION; deg += LABEL_INTERVAL) {
|
||||
labels.push(
|
||||
<span
|
||||
key={deg}
|
||||
className={deg === nearestLabel ? styles.labelActive : styles.label}
|
||||
style={`left: ${deg * PIXELS_PER_DEGREE}px`}
|
||||
>
|
||||
{deg}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.slider}
|
||||
onMouseDown={handlePointerDown}
|
||||
onTouchStart={handlePointerDown}
|
||||
>
|
||||
<div className={styles.track} style={`transform: translateX(${trackOffset}px)`}>
|
||||
<div className={styles.labelsRow}>
|
||||
{labels}
|
||||
</div>
|
||||
<div className={styles.dotsRow} />
|
||||
</div>
|
||||
<div className={styles.centerIndicator} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(RotationSlider);
|
||||
226
src/components/ui/mediaEditor/canvasUtils.ts
Normal file
226
src/components/ui/mediaEditor/canvasUtils.ts
Normal file
@ -0,0 +1,226 @@
|
||||
export type DrawTool = 'pen' | 'arrow' | 'brush' | 'neon' | 'eraser';
|
||||
|
||||
export const ARROW_ANIMATION_DURATION = 200;
|
||||
const offscreen = document.createElement('canvas');
|
||||
|
||||
export function getEffectiveDimensions(imgWidth: number, imgHeight: number, quarterTurns: number) {
|
||||
const isSideways = quarterTurns % 2 === 1;
|
||||
return {
|
||||
width: isSideways ? imgHeight : imgWidth,
|
||||
height: isSideways ? imgWidth : imgHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeRotationZoom(effectiveW: number, effectiveH: number, fineRotation: number) {
|
||||
if (fineRotation === 0 || effectiveW <= 0 || effectiveH <= 0) return 1;
|
||||
const rad = Math.abs(fineRotation * Math.PI / 180);
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
return Math.max(
|
||||
cos + (effectiveH / effectiveW) * sin,
|
||||
(effectiveW / effectiveH) * sin + cos,
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawAction {
|
||||
type: 'draw';
|
||||
tool: DrawTool;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
color: string;
|
||||
brushSize: number;
|
||||
completedAt?: number;
|
||||
isShiftPressed?: boolean;
|
||||
}
|
||||
|
||||
export function renderDrawAction(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
action: DrawAction,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
isComplete = true,
|
||||
) {
|
||||
if (action.points.length < 2) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (action.tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
ctx.lineWidth = action.brushSize * 2;
|
||||
} else if (action.tool === 'neon') {
|
||||
ctx.shadowColor = action.color;
|
||||
ctx.shadowBlur = action.brushSize * 2;
|
||||
ctx.strokeStyle = action.color;
|
||||
ctx.lineWidth = action.brushSize * 0.5;
|
||||
} else if (action.tool === 'brush') {
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = action.color;
|
||||
ctx.lineWidth = action.brushSize * 2;
|
||||
} else {
|
||||
ctx.strokeStyle = action.color;
|
||||
ctx.lineWidth = action.brushSize;
|
||||
}
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
if (action.tool === 'arrow') {
|
||||
renderArrow(ctx, action, offsetX, offsetY, isComplete);
|
||||
} else {
|
||||
renderPath(ctx, action, offsetX, offsetY);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
action: DrawAction,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
isComplete: boolean,
|
||||
) {
|
||||
if (action.points.length < 2) return;
|
||||
|
||||
const firstPoint = action.points[0];
|
||||
const lastPoint = action.points[action.points.length - 1];
|
||||
|
||||
// Draw the path
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(firstPoint.x + offsetX, firstPoint.y + offsetY);
|
||||
|
||||
for (let i = 1; i < action.points.length; i++) {
|
||||
const point = action.points[i];
|
||||
ctx.lineTo(point.x + offsetX, point.y + offsetY);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Only draw arrowhead when drawing is complete
|
||||
if (!isComplete) return;
|
||||
|
||||
// Calculate angle from a point further back for stable direction that follows the path
|
||||
// Use a point 10 steps back, or the first point if path is shorter
|
||||
const lookbackIndex = Math.max(0, action.points.length - 10);
|
||||
const referencePoint = action.points[lookbackIndex];
|
||||
|
||||
const angle = Math.atan2(
|
||||
lastPoint.y - referencePoint.y,
|
||||
lastPoint.x - referencePoint.x,
|
||||
);
|
||||
|
||||
// Animate arrowhead appearance
|
||||
const elapsed = action.completedAt ? Date.now() - action.completedAt : ARROW_ANIMATION_DURATION;
|
||||
const progress = Math.min(elapsed / ARROW_ANIMATION_DURATION, 1);
|
||||
// Ease out cubic for smooth animation
|
||||
const easedProgress = 1 - ((1 - progress) ** 3);
|
||||
|
||||
const headLength = action.brushSize * 3 * easedProgress;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPoint.x + offsetX, lastPoint.y + offsetY);
|
||||
ctx.lineTo(
|
||||
lastPoint.x + offsetX - headLength * Math.cos(angle - Math.PI / 6),
|
||||
lastPoint.y + offsetY - headLength * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(lastPoint.x + offsetX, lastPoint.y + offsetY);
|
||||
ctx.lineTo(
|
||||
lastPoint.x + offsetX - headLength * Math.cos(angle + Math.PI / 6),
|
||||
lastPoint.y + offsetY - headLength * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
action: DrawAction,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
) {
|
||||
ctx.beginPath();
|
||||
const firstPoint = action.points[0];
|
||||
ctx.moveTo(firstPoint.x + offsetX, firstPoint.y + offsetY);
|
||||
|
||||
for (let i = 1; i < action.points.length; i++) {
|
||||
const point = action.points[i];
|
||||
ctx.lineTo(point.x + offsetX, point.y + offsetY);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function applyCanvasTransform(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
rotation: number,
|
||||
flipH: boolean,
|
||||
quarterTurns = 0,
|
||||
scale = 1,
|
||||
) {
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(image.width, image.height, quarterTurns);
|
||||
ctx.translate(effW / 2, effH / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.scale(scale * (flipH ? -1 : 1), scale);
|
||||
ctx.translate(-image.width / 2, -image.height / 2);
|
||||
}
|
||||
|
||||
export function renderImageToCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
crop: { x: number; y: number; width: number; height: number },
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
isCropMode: boolean,
|
||||
rotation = 0,
|
||||
flipH = false,
|
||||
quarterTurns = 0,
|
||||
scale = 1,
|
||||
) {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (rotation !== 0 || flipH || quarterTurns !== 0 || scale !== 1) {
|
||||
applyCanvasTransform(ctx, img, rotation, flipH, quarterTurns, scale);
|
||||
}
|
||||
|
||||
if (isCropMode) {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
crop.x, crop.y, crop.width, crop.height,
|
||||
0, 0, targetWidth, targetHeight,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function renderActionsToCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
actions: DrawAction[],
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
currentAction?: DrawAction,
|
||||
offscreenWidth?: number,
|
||||
offscreenHeight?: number,
|
||||
) {
|
||||
const hasCurrentAction = currentAction && !actions.includes(currentAction);
|
||||
if (actions.length === 0 && !hasCurrentAction) return;
|
||||
|
||||
const width = offscreenWidth || ctx.canvas.width;
|
||||
const height = offscreenHeight || ctx.canvas.height;
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
const offCtx = offscreen.getContext('2d')!;
|
||||
offCtx.clearRect(0, 0, width, height);
|
||||
|
||||
actions.forEach((action) => {
|
||||
renderDrawAction(offCtx, action, offsetX, offsetY, true);
|
||||
});
|
||||
|
||||
if (hasCurrentAction) {
|
||||
renderDrawAction(offCtx, currentAction, offsetX, offsetY, false);
|
||||
}
|
||||
|
||||
ctx.drawImage(offscreen, 0, 0);
|
||||
}
|
||||
155
src/components/ui/mediaEditor/hooks/useCanvasRenderer.ts
Normal file
155
src/components/ui/mediaEditor/hooks/useCanvasRenderer.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useRef, useState } from '@teact';
|
||||
|
||||
import type { DrawAction } from '../canvasUtils';
|
||||
import type { CropState } from './useCropper';
|
||||
|
||||
import { fastRaf, throttleWith } from '../../../../util/schedulers';
|
||||
import {
|
||||
applyCanvasTransform, ARROW_ANIMATION_DURATION, computeRotationZoom,
|
||||
getEffectiveDimensions, renderActionsToCanvas, renderImageToCanvas,
|
||||
} from '../canvasUtils';
|
||||
import { getTotalRotation } from './useCropper';
|
||||
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
interface UseCanvasRendererOptions {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | undefined>;
|
||||
imageRef: React.RefObject<HTMLImageElement | undefined>;
|
||||
mode: 'crop' | 'draw';
|
||||
cropState: CropState;
|
||||
drawActions: DrawAction[];
|
||||
currentDrawAction?: DrawAction;
|
||||
}
|
||||
|
||||
export default function useCanvasRenderer({
|
||||
canvasRef,
|
||||
imageRef,
|
||||
mode,
|
||||
cropState,
|
||||
drawActions,
|
||||
currentDrawAction,
|
||||
}: UseCanvasRendererOptions) {
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const renderCanvas = useLastCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!canvas || !img) return;
|
||||
|
||||
const crop = {
|
||||
x: cropState.cropperX, y: cropState.cropperY,
|
||||
width: cropState.cropperWidth, height: cropState.cropperHeight,
|
||||
};
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rotation = getTotalRotation(cropState);
|
||||
const { flipH } = cropState;
|
||||
|
||||
if (mode === 'crop') {
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropState.quarterTurns,
|
||||
);
|
||||
const zoom = computeRotationZoom(effW, effH, cropState.rotation);
|
||||
|
||||
if (canvasSize.width !== effW || canvasSize.height !== effH) {
|
||||
setCanvasSize({ width: effW, height: effH });
|
||||
return;
|
||||
}
|
||||
|
||||
renderImageToCanvas(ctx, img, crop, effW, effH, true, rotation, flipH, cropState.quarterTurns, zoom);
|
||||
|
||||
const hasTransforms = rotation !== 0 || flipH || cropState.quarterTurns !== 0 || zoom !== 1;
|
||||
if (hasTransforms) {
|
||||
ctx.save();
|
||||
applyCanvasTransform(ctx, img, rotation, flipH, cropState.quarterTurns, zoom);
|
||||
renderActionsToCanvas(ctx, drawActions, 0, 0, undefined, img.width, img.height);
|
||||
ctx.restore();
|
||||
} else {
|
||||
renderActionsToCanvas(ctx, drawActions);
|
||||
}
|
||||
} else {
|
||||
if (crop.width <= 0 || crop.height <= 0) return;
|
||||
|
||||
const targetWidth = Math.round(crop.width);
|
||||
const targetHeight = Math.round(crop.height);
|
||||
|
||||
if (canvasSize.width !== targetWidth || canvasSize.height !== targetHeight) {
|
||||
setCanvasSize({ width: targetWidth, height: targetHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropState.quarterTurns,
|
||||
);
|
||||
const zoom = computeRotationZoom(effW, effH, cropState.rotation);
|
||||
const hasTransforms = rotation !== 0 || flipH || cropState.quarterTurns !== 0 || zoom !== 1;
|
||||
|
||||
// Create temp canvas at effective dimensions
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = effW;
|
||||
tempCanvas.height = effH;
|
||||
const tempCtx = tempCanvas.getContext('2d')!;
|
||||
|
||||
if (hasTransforms) {
|
||||
tempCtx.save();
|
||||
applyCanvasTransform(tempCtx, img, rotation, flipH, cropState.quarterTurns, zoom);
|
||||
}
|
||||
|
||||
// Draw image and actions (in image coords, transformed to effective space)
|
||||
tempCtx.drawImage(img, 0, 0);
|
||||
renderActionsToCanvas(tempCtx, drawActions, 0, 0, currentDrawAction, img.width, img.height);
|
||||
|
||||
if (hasTransforms) {
|
||||
tempCtx.restore();
|
||||
}
|
||||
|
||||
// Crop from effective space
|
||||
ctx.drawImage(tempCanvas, crop.x, crop.y, crop.width, crop.height, 0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
});
|
||||
|
||||
// Throttle re-renders to one per animation frame
|
||||
const scheduleRender = useMemo(() => throttleWith(fastRaf, renderCanvas), [renderCanvas]);
|
||||
|
||||
// Re-render canvas when dependencies change
|
||||
useEffect(() => {
|
||||
scheduleRender();
|
||||
}, [drawActions, currentDrawAction, canvasSize, mode, cropState, scheduleRender]);
|
||||
|
||||
// Animation loop for arrow spreading effect
|
||||
const animationFrameRef = useRef<number>();
|
||||
useEffect(() => {
|
||||
const hasAnimatingArrow = () => drawActions.some((action) => {
|
||||
return action.tool === 'arrow' && action.completedAt
|
||||
&& (Date.now() - action.completedAt) < ARROW_ANIMATION_DURATION;
|
||||
});
|
||||
|
||||
if (!hasAnimatingArrow()) return undefined;
|
||||
|
||||
const animate = () => {
|
||||
renderCanvas();
|
||||
if (hasAnimatingArrow()) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [drawActions, renderCanvas]);
|
||||
|
||||
const resetCanvasSize = useLastCallback(() => {
|
||||
setCanvasSize({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
return {
|
||||
canvasSize,
|
||||
renderCanvas,
|
||||
resetCanvasSize,
|
||||
};
|
||||
}
|
||||
199
src/components/ui/mediaEditor/hooks/useColorPicker.ts
Normal file
199
src/components/ui/mediaEditor/hooks/useColorPicker.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef, useState } from '@teact';
|
||||
|
||||
import { hex2rgb, hsv2rgb, rgb2hex, rgb2hsv } from '../../../../util/colors';
|
||||
import getPointerPosition from '../../../../util/events/getPointerPosition';
|
||||
import { clamp } from '../../../../util/math';
|
||||
|
||||
import useFlag from '../../../../hooks/useFlag';
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
const PREDEFINED_COLORS_BASE = [
|
||||
'#FE4438', '#FF8901', '#FFD60A', '#33C759',
|
||||
'#62E5E0', '#0A84FF', '#5856D6', '#BD5CF3',
|
||||
];
|
||||
|
||||
export function getPredefinedColors(theme: 'light' | 'dark') {
|
||||
return theme === 'light'
|
||||
? ['#000000', ...PREDEFINED_COLORS_BASE]
|
||||
: ['#FFFFFF', ...PREDEFINED_COLORS_BASE];
|
||||
}
|
||||
|
||||
interface PickerState {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
brightness: number;
|
||||
hexInputValue: string;
|
||||
rgbInputValue: string;
|
||||
}
|
||||
|
||||
function buildPickerState(h: number, s: number, v: number): PickerState {
|
||||
const rgb = hsv2rgb([h, s, v]);
|
||||
const hex = rgb2hex(rgb);
|
||||
return {
|
||||
hue: h,
|
||||
saturation: s,
|
||||
brightness: v,
|
||||
hexInputValue: hex.toUpperCase(),
|
||||
rgbInputValue: `${rgb[0]}, ${rgb[1]}, ${rgb[2]}`,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PICKER_STATE: PickerState = {
|
||||
hue: 0,
|
||||
saturation: 1,
|
||||
brightness: 1,
|
||||
hexInputValue: '',
|
||||
rgbInputValue: '',
|
||||
};
|
||||
|
||||
interface UseColorPickerOptions {
|
||||
initialColor: string;
|
||||
}
|
||||
|
||||
export default function useColorPicker({ initialColor }: UseColorPickerOptions) {
|
||||
const hueSliderRef = useRef<HTMLDivElement>();
|
||||
const satBrightRef = useRef<HTMLDivElement>();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState(initialColor);
|
||||
const [isColorPickerOpen, openColorPicker, closeColorPicker] = useFlag(false);
|
||||
const [pickerState, setPickerState] = useState<PickerState>(DEFAULT_PICKER_STATE);
|
||||
|
||||
const pickerColor = rgb2hex(hsv2rgb([pickerState.hue, pickerState.saturation, pickerState.brightness]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isColorPickerOpen) return;
|
||||
const rgb = hex2rgb(selectedColor.replace('#', ''));
|
||||
const [h, s, v] = rgb2hsv(rgb);
|
||||
setPickerState(buildPickerState(h, s, v));
|
||||
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
|
||||
}, [isColorPickerOpen]);
|
||||
|
||||
const updateFromHsv = useLastCallback((h: number, s: number, v: number) => {
|
||||
const state = buildPickerState(h, s, v);
|
||||
setPickerState(state);
|
||||
setSelectedColor(rgb2hex(hsv2rgb([h, s, v])));
|
||||
});
|
||||
|
||||
const setupColorDrag = useLastCallback((
|
||||
handler: (e: MouseEvent | TouchEvent) => void,
|
||||
) => {
|
||||
const handleMove = (ev: MouseEvent) => handler(ev);
|
||||
const handleUp = () => {
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
});
|
||||
|
||||
const handleHueChange = useLastCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
const el = hueSliderRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { x: clientX } = getPointerPosition(e as React.MouseEvent);
|
||||
const x = clamp(clientX - rect.left, 0, rect.width);
|
||||
updateFromHsv(x / rect.width, pickerState.saturation, pickerState.brightness);
|
||||
});
|
||||
|
||||
const handleSatBrightChange = useLastCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
const el = satBrightRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { x: clientX, y: clientY } = getPointerPosition(e as React.MouseEvent);
|
||||
const x = clamp(clientX - rect.left, 0, rect.width);
|
||||
const y = clamp(clientY - rect.top, 0, rect.height);
|
||||
updateFromHsv(pickerState.hue, x / rect.width, 1 - y / rect.height);
|
||||
});
|
||||
|
||||
const handleHexInput = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const cleanHex = e.target.value.toUpperCase().replace(/[^0-9A-F]/g, '').slice(0, 6);
|
||||
// Force the DOM input to show the cleaned value immediately
|
||||
e.target.value = `#${cleanHex}`;
|
||||
|
||||
// Expand 3-char shortcode (#EEE -> #EEEEEE) or use 6-char hex
|
||||
const fullHex = cleanHex.length === 3
|
||||
? cleanHex.split('').map((c) => c + c).join('')
|
||||
: cleanHex;
|
||||
|
||||
if (fullHex.length === 6) {
|
||||
const [h, s, v] = rgb2hsv(hex2rgb(fullHex));
|
||||
const state = buildPickerState(h, s, v);
|
||||
// Preserve the raw typed hex while updating HSV + rgb
|
||||
setPickerState({ ...state, hexInputValue: `#${cleanHex}` });
|
||||
setSelectedColor(rgb2hex(hsv2rgb([h, s, v])));
|
||||
} else {
|
||||
setPickerState((prev) => ({ ...prev, hexInputValue: `#${cleanHex}` }));
|
||||
}
|
||||
});
|
||||
|
||||
const handleRgbInput = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
const parts = value.split(',').map((p) => p.trim());
|
||||
if (parts.length === 3) {
|
||||
const r = parseInt(parts[0], 10);
|
||||
const g = parseInt(parts[1], 10);
|
||||
const b = parseInt(parts[2], 10);
|
||||
|
||||
if (![r, g, b].some((v) => Number.isNaN(v) || v < 0 || v > 255)) {
|
||||
const [h, s, v] = rgb2hsv([r, g, b]);
|
||||
const state = buildPickerState(h, s, v);
|
||||
// Preserve the raw typed rgb while updating HSV + hex
|
||||
setPickerState({ ...state, rgbInputValue: value });
|
||||
setSelectedColor(rgb2hex(hsv2rgb([h, s, v])));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPickerState((prev) => ({ ...prev, rgbInputValue: value }));
|
||||
});
|
||||
|
||||
const handleHexInputBlur = useLastCallback(() => {
|
||||
setPickerState((prev) => ({ ...prev, hexInputValue: pickerColor.toUpperCase() }));
|
||||
});
|
||||
|
||||
const handleRgbInputBlur = useLastCallback(() => {
|
||||
const rgb = hsv2rgb([pickerState.hue, pickerState.saturation, pickerState.brightness]);
|
||||
setPickerState((prev) => ({ ...prev, rgbInputValue: `${rgb[0]}, ${rgb[1]}, ${rgb[2]}` }));
|
||||
});
|
||||
|
||||
const handleColorSelect = useLastCallback((color: string) => {
|
||||
setSelectedColor(color);
|
||||
closeColorPicker();
|
||||
});
|
||||
|
||||
const handleHueSliderMouseDown = useLastCallback((e: React.MouseEvent) => {
|
||||
handleHueChange(e);
|
||||
setupColorDrag(handleHueChange);
|
||||
});
|
||||
|
||||
const handleSatBrightMouseDown = useLastCallback((e: React.MouseEvent) => {
|
||||
handleSatBrightChange(e);
|
||||
setupColorDrag(handleSatBrightChange);
|
||||
});
|
||||
|
||||
return {
|
||||
hueSliderRef,
|
||||
satBrightRef,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
isColorPickerOpen,
|
||||
openColorPicker,
|
||||
closeColorPicker,
|
||||
hue: pickerState.hue,
|
||||
saturation: pickerState.saturation,
|
||||
brightness: pickerState.brightness,
|
||||
pickerColor,
|
||||
hexInputValue: pickerState.hexInputValue,
|
||||
rgbInputValue: pickerState.rgbInputValue,
|
||||
handleHueChange,
|
||||
handleSatBrightChange,
|
||||
handleHexInput,
|
||||
handleHexInputBlur,
|
||||
handleRgbInput,
|
||||
handleRgbInputBlur,
|
||||
handleColorSelect,
|
||||
handleHueSliderMouseDown,
|
||||
handleSatBrightMouseDown,
|
||||
};
|
||||
}
|
||||
474
src/components/ui/mediaEditor/hooks/useCropper.ts
Normal file
474
src/components/ui/mediaEditor/hooks/useCropper.ts
Normal file
@ -0,0 +1,474 @@
|
||||
import { useRef } from '@teact';
|
||||
|
||||
import { clamp } from '../../../../util/math';
|
||||
import { getEffectiveDimensions } from '../canvasUtils';
|
||||
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
export interface CropState {
|
||||
cropperX: number;
|
||||
cropperY: number;
|
||||
cropperWidth: number;
|
||||
cropperHeight: number;
|
||||
aspectRatio: AspectRatio;
|
||||
rotation: number;
|
||||
quarterTurns: number;
|
||||
flipH: boolean;
|
||||
}
|
||||
|
||||
export function getTotalRotation(state: CropState): number {
|
||||
return state.rotation - state.quarterTurns * 90;
|
||||
}
|
||||
|
||||
export type AspectRatio =
|
||||
'free' | 'original' | 'square' | '3:2' | '2:3' | '4:3' | '3:4' | '5:4' | '4:5' | '16:9' | '9:16';
|
||||
|
||||
export type ResizeHandle = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
|
||||
interface AspectRatioOption {
|
||||
value: AspectRatio;
|
||||
labelKey?: 'Free' | 'Original' | 'Square';
|
||||
label?: string;
|
||||
ratio?: number;
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS: AspectRatioOption[] = [
|
||||
{ value: 'free', labelKey: 'Free' },
|
||||
{ value: 'original', labelKey: 'Original' },
|
||||
{ value: 'square', labelKey: 'Square', ratio: 1 },
|
||||
{ value: '3:2', label: '3:2', ratio: 3 / 2 },
|
||||
{ value: '2:3', label: '2:3', ratio: 2 / 3 },
|
||||
{ value: '4:3', label: '4:3', ratio: 4 / 3 },
|
||||
{ value: '3:4', label: '3:4', ratio: 3 / 4 },
|
||||
{ value: '5:4', label: '5:4', ratio: 5 / 4 },
|
||||
{ value: '4:5', label: '4:5', ratio: 4 / 5 },
|
||||
{ value: '16:9', label: '16:9', ratio: 16 / 9 },
|
||||
{ value: '9:16', label: '9:16', ratio: 9 / 16 },
|
||||
];
|
||||
|
||||
export const DEFAULT_CROP_STATE: CropState = {
|
||||
cropperX: 0,
|
||||
cropperY: 0,
|
||||
cropperWidth: 0,
|
||||
cropperHeight: 0,
|
||||
aspectRatio: 'free',
|
||||
rotation: 0,
|
||||
quarterTurns: 0,
|
||||
flipH: false,
|
||||
};
|
||||
|
||||
export interface CropAction {
|
||||
type: 'crop';
|
||||
previousState: CropState;
|
||||
}
|
||||
|
||||
const MIN_CROP_SIZE = 50;
|
||||
const MIN_ROTATION = -90;
|
||||
const MAX_ROTATION = 90;
|
||||
|
||||
function computeCenteredCrop(effW: number, effH: number, ratioValue: number | undefined) {
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (!ratioValue) {
|
||||
width = effW;
|
||||
height = effH;
|
||||
} else if (effW / effH > ratioValue) {
|
||||
height = effH;
|
||||
width = effH * ratioValue;
|
||||
} else {
|
||||
width = effW;
|
||||
height = effW / ratioValue;
|
||||
}
|
||||
|
||||
return {
|
||||
cropperX: (effW - width) / 2,
|
||||
cropperY: (effH - height) / 2,
|
||||
cropperWidth: width,
|
||||
cropperHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseCropperOptions {
|
||||
imageRef: React.RefObject<HTMLImageElement | undefined>;
|
||||
displaySize: { width: number; height: number };
|
||||
getDisplayScale: () => number;
|
||||
getDisplayCoordinates: (e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => { x: number; y: number };
|
||||
onAction: (action: CropAction) => void;
|
||||
cropState: CropState;
|
||||
setCropState: (value: CropState | ((prev: CropState) => CropState)) => void;
|
||||
}
|
||||
|
||||
export default function useCropper({
|
||||
imageRef,
|
||||
displaySize,
|
||||
getDisplayScale,
|
||||
getDisplayCoordinates,
|
||||
onAction,
|
||||
cropState,
|
||||
setCropState,
|
||||
}: UseCropperOptions) {
|
||||
const cropperDragStartRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
cropperX: number;
|
||||
cropperY: number;
|
||||
cropperWidth: number;
|
||||
cropperHeight: number;
|
||||
}>();
|
||||
|
||||
const cropStateRef = useRef<CropState>(DEFAULT_CROP_STATE);
|
||||
cropStateRef.current = cropState;
|
||||
|
||||
const getAspectRatioValue = useLastCallback((ratio: AspectRatio): number | undefined => {
|
||||
if (ratio === 'free') return undefined;
|
||||
if (ratio === 'original' && imageRef.current) {
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
imageRef.current.width, imageRef.current.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
return effW / effH;
|
||||
}
|
||||
const option = ASPECT_RATIOS.find((r) => r.value === ratio);
|
||||
return option?.ratio;
|
||||
});
|
||||
|
||||
const setupDragListeners = (
|
||||
onMove: (ev: MouseEvent | TouchEvent) => void,
|
||||
onUp: () => void,
|
||||
) => {
|
||||
const handleUp = () => {
|
||||
onUp();
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
document.removeEventListener('touchend', handleUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
document.addEventListener('touchend', handleUp);
|
||||
};
|
||||
|
||||
const handleCropperDragStart = useLastCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
const img = imageRef.current;
|
||||
if (!img || displaySize.width === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const { x, y } = getDisplayCoordinates(e);
|
||||
const displayScale = getDisplayScale();
|
||||
|
||||
cropperDragStartRef.current = {
|
||||
startX: x,
|
||||
startY: y,
|
||||
cropperX: cropState.cropperX,
|
||||
cropperY: cropState.cropperY,
|
||||
cropperWidth: cropState.cropperWidth,
|
||||
cropperHeight: cropState.cropperHeight,
|
||||
};
|
||||
|
||||
const handleMove = (ev: MouseEvent | TouchEvent) => {
|
||||
if (!cropperDragStartRef.current) return;
|
||||
|
||||
const coords = getDisplayCoordinates(ev);
|
||||
const displayDeltaX = coords.x - cropperDragStartRef.current.startX;
|
||||
const displayDeltaY = coords.y - cropperDragStartRef.current.startY;
|
||||
|
||||
const imageDeltaX = displayDeltaX / displayScale;
|
||||
const imageDeltaY = displayDeltaY / displayScale;
|
||||
|
||||
const newCropperX = cropperDragStartRef.current.cropperX + imageDeltaX;
|
||||
const newCropperY = cropperDragStartRef.current.cropperY + imageDeltaY;
|
||||
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
const constrainedX = clamp(newCropperX, 0, effW - cropperDragStartRef.current.cropperWidth);
|
||||
const constrainedY = clamp(newCropperY, 0, effH - cropperDragStartRef.current.cropperHeight);
|
||||
|
||||
setCropState((prev) => ({
|
||||
...prev,
|
||||
cropperX: constrainedX,
|
||||
cropperY: constrainedY,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (cropperDragStartRef.current) {
|
||||
const startState = cropperDragStartRef.current;
|
||||
if (startState.cropperX !== cropStateRef.current.cropperX
|
||||
|| startState.cropperY !== cropStateRef.current.cropperY) {
|
||||
const previousState: CropState = {
|
||||
...cropStateRef.current,
|
||||
cropperX: startState.cropperX,
|
||||
cropperY: startState.cropperY,
|
||||
cropperWidth: startState.cropperWidth,
|
||||
cropperHeight: startState.cropperHeight,
|
||||
};
|
||||
onAction({ type: 'crop', previousState });
|
||||
}
|
||||
}
|
||||
cropperDragStartRef.current = undefined;
|
||||
};
|
||||
|
||||
setupDragListeners(handleMove, handleUp);
|
||||
});
|
||||
|
||||
const handleCornerResizeStart = useLastCallback((e: React.MouseEvent | React.TouchEvent, handle: ResizeHandle) => {
|
||||
const img = imageRef.current;
|
||||
if (!img || displaySize.width === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const { x, y } = getDisplayCoordinates(e);
|
||||
const displayScale = getDisplayScale();
|
||||
|
||||
cropperDragStartRef.current = {
|
||||
startX: x,
|
||||
startY: y,
|
||||
cropperX: cropState.cropperX,
|
||||
cropperY: cropState.cropperY,
|
||||
cropperWidth: cropState.cropperWidth,
|
||||
cropperHeight: cropState.cropperHeight,
|
||||
};
|
||||
|
||||
const handleMove = (ev: MouseEvent | TouchEvent) => {
|
||||
if (!cropperDragStartRef.current) return;
|
||||
|
||||
const coords = getDisplayCoordinates(ev);
|
||||
const displayDeltaX = coords.x - cropperDragStartRef.current.startX;
|
||||
const displayDeltaY = coords.y - cropperDragStartRef.current.startY;
|
||||
|
||||
const imageDeltaX = displayDeltaX / displayScale;
|
||||
const imageDeltaY = displayDeltaY / displayScale;
|
||||
|
||||
const startState = cropperDragStartRef.current;
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
let newX = startState.cropperX;
|
||||
let newY = startState.cropperY;
|
||||
let newWidth = startState.cropperWidth;
|
||||
let newHeight = startState.cropperHeight;
|
||||
|
||||
const ratioValue = getAspectRatioValue(cropStateRef.current.aspectRatio);
|
||||
|
||||
if (handle === 'topLeft') {
|
||||
newX = startState.cropperX + imageDeltaX;
|
||||
newY = startState.cropperY + imageDeltaY;
|
||||
newWidth = startState.cropperWidth - imageDeltaX;
|
||||
newHeight = startState.cropperHeight - imageDeltaY;
|
||||
} else if (handle === 'topRight') {
|
||||
newY = startState.cropperY + imageDeltaY;
|
||||
newWidth = startState.cropperWidth + imageDeltaX;
|
||||
newHeight = startState.cropperHeight - imageDeltaY;
|
||||
} else if (handle === 'bottomLeft') {
|
||||
newX = startState.cropperX + imageDeltaX;
|
||||
newWidth = startState.cropperWidth - imageDeltaX;
|
||||
newHeight = startState.cropperHeight + imageDeltaY;
|
||||
} else if (handle === 'bottomRight') {
|
||||
newWidth = startState.cropperWidth + imageDeltaX;
|
||||
newHeight = startState.cropperHeight + imageDeltaY;
|
||||
}
|
||||
|
||||
if (ratioValue) {
|
||||
const currentRatio = newWidth / newHeight;
|
||||
if (currentRatio > ratioValue) {
|
||||
const adjustedWidth = newHeight * ratioValue;
|
||||
if (handle === 'topLeft' || handle === 'bottomLeft') {
|
||||
newX += (newWidth - adjustedWidth);
|
||||
}
|
||||
newWidth = adjustedWidth;
|
||||
} else {
|
||||
const adjustedHeight = newWidth / ratioValue;
|
||||
if (handle === 'topLeft' || handle === 'topRight') {
|
||||
newY += (newHeight - adjustedHeight);
|
||||
}
|
||||
newHeight = adjustedHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (newWidth < MIN_CROP_SIZE) {
|
||||
if (handle === 'topLeft' || handle === 'bottomLeft') {
|
||||
newX -= (MIN_CROP_SIZE - newWidth);
|
||||
}
|
||||
newWidth = MIN_CROP_SIZE;
|
||||
if (ratioValue) newHeight = MIN_CROP_SIZE / ratioValue;
|
||||
}
|
||||
if (newHeight < MIN_CROP_SIZE) {
|
||||
if (handle === 'topLeft' || handle === 'topRight') {
|
||||
newY -= (MIN_CROP_SIZE - newHeight);
|
||||
}
|
||||
newHeight = MIN_CROP_SIZE;
|
||||
if (ratioValue) newWidth = MIN_CROP_SIZE * ratioValue;
|
||||
}
|
||||
|
||||
// Clamp to image bounds, keeping the opposite edge fixed
|
||||
const rightEdge = newX + newWidth;
|
||||
const bottomEdge = newY + newHeight;
|
||||
|
||||
if (handle === 'topLeft' || handle === 'bottomLeft') {
|
||||
newX = Math.max(0, newX);
|
||||
newWidth = rightEdge - newX;
|
||||
} else {
|
||||
newWidth = Math.min(newWidth, effW - newX);
|
||||
}
|
||||
|
||||
if (handle === 'topLeft' || handle === 'topRight') {
|
||||
newY = Math.max(0, newY);
|
||||
newHeight = bottomEdge - newY;
|
||||
} else {
|
||||
newHeight = Math.min(newHeight, effH - newY);
|
||||
}
|
||||
|
||||
setCropState((prev) => ({
|
||||
...prev,
|
||||
cropperX: newX,
|
||||
cropperY: newY,
|
||||
cropperWidth: newWidth,
|
||||
cropperHeight: newHeight,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (cropperDragStartRef.current) {
|
||||
const startState = cropperDragStartRef.current;
|
||||
if (startState.cropperX !== cropStateRef.current.cropperX
|
||||
|| startState.cropperY !== cropStateRef.current.cropperY
|
||||
|| startState.cropperWidth !== cropStateRef.current.cropperWidth
|
||||
|| startState.cropperHeight !== cropStateRef.current.cropperHeight) {
|
||||
const previousState: CropState = {
|
||||
...cropStateRef.current,
|
||||
cropperX: startState.cropperX,
|
||||
cropperY: startState.cropperY,
|
||||
cropperWidth: startState.cropperWidth,
|
||||
cropperHeight: startState.cropperHeight,
|
||||
};
|
||||
onAction({ type: 'crop', previousState });
|
||||
}
|
||||
}
|
||||
cropperDragStartRef.current = undefined;
|
||||
};
|
||||
|
||||
setupDragListeners(handleMove, handleUp);
|
||||
});
|
||||
|
||||
const handleAspectRatioChange = useLastCallback((newRatio: AspectRatio) => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
|
||||
const previousState = { ...cropStateRef.current };
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
|
||||
setCropState({
|
||||
...cropStateRef.current,
|
||||
aspectRatio: newRatio,
|
||||
...computeCenteredCrop(effW, effH, getAspectRatioValue(newRatio)),
|
||||
});
|
||||
onAction({ type: 'crop', previousState });
|
||||
});
|
||||
|
||||
const initCropState = useLastCallback((width: number, height: number) => {
|
||||
setCropState({
|
||||
aspectRatio: 'free',
|
||||
cropperX: 0,
|
||||
cropperY: 0,
|
||||
cropperWidth: width,
|
||||
cropperHeight: height,
|
||||
rotation: 0,
|
||||
quarterTurns: 0,
|
||||
flipH: false,
|
||||
});
|
||||
});
|
||||
|
||||
const getCroppedRegion = useLastCallback(() => {
|
||||
const { cropperX, cropperY, cropperWidth, cropperHeight } = cropStateRef.current;
|
||||
return {
|
||||
x: cropperX,
|
||||
y: cropperY,
|
||||
width: cropperWidth,
|
||||
height: cropperHeight,
|
||||
};
|
||||
});
|
||||
|
||||
const rotationStartStateRef = useRef<CropState | undefined>();
|
||||
|
||||
const handleRotationChange = useLastCallback((value: number) => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
|
||||
if (!rotationStartStateRef.current) {
|
||||
rotationStartStateRef.current = { ...cropStateRef.current };
|
||||
}
|
||||
|
||||
const { width: effW, height: effH } = getEffectiveDimensions(
|
||||
img.width, img.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
|
||||
setCropState({
|
||||
...cropStateRef.current,
|
||||
rotation: clamp(value, MIN_ROTATION, MAX_ROTATION),
|
||||
...computeCenteredCrop(effW, effH, getAspectRatioValue(cropStateRef.current.aspectRatio)),
|
||||
});
|
||||
});
|
||||
|
||||
const handleRotationChangeEnd = useLastCallback(() => {
|
||||
if (rotationStartStateRef.current) {
|
||||
onAction({ type: 'crop', previousState: rotationStartStateRef.current });
|
||||
rotationStartStateRef.current = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const handleQuarterRotate = useLastCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
|
||||
const previousState = { ...cropStateRef.current };
|
||||
const newQuarterTurns = (cropStateRef.current.quarterTurns + 1) % 4;
|
||||
const { width: newEffW, height: newEffH } = getEffectiveDimensions(
|
||||
img.width, img.height, newQuarterTurns,
|
||||
);
|
||||
|
||||
setCropState({
|
||||
...cropStateRef.current,
|
||||
quarterTurns: newQuarterTurns,
|
||||
rotation: 0,
|
||||
...computeCenteredCrop(newEffW, newEffH, getAspectRatioValue(cropStateRef.current.aspectRatio)),
|
||||
});
|
||||
onAction({ type: 'crop', previousState });
|
||||
});
|
||||
|
||||
const handleFlip = useLastCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
|
||||
const previousState = { ...cropStateRef.current };
|
||||
const { width: effW } = getEffectiveDimensions(
|
||||
img.width, img.height, cropStateRef.current.quarterTurns,
|
||||
);
|
||||
|
||||
setCropState({
|
||||
...cropStateRef.current,
|
||||
flipH: !cropStateRef.current.flipH,
|
||||
cropperX: effW - cropStateRef.current.cropperX - cropStateRef.current.cropperWidth,
|
||||
});
|
||||
onAction({ type: 'crop', previousState });
|
||||
});
|
||||
|
||||
return {
|
||||
getCroppedRegion,
|
||||
initCropState,
|
||||
handleCropperDragStart,
|
||||
handleCornerResizeStart,
|
||||
handleAspectRatioChange,
|
||||
handleRotationChange,
|
||||
handleRotationChangeEnd,
|
||||
handleQuarterRotate,
|
||||
handleFlip,
|
||||
};
|
||||
}
|
||||
71
src/components/ui/mediaEditor/hooks/useDisplaySize.ts
Normal file
71
src/components/ui/mediaEditor/hooks/useDisplaySize.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { ElementRef } from '@teact';
|
||||
import { useEffect, useState } from '@teact';
|
||||
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
interface UseDisplaySizeOptions {
|
||||
canvasAreaRef: ElementRef<HTMLDivElement>;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
reservedHeight?: number;
|
||||
}
|
||||
|
||||
export default function useDisplaySize({
|
||||
canvasAreaRef,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
reservedHeight = 0,
|
||||
}: UseDisplaySizeOptions) {
|
||||
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const getDisplayScale = useLastCallback(() => {
|
||||
if (displaySize.width === 0 || imageWidth === 0) return 1;
|
||||
return Math.min(
|
||||
displaySize.width / imageWidth,
|
||||
displaySize.height / imageHeight,
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const canvasArea = canvasAreaRef.current;
|
||||
if (!canvasArea || imageWidth === 0) return undefined;
|
||||
|
||||
const updateDisplaySize = () => {
|
||||
const areaRect = canvasArea.getBoundingClientRect();
|
||||
const style = getComputedStyle(canvasArea);
|
||||
const paddingX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const paddingY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
||||
const availableWidth = areaRect.width - paddingX;
|
||||
const availableHeight = areaRect.height - paddingY - reservedHeight;
|
||||
|
||||
if (availableWidth <= 0 || availableHeight <= 0) return;
|
||||
|
||||
const scaleToFit = Math.min(
|
||||
availableWidth / imageWidth,
|
||||
availableHeight / imageHeight,
|
||||
);
|
||||
|
||||
const scale = Math.min(scaleToFit, 1);
|
||||
|
||||
setDisplaySize({
|
||||
width: imageWidth * scale,
|
||||
height: imageHeight * scale,
|
||||
});
|
||||
};
|
||||
|
||||
updateDisplaySize();
|
||||
|
||||
window.addEventListener('resize', updateDisplaySize);
|
||||
return () => window.removeEventListener('resize', updateDisplaySize);
|
||||
}, [canvasAreaRef, imageWidth, imageHeight, reservedHeight]);
|
||||
|
||||
const resetDisplaySize = useLastCallback(() => {
|
||||
setDisplaySize({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
return {
|
||||
displaySize,
|
||||
getDisplayScale,
|
||||
resetDisplaySize,
|
||||
};
|
||||
}
|
||||
112
src/components/ui/mediaEditor/hooks/useDrawing.ts
Normal file
112
src/components/ui/mediaEditor/hooks/useDrawing.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { useRef, useState } from '@teact';
|
||||
|
||||
import type { DrawAction, DrawTool } from '../canvasUtils';
|
||||
|
||||
import useFlag from '../../../../hooks/useFlag';
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
const DEFAULT_BRUSH_SIZE = 5;
|
||||
|
||||
interface UseDrawingOptions {
|
||||
getCanvasCoordinates: (e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => { x: number; y: number };
|
||||
canvasToImageCoords: (x: number, y: number) => { x: number; y: number };
|
||||
selectedColor: string;
|
||||
onActionComplete: (action: DrawAction) => void;
|
||||
}
|
||||
|
||||
export default function useDrawing({
|
||||
getCanvasCoordinates,
|
||||
canvasToImageCoords,
|
||||
selectedColor,
|
||||
onActionComplete,
|
||||
}: UseDrawingOptions) {
|
||||
const [drawTool, setDrawTool] = useState<DrawTool>('pen');
|
||||
const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE);
|
||||
const [currentDrawAction, setCurrentDrawAction] = useState<DrawAction | undefined>(undefined);
|
||||
const [isDrawing, markDrawing, unmarkDrawing] = useFlag(false);
|
||||
const lastCompletedActionRef = useRef<DrawAction | undefined>(undefined);
|
||||
|
||||
const handlePointerMove = useLastCallback((e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
// Also check lastCompletedActionRef to prevent moves after completion (stale state race)
|
||||
if (!isDrawing || !currentDrawAction || lastCompletedActionRef.current === currentDrawAction) return;
|
||||
|
||||
const canvasCoords = getCanvasCoordinates(e);
|
||||
const imageCoords = canvasToImageCoords(canvasCoords.x, canvasCoords.y);
|
||||
const isShiftPressed = 'shiftKey' in e ? e.shiftKey : false;
|
||||
|
||||
// When shift is pressed, only keep first and last point (straight line)
|
||||
const newPoints = isShiftPressed
|
||||
? [currentDrawAction.points[0], imageCoords]
|
||||
: [...currentDrawAction.points, imageCoords];
|
||||
|
||||
setCurrentDrawAction({
|
||||
...currentDrawAction,
|
||||
points: newPoints,
|
||||
isShiftPressed,
|
||||
});
|
||||
});
|
||||
|
||||
const handlePointerUp = useLastCallback((e?: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
|
||||
// Use ref to prevent double completion from mouseup + mouseleave firing together
|
||||
if (!isDrawing || !currentDrawAction || lastCompletedActionRef.current === currentDrawAction) return;
|
||||
|
||||
unmarkDrawing();
|
||||
const completedAction = {
|
||||
...currentDrawAction,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
lastCompletedActionRef.current = completedAction;
|
||||
setCurrentDrawAction(undefined);
|
||||
if (completedAction.points.length > 1) {
|
||||
onActionComplete(completedAction);
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', handlePointerMove);
|
||||
document.removeEventListener('touchmove', handlePointerMove);
|
||||
document.removeEventListener('mouseup', handlePointerUp);
|
||||
document.removeEventListener('touchend', handlePointerUp);
|
||||
});
|
||||
|
||||
const handlePointerDown = useLastCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
markDrawing();
|
||||
const canvasCoords = getCanvasCoordinates(e);
|
||||
const imageCoords = canvasToImageCoords(canvasCoords.x, canvasCoords.y);
|
||||
const isShiftPressed = 'shiftKey' in e ? e.shiftKey : false;
|
||||
|
||||
setCurrentDrawAction({
|
||||
type: 'draw',
|
||||
tool: drawTool,
|
||||
points: [imageCoords],
|
||||
color: selectedColor,
|
||||
brushSize,
|
||||
isShiftPressed,
|
||||
});
|
||||
|
||||
// Attach document listeners to continue drawing even when cursor leaves canvas
|
||||
document.addEventListener('mousemove', handlePointerMove);
|
||||
document.addEventListener('touchmove', handlePointerMove);
|
||||
document.addEventListener('mouseup', handlePointerUp);
|
||||
document.addEventListener('touchend', handlePointerUp);
|
||||
});
|
||||
|
||||
const resetDrawing = useLastCallback(() => {
|
||||
setCurrentDrawAction(undefined);
|
||||
unmarkDrawing();
|
||||
});
|
||||
|
||||
return {
|
||||
drawTool,
|
||||
setDrawTool,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
currentDrawAction,
|
||||
isDrawing,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
resetDrawing,
|
||||
};
|
||||
}
|
||||
|
||||
export const MIN_BRUSH_SIZE = 2;
|
||||
export const MAX_BRUSH_SIZE = 50;
|
||||
@ -1,7 +1,7 @@
|
||||
import { addCallback } from '../../../lib/teact/teactn';
|
||||
|
||||
import type { ApiError } from '../../../api/types';
|
||||
import type { ActionReturnType, GlobalState } from '../../types';
|
||||
import { type ApiError } from '../../../api/types';
|
||||
|
||||
import {
|
||||
ANIMATION_WAVE_MIN_INTERVAL,
|
||||
@ -19,7 +19,7 @@ import { refreshFromCache } from '../../../util/localization';
|
||||
import * as langProvider from '../../../util/oldLangProvider';
|
||||
import updateIcon from '../../../util/updateIcon';
|
||||
import { setPageTitle, setPageTitleInstant } from '../../../util/updatePageTitle';
|
||||
import { getAllowedAttachmentOptions, getChatTitle } from '../../helpers';
|
||||
import { canEditMediaInEditor, getAllowedAttachmentOptions, getChatTitle } from '../../helpers';
|
||||
import { addTabStateResetterAction } from '../../helpers/meta';
|
||||
import {
|
||||
addActionHandler, getActions, getGlobal, setGlobal,
|
||||
@ -42,6 +42,7 @@ import {
|
||||
selectTopic,
|
||||
} from '../../selectors';
|
||||
import { selectSharedSettings } from '../../selectors/sharedState';
|
||||
import { selectDraft, selectEditingId } from '../../selectors/threads';
|
||||
|
||||
import { getIsMobile, getIsTablet } from '../../../hooks/useAppLayout';
|
||||
|
||||
@ -993,3 +994,25 @@ addActionHandler('openCocoonModal', (global, actions, payload): ActionReturnType
|
||||
});
|
||||
|
||||
addTabStateResetterAction('closeCocoonModal', 'isCocoonModalOpen');
|
||||
|
||||
addActionHandler('requestMessageMediaEditor', (global, actions, payload): ActionReturnType => {
|
||||
const { tabId = getCurrentTabId() } = payload || {};
|
||||
const currentMessageList = selectCurrentMessageList(global, tabId);
|
||||
if (!currentMessageList) return;
|
||||
|
||||
const draft = selectDraft(global, currentMessageList.chatId, currentMessageList.threadId);
|
||||
const replyToMessage = draft?.replyInfo
|
||||
? selectChatMessage(global, currentMessageList.chatId, draft.replyInfo.replyToMsgId)
|
||||
: undefined;
|
||||
const editingId = selectEditingId(global, currentMessageList.chatId, currentMessageList.threadId);
|
||||
const editingMessage = editingId ? selectChatMessage(global, currentMessageList.chatId, editingId) : undefined;
|
||||
|
||||
const message = replyToMessage || editingMessage;
|
||||
if (!message || !canEditMediaInEditor(message)) return;
|
||||
|
||||
return updateTabState(global, {
|
||||
shouldOpenMessageMediaEditor: true,
|
||||
}, tabId);
|
||||
});
|
||||
|
||||
addTabStateResetterAction('resetMessageMediaEditorRequest', 'shouldOpenMessageMediaEditor');
|
||||
|
||||
@ -61,6 +61,10 @@ export function canEditMedia(message: MediaContainer) {
|
||||
return !video?.isRound && !Object.keys(otherMedia).length;
|
||||
}
|
||||
|
||||
export function canEditMediaInEditor(message: MediaContainer) {
|
||||
return canEditMedia(message) && (getMessagePhoto(message) || getMessageDocumentPhoto(message));
|
||||
}
|
||||
|
||||
export function getMessagePhoto(message: MediaContainer) {
|
||||
return message.content.photo;
|
||||
}
|
||||
|
||||
@ -3060,6 +3060,9 @@ export interface ActionPayloads {
|
||||
|
||||
openCocoonModal: WithTabId | undefined;
|
||||
closeCocoonModal: WithTabId | undefined;
|
||||
|
||||
requestMessageMediaEditor: WithTabId | undefined;
|
||||
resetMessageMediaEditorRequest: WithTabId | undefined;
|
||||
}
|
||||
|
||||
export interface RequiredActionPayloads {
|
||||
|
||||
@ -1029,4 +1029,6 @@ export type TabState = {
|
||||
shouldSaveAttachmentsCompression?: boolean;
|
||||
|
||||
isCocoonModalOpen?: boolean;
|
||||
|
||||
shouldOpenMessageMediaEditor?: boolean;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -43,292 +43,298 @@ $icons-map: (
|
||||
"unlist": "\f119",
|
||||
"unlist-outline": "\f11a",
|
||||
"unique-profile": "\f11b",
|
||||
"understood": "\f11c",
|
||||
"underlined": "\f11d",
|
||||
"unarchive": "\f11e",
|
||||
"truck": "\f11f",
|
||||
"transcribe": "\f120",
|
||||
"trade": "\f121",
|
||||
"topic-new": "\f122",
|
||||
"tools": "\f123",
|
||||
"toncoin": "\f124",
|
||||
"timer": "\f125",
|
||||
"tag": "\f126",
|
||||
"tag-name": "\f127",
|
||||
"tag-filter": "\f128",
|
||||
"tag-crossed": "\f129",
|
||||
"tag-add": "\f12a",
|
||||
"strikethrough": "\f12b",
|
||||
"story-reply": "\f12c",
|
||||
"story-priority": "\f12d",
|
||||
"story-expired": "\f12e",
|
||||
"story-caption": "\f12f",
|
||||
"stop": "\f130",
|
||||
"stop-raising-hand": "\f131",
|
||||
"stickers": "\f132",
|
||||
"stealth-past": "\f133",
|
||||
"stealth-future": "\f134",
|
||||
"stats": "\f135",
|
||||
"stars-refund": "\f136",
|
||||
"stars-lock": "\f137",
|
||||
"star": "\f138",
|
||||
"sport": "\f139",
|
||||
"spoiler": "\f13a",
|
||||
"spoiler-disable": "\f13b",
|
||||
"speaker": "\f13c",
|
||||
"speaker-story": "\f13d",
|
||||
"speaker-outline": "\f13e",
|
||||
"speaker-muted-story": "\f13f",
|
||||
"sort": "\f140",
|
||||
"sort-by-price": "\f141",
|
||||
"sort-by-number": "\f142",
|
||||
"sort-by-date": "\f143",
|
||||
"smile": "\f144",
|
||||
"smallscreen": "\f145",
|
||||
"skip-previous": "\f146",
|
||||
"skip-next": "\f147",
|
||||
"sidebar": "\f148",
|
||||
"show-message": "\f149",
|
||||
"share-screen": "\f14a",
|
||||
"share-screen-stop": "\f14b",
|
||||
"share-screen-outlined": "\f14c",
|
||||
"share-filled": "\f14d",
|
||||
"settings": "\f14e",
|
||||
"settings-filled": "\f14f",
|
||||
"send": "\f150",
|
||||
"send-outline": "\f151",
|
||||
"sell": "\f152",
|
||||
"sell-outline": "\f153",
|
||||
"select": "\f154",
|
||||
"search": "\f155",
|
||||
"sd-photo": "\f156",
|
||||
"scheduled": "\f157",
|
||||
"schedule": "\f158",
|
||||
"saved-messages": "\f159",
|
||||
"save-story": "\f15a",
|
||||
"revote": "\f15b",
|
||||
"revenue-split": "\f15c",
|
||||
"reply": "\f15d",
|
||||
"reply-filled": "\f15e",
|
||||
"replies": "\f15f",
|
||||
"replace": "\f160",
|
||||
"reorder-tabs": "\f161",
|
||||
"reopen-topic": "\f162",
|
||||
"remove": "\f163",
|
||||
"remove-quote": "\f164",
|
||||
"reload": "\f165",
|
||||
"refund": "\f166",
|
||||
"recent": "\f167",
|
||||
"readchats": "\f168",
|
||||
"radial-badge": "\f169",
|
||||
"quote": "\f16a",
|
||||
"quote-text": "\f16b",
|
||||
"proof-of-ownership": "\f16c",
|
||||
"privacy-policy": "\f16d",
|
||||
"previous": "\f16e",
|
||||
"poll": "\f16f",
|
||||
"play": "\f170",
|
||||
"play-story": "\f171",
|
||||
"pip": "\f172",
|
||||
"pinned-message": "\f173",
|
||||
"pinned-chat": "\f174",
|
||||
"pin": "\f175",
|
||||
"pin-list": "\f176",
|
||||
"pin-badge": "\f177",
|
||||
"photo": "\f178",
|
||||
"phone": "\f179",
|
||||
"phone-discard": "\f17a",
|
||||
"phone-discard-outline": "\f17b",
|
||||
"permissions": "\f17c",
|
||||
"pause": "\f17d",
|
||||
"password-off": "\f17e",
|
||||
"open-in-new-tab": "\f17f",
|
||||
"one-filled": "\f180",
|
||||
"note": "\f181",
|
||||
"non-contacts": "\f182",
|
||||
"noise-suppression": "\f183",
|
||||
"nochannel": "\f184",
|
||||
"next": "\f185",
|
||||
"next-link": "\f186",
|
||||
"new-chat-filled": "\f187",
|
||||
"my-notes": "\f188",
|
||||
"muted": "\f189",
|
||||
"mute": "\f18a",
|
||||
"move-caption-up": "\f18b",
|
||||
"move-caption-down": "\f18c",
|
||||
"more": "\f18d",
|
||||
"more-circle": "\f18e",
|
||||
"monospace": "\f18f",
|
||||
"microphone": "\f190",
|
||||
"microphone-alt": "\f191",
|
||||
"message": "\f192",
|
||||
"message-succeeded": "\f193",
|
||||
"message-read": "\f194",
|
||||
"message-pending": "\f195",
|
||||
"message-failed": "\f196",
|
||||
"menu": "\f197",
|
||||
"mention": "\f198",
|
||||
"loop": "\f199",
|
||||
"logout": "\f19a",
|
||||
"lock": "\f19b",
|
||||
"lock-badge": "\f19c",
|
||||
"location": "\f19d",
|
||||
"link": "\f19e",
|
||||
"link-broken": "\f19f",
|
||||
"link-badge": "\f1a0",
|
||||
"large-play": "\f1a1",
|
||||
"large-pause": "\f1a2",
|
||||
"language": "\f1a3",
|
||||
"lamp": "\f1a4",
|
||||
"keyboard": "\f1a5",
|
||||
"key": "\f1a6",
|
||||
"italic": "\f1a7",
|
||||
"install": "\f1a8",
|
||||
"info": "\f1a9",
|
||||
"info-filled": "\f1aa",
|
||||
"help": "\f1ab",
|
||||
"heart": "\f1ac",
|
||||
"heart-outline": "\f1ad",
|
||||
"hd-photo": "\f1ae",
|
||||
"hashtag": "\f1af",
|
||||
"hand-stop": "\f1b0",
|
||||
"grouped": "\f1b1",
|
||||
"grouped-disable": "\f1b2",
|
||||
"group": "\f1b3",
|
||||
"group-filled": "\f1b4",
|
||||
"gift": "\f1b5",
|
||||
"gift-transfer-inline": "\f1b6",
|
||||
"gifs": "\f1b7",
|
||||
"fullscreen": "\f1b8",
|
||||
"frozen-time": "\f1b9",
|
||||
"fragment": "\f1ba",
|
||||
"forward": "\f1bb",
|
||||
"forums": "\f1bc",
|
||||
"fontsize": "\f1bd",
|
||||
"folder": "\f1be",
|
||||
"folder-badge": "\f1bf",
|
||||
"flag": "\f1c0",
|
||||
"file-badge": "\f1c1",
|
||||
"favorite": "\f1c2",
|
||||
"favorite-filled": "\f1c3",
|
||||
"eye": "\f1c4",
|
||||
"eye-outline": "\f1c5",
|
||||
"eye-crossed": "\f1c6",
|
||||
"eye-crossed-outline": "\f1c7",
|
||||
"expand": "\f1c8",
|
||||
"expand-modal": "\f1c9",
|
||||
"enter": "\f1ca",
|
||||
"email": "\f1cb",
|
||||
"edit": "\f1cc",
|
||||
"eats": "\f1cd",
|
||||
"dropdown-arrows": "\f1ce",
|
||||
"download": "\f1cf",
|
||||
"down": "\f1d0",
|
||||
"double-badge": "\f1d1",
|
||||
"document": "\f1d2",
|
||||
"diamond": "\f1d3",
|
||||
"delete": "\f1d4",
|
||||
"delete-user": "\f1d5",
|
||||
"delete-left": "\f1d6",
|
||||
"delete-filled": "\f1d7",
|
||||
"data": "\f1d8",
|
||||
"darkmode": "\f1d9",
|
||||
"crown-wear": "\f1da",
|
||||
"crown-wear-outline": "\f1db",
|
||||
"crown-take-off": "\f1dc",
|
||||
"crown-take-off-outline": "\f1dd",
|
||||
"craft": "\f1de",
|
||||
"copy": "\f1df",
|
||||
"copy-media": "\f1e0",
|
||||
"comments": "\f1e1",
|
||||
"comments-sticker": "\f1e2",
|
||||
"combine-craft": "\f1e3",
|
||||
"colorize": "\f1e4",
|
||||
"collapse": "\f1e5",
|
||||
"collapse-modal": "\f1e6",
|
||||
"cloud-download": "\f1e7",
|
||||
"closed-gift": "\f1e8",
|
||||
"close": "\f1e9",
|
||||
"close-topic": "\f1ea",
|
||||
"close-circle": "\f1eb",
|
||||
"clock": "\f1ec",
|
||||
"clock-edit": "\f1ed",
|
||||
"check": "\f1ee",
|
||||
"chats-badge": "\f1ef",
|
||||
"chat-badge": "\f1f0",
|
||||
"channelviews": "\f1f1",
|
||||
"channel": "\f1f2",
|
||||
"channel-filled": "\f1f3",
|
||||
"cash-circle": "\f1f4",
|
||||
"card": "\f1f5",
|
||||
"car": "\f1f6",
|
||||
"camera": "\f1f7",
|
||||
"camera-add": "\f1f8",
|
||||
"calendar": "\f1f9",
|
||||
"calendar-filter": "\f1fa",
|
||||
"bug": "\f1fb",
|
||||
"bots": "\f1fc",
|
||||
"bot-commands-filled": "\f1fd",
|
||||
"bot-command": "\f1fe",
|
||||
"boosts": "\f1ff",
|
||||
"boostcircle": "\f200",
|
||||
"boost": "\f201",
|
||||
"boost-outline": "\f202",
|
||||
"boost-craft-chance": "\f203",
|
||||
"bold": "\f204",
|
||||
"avatar-saved-messages": "\f205",
|
||||
"avatar-deleted-account": "\f206",
|
||||
"avatar-archived-chats": "\f207",
|
||||
"author-hidden": "\f208",
|
||||
"auction": "\f209",
|
||||
"auction-next-round": "\f20a",
|
||||
"auction-filled": "\f20b",
|
||||
"auction-drop": "\f20c",
|
||||
"attach": "\f20d",
|
||||
"ask-support": "\f20e",
|
||||
"arrow-right": "\f20f",
|
||||
"arrow-left": "\f210",
|
||||
"arrow-down": "\f211",
|
||||
"arrow-down-circle": "\f212",
|
||||
"archive": "\f213",
|
||||
"archive-to-main": "\f214",
|
||||
"archive-from-main": "\f215",
|
||||
"archive-filled": "\f216",
|
||||
"animations": "\f217",
|
||||
"animals": "\f218",
|
||||
"allow-speak": "\f219",
|
||||
"admin": "\f21a",
|
||||
"add": "\f21b",
|
||||
"add-user": "\f21c",
|
||||
"add-user-filled": "\f21d",
|
||||
"add-one-badge": "\f21e",
|
||||
"add-filled": "\f21f",
|
||||
"active-sessions": "\f220",
|
||||
"rating-icons-negative": "\f221",
|
||||
"rating-icons-level90": "\f222",
|
||||
"rating-icons-level9": "\f223",
|
||||
"rating-icons-level80": "\f224",
|
||||
"rating-icons-level8": "\f225",
|
||||
"rating-icons-level70": "\f226",
|
||||
"rating-icons-level7": "\f227",
|
||||
"rating-icons-level60": "\f228",
|
||||
"rating-icons-level6": "\f229",
|
||||
"rating-icons-level50": "\f22a",
|
||||
"rating-icons-level5": "\f22b",
|
||||
"rating-icons-level40": "\f22c",
|
||||
"rating-icons-level4": "\f22d",
|
||||
"rating-icons-level30": "\f22e",
|
||||
"rating-icons-level3": "\f22f",
|
||||
"rating-icons-level20": "\f230",
|
||||
"rating-icons-level2": "\f231",
|
||||
"rating-icons-level10": "\f232",
|
||||
"rating-icons-level1": "\f233",
|
||||
"folder-tabs-user": "\f234",
|
||||
"folder-tabs-star": "\f235",
|
||||
"folder-tabs-group": "\f236",
|
||||
"folder-tabs-folder": "\f237",
|
||||
"folder-tabs-chats": "\f238",
|
||||
"folder-tabs-chat": "\f239",
|
||||
"folder-tabs-channel": "\f23a",
|
||||
"folder-tabs-bot": "\f23b",
|
||||
"undo": "\f11c",
|
||||
"understood": "\f11d",
|
||||
"underlined": "\f11e",
|
||||
"unarchive": "\f11f",
|
||||
"truck": "\f120",
|
||||
"transcribe": "\f121",
|
||||
"trade": "\f122",
|
||||
"topic-new": "\f123",
|
||||
"tools": "\f124",
|
||||
"toncoin": "\f125",
|
||||
"timer": "\f126",
|
||||
"tag": "\f127",
|
||||
"tag-name": "\f128",
|
||||
"tag-filter": "\f129",
|
||||
"tag-crossed": "\f12a",
|
||||
"tag-add": "\f12b",
|
||||
"strikethrough": "\f12c",
|
||||
"story-reply": "\f12d",
|
||||
"story-priority": "\f12e",
|
||||
"story-expired": "\f12f",
|
||||
"story-caption": "\f130",
|
||||
"stop": "\f131",
|
||||
"stop-raising-hand": "\f132",
|
||||
"stickers": "\f133",
|
||||
"stealth-past": "\f134",
|
||||
"stealth-future": "\f135",
|
||||
"stats": "\f136",
|
||||
"stars-refund": "\f137",
|
||||
"stars-lock": "\f138",
|
||||
"star": "\f139",
|
||||
"sport": "\f13a",
|
||||
"spoiler": "\f13b",
|
||||
"spoiler-disable": "\f13c",
|
||||
"speaker": "\f13d",
|
||||
"speaker-story": "\f13e",
|
||||
"speaker-outline": "\f13f",
|
||||
"speaker-muted-story": "\f140",
|
||||
"sort": "\f141",
|
||||
"sort-by-price": "\f142",
|
||||
"sort-by-number": "\f143",
|
||||
"sort-by-date": "\f144",
|
||||
"smile": "\f145",
|
||||
"smallscreen": "\f146",
|
||||
"skip-previous": "\f147",
|
||||
"skip-next": "\f148",
|
||||
"sidebar": "\f149",
|
||||
"show-message": "\f14a",
|
||||
"share-screen": "\f14b",
|
||||
"share-screen-stop": "\f14c",
|
||||
"share-screen-outlined": "\f14d",
|
||||
"share-filled": "\f14e",
|
||||
"settings": "\f14f",
|
||||
"settings-filled": "\f150",
|
||||
"send": "\f151",
|
||||
"send-outline": "\f152",
|
||||
"sell": "\f153",
|
||||
"sell-outline": "\f154",
|
||||
"select": "\f155",
|
||||
"search": "\f156",
|
||||
"sd-photo": "\f157",
|
||||
"scheduled": "\f158",
|
||||
"schedule": "\f159",
|
||||
"saved-messages": "\f15a",
|
||||
"save-story": "\f15b",
|
||||
"rotate": "\f15c",
|
||||
"revote": "\f15d",
|
||||
"revenue-split": "\f15e",
|
||||
"reply": "\f15f",
|
||||
"reply-filled": "\f160",
|
||||
"replies": "\f161",
|
||||
"replace": "\f162",
|
||||
"reorder-tabs": "\f163",
|
||||
"reopen-topic": "\f164",
|
||||
"remove": "\f165",
|
||||
"remove-quote": "\f166",
|
||||
"reload": "\f167",
|
||||
"refund": "\f168",
|
||||
"redo": "\f169",
|
||||
"recent": "\f16a",
|
||||
"readchats": "\f16b",
|
||||
"radial-badge": "\f16c",
|
||||
"quote": "\f16d",
|
||||
"quote-text": "\f16e",
|
||||
"proof-of-ownership": "\f16f",
|
||||
"privacy-policy": "\f170",
|
||||
"previous": "\f171",
|
||||
"poll": "\f172",
|
||||
"play": "\f173",
|
||||
"play-story": "\f174",
|
||||
"pip": "\f175",
|
||||
"pinned-message": "\f176",
|
||||
"pinned-chat": "\f177",
|
||||
"pin": "\f178",
|
||||
"pin-list": "\f179",
|
||||
"pin-badge": "\f17a",
|
||||
"photo": "\f17b",
|
||||
"phone": "\f17c",
|
||||
"phone-discard": "\f17d",
|
||||
"phone-discard-outline": "\f17e",
|
||||
"permissions": "\f17f",
|
||||
"pause": "\f180",
|
||||
"password-off": "\f181",
|
||||
"open-in-new-tab": "\f182",
|
||||
"one-filled": "\f183",
|
||||
"note": "\f184",
|
||||
"non-contacts": "\f185",
|
||||
"noise-suppression": "\f186",
|
||||
"nochannel": "\f187",
|
||||
"next": "\f188",
|
||||
"next-link": "\f189",
|
||||
"new-chat-filled": "\f18a",
|
||||
"my-notes": "\f18b",
|
||||
"muted": "\f18c",
|
||||
"mute": "\f18d",
|
||||
"move-caption-up": "\f18e",
|
||||
"move-caption-down": "\f18f",
|
||||
"more": "\f190",
|
||||
"more-circle": "\f191",
|
||||
"monospace": "\f192",
|
||||
"microphone": "\f193",
|
||||
"microphone-alt": "\f194",
|
||||
"message": "\f195",
|
||||
"message-succeeded": "\f196",
|
||||
"message-read": "\f197",
|
||||
"message-pending": "\f198",
|
||||
"message-failed": "\f199",
|
||||
"menu": "\f19a",
|
||||
"mention": "\f19b",
|
||||
"loop": "\f19c",
|
||||
"logout": "\f19d",
|
||||
"lock": "\f19e",
|
||||
"lock-badge": "\f19f",
|
||||
"location": "\f1a0",
|
||||
"link": "\f1a1",
|
||||
"link-broken": "\f1a2",
|
||||
"link-badge": "\f1a3",
|
||||
"large-play": "\f1a4",
|
||||
"large-pause": "\f1a5",
|
||||
"language": "\f1a6",
|
||||
"lamp": "\f1a7",
|
||||
"keyboard": "\f1a8",
|
||||
"key": "\f1a9",
|
||||
"italic": "\f1aa",
|
||||
"install": "\f1ab",
|
||||
"info": "\f1ac",
|
||||
"info-filled": "\f1ad",
|
||||
"help": "\f1ae",
|
||||
"heart": "\f1af",
|
||||
"heart-outline": "\f1b0",
|
||||
"hd-photo": "\f1b1",
|
||||
"hashtag": "\f1b2",
|
||||
"hand-stop": "\f1b3",
|
||||
"grouped": "\f1b4",
|
||||
"grouped-disable": "\f1b5",
|
||||
"group": "\f1b6",
|
||||
"group-filled": "\f1b7",
|
||||
"gift": "\f1b8",
|
||||
"gift-transfer-inline": "\f1b9",
|
||||
"gifs": "\f1ba",
|
||||
"fullscreen": "\f1bb",
|
||||
"frozen-time": "\f1bc",
|
||||
"fragment": "\f1bd",
|
||||
"forward": "\f1be",
|
||||
"forums": "\f1bf",
|
||||
"fontsize": "\f1c0",
|
||||
"folder": "\f1c1",
|
||||
"folder-badge": "\f1c2",
|
||||
"flip": "\f1c3",
|
||||
"flag": "\f1c4",
|
||||
"file-badge": "\f1c5",
|
||||
"favorite": "\f1c6",
|
||||
"favorite-filled": "\f1c7",
|
||||
"eye": "\f1c8",
|
||||
"eye-outline": "\f1c9",
|
||||
"eye-crossed": "\f1ca",
|
||||
"eye-crossed-outline": "\f1cb",
|
||||
"expand": "\f1cc",
|
||||
"expand-modal": "\f1cd",
|
||||
"enter": "\f1ce",
|
||||
"email": "\f1cf",
|
||||
"edit": "\f1d0",
|
||||
"eats": "\f1d1",
|
||||
"dropdown-arrows": "\f1d2",
|
||||
"download": "\f1d3",
|
||||
"down": "\f1d4",
|
||||
"double-badge": "\f1d5",
|
||||
"document": "\f1d6",
|
||||
"diamond": "\f1d7",
|
||||
"delete": "\f1d8",
|
||||
"delete-user": "\f1d9",
|
||||
"delete-left": "\f1da",
|
||||
"delete-filled": "\f1db",
|
||||
"data": "\f1dc",
|
||||
"darkmode": "\f1dd",
|
||||
"crown-wear": "\f1de",
|
||||
"crown-wear-outline": "\f1df",
|
||||
"crown-take-off": "\f1e0",
|
||||
"crown-take-off-outline": "\f1e1",
|
||||
"crop": "\f1e2",
|
||||
"craft": "\f1e3",
|
||||
"copy": "\f1e4",
|
||||
"copy-media": "\f1e5",
|
||||
"comments": "\f1e6",
|
||||
"comments-sticker": "\f1e7",
|
||||
"combine-craft": "\f1e8",
|
||||
"colorize": "\f1e9",
|
||||
"collapse": "\f1ea",
|
||||
"collapse-modal": "\f1eb",
|
||||
"cloud-download": "\f1ec",
|
||||
"closed-gift": "\f1ed",
|
||||
"close": "\f1ee",
|
||||
"close-topic": "\f1ef",
|
||||
"close-circle": "\f1f0",
|
||||
"clock": "\f1f1",
|
||||
"clock-edit": "\f1f2",
|
||||
"check": "\f1f3",
|
||||
"chats-badge": "\f1f4",
|
||||
"chat-badge": "\f1f5",
|
||||
"channelviews": "\f1f6",
|
||||
"channel": "\f1f7",
|
||||
"channel-filled": "\f1f8",
|
||||
"cash-circle": "\f1f9",
|
||||
"card": "\f1fa",
|
||||
"car": "\f1fb",
|
||||
"camera": "\f1fc",
|
||||
"camera-add": "\f1fd",
|
||||
"calendar": "\f1fe",
|
||||
"calendar-filter": "\f1ff",
|
||||
"bug": "\f200",
|
||||
"brush": "\f201",
|
||||
"bots": "\f202",
|
||||
"bot-commands-filled": "\f203",
|
||||
"bot-command": "\f204",
|
||||
"boosts": "\f205",
|
||||
"boostcircle": "\f206",
|
||||
"boost": "\f207",
|
||||
"boost-outline": "\f208",
|
||||
"boost-craft-chance": "\f209",
|
||||
"bold": "\f20a",
|
||||
"avatar-saved-messages": "\f20b",
|
||||
"avatar-deleted-account": "\f20c",
|
||||
"avatar-archived-chats": "\f20d",
|
||||
"author-hidden": "\f20e",
|
||||
"auction": "\f20f",
|
||||
"auction-next-round": "\f210",
|
||||
"auction-filled": "\f211",
|
||||
"auction-drop": "\f212",
|
||||
"attach": "\f213",
|
||||
"ask-support": "\f214",
|
||||
"arrow-right": "\f215",
|
||||
"arrow-left": "\f216",
|
||||
"arrow-down": "\f217",
|
||||
"arrow-down-circle": "\f218",
|
||||
"archive": "\f219",
|
||||
"archive-to-main": "\f21a",
|
||||
"archive-from-main": "\f21b",
|
||||
"archive-filled": "\f21c",
|
||||
"animations": "\f21d",
|
||||
"animals": "\f21e",
|
||||
"allow-speak": "\f21f",
|
||||
"admin": "\f220",
|
||||
"add": "\f221",
|
||||
"add-user": "\f222",
|
||||
"add-user-filled": "\f223",
|
||||
"add-one-badge": "\f224",
|
||||
"add-filled": "\f225",
|
||||
"active-sessions": "\f226",
|
||||
"folder-tabs-user": "\f227",
|
||||
"folder-tabs-star": "\f228",
|
||||
"folder-tabs-group": "\f229",
|
||||
"folder-tabs-folder": "\f22a",
|
||||
"folder-tabs-chats": "\f22b",
|
||||
"folder-tabs-chat": "\f22c",
|
||||
"folder-tabs-channel": "\f22d",
|
||||
"folder-tabs-bot": "\f22e",
|
||||
"rating-icons-negative": "\f22f",
|
||||
"rating-icons-level90": "\f230",
|
||||
"rating-icons-level9": "\f231",
|
||||
"rating-icons-level80": "\f232",
|
||||
"rating-icons-level8": "\f233",
|
||||
"rating-icons-level70": "\f234",
|
||||
"rating-icons-level7": "\f235",
|
||||
"rating-icons-level60": "\f236",
|
||||
"rating-icons-level6": "\f237",
|
||||
"rating-icons-level50": "\f238",
|
||||
"rating-icons-level5": "\f239",
|
||||
"rating-icons-level40": "\f23a",
|
||||
"rating-icons-level4": "\f23b",
|
||||
"rating-icons-level30": "\f23c",
|
||||
"rating-icons-level3": "\f23d",
|
||||
"rating-icons-level20": "\f23e",
|
||||
"rating-icons-level2": "\f23f",
|
||||
"rating-icons-level10": "\f240",
|
||||
"rating-icons-level1": "\f241",
|
||||
);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -26,6 +26,7 @@ export type FontIconName =
|
||||
| 'unlist'
|
||||
| 'unlist-outline'
|
||||
| 'unique-profile'
|
||||
| 'undo'
|
||||
| 'understood'
|
||||
| 'underlined'
|
||||
| 'unarchive'
|
||||
@ -89,6 +90,7 @@ export type FontIconName =
|
||||
| 'schedule'
|
||||
| 'saved-messages'
|
||||
| 'save-story'
|
||||
| 'rotate'
|
||||
| 'revote'
|
||||
| 'revenue-split'
|
||||
| 'reply'
|
||||
@ -101,6 +103,7 @@ export type FontIconName =
|
||||
| 'remove-quote'
|
||||
| 'reload'
|
||||
| 'refund'
|
||||
| 'redo'
|
||||
| 'recent'
|
||||
| 'readchats'
|
||||
| 'radial-badge'
|
||||
@ -190,6 +193,7 @@ export type FontIconName =
|
||||
| 'fontsize'
|
||||
| 'folder'
|
||||
| 'folder-badge'
|
||||
| 'flip'
|
||||
| 'flag'
|
||||
| 'file-badge'
|
||||
| 'favorite'
|
||||
@ -220,6 +224,7 @@ export type FontIconName =
|
||||
| 'crown-wear-outline'
|
||||
| 'crown-take-off'
|
||||
| 'crown-take-off-outline'
|
||||
| 'crop'
|
||||
| 'craft'
|
||||
| 'copy'
|
||||
| 'copy-media'
|
||||
@ -250,6 +255,7 @@ export type FontIconName =
|
||||
| 'calendar'
|
||||
| 'calendar-filter'
|
||||
| 'bug'
|
||||
| 'brush'
|
||||
| 'bots'
|
||||
| 'bot-commands-filled'
|
||||
| 'bot-command'
|
||||
@ -287,6 +293,14 @@ export type FontIconName =
|
||||
| 'add-one-badge'
|
||||
| 'add-filled'
|
||||
| 'active-sessions'
|
||||
| 'folder-tabs-user'
|
||||
| 'folder-tabs-star'
|
||||
| 'folder-tabs-group'
|
||||
| 'folder-tabs-folder'
|
||||
| 'folder-tabs-chats'
|
||||
| 'folder-tabs-chat'
|
||||
| 'folder-tabs-channel'
|
||||
| 'folder-tabs-bot'
|
||||
| 'rating-icons-negative'
|
||||
| 'rating-icons-level90'
|
||||
| 'rating-icons-level9'
|
||||
@ -305,12 +319,4 @@ export type FontIconName =
|
||||
| 'rating-icons-level20'
|
||||
| 'rating-icons-level2'
|
||||
| 'rating-icons-level10'
|
||||
| 'rating-icons-level1'
|
||||
| 'folder-tabs-user'
|
||||
| 'folder-tabs-star'
|
||||
| 'folder-tabs-group'
|
||||
| 'folder-tabs-folder'
|
||||
| 'folder-tabs-chats'
|
||||
| 'folder-tabs-chat'
|
||||
| 'folder-tabs-channel'
|
||||
| 'folder-tabs-bot';
|
||||
| 'rating-icons-level1';
|
||||
|
||||
23
src/types/language.d.ts
vendored
23
src/types/language.d.ts
vendored
@ -1783,6 +1783,29 @@ export interface LangPair {
|
||||
'GiftValueForSaleOnFragment': undefined;
|
||||
'GiftValueForSaleOnTelegram': undefined;
|
||||
'EmbeddedMessageNoCaption': undefined;
|
||||
'EditMedia': undefined;
|
||||
'Draw': undefined;
|
||||
'Crop': undefined;
|
||||
'Clear': undefined;
|
||||
'Undo': undefined;
|
||||
'Redo': undefined;
|
||||
'ResetCrop': undefined;
|
||||
'CustomColor': undefined;
|
||||
'Size': undefined;
|
||||
'Tool': undefined;
|
||||
'Pen': undefined;
|
||||
'Arrow': undefined;
|
||||
'Brush': undefined;
|
||||
'Neon': undefined;
|
||||
'Eraser': undefined;
|
||||
'AspectRatio': undefined;
|
||||
'Free': undefined;
|
||||
'Original': undefined;
|
||||
'Square': undefined;
|
||||
'HEX': undefined;
|
||||
'RGB': undefined;
|
||||
'Adjust': undefined;
|
||||
'Text': undefined;
|
||||
'ConfirmBuyGiftForTonDescription': undefined;
|
||||
'TitleGiftLocked': undefined;
|
||||
'QuickPreview': undefined;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user