import '@renderer/assets/styles/selection-toolbar.scss' import { AppLogo } from '@renderer/config/env' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import type { ActionItem } from '@renderer/types/selectionTypes' import { defaultLanguage } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Avatar } from 'antd' import { ClipboardCheck, ClipboardCopy, ClipboardX, MessageSquareHeart } from 'lucide-react' import { DynamicIcon } from 'lucide-react/dynamic' import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { TextSelectionData } from 'selection-hook' import styled from 'styled-components' //tell main the actual size of the content const updateWindowSize = () => { const rootElement = document.getElementById('root') if (!rootElement) { console.error('SelectionToolbar: Root element not found') return } window.api?.selection.determineToolbarSize(rootElement.scrollWidth, rootElement.scrollHeight) } /** * ActionIcons is a component that renders the action icons */ const ActionIcons: FC<{ actionItems: ActionItem[] isCompact: boolean handleAction: (action: ActionItem) => void copyIconStatus: 'normal' | 'success' | 'fail' copyIconAnimation: 'none' | 'enter' | 'exit' }> = memo(({ actionItems, isCompact, handleAction, copyIconStatus, copyIconAnimation }) => { const { t } = useTranslation() const renderCopyIcon = useCallback(() => { return ( <> {copyIconStatus === 'success' && ( )} {copyIconStatus === 'fail' && ( )} ) }, [copyIconStatus, copyIconAnimation]) const renderActionButton = useCallback( (action: ActionItem) => { const displayName = action.isBuiltIn ? t(action.name) : action.name return ( handleAction(action)} title={isCompact ? displayName : undefined}> {action.id === 'copy' ? ( renderCopyIcon() ) : ( } /> )} {!isCompact && {displayName}} ) }, [handleAction, isCompact, t, renderCopyIcon] ) return <>{actionItems?.map(renderActionButton)} }) /** * demo is used in the settings page */ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { const { language, customCss } = useSettings() const { isCompact, actionItems } = useSelectionAssistant() const [animateKey, setAnimateKey] = useState(0) const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal') const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none') const copyIconTimeoutRef = useRef(undefined) const realActionItems = useMemo(() => { return actionItems?.filter((item) => item.enabled) }, [actionItems]) const selectedText = useRef('') // listen to selectionService events useEffect(() => { // TextSelection const textSelectionListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_TextSelected, (_, selectionData: TextSelectionData) => { selectedText.current = selectionData.text setTimeout(() => { //make sure the animation is active setAnimateKey((prev) => prev + 1) }, 400) } ) // ToolbarVisibilityChange const toolbarVisibilityChangeListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_ToolbarVisibilityChange, (_, isVisible: boolean) => { if (!isVisible) { if (!demo) updateWindowSize() onHideCleanUp() } } ) if (!demo) updateWindowSize() return () => { textSelectionListenRemover() toolbarVisibilityChangeListenRemover() } }, [demo]) //make sure the toolbar size is updated when the compact mode/actionItems is changed useEffect(() => { if (!demo) updateWindowSize() }, [demo, isCompact, actionItems]) useEffect(() => { !demo && i18n.changeLanguage(language || navigator.language || defaultLanguage) }, [language, demo]) useEffect(() => { if (demo) return let customCssElement = document.getElementById('user-defined-custom-css') as HTMLStyleElement if (customCssElement) { customCssElement.remove() } if (customCss) { const newCustomCss = customCss.replace(/background(-image|-color)?\s*:[^;]+;/gi, '') customCssElement = document.createElement('style') customCssElement.id = 'user-defined-custom-css' customCssElement.textContent = newCustomCss document.head.appendChild(customCssElement) } }, [customCss, demo]) const onHideCleanUp = () => { setCopyIconStatus('normal') setCopyIconAnimation('none') clearTimeout(copyIconTimeoutRef.current) } const handleAction = useCallback( (action: ActionItem) => { if (demo) return /** avoid mutating the original action, it will cause syncing issue */ const newAction = { ...action, selectedText: selectedText.current } switch (action.id) { case 'copy': handleCopy() break case 'search': handleSearch(newAction) break case 'quote': handleQuote(newAction) break default: handleDefaultAction(newAction) break } }, [demo] ) // copy selected text to clipboard const handleCopy = async () => { if (selectedText.current) { const result = await window.api?.selection.writeToClipboard(selectedText.current) setCopyIconStatus(result ? 'success' : 'fail') setCopyIconAnimation('enter') copyIconTimeoutRef.current = setTimeout(() => { setCopyIconAnimation('exit') }, 2000) } } const handleSearch = (action: ActionItem) => { if (!action.searchEngine) return const customUrl = action.searchEngine.split('|')[1] if (!customUrl) return const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || '')) window.api?.openWebsite(searchUrl) window.api?.selection.hideToolbar() } /** * Quote the selected text to the inputbar of the main window */ const handleQuote = (action: ActionItem) => { if (action.selectedText) { window.api?.quoteToMainWindow(action.selectedText) window.api?.selection.hideToolbar() } } const handleDefaultAction = (action: ActionItem) => { window.api?.selection.processAction(action) window.api?.selection.hideToolbar() } return ( ) } const Container = styled.div` display: inline-flex; flex-direction: row; align-items: center; border-radius: 6px; background-color: var(--color-selection-toolbar-background); border-color: var(--color-selection-toolbar-border); box-shadow: 0px 2px 3px var(--color-selection-toolbar-shadow); padding: 2px; margin: 2px 3px 5px 3px; user-select: none; border-width: 1px; border-style: solid; height: 36px; padding-right: 4px; box-sizing: border-box; ` const LogoWrapper = styled.div<{ $draggable: boolean }>` display: flex; align-items: center; justify-content: center; margin-left: 5px; background-color: transparent; ${({ $draggable }) => $draggable && ' -webkit-app-region: drag;'} ` const Logo = styled(Avatar)` height: 22px; width: 22px; &.animate { animation: rotate 1s ease; } @keyframes rotate { 0% { transform: rotate(0deg) scale(1); } 25% { transform: rotate(-15deg) scale(1.05); } 75% { transform: rotate(15deg) scale(1.05); } 100% { transform: rotate(0deg) scale(1); } } ` const ActionWrapper = styled.div` display: flex; flex-direction: row; align-items: center; justify-content: center; margin-left: 3px; background-color: transparent; ` const ActionButton = styled.div` display: flex; flex-direction: row; align-items: center; justify-content: center; margin: 0 2px; background-color: transparent; cursor: pointer; border-radius: 4px; padding: 4px 6px; transition: all 0.1s ease-in-out; will-change: color, background-color; .btn-icon { width: 16px; height: 16px; color: var(--color-selection-toolbar-text); background-color: transparent; transition: color 0.1s ease-in-out; will-change: color; } .btn-title { color: var(--color-selection-toolbar-text); --font-size: 14px; transition: color 0.1s ease-in-out; will-change: color; } &:hover { color: var(--color-primary); .btn-icon { color: var(--color-primary); } .btn-title { color: var(--color-primary); } background-color: var(--color-selection-toolbar-hover-bg); } ` const ActionIcon = styled.div` display: flex; align-items: center; justify-content: center; position: relative; height: 16px; width: 16px; background-color: transparent; .btn-icon { position: absolute; top: 0; left: 0; } .btn-icon:nth-child(2) { top: 0px; left: 0px; } .icon-fail { color: var(--color-error); } .icon-success { color: var(--color-primary); } .icon-scale-in { animation: scaleIn 0.5s forwards; } .icon-scale-out { animation: scaleOut 0.5s forwards; } .icon-fade-in { animation: fadeIn 0.3s forwards; } .icon-fade-out { animation: fadeOut 0.3s forwards; } @keyframes scaleIn { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes scaleOut { from { transform: scale(1); opacity: 1; } to { transform: scale(0); opacity: 0; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } ` const ActionTitle = styled.span` font-size: 14px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: 3px; background-color: transparent; ` export default SelectionToolbar