From 8aa0cf1d6db350c75d2bfbdbb0f6f7757cd65c9e Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Tue, 17 Mar 2026 09:27:38 -0700 Subject: [PATCH 1/7] many optimizations and performance improvements --- CLAUDE.md | 2 + package-lock.json | 28 ++ package.json | 1 + src/main/clipboard/data.test.ts | 100 ++++- src/main/clipboard/data.ts | 25 +- src/renderer/src/components/SearchBar.tsx | 5 +- src/renderer/src/components/StatusBar.tsx | 6 +- .../src/components/clips/Clips.module.css | 1 - src/renderer/src/components/clips/Clips.tsx | 64 ++- .../components/clips/clip/BookmarkClip.tsx | 5 +- .../src/components/clips/clip/Clip.module.css | 25 +- .../components/clips/clip/ClipContextMenu.tsx | 41 +- .../src/components/clips/clip/ClipOptions.tsx | 55 +-- .../src/components/clips/clip/ClipWrapper.tsx | 26 +- .../src/components/clips/clip/HtmlClip.tsx | 5 +- .../components/clips/clip/ImageClip.test.tsx | 54 ++- .../src/components/clips/clip/ImageClip.tsx | 97 ++--- .../src/components/clips/clip/RtfClip.tsx | 5 +- .../clips/clip/SyntaxHighlightedCode.test.tsx | 159 +++++++ .../clips/clip/SyntaxHighlightedCode.tsx | 115 +++++ .../components/clips/clip/TextClip.test.tsx | 61 ++- .../src/components/clips/clip/TextClip.tsx | 93 ++-- .../settings/usersettings/useUserSettings.ts | 4 +- .../src/hooks/useNativeContextMenu.ts | 5 +- src/renderer/src/providers/clips/index.tsx | 89 ++-- src/renderer/src/providers/clips/types.ts | 33 +- .../src/utils/languageDetection.test.ts | 94 +++- src/renderer/src/utils/languageDetection.ts | 408 +++++++++++------- 28 files changed, 1107 insertions(+), 499 deletions(-) create mode 100644 src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx create mode 100644 src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c9d23f8..b2346af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,13 @@ Clipless is an Electron clipboard manager built with React and TypeScript. It mo After making any code changes, always run the following before considering work complete: 1. **Lint and typecheck** — must produce zero errors and zero warnings: + ```bash npm run lint && npm run typecheck ``` 2. **Unit tests with coverage** — must maintain 100% code coverage across statements, branches, functions, and lines: + ```bash npx vitest run --coverage ``` diff --git a/package-lock.json b/package-lock.json index ae069c1..8da4fdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-virtual": "^3.13.23", "classnames": "^2.5.1", "electron-updater": "^6.3.9", "react-outside-click-handler": "^1.3.0", @@ -2755,6 +2756,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index b923753..288aa0c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-virtual": "^3.13.23", "classnames": "^2.5.1", "electron-updater": "^6.3.9", "react-outside-click-handler": "^1.3.0", diff --git a/src/main/clipboard/data.test.ts b/src/main/clipboard/data.test.ts index 36b25a4..d29a699 100644 --- a/src/main/clipboard/data.test.ts +++ b/src/main/clipboard/data.test.ts @@ -5,7 +5,12 @@ vi.mock('electron', () => ({ readText: vi.fn().mockReturnValue(''), readHTML: vi.fn().mockReturnValue(''), readRTF: vi.fn().mockReturnValue(''), - readImage: vi.fn().mockReturnValue({ isEmpty: () => true, toDataURL: () => '' }), + readImage: vi.fn().mockReturnValue({ + isEmpty: () => true, + toDataURL: () => '', + getSize: () => ({ width: 0, height: 0 }), + toBitmap: () => Buffer.from(''), + }), readBookmark: vi.fn().mockReturnValue({ title: '', url: '' }), writeText: vi.fn(), writeHTML: vi.fn(), @@ -33,20 +38,33 @@ import { setClipboardRTF, setClipboardImage, setClipboardBookmark, + clearImageCache, } from './data'; + +function createMockImage( + empty: boolean, + dataUrl = '', + width = 0, + height = 0 +): Electron.NativeImage { + return { + isEmpty: () => empty, + toDataURL: () => dataUrl, + getSize: () => ({ width, height }), + toBitmap: () => Buffer.from('mock-bitmap-data-for-testing'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} import { clipboard, nativeImage } from 'electron'; describe('getCurrentClipboardData', () => { beforeEach(() => { vi.clearAllMocks(); + clearImageCache(); vi.mocked(clipboard.readText).mockReturnValue(''); vi.mocked(clipboard.readRTF).mockReturnValue(''); vi.mocked(clipboard.readHTML).mockReturnValue(''); - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => true, - toDataURL: () => '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue(createMockImage(true)); vi.mocked(clipboard.readBookmark).mockReturnValue({ title: '', url: '' }); }); @@ -69,15 +87,61 @@ describe('getCurrentClipboardData', () => { }); it('returns image type when only image is available', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => false, - toDataURL: () => 'data:image/png;base64,abc', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue( + createMockImage(false, 'data:image/png;base64,abc', 100, 100) + ); const result = getCurrentClipboardData(); expect(result).toEqual({ type: 'image', content: 'data:image/png;base64,abc' }); }); + it('caches image data URL and skips toDataURL on unchanged image', () => { + const toDataURL = vi.fn().mockReturnValue('data:image/png;base64,abc'); + const mockImage = { + isEmpty: () => false, + toDataURL, + getSize: () => ({ width: 100, height: 100 }), + toBitmap: () => Buffer.from('same-bitmap-data'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage as any); + + // First call — should call toDataURL + getCurrentClipboardData(); + expect(toDataURL).toHaveBeenCalledTimes(1); + + // Second call with same image — should NOT call toDataURL again + getCurrentClipboardData(); + expect(toDataURL).toHaveBeenCalledTimes(1); + }); + + it('calls toDataURL when image fingerprint changes', () => { + const toDataURL1 = vi.fn().mockReturnValue('data:image/png;base64,first'); + const mockImage1 = { + isEmpty: () => false, + toDataURL: toDataURL1, + getSize: () => ({ width: 100, height: 100 }), + toBitmap: () => Buffer.from('bitmap-data-1'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage1 as any); + const result1 = getCurrentClipboardData(); + expect(result1?.content).toBe('data:image/png;base64,first'); + + // Different image + const toDataURL2 = vi.fn().mockReturnValue('data:image/png;base64,second'); + const mockImage2 = { + isEmpty: () => false, + toDataURL: toDataURL2, + getSize: () => ({ width: 200, height: 200 }), + toBitmap: () => Buffer.from('bitmap-data-2'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage2 as any); + const result2 = getCurrentClipboardData(); + expect(result2?.content).toBe('data:image/png;base64,second'); + expect(toDataURL2).toHaveBeenCalledTimes(1); + }); + it('returns bookmark type when only bookmark is available', () => { vi.mocked(clipboard.readBookmark).mockReturnValue({ title: 'Example', @@ -149,20 +213,14 @@ describe('getClipboardRTF', () => { describe('getClipboardImage', () => { it('returns data URL when image exists', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => false, - toDataURL: () => 'data:image/png;base64,abc', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue( + createMockImage(false, 'data:image/png;base64,abc', 100, 100) + ); expect(getClipboardImage()).toBe('data:image/png;base64,abc'); }); it('returns null when no image', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => true, - toDataURL: () => '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue(createMockImage(true)); expect(getClipboardImage()).toBeNull(); }); }); diff --git a/src/main/clipboard/data.ts b/src/main/clipboard/data.ts index 5f79918..62ffac2 100644 --- a/src/main/clipboard/data.ts +++ b/src/main/clipboard/data.ts @@ -1,5 +1,17 @@ import { clipboard, nativeImage } from 'electron'; +// Cached image fingerprint to avoid expensive toDataURL on every poll +let lastImageFingerprint = ''; +let lastImageDataUrl = ''; + +function getImageFingerprint(image: Electron.NativeImage): string { + const size = image.getSize(); + const bitmap = image.toBitmap(); + // Use dimensions + bitmap byte length + first 64 bytes as a fast fingerprint + const sample = bitmap.subarray(0, 64).toString('base64'); + return `${size.width}x${size.height}:${bitmap.length}:${sample}`; +} + // Helper function to determine the current clipboard type and content export const getCurrentClipboardData = (): { type: string; content: string } | null => { // Priority: text > rtf > html > image > bookmark @@ -20,7 +32,13 @@ export const getCurrentClipboardData = (): { type: string; content: string } | n const image = clipboard.readImage(); if (!image.isEmpty()) { - return { type: 'image', content: image.toDataURL() }; + const fingerprint = getImageFingerprint(image); + if (fingerprint !== lastImageFingerprint) { + // Image changed — do the expensive toDataURL conversion + lastImageFingerprint = fingerprint; + lastImageDataUrl = image.toDataURL(); + } + return { type: 'image', content: lastImageDataUrl }; } try { @@ -35,6 +53,11 @@ export const getCurrentClipboardData = (): { type: string; content: string } | n return null; }; +export function clearImageCache(): void { + lastImageFingerprint = ''; + lastImageDataUrl = ''; +} + // Clipboard read operations export const getClipboardText = (): string => clipboard.readText(); export const getClipboardHTML = (): string => clipboard.readHTML(); diff --git a/src/renderer/src/components/SearchBar.tsx b/src/renderer/src/components/SearchBar.tsx index a9346c5..b211290 100644 --- a/src/renderer/src/components/SearchBar.tsx +++ b/src/renderer/src/components/SearchBar.tsx @@ -1,12 +1,13 @@ import React, { useRef, useEffect } from 'react'; -import { useClips } from '../providers/clips'; +import { useClipsData, useClipsMeta } from '../providers/clips'; import { useTheme } from '../providers/theme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import styles from './SearchBar.module.css'; export const SearchBar: React.FC = () => { - const { searchTerm, setSearchTerm, isSearchVisible, setIsSearchVisible } = useClips(); + const { searchTerm } = useClipsData(); + const { setSearchTerm, isSearchVisible, setIsSearchVisible } = useClipsMeta(); const { isLight } = useTheme(); const inputRef = useRef(null); diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index 9aeab2e..d54505d 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useClips } from '../providers/clips'; +import { useClipsData, useClipsActions, useClipsMeta } from '../providers/clips'; import { useTheme } from '../providers/theme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; @@ -10,7 +10,9 @@ interface StatusBarProps { } export const StatusBar: React.FC = ({ onOpenSettings }) => { - const { clips, maxClips, isClipLocked, isSearchVisible, setIsSearchVisible } = useClips(); + const { clips } = useClipsData(); + const { isClipLocked } = useClipsActions(); + const { maxClips, isSearchVisible, setIsSearchVisible } = useClipsMeta(); const { isLight } = useTheme(); // Count non-empty clips diff --git a/src/renderer/src/components/clips/Clips.module.css b/src/renderer/src/components/clips/Clips.module.css index 66da4ef..0e13721 100644 --- a/src/renderer/src/components/clips/Clips.module.css +++ b/src/renderer/src/components/clips/Clips.module.css @@ -22,7 +22,6 @@ width: 100%; margin: 0; padding: 0; - list-style: none; } .emptyState { diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 9d19628..bce8b2c 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -1,34 +1,70 @@ -import { useClips } from '../../providers/clips'; +import { useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useClipsData, useClipsMeta } from '../../providers/clips'; import { useTheme } from '../../providers/theme'; import { Clip } from './clip'; import classNames from 'classnames'; import styles from './Clips.module.css'; export function Clips(): React.JSX.Element { - const { clips, filteredClips, searchTerm } = useClips(); + const { clips, filteredClips, searchTerm } = useClipsData(); + const { clipCopyIndex } = useClipsMeta(); const { isLight } = useTheme(); + const scrollContainerRef = useRef(null); const isFiltering = searchTerm.trim().length > 0; const showEmpty = isFiltering && filteredClips.length === 0; + const items = isFiltering + ? filteredClips.map(({ clip, originalIndex }) => ({ clip, index: originalIndex })) + : clips.map((clip, index) => ({ clip, index })); + + const virtualizer = useVirtualizer({ + count: showEmpty ? 0 : items.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 40, + overscan: 5, + }); + return ( -
+
{showEmpty ? (
No clips match “{searchTerm}”
- ) : isFiltering ? ( -
    - {filteredClips.map(({ clip, originalIndex }) => ( - - ))} -
) : ( -
    - {clips.map((clip, index) => ( - - ))} -
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const { clip, index } = items[virtualRow.index]; + return ( +
+ +
+ ); + })} +
)}
); diff --git a/src/renderer/src/components/clips/clip/BookmarkClip.tsx b/src/renderer/src/components/clips/clip/BookmarkClip.tsx index 48f406b..cdd6e63 100644 --- a/src/renderer/src/components/clips/clip/BookmarkClip.tsx +++ b/src/renderer/src/components/clips/clip/BookmarkClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface BookmarkClipProps { clip: ClipItem; } -export const BookmarkClip = ({ clip }: BookmarkClipProps) => { +export const BookmarkClip = memo(function BookmarkClip({ clip }: BookmarkClipProps) { const { isLight } = useTheme(); return ( @@ -18,4 +19,4 @@ export const BookmarkClip = ({ clip }: BookmarkClipProps) => {
); -}; +}); diff --git a/src/renderer/src/components/clips/clip/Clip.module.css b/src/renderer/src/components/clips/clip/Clip.module.css index f0208cb..45ddb8d 100644 --- a/src/renderer/src/components/clips/clip/Clip.module.css +++ b/src/renderer/src/components/clips/clip/Clip.module.css @@ -25,20 +25,20 @@ background-color: #e0e0e0; } -/* Zebra striping for even rows */ -.clip:nth-child(even) .clipRow { +/* Zebra striping for even rows (class-based for virtualization compat) */ +.clip.evenRow .clipRow { background-color: #383838; } -.clip:nth-child(even) .clipRow.light { +.clip.evenRow .clipRow.light { background-color: #f0f0f0; } -.clip:nth-child(even) .clipRow:hover { +.clip.evenRow .clipRow:hover { background-color: #505050; } -.clip:nth-child(even) .clipRow.light:hover { +.clip.evenRow .clipRow.light:hover { background-color: #e0e0e0; } @@ -330,9 +330,6 @@ z-index: 9999; opacity: 0; visibility: hidden; - transition: - opacity 0.3s ease, - visibility 0.3s ease; pointer-events: none; max-width: 20rem; max-height: 20rem; @@ -344,18 +341,10 @@ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); } -.imagePreviewContainer:hover .imagePopover { +.imagePopoverVisible { opacity: 1; visibility: visible; - pointer-events: auto; - transition-delay: 0.5s; -} - -.imagePopover:hover { - opacity: 1; - visibility: visible; - pointer-events: auto; - transition-delay: 0s; + pointer-events: none; } .popoverImage { diff --git a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx index e600249..4cf8411 100644 --- a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx +++ b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useClips } from '../../../providers/clips'; +import { useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import classNames from 'classnames'; import styles from './ClipContextMenu.module.css'; @@ -10,47 +10,18 @@ interface ClipContextMenuProps { x: number; y: number; onClose: () => void; + hasPatterns: boolean; } -export function ClipContextMenu({ index, x, y, onClose }: ClipContextMenuProps) { +export function ClipContextMenu({ index, x, y, onClose, hasPatterns }: ClipContextMenuProps) { const { isLight } = useTheme(); - const { isClipLocked, toggleClipLock, emptyClip, getClip, copyClipToClipboard } = useClips(); + const { isClipLocked, toggleClipLock, emptyClip, getClip, copyClipToClipboard } = + useClipsActions(); const menuRef = useRef(null); const clip = getClip(index); const isFirstClip = index === 0; - // Check for patterns - const [hasPatterns, setHasPatterns] = useState(false); - - useEffect(() => { - let isCancelled = false; - - const checkPatterns = async () => { - if (!clip.content || clip.content.trim().length === 0) { - setHasPatterns(false); - return; - } - - try { - const matches = await window.api.quickClipsScanText(clip.content); - if (!isCancelled) { - setHasPatterns(matches.length > 0); - } - } catch { - if (!isCancelled) { - setHasPatterns(false); - } - } - }; - - checkPatterns(); - - return () => { - isCancelled = true; - }; - }, [clip.content]); - // Handle clicks outside menu useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/src/renderer/src/components/clips/clip/ClipOptions.tsx b/src/renderer/src/components/clips/clip/ClipOptions.tsx index ba9aad9..e9847dc 100644 --- a/src/renderer/src/components/clips/clip/ClipOptions.tsx +++ b/src/renderer/src/components/clips/clip/ClipOptions.tsx @@ -1,61 +1,32 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import OutsideClickHandler from 'react-outside-click-handler'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useClips } from '../../../providers/clips'; +import { useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import styles from './ClipOptions.module.css'; import classNames from 'classnames'; -export function ClipOptions({ index }): React.JSX.Element { +interface ClipOptionsProps { + index: number; + hasPatterns: boolean; + clipContent: string; +} + +export function ClipOptions({ index, hasPatterns, clipContent }: ClipOptionsProps) { const [visible, setVisible] = useState(false); const { isLight } = useTheme(); const toggleVisibility = useCallback(() => { - setVisible(!visible); - }, [visible, setVisible]); - - const { isClipLocked, toggleClipLock, emptyClip, getClip } = useClips(); + setVisible((v) => !v); + }, []); - const clip = getClip(index); + const { isClipLocked, toggleClipLock, emptyClip } = useClipsActions(); // Check if this is the first clip (cannot be locked or emptied) const isFirstClip = index === 0; - // Check if this clip has patterns (we'll do a simple check) - const [hasPatterns, setHasPatterns] = useState(false); - - // Check for patterns when component mounts or clip content changes - useEffect(() => { - let isCancelled = false; - - const checkPatterns = async () => { - if (!clip.content || clip.content.trim().length === 0) { - setHasPatterns(false); - return; - } - - try { - const matches = await window.api.quickClipsScanText(clip.content); - if (!isCancelled) { - setHasPatterns(matches.length > 0); - } - } catch { - if (!isCancelled) { - setHasPatterns(false); - } - } - }; - - const timeoutId = setTimeout(checkPatterns, 200); - - return () => { - isCancelled = true; - clearTimeout(timeoutId); - }; - }, [clip.content]); - const handleScanClick = async () => { try { - await window.api.openToolsLauncher(clip.content); + await window.api.openToolsLauncher(clipContent); setVisible(false); // Close the options menu } catch (error) { console.error('Failed to open tools launcher:', error); diff --git a/src/renderer/src/components/clips/clip/ClipWrapper.tsx b/src/renderer/src/components/clips/clip/ClipWrapper.tsx index 1005518..c39bfcb 100644 --- a/src/renderer/src/components/clips/clip/ClipWrapper.tsx +++ b/src/renderer/src/components/clips/clip/ClipWrapper.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import { useState } from 'react'; -import { ClipItem, useClips } from '../../../providers/clips'; +import { memo, useState } from 'react'; +import { ClipItem, useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import { usePatternDetection } from '../../../hooks/usePatternDetection'; import { useContextMenu } from '../../../hooks/useContextMenu'; @@ -17,10 +17,17 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; interface ClipProps { clip: ClipItem; index: number; + isCurrentCopiedClip: boolean; + isEvenRow?: boolean; } -export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { - const { copyClipToClipboard, clipCopyIndex, updateClip } = useClips(); +export const ClipWrapper = memo(function ClipWrapper({ + clip, + index, + isCurrentCopiedClip, + isEvenRow, +}: ClipProps): React.JSX.Element { + const { copyClipToClipboard, updateClip } = useClipsActions(); const { isLight } = useTheme(); const { hasPatterns } = usePatternDetection(clip.content); const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu(); @@ -60,10 +67,8 @@ export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { } }; - const isCurrentCopiedClip = clipCopyIndex === index; - return ( -
  • +
    - +
    {/* Context Menu */} @@ -109,8 +114,9 @@ export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { x={contextMenu.x} y={contextMenu.y} onClose={closeContextMenu} + hasPatterns={hasPatterns} /> )} -
  • + ); -} +}); diff --git a/src/renderer/src/components/clips/clip/HtmlClip.tsx b/src/renderer/src/components/clips/clip/HtmlClip.tsx index 3057c21..ef8215e 100644 --- a/src/renderer/src/components/clips/clip/HtmlClip.tsx +++ b/src/renderer/src/components/clips/clip/HtmlClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface HtmlClipProps { clip: ClipItem; } -export const HtmlClip = ({ clip }: HtmlClipProps) => { +export const HtmlClip = memo(function HtmlClip({ clip }: HtmlClipProps) { const { isLight } = useTheme(); return (
    @@ -15,4 +16,4 @@ export const HtmlClip = ({ clip }: HtmlClipProps) => { {clip.content}
    ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/ImageClip.test.tsx b/src/renderer/src/components/clips/clip/ImageClip.test.tsx index 5d543f5..36d7712 100644 --- a/src/renderer/src/components/clips/clip/ImageClip.test.tsx +++ b/src/renderer/src/components/clips/clip/ImageClip.test.tsx @@ -15,6 +15,7 @@ vi.mock('./Clip.module.css', () => ({ imagePreviewContainer: 'imagePreviewContainer', imagePreview: 'imagePreview', imagePopover: 'imagePopover', + imagePopoverVisible: 'imagePopoverVisible', popoverImage: 'popoverImage', imageInfo: 'imageInfo', imageFilename: 'imageFilename', @@ -26,11 +27,13 @@ vi.mock('./Clip.module.css', () => ({ describe('ImageClip', () => { afterEach(() => { cleanup(); + themeState.isLight = false; }); + it('renders image with correct src', () => { render(); - const images = screen.getAllByRole('img'); - expect(images[0]).toHaveAttribute('src', 'data:image/png;base64,abc123'); + const img = screen.getAllByRole('img')[0]; + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc123'); }); it('displays image format', () => { @@ -50,13 +53,10 @@ describe('ImageClip', () => { expect(screen.getByText(/Unknown format/)).toBeInTheDocument(); }); - it('handles mouse enter and positions popover', () => { - const { container } = render( - - ); + it('shows popover on mouse enter and hides on mouse leave', () => { + render(); const img = screen.getAllByRole('img')[0]; - // Mock getBoundingClientRect img.getBoundingClientRect = vi.fn().mockReturnValue({ top: 100, left: 50, @@ -66,21 +66,26 @@ describe('ImageClip', () => { height: 100, }); - // Mock viewport Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + // Popover should not exist before hover + expect(document.body.querySelector('.imagePopoverVisible')).toBeNull(); + fireEvent.mouseEnter(img); - // Popover should have been positioned - const popover = container.querySelector('.imagePopover'); + // Popover should be portaled to body + const popover = document.body.querySelector('.imagePopoverVisible'); expect(popover).toBeTruthy(); + + fireEvent.mouseLeave(img); + + // Popover should be removed + expect(document.body.querySelector('.imagePopoverVisible')).toBeNull(); }); it('positions popover to the left when right edge exceeded', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -96,14 +101,12 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.left).toBeTruthy(); }); it('clamps popover to top edge', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -119,14 +122,12 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.top).toBe('16px'); }); it('clamps popover to bottom edge', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -142,18 +143,14 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; - // Should be clamped to viewport - popoverHeight - 16 + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.top).toBe(`${800 - 320 - 16}px`); }); it('clamps popover to left edge when positioned left goes negative', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; - // right edge exceeds viewport width, left edge is very close to 0 img.getBoundingClientRect = vi.fn().mockReturnValue({ top: 100, left: 5, @@ -167,7 +164,7 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.left).toBe('16px'); }); @@ -176,7 +173,6 @@ describe('ImageClip', () => { render(); const img = screen.getAllByRole('img')[0]; - // Mock parentNode const parent = document.createElement('div'); Object.defineProperty(img, 'parentNode', { value: parent, writable: true }); diff --git a/src/renderer/src/components/clips/clip/ImageClip.tsx b/src/renderer/src/components/clips/clip/ImageClip.tsx index f8af930..40c1395 100644 --- a/src/renderer/src/components/clips/clip/ImageClip.tsx +++ b/src/renderer/src/components/clips/clip/ImageClip.tsx @@ -1,4 +1,5 @@ -import { useRef } from 'react'; +import { memo, useState } from 'react'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -8,50 +9,40 @@ interface ImageClipProps { clip: ClipItem; } -export const ImageClip = ({ clip }: ImageClipProps) => { +export const ImageClip = memo(function ImageClip({ clip }: ImageClipProps) { const { isLight } = useTheme(); - const popoverRef = useRef(null); + const [popoverStyle, setPopoverStyle] = useState({}); + const [showPopover, setShowPopover] = useState(false); const handleImageMouseEnter = (e: React.MouseEvent) => { - const popover = popoverRef.current; - /* istanbul ignore else -- @preserve */ - if (popover) { - const rect = e.currentTarget.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const popoverHeight = 320; // 20rem max-height - const popoverWidth = 320; // 20rem max-width + const rect = e.currentTarget.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const popoverHeight = 320; + const popoverWidth = 320; - // Calculate preferred position (to the right of the image) - let left = rect.right + 16; - let top = rect.top + rect.height / 2 - popoverHeight / 2; // Center the popover vertically on the image + let left = rect.right + 16; + let top = rect.top + rect.height / 2 - popoverHeight / 2; - // Check if popover would extend beyond right edge of viewport - if (left + popoverWidth > viewportWidth) { - // Position to the left of the image instead - left = rect.left - popoverWidth - 16; - } - - // Ensure popover doesn't go beyond left edge - if (left < 16) { - left = 16; - } - - // Check if popover would extend beyond bottom of viewport - if (top + popoverHeight > viewportHeight) { - // Position at bottom edge of viewport with padding - top = viewportHeight - popoverHeight - 16; - } + if (left + popoverWidth > viewportWidth) { + left = rect.left - popoverWidth - 16; + } + if (left < 16) { + left = 16; + } + if (top + popoverHeight > viewportHeight) { + top = viewportHeight - popoverHeight - 16; + } + if (top < 16) { + top = 16; + } - // Check if popover would extend beyond top of viewport - if (top < 16) { - top = 16; - } + setPopoverStyle({ left: `${left}px`, top: `${top}px` }); + setShowPopover(true); + }; - popover.style.left = `${left}px`; - popover.style.top = `${top}px`; - popover.style.transform = 'none'; // Always use none since we calculate exact position - } + const handleImageMouseLeave = () => { + setShowPopover(false); }; return ( @@ -61,8 +52,8 @@ export const ImageClip = ({ clip }: ImageClipProps) => { alt="Clipboard image preview" className={classNames(styles.imagePreview, { [styles.light]: isLight })} onMouseEnter={handleImageMouseEnter} + onMouseLeave={handleImageMouseLeave} onError={(e) => { - // Fallback to text if image fails to load const target = e.target as HTMLImageElement; target.style.display = 'none'; const fallback = document.createElement('span'); @@ -72,16 +63,22 @@ export const ImageClip = ({ clip }: ImageClipProps) => { target.parentNode?.appendChild(fallback); }} /> -
    - Large image preview -
    + {showPopover && + createPortal( +
    + Large image preview +
    , + document.body + )}
    Image ( @@ -96,4 +93,4 @@ export const ImageClip = ({ clip }: ImageClipProps) => {
    ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/RtfClip.tsx b/src/renderer/src/components/clips/clip/RtfClip.tsx index 961baf1..f87557f 100644 --- a/src/renderer/src/components/clips/clip/RtfClip.tsx +++ b/src/renderer/src/components/clips/clip/RtfClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface RtfClipProps { clip: ClipItem; } -export const RtfClip = ({ clip }: RtfClipProps) => { +export const RtfClip = memo(function RtfClip({ clip }: RtfClipProps) { const { isLight } = useTheme(); return ( @@ -16,4 +17,4 @@ export const RtfClip = ({ clip }: RtfClipProps) => { {clip.content} ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx new file mode 100644 index 0000000..97f34e8 --- /dev/null +++ b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { createRef } from 'react'; +import { SyntaxHighlightedCode } from './SyntaxHighlightedCode'; + +vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockHighlighter = ({ children, PreTag }: any) => { + const Tag = PreTag || 'pre'; + return ( + + {children} + + ); + }; + MockHighlighter.registerLanguage = vi.fn(); + MockHighlighter.alias = vi.fn(); + return { default: MockHighlighter }; +}); + +vi.mock('react-syntax-highlighter/dist/esm/styles/prism/material-dark', () => ({ + default: {}, +})); + +vi.mock('react-syntax-highlighter/dist/esm/styles/prism/material-light', () => ({ + default: {}, +})); + +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/javascript', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/typescript', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/python', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/java', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/csharp', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/cpp', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/c', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/markup', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/css', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/json', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/sql', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/bash', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/powershell', () => ({ + default: {}, +})); + +vi.mock('./Clip.module.css', () => ({ + default: { + textEditorWrapper: 'textEditorWrapper', + syntaxHighlightContainer: 'syntaxHighlightContainer', + textEditor: 'textEditor', + syntaxOverlay: 'syntaxOverlay', + light: 'light', + }, +})); + +describe('SyntaxHighlightedCode', () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + editValue: 'const x = 1;', + syntaxLanguage: 'javascript', + isLight: false, + onChange: vi.fn(), + onBlur: vi.fn(), + onKeyDown: vi.fn(), + }; + + it('renders the syntax highlighter and textarea', () => { + render(); + expect(screen.getByTestId('prism-highlighter')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays the edit value in the highlighter', () => { + render(); + expect(screen.getByTestId('prism-highlighter')).toHaveTextContent('const x = 1;'); + }); + + it('passes edit value to the textarea', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('const x = 1;'); + }); + + it('calls onChange when textarea value changes', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'const y = 2;' } }); + expect(onChange).toHaveBeenCalled(); + }); + + it('calls onBlur when textarea loses focus', () => { + const onBlur = vi.fn(); + render(); + fireEvent.blur(screen.getByRole('textbox')); + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onKeyDown on key press', () => { + const onKeyDown = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + expect(onKeyDown).toHaveBeenCalled(); + }); + + it('applies dark theme styles by default', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea.className).not.toContain('light'); + expect(textarea.style.caretColor).toBe('#fff'); + }); + + it('applies light theme styles when isLight is true', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea.className).toContain('light'); + expect(textarea.style.caretColor).toBe('#000'); + }); + + it('forwards ref to the textarea', () => { + const ref = createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('textbox')); + }); + + it('sets textarea to transparent color for overlay effect', () => { + render(); + expect(screen.getByRole('textbox').style.color).toBe('transparent'); + }); + + it('disables spellcheck on the textarea', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('spellcheck', 'false'); + }); +}); diff --git a/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx new file mode 100644 index 0000000..fe33010 --- /dev/null +++ b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx @@ -0,0 +1,115 @@ +import { forwardRef } from 'react'; +import classNames from 'classnames'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark'; +import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; +import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; +import java from 'react-syntax-highlighter/dist/esm/languages/prism/java'; +import csharp from 'react-syntax-highlighter/dist/esm/languages/prism/csharp'; +import cpp from 'react-syntax-highlighter/dist/esm/languages/prism/cpp'; +import c from 'react-syntax-highlighter/dist/esm/languages/prism/c'; +import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'; +import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; +import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'; +import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; +import powershell from 'react-syntax-highlighter/dist/esm/languages/prism/powershell'; +import styles from './Clip.module.css'; + +SyntaxHighlighter.registerLanguage('javascript', javascript); +SyntaxHighlighter.registerLanguage('typescript', typescript); +SyntaxHighlighter.registerLanguage('python', python); +SyntaxHighlighter.registerLanguage('java', java); +SyntaxHighlighter.registerLanguage('csharp', csharp); +SyntaxHighlighter.registerLanguage('cpp', cpp); +SyntaxHighlighter.registerLanguage('c', c); +SyntaxHighlighter.registerLanguage('markup', markup); +SyntaxHighlighter.registerLanguage('css', css); +SyntaxHighlighter.registerLanguage('json', json); +SyntaxHighlighter.registerLanguage('sql', sql); +SyntaxHighlighter.registerLanguage('bash', bash); +SyntaxHighlighter.registerLanguage('powershell', powershell); + +interface SyntaxHighlightedCodeProps { + editValue: string; + syntaxLanguage: string; + isLight: boolean; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +export const SyntaxHighlightedCode = forwardRef( + ({ editValue, syntaxLanguage, isLight, onChange, onBlur, onKeyDown }, ref) => { + const syntaxStyle = isLight ? materialLight : materialDark; + const borderColor = isLight ? '#d0d0d0' : '#404040'; + const backgroundColor = isLight ? '#f8f8f8' : '#404040'; + + return ( +
    +
    + ( +
    +                {children}
    +              
    + )} + > + {editValue} +
    +