@@ -4,4 +4,3 @@ export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' export const LOW_BALANCE_OFFER = 'low-balance-offer' export const PLATE_ADD_VOICE = 'plate-add-voice' -export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' @@ -1,44 +0,0 @@ -.container { - width: 100%; - max-width: 920px; - padding: 32px 0 12px 0px; - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.text_heading { - font-weight: 800; - font-size: 56px; - margin-bottom: 16px; -} - -.text { - color: #ffffff; - font-size: 18px; - font-weight: 500; - margin: 0 0 24px; -} - -.highlight { - color: #7F7DF3; -} - -.buttons { - display: flex; - justify-content: flex-end; - flex-direction: row; - align-items: flex-end; - margin-top: 12px; - gap: 8px; -} - -.buttonSecondary { - background-color: #2A2B30; - color: #ffffff; - border: none; - border-radius: 12px; - padding: 15px 25px; - cursor: pointer; - text-transform: none; -} @@ -1,68 +0,0 @@ -import React from 'react' - -import { getModalById, PlateTemplate, SUBSCRIPTION_CHANGE_NOTIFICATION } from '#/features/modals' -import { c } from '#/shared' -import { CommonButton } from '#/shared/ui/button' - -import styles from './subscription-change-notification-plate.module.scss' - -const formatTokens = (num: number) => num.toLocaleString('ru-RU') - -export const SubscriptionChangeNotificationPlate = () => { - const modal = getModalById(SUBSCRIPTION_CHANGE_NOTIFICATION) - - const fromTokens = modal.getStoreProperty('fromTokens') ?? 0 - const toTokens = modal.getStoreProperty('toTokens') ?? 0 - const isUpgrade = modal.getStoreProperty('isUpgrade') ?? false - const actionType = modal.getStoreProperty<'topup' | 'upgrade'>('actionType') - const tokensToAdd = modal.getStoreProperty('tokensToAdd') - const purchasePrice = modal.getStoreProperty('purchasePrice') - - const handleClose = () => { - modal.setState(false) - } - - const handleConfirm = () => { - modal.getStoreProperty<() => void>('onConfirm')?.() - modal.setState(false) - } - - return ( - -
e.stopPropagation()}> -

Смена подписки

- - {(actionType === 'topup' || actionType === 'upgrade') && tokensToAdd != null && purchasePrice != null ? ( -

- Вы докупите {formatTokens(tokensToAdd)} токенов за{' '} - {formatTokens(purchasePrice)} руб. Продолжить? -

- ) : !isUpgrade ? ( -

- Вы собираетесь перейти на подписку{' '} - {formatTokens(toTokens)} токенов. -
- Обратите внимание! Все неиспользованные токены сгорят после перехода на подписку дешевле. -
- Вы можете использовать токены, а затем перейти на подписку дешевле. -
- Продолжить? -

- ) : ( -

- Уведомляем вас, что при смене подписки часть токенов сверх лимита текущей подписки сгорит и не перенесётся на новый план. Продолжить? -

- )} - -
- - {fromTokens !== toTokens ? 'Оформить подписку' : 'Подтвердить'} - - - Отмена - -
-
-
- ) -} @@ -1,5 +0,0 @@ -import { SubscriptionChangeNotificationPlate } from './subscription-change-notification-plate' - -export const SubscriptionChangeNotificationTrigger = () => { - return -} @@ -1,2 +0,0 @@ -export { SubscriptionChangeNotificationPlate } from './ui/subscription-change-notification-plate' -export { SubscriptionChangeNotificationTrigger } from './ui/subscription-change-notification-trigger' @@ -12,7 +12,6 @@ import { FlagProvider } from '@unleash/proxy-client-react' import { store } from '#/app/store/store' import { Error } from '#/shared' import { LowBalanceOfferTrigger } from '#/features/low-balance-offer' -import { SubscriptionChangeNotificationTrigger } from '#/features/subscription-change-notification' import { TourManager, getTourSteps, TourCard, setTourCompleted } from '#/features/nextstep-tour' import { pingFangFont } from '#/shared/lib/constants/font/font' import { getDeviceType } from '#/shared/lib/helpers' @@ -76,7 +75,6 @@ function AppContent({ {Component.getLayout ? Component.getLayout() : } - @@ -41,3 +41,4 @@ export const Modal: FC = ({ open, onClose, children, paperSx, wide = ) } + \ No newline at end of file @@ -18,13 +18,22 @@ function declineDays(days: number): string { export const SubscriptionDaysBadge = ({ days }: SubscriptionDaysBadgeProps) => { const normalizedDays = Number.isFinite(days) ? Math.max(0, Math.trunc(days)) : 0 - const isExpiring = normalizedDays <= 3; + const isExpiring = normalizedDays <= 3 return (
- Истечет через {normalizedDays} {declineDays(normalizedDays)} + {normalizedDays === 0 ? ( + <>Истекает сегодня + ) : ( + <> + Истечет через{' '} + + {normalizedDays} {declineDays(normalizedDays)} + + + )}
) @@ -44,6 +44,7 @@ export const CurrentPlanAndBalance = ({ nextPaymentAt, }: CurrentPlanAndBalanceProps) => { const formatterdPlanPrice = formatPlanPrice(planPrice, isIndividual) + const isDemoPlan = !formatterdPlanPrice.includes('₽') const nextPaymentFormatted = formatNextPaymentLabel(nextPaymentAt) const daysLeft = getDaysLeft(nextPaymentAt) const nextPaymentLabel = nextPaymentFormatted @@ -69,7 +70,7 @@ export const CurrentPlanAndBalance = ({ {commaSeparated(Math.round(Number(planTokenLimit)))} токенов - {formatterdPlanPrice} / мес + {isDemoPlan ? 'Демо доступ' : `${formatterdPlanPrice} / мес`} {nextPaymentLabel} @@ -3,11 +3,8 @@ import { Box } from '@mui/material' import Router from 'next/router' import { useSession } from 'next-auth/react' -import { useAppSelector } from '#/app/store/store' -import { balanceSelector } from '#/shared/lib/selectors' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import LightningSvg from '#/assets/svg/lightning.svg?react' -import { getModalById, SUBSCRIPTION_CHANGE_NOTIFICATION } from '#/features/modals' import { accountApi } from '#/shared/api/account-endpoints' import { commaSeparated } from '#/shared/lib/helpers' import { IFeatures, IOffer, formatFeatureValue } from '#/views/subscription' @@ -23,9 +20,6 @@ interface IOfferProps extends IOffer { const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_features, isCurrentSubscription, index, currentPlanTokens = 0 }) => { const { data } = useSession() - const isRecurring = useAppSelector((state) => state.user.payment_plan?.is_recurring) ?? false - const balance = useAppSelector(balanceSelector) - const currentPlanPrice = Number(useAppSelector((state) => state.user.payment_plan?.plan?.price) ?? 0) const { showMessage } = useShowDataStore() const unitMap: Record = { @@ -60,7 +54,6 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea const formattedFeatures = formatFeatures(grouped_features) - const modal = getModalById(SUBSCRIPTION_CHANGE_NOTIFICATION) const pay = async (planUid: string) => { try { @@ -73,86 +66,8 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea } } - const isFreePlan = currentPlanTokens <= 10 - - const toTokens = Number(tokens_per_plan) - const isCurrentPlan = Boolean(isCurrentSubscription) - const isUpgrade = !isCurrentPlan && currentPlanTokens < toTokens - const isDowngrade = !isCurrentPlan && currentPlanTokens > toTokens - const balanceExceedsLimit = balance > currentPlanTokens - const isTopUpCurrentPlan = isCurrentPlan - - const openPurchaseModal = (planUid: string, targetPlanTokens: number, actionType: 'topup' | 'upgrade') => { - const newPlanPrice = Number(price) || 0 - const spentTokens = Math.max(currentPlanTokens - balance, 0) - const tokensToAdd = Math.max(targetPlanTokens - balance, 0) - const purchasePrice = Math.round( - newPlanPrice - currentPlanPrice + (currentPlanPrice / Math.max(currentPlanTokens, 1)) * spentTokens - ) - - modal.setState(true, { - fromTokens: currentPlanTokens, - toTokens: targetPlanTokens, - tokensToAdd, - purchasePrice: Math.max(purchasePrice, 0), - isUpgrade: actionType === 'upgrade', - actionType, - onConfirm: () => pay(planUid), - }) - } - const handlePayClick = () => { - - // Бесплатный план: не показываем модалку - if (isFreePlan) { - pay(uid) - return - } - - // Не ежемесячный план: показываем модалку с уведомлением о том, что неиспользованные токены сгорят - if (!isRecurring) { - modal.setState(true, { - fromTokens: currentPlanTokens, - toTokens, - onConfirm: () => pay(uid), - }) - return - } - - // Апгрейд: модалка только если баланс превышает лимит текущего плана (токены сгорят) - if (isUpgrade && balanceExceedsLimit) { - openPurchaseModal(uid, toTokens, 'upgrade') - return - } - - // Апгрейд без токенов сверх лимита: показываем сколько токенов докупит юзер - if (isUpgrade && !balanceExceedsLimit) { - openPurchaseModal(uid, toTokens, 'upgrade') - return - } - - // Рекуррентный юзер докупает на текущем плане - if (isTopUpCurrentPlan) { - const tokensToAdd = toTokens - balance - if (tokensToAdd > 0) { - openPurchaseModal(uid, toTokens, 'topup') - return - } pay(uid) - return - } - - // Даунгрейд: модалка всегда (неиспользованные токены сгорят) - if (isDowngrade) { - modal.setState(true, { - fromTokens: currentPlanTokens, - toTokens, - onConfirm: () => pay(uid), - }) - return - } - - pay(uid) } return ( @@ -36,6 +36,7 @@ export const SubscriptionRecurrentPage = () => { const { data } = useSession() const { showMessage } = useShowDataStore() const [isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen] = useState(false) + const isDemoPlan = Number(planPrice) <= 0 const handleOpenCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(true) const handleCloseCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(false) @@ -112,32 +113,37 @@ export const SubscriptionRecurrentPage = () => { - - {isIndividual || !isRecurring ? ( - - - - - - ) : ( - - )} - + {!isDemoPlan ? ( + + {isIndividual || !isRecurring ? ( + + + + + + ) : ( + + )} + + ) : null} @@ -39,18 +37,6 @@ export function VoiceCloneMobile() { ))} - -
- false} - styles='chats' - viewMobileSettings={() => {}} - currentVersion='' - input_types={inputTypes} - /> -
@@ -223,7 +223,6 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { const generationsInitialLoading = loading && offset.current === 0 const mobileScrollAreaHeight = `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 110px'})` const compactPlaygroundEmpty = scope === 'playground' && !hasGenerations - /** Меню + отступы layout + шапка страницы + табы — чтобы основной блок доходил до низа экрана без серой полосы */ const mobileVoiceCompactMinHeight = 'calc(100dvh - 200px)' return ( @@ -389,7 +388,12 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { sx={{ position: 'relative', ...(compactPlaygroundEmpty - ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } + ? { + height: mobileVoiceCompactMinHeight, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + } : {}), }} > @@ -415,7 +419,7 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { position: 'relative', borderRadius: '13px', ...(compactPlaygroundEmpty - ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } + ? { height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } : {}), }} > @@ -3,3 +3,116 @@ border: 0; cursor: pointer; } + +// Обёртка сообщения +.messageWrapper { + display: flex; + align-items: center; + margin-top: 16px; + margin-bottom: 16px; +} + +// Аватар +.avatar { + margin-right: 8px; +} + +// Контейнер контента +.contentContainer { + padding-left: 0; + width: fit-content; + max-width: 68%; + margin-right: auto; + + @media (max-width: 768px) { + max-width: 95%; + } +} + +// Пузырь сообщения +.bubble { + overflow-y: auto; + position: relative; + padding: 15px 23px; + border: 1px solid #303035; + color: #a6a5a5; + box-shadow: none; + line-height: 22.5px; + font-size: 15px; + margin-top: 4px; + text-align: left; + font-family: Raleway, sans-serif; + border-radius: 13px; + + p { + color: inherit; + font-style: inherit; + } +} + +// Пузырь с файлом +.bubbleFile { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + color: #7f7df3 !important; + font-weight: 600 !important; + line-height: 140%; + letter-spacing: 0.2px; + cursor: pointer; +} + +// Кнопка меню +.menuButton { + position: sticky; + top: 20px; +} + +// Меню +.menu { + :global(.MuiMenu-list) { + background-color: #303035; + color: #8280ff; + border-radius: 15px; + } + + :global(.MuiPopover-paper) { + background-color: #303035; + border-radius: 15px; + } +} + +// Пункт меню +.menuItem { + font-size: 15px; + font-weight: 500; + display: flex; + align-items: center; + gap: 10px; +} + +.menuItemDelete { + font-size: 15px; + font-weight: 500; + display: flex; + align-items: center; + gap: 5px; +} + +// Имя модели и время +.metaText { + color: #a6a5a5; + line-height: 16.8px; + font-size: 13px; + font-weight: 600; + margin-right: 16px; +} + +.metaTime { + color: #a6a5a5; + line-height: 19.6px; + font-size: 13px; + font-weight: 600; + margin-right: 32px; +} @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { Box, Menu, MenuItem, Slide, Typography } from '@mui/material' +import { Box, Menu, MenuItem, Typography } from '@mui/material' import Stack from '@mui/material/Stack' import Image from 'next/image' @@ -12,7 +12,6 @@ import { FullscreenIcon } from './icons/fullscreen-icon' import { FullscreenMessageModal } from './fullscreen-message-modal' export function BotMessage(props: any) { - // const LazyCode = dynamic(() => import('src/widgets/chat-gpt-field/ui/code')) const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) const [markdownMessage, setMarkDownMessage] = React.useState('') @@ -21,9 +20,11 @@ export function BotMessage(props: any) { const copy = (text: string) => { navigator.clipboard.writeText(text) } + const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget) } + const handleClose = () => { setAnchorEl(null) } @@ -46,100 +47,41 @@ export function BotMessage(props: any) { } }, [props]) - const flexStyle = props.message.file - ? { - display: 'flex', - alignItems: 'center', - justifyContent: 'start', - gap: '12px', - color: '#7f7df3 !important', - fontWeight: '600 !important', - lineHeight: '140%', - letterSpacing: '0.2px', - cursor: 'pointer', - } - : {} - return ( - // - + {/* Обёртка сообщения */} + + + {/* Аватар (только десктоп) */} {props.desktop && ( - + {''} )} - + + {/* Контейнер контента */} + - + {props.modelTitle} - + {formatDate(props.message.created_at, { hour: 'numeric', minute: 'numeric', })} - + + + {/* Пузырь сообщения */} { if (props.message.file) { window.open(props.message.file, '_blank') } }} - className='smallScroll' - sx={{ - overflowY: 'auto', - position: 'relative', - padding: '15px 23px', - border: `1px solid #303035`, - color: '#A6A5A5', - boxShadow: 'none', - lineHeight: '22.5px', - fontSize: '15px', - marginTop: 0.5, - textAlign: 'left', - fontFamily: 'Raleway,sans-serif', - borderRadius: '13px', - '& p': { - color: 'inherit', - fontStyle: 'inherit', - }, - ...flexStyle, - }} + className={`smallScroll ${styles.bubble} ${props.message.file ? styles.bubbleFile : ''}`} > {props.message.file ? ( @@ -150,20 +92,17 @@ export function BotMessage(props: any) { fill='#7f7df3' /> - ) : ( - '' - )} + ) : null} + + {/* Кнопка меню */} + { - setFullscreenModalOpen(true) - }} + className={styles.menuItem} + onClick={() => setFullscreenModalOpen(true)} > На весь экран + { - copy(props.message.content ? props.message.content : props.message.file.split('/')[4].split('?')[0]) - }} + className={styles.menuItem} + onClick={() => copy(props.message.content ? props.message.content : props.message.file.split('/')[4].split('?')[0])} > - - - + + + Копировать + { - props.deleteMessage(props.message.uid) - }} + className={styles.menuItemDelete} + onClick={() => props.deleteMessage(props.message.uid)} > + setFullscreenModalOpen(false)} @@ -12,6 +12,8 @@ import { PreviewView } from '#/widgets/messages' import { IsNextDay } from '#/widgets/messages/ui/is-next-day' import { makeThinScrollbar } from '#/shared/lib/constants/styles' +import styles from './chat-messages.module.scss' + interface IMessagesList { device: 'mobile' | 'desktop' modelType: string @@ -27,18 +29,20 @@ interface IMessagesList { loading: boolean } +const SCROLL_THRESHOLD = 5 + export const ChatMessagesList: React.FC = memo( ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading }) => { const paginationScroll = React.useRef() const [isPaginating, setIsPaginating] = React.useState(false) const [chatScrollHeight, setChatScrollHeight] = React.useState(0) - const [scrollBottom, setScrollBottom] = React.useState(0) + const lastScrollTop = useRef(0) + const [showScrollDown, setShowScrollDown] = useState(false) const desktop = device === 'desktop' const { status } = useSession() const [modal, setModal] = useState(false) const [isNewMessage, setIsNewMessage] = useState(false) - const [currentSrc, setCurrentSrc] = useState(null) const onlyImageMessage = useMemo(() => { @@ -62,17 +66,15 @@ export const ChatMessagesList: React.FC = memo( const time = setTimeout(() => { if (block) { - //@ts-ignore block.scrollTo({ top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку + behavior: 'smooth', }) } }, 250) return () => clearTimeout(time) } else if (messageResponse != undefined && isPaginating) { if (block) { - //@ts-ignore block.scrollTop = block.scrollHeight - chatScrollHeight setChatScrollHeight(paginationScroll.current.scrollHeight) } @@ -85,15 +87,31 @@ export const ChatMessagesList: React.FC = memo( }, [messageResponse]) const handleScroll = () => { - setScrollBottom(paginationScroll.current?.scrollHeight - paginationScroll.current?.scrollTop - paginationScroll.current?.clientHeight) - - if (paginationScroll.current && messageResponse?.length !== 0) { - const { scrollTop, scrollHeight, clientHeight } = paginationScroll.current - if (scrollTop === 0) { - if (getMessagesPagination) { - setIsPaginating(true) - getMessagesPagination() - } + const block = paginationScroll.current + if (!block) return + + const { scrollTop, scrollHeight, clientHeight } = block + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + + const isScrollingDown = scrollTop > lastScrollTop.current + SCROLL_THRESHOLD + const isScrollingUp = scrollTop < lastScrollTop.current - SCROLL_THRESHOLD + + if (isScrollingDown) { + setShowScrollDown(true) + } else if (isScrollingUp) { + setShowScrollDown(false) + } + + if (distanceFromBottom < 50) { + setShowScrollDown(false) + } + + lastScrollTop.current = scrollTop + + if (scrollTop === 0 && messageResponse?.length !== 0) { + if (getMessagesPagination) { + setIsPaginating(true) + getMessagesPagination() } } } @@ -128,25 +146,18 @@ export const ChatMessagesList: React.FC = memo( ref={paginationScroll} onScroll={handleScroll} > - {scrollBottom > 500 && ( + {showScrollDown && ( { - //@ts-ignore const block = paginationScroll.current - block.scrollTo({ top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку + behavior: 'smooth', }) }} > @@ -156,22 +167,16 @@ export const ChatMessagesList: React.FC = memo( {loading && ( )} @@ -205,4 +210,4 @@ export const ChatMessagesList: React.FC = memo( } ) -ChatMessagesList.displayName = 'ChatMessagesList' +ChatMessagesList.displayName = 'ChatMessagesList' \ No newline at end of file @@ -0,0 +1,20 @@ +.scrollDownButton { + position: absolute; + width: fit-content; + cursor: pointer; + margin: 0 auto; + bottom: 150px; + z-index: 10; +} + +.loadingIndicator { + position: absolute; + width: fit-content; + margin: 0 auto; + top: 10px; + z-index: 10; +} + +.circularProgress { + color: #7F7DF3; +} \ No newline at end of file @@ -1,16 +1,16 @@ .closeIcon { cursor: pointer; position: absolute; - top: 22px; + top: 45px; right: 25px; @media (min-width: 400px) and (max-width: 550px) { - top: 20px; + top: 35px; right: 20px; } @media (max-width: 768px) { - top: 20px; + top: 30px; right: 20px; } } @@ -53,10 +53,48 @@ } .markdownContent { + font-weight: 400; + font-style: normal; + color: #ffffff; + text-align: left; + + p, li, td, blockquote { + font-size: var(--fs-base) !important; + } + + h1 { + font-size: var(--fs-h1) !important; + font-weight: 700; + margin-top: 16px; + margin-bottom: 6px; + } + + h2 { + font-size: var(--fs-h2) !important; + font-weight: 700; + margin-top: 14px; + margin-bottom: 6px; + } + + h3 { + font-size: var(--fs-h3) !important; + font-weight: 600; + margin-top: 12px; + margin-bottom: 5px; + } + + strong { + font-weight: 700; + } + + code { + font-size: var(--fs-code) !important; + } + ul, ol { - padding-left: 1.5em; - margin: 0.5em 0; + padding-left: 24px; + margin: 8px 0; } ul { @@ -68,38 +106,29 @@ } li { - margin: 0.25em 0; + margin: 4px 0; } } .content { - height: 500px; overflow-y: auto; - margin-bottom: 20px; flex: 1; min-height: 0; + padding-top: 20px; + padding-right: 8px; + padding-left: 20px; @media (min-width: 769px) and (max-height: 767px) { - height: auto; flex: 1; - margin-bottom: 15px; + padding-top: 15px; + padding-left: 15px; } @media (max-width: 768px) { - height: calc(100vh - 150px); - flex: 0 0 auto; - margin-bottom: 15px; - } - - &.macos { - @media (max-width: 768px) { - @media (max-width: 450px) { - height: calc(100vh - 200px); - margin-bottom: 15px; - } - } + flex: 1; + padding-top: 15px; + padding-left: 15px; } - } .footer { @@ -108,31 +137,30 @@ display: flex; align-items: center; gap: 10px; - margin: auto 0 0 -30px; - padding: 0 30px; flex-shrink: 0; + margin: auto -30px 0; + padding: 0 30px; @media (min-width: 769px) and (max-height: 767px) { height: 70px; - margin: auto 0 0 -25px; + margin: auto -25px 0; padding: 0 25px; } @media (max-width: 768px) { - height: 50px; + height: 60px; + margin: auto -20px 0; padding: 0 20px; gap: 8px; - margin: 0; - - @media (max-width: 450px) { - flex-direction: column; - height: auto; - margin: 0; - padding: 5px; - gap: 15px; - align-items: stretch; - } - } + + @media (max-width: 450px) { + flex-direction: column; + height: auto; + padding: 12px 20px; + gap: 12px; + align-items: stretch; + } + } } .zoomButtonsContainer { @@ -262,4 +290,4 @@ @media (max-width: 768px) { gap: 8px; } -} +} \ No newline at end of file @@ -27,10 +27,9 @@ export const FullscreenMessageModal: React.FC = ({ const [fontSize, setFontSize] = useState(16) const contentRef = useRef(null) const markdownRef = useRef(null) - - // Проверка на macOS - const isMacOS = typeof navigator !== 'undefined' && - (navigator.platform.toUpperCase().indexOf('MAC') >= 0 || + + const isMacOS = typeof navigator !== 'undefined' && + (navigator.platform.toUpperCase().indexOf('MAC') >= 0 || (navigator.userAgent.includes('Mac') && !navigator.userAgent.includes('iPhone') && !navigator.userAgent.includes('iPad'))) const handleFontChange = (event: SelectChangeEvent) => { @@ -47,26 +46,21 @@ export const FullscreenMessageModal: React.FC = ({ const copyIconRef = useRef(null) - // Функция для создания отформатированного HTML для Word const createFormattedHtml = useCallback((htmlContent: string) => { - // Создаем временный контейнер для обработки HTML const tempDiv = document.createElement('div') tempDiv.innerHTML = htmlContent - - // Добавляем inline стили ко всем элементам через setAttribute + const allElements = tempDiv.querySelectorAll('*') allElements.forEach((el) => { const htmlEl = el as HTMLElement - // Получаем текущий style или создаем новый const currentStyle = htmlEl.getAttribute('style') || '' const newStyle = `${currentStyle ? currentStyle + '; ' : ''}font-family: ${fontFamily}; font-size: ${fontSize}pt;` htmlEl.setAttribute('style', newStyle) }) - - // Также применяем к самому контейнеру + const containerStyle = `font-family: ${fontFamily}; font-size: ${fontSize}pt;` tempDiv.setAttribute('style', containerStyle) - + return ` @@ -83,7 +77,6 @@ export const FullscreenMessageModal: React.FC = ({ ` }, [fontFamily, fontSize]) - // Функция для смены иконки на галочку const showCopySuccess = () => { const svg = copyIconRef.current if (!svg) return @@ -100,8 +93,7 @@ export const FullscreenMessageModal: React.FC = ({ /> ` svg.style.animation = 'pulse 0.3s ease' - - // Добавляем анимацию (только один раз) + if (!document.querySelector('#copy-icon-animation')) { const style = document.createElement('style') style.id = 'copy-icon-animation' @@ -113,8 +105,7 @@ export const FullscreenMessageModal: React.FC = ({ ` document.head.appendChild(style) } - - // Возвращаем исходную иконку через 1.5 секунды + setTimeout(() => { if (svg) { svg.innerHTML = originalHTML @@ -123,7 +114,6 @@ export const FullscreenMessageModal: React.FC = ({ }, 1500) } - // Обработка копирования Ctrl+C только когда модалка открыта и выделение внутри неё useEffect(() => { if (!open) return @@ -147,7 +137,6 @@ export const FullscreenMessageModal: React.FC = ({ return () => document.removeEventListener('copy', handleCopy) }, [open, createFormattedHtml]) - // Обработка копирования по клику на иконку const handleCopy = async (e?: React.MouseEvent) => { if (e) { e.preventDefault() @@ -161,10 +150,9 @@ export const FullscreenMessageModal: React.FC = ({ const formattedHtml = createFormattedHtml(htmlContent) try { - // Используем правильную кодировку UTF-8 для Blob const htmlBlob = new Blob([formattedHtml], { type: 'text/html;charset=utf-8' }) const textBlob = new Blob([textContent], { type: 'text/plain;charset=utf-8' }) - + const clipboardItem = new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob, @@ -172,9 +160,7 @@ export const FullscreenMessageModal: React.FC = ({ await navigator.clipboard.write([clipboardItem]) showCopySuccess() } catch { - // Fallback для старых браузеров и Windows - используем метод через execCommand с правильно форматированным HTML try { - // Создаем временный div с полным HTML документом для Word const tempDiv = document.createElement('div') tempDiv.style.position = 'absolute' tempDiv.style.left = '-9999px' @@ -188,13 +174,12 @@ export const FullscreenMessageModal: React.FC = ({ if (selection) { selection.removeAllRanges() selection.addRange(range) - - // Используем execCommand для копирования + const success = document.execCommand('copy') - + selection.removeAllRanges() document.body.removeChild(tempDiv) - + if (success) { showCopySuccess() } @@ -202,12 +187,10 @@ export const FullscreenMessageModal: React.FC = ({ document.body.removeChild(tempDiv) } } catch { - // Последняя попытка - просто скопировать текст без форматирования try { await navigator.clipboard.writeText(textContent) showCopySuccess() } catch { - // Игнорируем ошибки } } } @@ -287,16 +270,14 @@ export const FullscreenMessageModal: React.FC = ({ @@ -1,4 +1,4 @@ - +desktop.ini /node_modules /.pnp .pnp.js