diff --git a/src/assets/font-icons/brush.svg b/src/assets/font-icons/brush.svg new file mode 100644 index 000000000..99adc1d2c --- /dev/null +++ b/src/assets/font-icons/brush.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/crop.svg b/src/assets/font-icons/crop.svg new file mode 100644 index 000000000..57d9a50ad --- /dev/null +++ b/src/assets/font-icons/crop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/flip.svg b/src/assets/font-icons/flip.svg new file mode 100644 index 000000000..e5a0d4f75 --- /dev/null +++ b/src/assets/font-icons/flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/redo.svg b/src/assets/font-icons/redo.svg new file mode 100644 index 000000000..c18e2dcdc --- /dev/null +++ b/src/assets/font-icons/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/rotate.svg b/src/assets/font-icons/rotate.svg new file mode 100644 index 000000000..62df18363 --- /dev/null +++ b/src/assets/font-icons/rotate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/undo.svg b/src/assets/font-icons/undo.svg new file mode 100644 index 000000000..f01dc1cb0 --- /dev/null +++ b/src/assets/font-icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index a42a86f63..ae945568b 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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}**."; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 04b93f4dd..8210e867a 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -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 = ({ +const Composer = ({ type, isOnActiveTab, dropAreaState, @@ -436,11 +444,13 @@ const Composer: FC = ({ isAppConfigLoaded, insertingPeerIdMention, pollMaxAnswers, + replyToMessage, + shouldOpenMessageMediaEditor, onDropHide, onFocus, onBlur, onForward, -}) => { +}: OwnProps & StateProps) => { const { sendMessage, clearDraft, @@ -695,6 +705,24 @@ const Composer: FC = ({ 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( 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( isAppConfigLoaded, insertingPeerIdMention, pollMaxAnswers: appConfig.pollMaxAnswers, + shouldOpenMessageMediaEditor, + replyToMessage, }; }, )(Composer)); diff --git a/src/components/common/embedded/EmbeddedMessage.scss b/src/components/common/embedded/EmbeddedMessage.scss index 39c786bb6..490b73190 100644 --- a/src/components/common/embedded/EmbeddedMessage.scss +++ b/src/components/common/embedded/EmbeddedMessage.scss @@ -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; diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index 7046ed07b..4a9eea144 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -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 = ({ +const EmbeddedMessage = ({ className, message, replyInfo, @@ -91,10 +90,12 @@ const EmbeddedMessage: FC = ({ requestedChatTranslationLanguage, isMediaNsfw, noCaptions, + pictogramActionIcon, observeIntersectionForLoading, observeIntersectionForPlaying, onClick, -}) => { + onPictogramClick, +}: OwnProps) => { const ref = useRef(); const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); @@ -144,8 +145,6 @@ const EmbeddedMessage: FC = ({ : 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 = ({ suggestedPostInfo && 'is-suggested-post', )} dir={lang.isRtl ? 'rtl' : undefined} - onClick={handleClick} - onMouseDown={handleMouseDown} + onClick={onClick} >
- {mediaThumbnail && renderPictogram( - mediaThumbnail, mediaBlobUrl, isVideoThumbnail, isRoundVideo, isProtected, isSpoiler, - )} + {mediaThumbnail && renderPictogram({ + thumbDataUri: mediaThumbnail, + blobUrl: mediaBlobUrl, + isFullVideo: isVideoThumbnail, + isRoundVideo, + isProtected, + isSpoiler, + pictogramActionIcon, + onPictogramClick, + })}

{renderTextContent()} @@ -337,21 +342,35 @@ const EmbeddedMessage: FC = ({ ); }; -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 ( -

+
{!isSpoiler && !shouldRenderVideo && ( {isProtected && } + {pictogramActionIcon && }
); } diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index cfc86a051..f3dd18081 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -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(); const svgRef = useRef(); - 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(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} /> ))}
@@ -789,6 +833,14 @@ const AttachmentModal = ({
+ ); }; @@ -802,7 +854,7 @@ export default memo(withGlobal( 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( captionLimit: selectCurrentLimit(global, 'captionLength'), attachmentSettings, shouldSaveAttachmentsCompression, + shouldOpenMessageMediaEditor, }; }, )(AttachmentModal)); diff --git a/src/components/middle/composer/AttachmentModalItem.tsx b/src/components/middle/composer/AttachmentModalItem.tsx index b656dbf8b..6e5ff6798 100644 --- a/src/components/middle/composer/AttachmentModalItem.tsx +++ b/src/components/middle/composer/AttachmentModalItem.tsx @@ -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 = ({ +const AttachmentModalItem = ({ attachment, className, isSingle, @@ -39,13 +40,19 @@ const AttachmentModalItem: FC = ({ 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 = ({ /> ); - default: + default: { + const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile; return ( <> = ({ previewData={attachment.previewBlobUrl} size={attachment.size} smaller + onClick={canEdit ? handleEditClick : undefined} + actionIcon={canEdit ? 'edit' : undefined} /> {onDelete && ( = ({ )} ); + } } - }, [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 = ({ /> {shouldRenderOverlay && (
+ {displayType === 'photo' && onEdit && ( + + )} void; + onClear?: NoneToVoidFunction; }; const CLOSE_DURATION = 350; @@ -108,10 +109,12 @@ const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => { exitForwardMode, setShouldPreventComposerAnimation, openSuggestMessageModal, + requestMessageMediaEditor, } = getActions(); const ref = useRef(); 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): 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} />
+ + {isColorPickerOpen && ( +
+
+
+
+
+ +
+ + +
+
+
+ )} + +
+ + {lang('Size')} + {brushSize} + + +
+ +
{lang('Tool')}
+
+ {DRAW_TOOLS.map((tool) => { + const iconClassName = buildClassName( + 'ListItem-main-icon', + styles.toolIcon, + drawTool === tool.id && styles.toolIconActive, + ); + return ( + onToolChange(tool.id)} + > + + + + + {lang(tool.labelKey)} + + + ); + })} +
+ + ); +} + +export default memo(DrawPanel); diff --git a/src/components/ui/mediaEditor/DrawToolSvgs.tsx b/src/components/ui/mediaEditor/DrawToolSvgs.tsx new file mode 100644 index 000000000..9ea88eabf --- /dev/null +++ b/src/components/ui/mediaEditor/DrawToolSvgs.tsx @@ -0,0 +1,277 @@ +/* eslint-disable @stylistic/max-len */ + +export function PenSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function ArrowSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function BrushSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function NeonSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function EraserSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/ui/mediaEditor/MediaEditor.module.scss b/src/components/ui/mediaEditor/MediaEditor.module.scss new file mode 100644 index 000000000..e60dfbe2e --- /dev/null +++ b/src/components/ui/mediaEditor/MediaEditor.module.scss @@ -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; + } +} diff --git a/src/components/ui/mediaEditor/MediaEditor.tsx b/src/components/ui/mediaEditor/MediaEditor.tsx new file mode 100644 index 000000000..93f4138c6 --- /dev/null +++ b/src/components/ui/mediaEditor/MediaEditor.tsx @@ -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: , +})); + +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(); + const canvasRef = useRef(); + const canvasAreaRef = useRef(); + const originalImageRef = useRef(undefined); + + const [mode, setMode] = useState(INITIAL_MODE); + const [isTransitioning, setIsTransitioning] = useState(false); + const [snapshotSrc, setSnapshotSrc] = useState(); + const [snapshotStyle, setSnapshotStyle] = useState(''); + const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + + const [cropState, setCropState] = useState(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([]); + const [redoStack, setRedoStack] = useState([]); + + const actionsRef = useRef([]); + const redoStackRef = useRef([]); + 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 ( + + ); + case 'draw': + return ( + + ); + 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 ( + +
+
+
+ + {snapshotSrc && ( + + )} + {shouldShowCropOverlay && !transformAnimType && displaySize.width > 0 && ( + + )} +
+ +
+
+
+ +
+
+ +
{lang('EditMedia')}
+
+
+
+ +
+ + {renderPanelContent()} + + +
+
+ + 0} + iconName="check" + className={styles.saveButton} + onClick={handleSave} + ariaLabel={lang('Save')} + /> +
+
+ ); +}; + +function getExtensionFromMimeType(mimeType: string): string { + return mimeType.split('/')[1]; +} + +export default memo(MediaEditor); diff --git a/src/components/ui/mediaEditor/RotationSlider.module.scss b/src/components/ui/mediaEditor/RotationSlider.module.scss new file mode 100644 index 000000000..ddfda16c6 --- /dev/null +++ b/src/components/ui/mediaEditor/RotationSlider.module.scss @@ -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; +} diff --git a/src/components/ui/mediaEditor/RotationSlider.tsx b/src/components/ui/mediaEditor/RotationSlider.tsx new file mode 100644 index 000000000..23aac1e5e --- /dev/null +++ b/src/components/ui/mediaEditor/RotationSlider.tsx @@ -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( + + {deg} + , + ); + } + + return ( +
+
+
+
+ {labels} +
+
+
+
+
+
+ ); +} + +export default memo(RotationSlider); diff --git a/src/components/ui/mediaEditor/canvasUtils.ts b/src/components/ui/mediaEditor/canvasUtils.ts new file mode 100644 index 000000000..70450263d --- /dev/null +++ b/src/components/ui/mediaEditor/canvasUtils.ts @@ -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); +} diff --git a/src/components/ui/mediaEditor/hooks/useCanvasRenderer.ts b/src/components/ui/mediaEditor/hooks/useCanvasRenderer.ts new file mode 100644 index 000000000..7919d4d0a --- /dev/null +++ b/src/components/ui/mediaEditor/hooks/useCanvasRenderer.ts @@ -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; + imageRef: React.RefObject; + 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(); + 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, + }; +} diff --git a/src/components/ui/mediaEditor/hooks/useColorPicker.ts b/src/components/ui/mediaEditor/hooks/useColorPicker.ts new file mode 100644 index 000000000..66b948039 --- /dev/null +++ b/src/components/ui/mediaEditor/hooks/useColorPicker.ts @@ -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(); + const satBrightRef = useRef(); + + const [selectedColor, setSelectedColor] = useState(initialColor); + const [isColorPickerOpen, openColorPicker, closeColorPicker] = useFlag(false); + const [pickerState, setPickerState] = useState(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) => { + 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) => { + 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, + }; +} diff --git a/src/components/ui/mediaEditor/hooks/useCropper.ts b/src/components/ui/mediaEditor/hooks/useCropper.ts new file mode 100644 index 000000000..c78c6e48c --- /dev/null +++ b/src/components/ui/mediaEditor/hooks/useCropper.ts @@ -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; + 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(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(); + + 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, + }; +} diff --git a/src/components/ui/mediaEditor/hooks/useDisplaySize.ts b/src/components/ui/mediaEditor/hooks/useDisplaySize.ts new file mode 100644 index 000000000..31f4a5dc4 --- /dev/null +++ b/src/components/ui/mediaEditor/hooks/useDisplaySize.ts @@ -0,0 +1,71 @@ +import type { ElementRef } from '@teact'; +import { useEffect, useState } from '@teact'; + +import useLastCallback from '../../../../hooks/useLastCallback'; + +interface UseDisplaySizeOptions { + canvasAreaRef: ElementRef; + 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, + }; +} diff --git a/src/components/ui/mediaEditor/hooks/useDrawing.ts b/src/components/ui/mediaEditor/hooks/useDrawing.ts new file mode 100644 index 000000000..350945840 --- /dev/null +++ b/src/components/ui/mediaEditor/hooks/useDrawing.ts @@ -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('pen'); + const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE); + const [currentDrawAction, setCurrentDrawAction] = useState(undefined); + const [isDrawing, markDrawing, unmarkDrawing] = useFlag(false); + const lastCompletedActionRef = useRef(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; diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 64ac3d2c9..316612e7f 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -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'); diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index 7b5f1736d..f34b2c1dc 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -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; } diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 306a8a2a3..8d27ecb30 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -3060,6 +3060,9 @@ export interface ActionPayloads { openCocoonModal: WithTabId | undefined; closeCocoonModal: WithTabId | undefined; + + requestMessageMediaEditor: WithTabId | undefined; + resetMessageMediaEditorRequest: WithTabId | undefined; } export interface RequiredActionPayloads { diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index afc4e73d0..181e63f45 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -1029,4 +1029,6 @@ export type TabState = { shouldSaveAttachmentsCompression?: boolean; isCocoonModalOpen?: boolean; + + shouldOpenMessageMediaEditor?: boolean; }; diff --git a/src/styles/icons.css b/src/styles/icons.css index 83cf54e09..38f2e1352 100644 --- a/src/styles/icons.css +++ b/src/styles/icons.css @@ -3,8 +3,8 @@ font-weight: normal; font-style: normal; font-display: block; - src: url("./icons.woff2?9bd1ca23f0a305a032d8f27f3b31a6cc") format("woff2"), -url("./icons.woff?9bd1ca23f0a305a032d8f27f3b31a6cc") format("woff"); + src: url("./icons.woff2?d1a9cacb64e401206f928a39a587a2bd") format("woff2"), +url("./icons.woff?d1a9cacb64e401206f928a39a587a2bd") format("woff"); } .icon-char::before { @@ -102,867 +102,885 @@ url("./icons.woff?9bd1ca23f0a305a032d8f27f3b31a6cc") format("woff"); .icon-unique-profile::before { content: "\f11b"; } -.icon-understood::before { +.icon-undo::before { content: "\f11c"; } -.icon-underlined::before { +.icon-understood::before { content: "\f11d"; } -.icon-unarchive::before { +.icon-underlined::before { content: "\f11e"; } -.icon-truck::before { +.icon-unarchive::before { content: "\f11f"; } -.icon-transcribe::before { +.icon-truck::before { content: "\f120"; } -.icon-trade::before { +.icon-transcribe::before { content: "\f121"; } -.icon-topic-new::before { +.icon-trade::before { content: "\f122"; } -.icon-tools::before { +.icon-topic-new::before { content: "\f123"; } -.icon-toncoin::before { +.icon-tools::before { content: "\f124"; } -.icon-timer::before { +.icon-toncoin::before { content: "\f125"; } -.icon-tag::before { +.icon-timer::before { content: "\f126"; } -.icon-tag-name::before { +.icon-tag::before { content: "\f127"; } -.icon-tag-filter::before { +.icon-tag-name::before { content: "\f128"; } -.icon-tag-crossed::before { +.icon-tag-filter::before { content: "\f129"; } -.icon-tag-add::before { +.icon-tag-crossed::before { content: "\f12a"; } -.icon-strikethrough::before { +.icon-tag-add::before { content: "\f12b"; } -.icon-story-reply::before { +.icon-strikethrough::before { content: "\f12c"; } -.icon-story-priority::before { +.icon-story-reply::before { content: "\f12d"; } -.icon-story-expired::before { +.icon-story-priority::before { content: "\f12e"; } -.icon-story-caption::before { +.icon-story-expired::before { content: "\f12f"; } -.icon-stop::before { +.icon-story-caption::before { content: "\f130"; } -.icon-stop-raising-hand::before { +.icon-stop::before { content: "\f131"; } -.icon-stickers::before { +.icon-stop-raising-hand::before { content: "\f132"; } -.icon-stealth-past::before { +.icon-stickers::before { content: "\f133"; } -.icon-stealth-future::before { +.icon-stealth-past::before { content: "\f134"; } -.icon-stats::before { +.icon-stealth-future::before { content: "\f135"; } -.icon-stars-refund::before { +.icon-stats::before { content: "\f136"; } -.icon-stars-lock::before { +.icon-stars-refund::before { content: "\f137"; } -.icon-star::before { +.icon-stars-lock::before { content: "\f138"; } -.icon-sport::before { +.icon-star::before { content: "\f139"; } -.icon-spoiler::before { +.icon-sport::before { content: "\f13a"; } -.icon-spoiler-disable::before { +.icon-spoiler::before { content: "\f13b"; } -.icon-speaker::before { +.icon-spoiler-disable::before { content: "\f13c"; } -.icon-speaker-story::before { +.icon-speaker::before { content: "\f13d"; } -.icon-speaker-outline::before { +.icon-speaker-story::before { content: "\f13e"; } -.icon-speaker-muted-story::before { +.icon-speaker-outline::before { content: "\f13f"; } -.icon-sort::before { +.icon-speaker-muted-story::before { content: "\f140"; } -.icon-sort-by-price::before { +.icon-sort::before { content: "\f141"; } -.icon-sort-by-number::before { +.icon-sort-by-price::before { content: "\f142"; } -.icon-sort-by-date::before { +.icon-sort-by-number::before { content: "\f143"; } -.icon-smile::before { +.icon-sort-by-date::before { content: "\f144"; } -.icon-smallscreen::before { +.icon-smile::before { content: "\f145"; } -.icon-skip-previous::before { +.icon-smallscreen::before { content: "\f146"; } -.icon-skip-next::before { +.icon-skip-previous::before { content: "\f147"; } -.icon-sidebar::before { +.icon-skip-next::before { content: "\f148"; } -.icon-show-message::before { +.icon-sidebar::before { content: "\f149"; } -.icon-share-screen::before { +.icon-show-message::before { content: "\f14a"; } -.icon-share-screen-stop::before { +.icon-share-screen::before { content: "\f14b"; } -.icon-share-screen-outlined::before { +.icon-share-screen-stop::before { content: "\f14c"; } -.icon-share-filled::before { +.icon-share-screen-outlined::before { content: "\f14d"; } -.icon-settings::before { +.icon-share-filled::before { content: "\f14e"; } -.icon-settings-filled::before { +.icon-settings::before { content: "\f14f"; } -.icon-send::before { +.icon-settings-filled::before { content: "\f150"; } -.icon-send-outline::before { +.icon-send::before { content: "\f151"; } -.icon-sell::before { +.icon-send-outline::before { content: "\f152"; } -.icon-sell-outline::before { +.icon-sell::before { content: "\f153"; } -.icon-select::before { +.icon-sell-outline::before { content: "\f154"; } -.icon-search::before { +.icon-select::before { content: "\f155"; } -.icon-sd-photo::before { +.icon-search::before { content: "\f156"; } -.icon-scheduled::before { +.icon-sd-photo::before { content: "\f157"; } -.icon-schedule::before { +.icon-scheduled::before { content: "\f158"; } -.icon-saved-messages::before { +.icon-schedule::before { content: "\f159"; } -.icon-save-story::before { +.icon-saved-messages::before { content: "\f15a"; } -.icon-revote::before { +.icon-save-story::before { content: "\f15b"; } -.icon-revenue-split::before { +.icon-rotate::before { content: "\f15c"; } -.icon-reply::before { +.icon-revote::before { content: "\f15d"; } -.icon-reply-filled::before { +.icon-revenue-split::before { content: "\f15e"; } -.icon-replies::before { +.icon-reply::before { content: "\f15f"; } -.icon-replace::before { +.icon-reply-filled::before { content: "\f160"; } -.icon-reorder-tabs::before { +.icon-replies::before { content: "\f161"; } -.icon-reopen-topic::before { +.icon-replace::before { content: "\f162"; } -.icon-remove::before { +.icon-reorder-tabs::before { content: "\f163"; } -.icon-remove-quote::before { +.icon-reopen-topic::before { content: "\f164"; } -.icon-reload::before { +.icon-remove::before { content: "\f165"; } -.icon-refund::before { +.icon-remove-quote::before { content: "\f166"; } -.icon-recent::before { +.icon-reload::before { content: "\f167"; } -.icon-readchats::before { +.icon-refund::before { content: "\f168"; } -.icon-radial-badge::before { +.icon-redo::before { content: "\f169"; } -.icon-quote::before { +.icon-recent::before { content: "\f16a"; } -.icon-quote-text::before { +.icon-readchats::before { content: "\f16b"; } -.icon-proof-of-ownership::before { +.icon-radial-badge::before { content: "\f16c"; } -.icon-privacy-policy::before { +.icon-quote::before { content: "\f16d"; } -.icon-previous::before { +.icon-quote-text::before { content: "\f16e"; } -.icon-poll::before { +.icon-proof-of-ownership::before { content: "\f16f"; } -.icon-play::before { +.icon-privacy-policy::before { content: "\f170"; } -.icon-play-story::before { +.icon-previous::before { content: "\f171"; } -.icon-pip::before { +.icon-poll::before { content: "\f172"; } -.icon-pinned-message::before { +.icon-play::before { content: "\f173"; } -.icon-pinned-chat::before { +.icon-play-story::before { content: "\f174"; } -.icon-pin::before { +.icon-pip::before { content: "\f175"; } -.icon-pin-list::before { +.icon-pinned-message::before { content: "\f176"; } -.icon-pin-badge::before { +.icon-pinned-chat::before { content: "\f177"; } -.icon-photo::before { +.icon-pin::before { content: "\f178"; } -.icon-phone::before { +.icon-pin-list::before { content: "\f179"; } -.icon-phone-discard::before { +.icon-pin-badge::before { content: "\f17a"; } -.icon-phone-discard-outline::before { +.icon-photo::before { content: "\f17b"; } -.icon-permissions::before { +.icon-phone::before { content: "\f17c"; } -.icon-pause::before { +.icon-phone-discard::before { content: "\f17d"; } -.icon-password-off::before { +.icon-phone-discard-outline::before { content: "\f17e"; } -.icon-open-in-new-tab::before { +.icon-permissions::before { content: "\f17f"; } -.icon-one-filled::before { +.icon-pause::before { content: "\f180"; } -.icon-note::before { +.icon-password-off::before { content: "\f181"; } -.icon-non-contacts::before { +.icon-open-in-new-tab::before { content: "\f182"; } -.icon-noise-suppression::before { +.icon-one-filled::before { content: "\f183"; } -.icon-nochannel::before { +.icon-note::before { content: "\f184"; } -.icon-next::before { +.icon-non-contacts::before { content: "\f185"; } -.icon-next-link::before { +.icon-noise-suppression::before { content: "\f186"; } -.icon-new-chat-filled::before { +.icon-nochannel::before { content: "\f187"; } -.icon-my-notes::before { +.icon-next::before { content: "\f188"; } -.icon-muted::before { +.icon-next-link::before { content: "\f189"; } -.icon-mute::before { +.icon-new-chat-filled::before { content: "\f18a"; } -.icon-move-caption-up::before { +.icon-my-notes::before { content: "\f18b"; } -.icon-move-caption-down::before { +.icon-muted::before { content: "\f18c"; } -.icon-more::before { +.icon-mute::before { content: "\f18d"; } -.icon-more-circle::before { +.icon-move-caption-up::before { content: "\f18e"; } -.icon-monospace::before { +.icon-move-caption-down::before { content: "\f18f"; } -.icon-microphone::before { +.icon-more::before { content: "\f190"; } -.icon-microphone-alt::before { +.icon-more-circle::before { content: "\f191"; } -.icon-message::before { +.icon-monospace::before { content: "\f192"; } -.icon-message-succeeded::before { +.icon-microphone::before { content: "\f193"; } -.icon-message-read::before { +.icon-microphone-alt::before { content: "\f194"; } -.icon-message-pending::before { +.icon-message::before { content: "\f195"; } -.icon-message-failed::before { +.icon-message-succeeded::before { content: "\f196"; } -.icon-menu::before { +.icon-message-read::before { content: "\f197"; } -.icon-mention::before { +.icon-message-pending::before { content: "\f198"; } -.icon-loop::before { +.icon-message-failed::before { content: "\f199"; } -.icon-logout::before { +.icon-menu::before { content: "\f19a"; } -.icon-lock::before { +.icon-mention::before { content: "\f19b"; } -.icon-lock-badge::before { +.icon-loop::before { content: "\f19c"; } -.icon-location::before { +.icon-logout::before { content: "\f19d"; } -.icon-link::before { +.icon-lock::before { content: "\f19e"; } -.icon-link-broken::before { +.icon-lock-badge::before { content: "\f19f"; } -.icon-link-badge::before { +.icon-location::before { content: "\f1a0"; } -.icon-large-play::before { +.icon-link::before { content: "\f1a1"; } -.icon-large-pause::before { +.icon-link-broken::before { content: "\f1a2"; } -.icon-language::before { +.icon-link-badge::before { content: "\f1a3"; } -.icon-lamp::before { +.icon-large-play::before { content: "\f1a4"; } -.icon-keyboard::before { +.icon-large-pause::before { content: "\f1a5"; } -.icon-key::before { +.icon-language::before { content: "\f1a6"; } -.icon-italic::before { +.icon-lamp::before { content: "\f1a7"; } -.icon-install::before { +.icon-keyboard::before { content: "\f1a8"; } -.icon-info::before { +.icon-key::before { content: "\f1a9"; } -.icon-info-filled::before { +.icon-italic::before { content: "\f1aa"; } -.icon-help::before { +.icon-install::before { content: "\f1ab"; } -.icon-heart::before { +.icon-info::before { content: "\f1ac"; } -.icon-heart-outline::before { +.icon-info-filled::before { content: "\f1ad"; } -.icon-hd-photo::before { +.icon-help::before { content: "\f1ae"; } -.icon-hashtag::before { +.icon-heart::before { content: "\f1af"; } -.icon-hand-stop::before { +.icon-heart-outline::before { content: "\f1b0"; } -.icon-grouped::before { +.icon-hd-photo::before { content: "\f1b1"; } -.icon-grouped-disable::before { +.icon-hashtag::before { content: "\f1b2"; } -.icon-group::before { +.icon-hand-stop::before { content: "\f1b3"; } -.icon-group-filled::before { +.icon-grouped::before { content: "\f1b4"; } -.icon-gift::before { +.icon-grouped-disable::before { content: "\f1b5"; } -.icon-gift-transfer-inline::before { +.icon-group::before { content: "\f1b6"; } -.icon-gifs::before { +.icon-group-filled::before { content: "\f1b7"; } -.icon-fullscreen::before { +.icon-gift::before { content: "\f1b8"; } -.icon-frozen-time::before { +.icon-gift-transfer-inline::before { content: "\f1b9"; } -.icon-fragment::before { +.icon-gifs::before { content: "\f1ba"; } -.icon-forward::before { +.icon-fullscreen::before { content: "\f1bb"; } -.icon-forums::before { +.icon-frozen-time::before { content: "\f1bc"; } -.icon-fontsize::before { +.icon-fragment::before { content: "\f1bd"; } -.icon-folder::before { +.icon-forward::before { content: "\f1be"; } -.icon-folder-badge::before { +.icon-forums::before { content: "\f1bf"; } -.icon-flag::before { +.icon-fontsize::before { content: "\f1c0"; } -.icon-file-badge::before { +.icon-folder::before { content: "\f1c1"; } -.icon-favorite::before { +.icon-folder-badge::before { content: "\f1c2"; } -.icon-favorite-filled::before { +.icon-flip::before { content: "\f1c3"; } -.icon-eye::before { +.icon-flag::before { content: "\f1c4"; } -.icon-eye-outline::before { +.icon-file-badge::before { content: "\f1c5"; } -.icon-eye-crossed::before { +.icon-favorite::before { content: "\f1c6"; } -.icon-eye-crossed-outline::before { +.icon-favorite-filled::before { content: "\f1c7"; } -.icon-expand::before { +.icon-eye::before { content: "\f1c8"; } -.icon-expand-modal::before { +.icon-eye-outline::before { content: "\f1c9"; } -.icon-enter::before { +.icon-eye-crossed::before { content: "\f1ca"; } -.icon-email::before { +.icon-eye-crossed-outline::before { content: "\f1cb"; } -.icon-edit::before { +.icon-expand::before { content: "\f1cc"; } -.icon-eats::before { +.icon-expand-modal::before { content: "\f1cd"; } -.icon-dropdown-arrows::before { +.icon-enter::before { content: "\f1ce"; } -.icon-download::before { +.icon-email::before { content: "\f1cf"; } -.icon-down::before { +.icon-edit::before { content: "\f1d0"; } -.icon-double-badge::before { +.icon-eats::before { content: "\f1d1"; } -.icon-document::before { +.icon-dropdown-arrows::before { content: "\f1d2"; } -.icon-diamond::before { +.icon-download::before { content: "\f1d3"; } -.icon-delete::before { +.icon-down::before { content: "\f1d4"; } -.icon-delete-user::before { +.icon-double-badge::before { content: "\f1d5"; } -.icon-delete-left::before { +.icon-document::before { content: "\f1d6"; } -.icon-delete-filled::before { +.icon-diamond::before { content: "\f1d7"; } -.icon-data::before { +.icon-delete::before { content: "\f1d8"; } -.icon-darkmode::before { +.icon-delete-user::before { content: "\f1d9"; } -.icon-crown-wear::before { +.icon-delete-left::before { content: "\f1da"; } -.icon-crown-wear-outline::before { +.icon-delete-filled::before { content: "\f1db"; } -.icon-crown-take-off::before { +.icon-data::before { content: "\f1dc"; } -.icon-crown-take-off-outline::before { +.icon-darkmode::before { content: "\f1dd"; } -.icon-craft::before { +.icon-crown-wear::before { content: "\f1de"; } -.icon-copy::before { +.icon-crown-wear-outline::before { content: "\f1df"; } -.icon-copy-media::before { +.icon-crown-take-off::before { content: "\f1e0"; } -.icon-comments::before { +.icon-crown-take-off-outline::before { content: "\f1e1"; } -.icon-comments-sticker::before { +.icon-crop::before { content: "\f1e2"; } -.icon-combine-craft::before { +.icon-craft::before { content: "\f1e3"; } -.icon-colorize::before { +.icon-copy::before { content: "\f1e4"; } -.icon-collapse::before { +.icon-copy-media::before { content: "\f1e5"; } -.icon-collapse-modal::before { +.icon-comments::before { content: "\f1e6"; } -.icon-cloud-download::before { +.icon-comments-sticker::before { content: "\f1e7"; } -.icon-closed-gift::before { +.icon-combine-craft::before { content: "\f1e8"; } -.icon-close::before { +.icon-colorize::before { content: "\f1e9"; } -.icon-close-topic::before { +.icon-collapse::before { content: "\f1ea"; } -.icon-close-circle::before { +.icon-collapse-modal::before { content: "\f1eb"; } -.icon-clock::before { +.icon-cloud-download::before { content: "\f1ec"; } -.icon-clock-edit::before { +.icon-closed-gift::before { content: "\f1ed"; } -.icon-check::before { +.icon-close::before { content: "\f1ee"; } -.icon-chats-badge::before { +.icon-close-topic::before { content: "\f1ef"; } -.icon-chat-badge::before { +.icon-close-circle::before { content: "\f1f0"; } -.icon-channelviews::before { +.icon-clock::before { content: "\f1f1"; } -.icon-channel::before { +.icon-clock-edit::before { content: "\f1f2"; } -.icon-channel-filled::before { +.icon-check::before { content: "\f1f3"; } -.icon-cash-circle::before { +.icon-chats-badge::before { content: "\f1f4"; } -.icon-card::before { +.icon-chat-badge::before { content: "\f1f5"; } -.icon-car::before { +.icon-channelviews::before { content: "\f1f6"; } -.icon-camera::before { +.icon-channel::before { content: "\f1f7"; } -.icon-camera-add::before { +.icon-channel-filled::before { content: "\f1f8"; } -.icon-calendar::before { +.icon-cash-circle::before { content: "\f1f9"; } -.icon-calendar-filter::before { +.icon-card::before { content: "\f1fa"; } -.icon-bug::before { +.icon-car::before { content: "\f1fb"; } -.icon-bots::before { +.icon-camera::before { content: "\f1fc"; } -.icon-bot-commands-filled::before { +.icon-camera-add::before { content: "\f1fd"; } -.icon-bot-command::before { +.icon-calendar::before { content: "\f1fe"; } -.icon-boosts::before { +.icon-calendar-filter::before { content: "\f1ff"; } -.icon-boostcircle::before { +.icon-bug::before { content: "\f200"; } -.icon-boost::before { +.icon-brush::before { content: "\f201"; } -.icon-boost-outline::before { +.icon-bots::before { content: "\f202"; } -.icon-boost-craft-chance::before { +.icon-bot-commands-filled::before { content: "\f203"; } -.icon-bold::before { +.icon-bot-command::before { content: "\f204"; } -.icon-avatar-saved-messages::before { +.icon-boosts::before { content: "\f205"; } -.icon-avatar-deleted-account::before { +.icon-boostcircle::before { content: "\f206"; } -.icon-avatar-archived-chats::before { +.icon-boost::before { content: "\f207"; } -.icon-author-hidden::before { +.icon-boost-outline::before { content: "\f208"; } -.icon-auction::before { +.icon-boost-craft-chance::before { content: "\f209"; } -.icon-auction-next-round::before { +.icon-bold::before { content: "\f20a"; } -.icon-auction-filled::before { +.icon-avatar-saved-messages::before { content: "\f20b"; } -.icon-auction-drop::before { +.icon-avatar-deleted-account::before { content: "\f20c"; } -.icon-attach::before { +.icon-avatar-archived-chats::before { content: "\f20d"; } -.icon-ask-support::before { +.icon-author-hidden::before { content: "\f20e"; } -.icon-arrow-right::before { +.icon-auction::before { content: "\f20f"; } -.icon-arrow-left::before { +.icon-auction-next-round::before { content: "\f210"; } -.icon-arrow-down::before { +.icon-auction-filled::before { content: "\f211"; } -.icon-arrow-down-circle::before { +.icon-auction-drop::before { content: "\f212"; } -.icon-archive::before { +.icon-attach::before { content: "\f213"; } -.icon-archive-to-main::before { +.icon-ask-support::before { content: "\f214"; } -.icon-archive-from-main::before { +.icon-arrow-right::before { content: "\f215"; } -.icon-archive-filled::before { +.icon-arrow-left::before { content: "\f216"; } -.icon-animations::before { +.icon-arrow-down::before { content: "\f217"; } -.icon-animals::before { +.icon-arrow-down-circle::before { content: "\f218"; } -.icon-allow-speak::before { +.icon-archive::before { content: "\f219"; } -.icon-admin::before { +.icon-archive-to-main::before { content: "\f21a"; } -.icon-add::before { +.icon-archive-from-main::before { content: "\f21b"; } -.icon-add-user::before { +.icon-archive-filled::before { content: "\f21c"; } -.icon-add-user-filled::before { +.icon-animations::before { content: "\f21d"; } -.icon-add-one-badge::before { +.icon-animals::before { content: "\f21e"; } -.icon-add-filled::before { +.icon-allow-speak::before { content: "\f21f"; } -.icon-active-sessions::before { +.icon-admin::before { content: "\f220"; } -.icon-rating-icons-negative::before { +.icon-add::before { content: "\f221"; } -.icon-rating-icons-level90::before { +.icon-add-user::before { content: "\f222"; } -.icon-rating-icons-level9::before { +.icon-add-user-filled::before { content: "\f223"; } -.icon-rating-icons-level80::before { +.icon-add-one-badge::before { content: "\f224"; } -.icon-rating-icons-level8::before { +.icon-add-filled::before { content: "\f225"; } -.icon-rating-icons-level70::before { +.icon-active-sessions::before { content: "\f226"; } -.icon-rating-icons-level7::before { +.icon-folder-tabs-user::before { content: "\f227"; } -.icon-rating-icons-level60::before { +.icon-folder-tabs-star::before { content: "\f228"; } -.icon-rating-icons-level6::before { +.icon-folder-tabs-group::before { content: "\f229"; } -.icon-rating-icons-level50::before { +.icon-folder-tabs-folder::before { content: "\f22a"; } -.icon-rating-icons-level5::before { +.icon-folder-tabs-chats::before { content: "\f22b"; } -.icon-rating-icons-level40::before { +.icon-folder-tabs-chat::before { content: "\f22c"; } -.icon-rating-icons-level4::before { +.icon-folder-tabs-channel::before { content: "\f22d"; } -.icon-rating-icons-level30::before { +.icon-folder-tabs-bot::before { content: "\f22e"; } -.icon-rating-icons-level3::before { +.icon-rating-icons-negative::before { content: "\f22f"; } -.icon-rating-icons-level20::before { +.icon-rating-icons-level90::before { content: "\f230"; } -.icon-rating-icons-level2::before { +.icon-rating-icons-level9::before { content: "\f231"; } -.icon-rating-icons-level10::before { +.icon-rating-icons-level80::before { content: "\f232"; } -.icon-rating-icons-level1::before { +.icon-rating-icons-level8::before { content: "\f233"; } -.icon-folder-tabs-user::before { +.icon-rating-icons-level70::before { content: "\f234"; } -.icon-folder-tabs-star::before { +.icon-rating-icons-level7::before { content: "\f235"; } -.icon-folder-tabs-group::before { +.icon-rating-icons-level60::before { content: "\f236"; } -.icon-folder-tabs-folder::before { +.icon-rating-icons-level6::before { content: "\f237"; } -.icon-folder-tabs-chats::before { +.icon-rating-icons-level50::before { content: "\f238"; } -.icon-folder-tabs-chat::before { +.icon-rating-icons-level5::before { content: "\f239"; } -.icon-folder-tabs-channel::before { +.icon-rating-icons-level40::before { content: "\f23a"; } -.icon-folder-tabs-bot::before { +.icon-rating-icons-level4::before { content: "\f23b"; } +.icon-rating-icons-level30::before { + content: "\f23c"; +} +.icon-rating-icons-level3::before { + content: "\f23d"; +} +.icon-rating-icons-level20::before { + content: "\f23e"; +} +.icon-rating-icons-level2::before { + content: "\f23f"; +} +.icon-rating-icons-level10::before { + content: "\f240"; +} +.icon-rating-icons-level1::before { + content: "\f241"; +} diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 46f0a6232..de98a69b0 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -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", ); diff --git a/src/styles/icons.woff b/src/styles/icons.woff index ecabd7d7c..7534a641d 100644 Binary files a/src/styles/icons.woff and b/src/styles/icons.woff differ diff --git a/src/styles/icons.woff2 b/src/styles/icons.woff2 index 8e9dee834..1f50c0b46 100644 Binary files a/src/styles/icons.woff2 and b/src/styles/icons.woff2 differ diff --git a/src/types/icons/font.ts b/src/types/icons/font.ts index a98c8ec7a..fd6833b73 100644 --- a/src/types/icons/font.ts +++ b/src/types/icons/font.ts @@ -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'; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index a0ae21838..4f880dde8 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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;