Implement Media Editor (#6658)

Co-authored-by: Shahaf Antwarg <santwarg@gmail.com>
This commit is contained in:
Alexander Zinchuk 2026-03-05 12:43:29 +01:00
parent 340e842a20
commit 02a5a2a44f
37 changed files with 4666 additions and 623 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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}**.";

View File

@ -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));

View File

@ -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;

View File

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

View File

@ -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));

View File

@ -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}

View File

@ -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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -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');

View File

@ -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;
}

View File

@ -3060,6 +3060,9 @@ export interface ActionPayloads {
openCocoonModal: WithTabId | undefined;
closeCocoonModal: WithTabId | undefined;
requestMessageMediaEditor: WithTabId | undefined;
resetMessageMediaEditorRequest: WithTabId | undefined;
}
export interface RequiredActionPayloads {

View File

@ -1029,4 +1029,6 @@ export type TabState = {
shouldSaveAttachmentsCompression?: boolean;
isCocoonModalOpen?: boolean;
shouldOpenMessageMediaEditor?: boolean;
};

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -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';

View File

@ -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;