diff --git a/packages/pastebar-app-ui/src/App.tsx b/packages/pastebar-app-ui/src/App.tsx index 8e56f7c3..fbfb561c 100644 --- a/packages/pastebar-app-ui/src/App.tsx +++ b/packages/pastebar-app-ui/src/App.tsx @@ -34,6 +34,8 @@ import { settingsStoreAtom, themeStoreAtom, uiStoreAtom, + DEFAULT_SPECIAL_PASTE_OPERATIONS, + DEFAULT_SPECIAL_PASTE_CATEGORIES, } from './store' const appIdleEvents = ['mousemove', 'keydown', 'scroll', 'keypress', 'mousedown'] @@ -238,6 +240,14 @@ function App() { globalTemplates: settings.globalTemplates?.valueText ? settings.globalTemplates.valueText // Will be parsed by initSettings in store : [], // Default to empty array + isSpecialCopyPasteHistoryEnabled: + settings.isSpecialCopyPasteHistoryEnabled?.valueBool ?? true, + enabledSpecialPasteOperations: settings.enabledSpecialPasteOperations?.valueText + ? settings.enabledSpecialPasteOperations.valueText.split(',').filter(Boolean) + : [...DEFAULT_SPECIAL_PASTE_OPERATIONS], + specialPasteCategoriesOrder: settings.specialPasteCategoriesOrder?.valueText + ? settings.specialPasteCategoriesOrder.valueText.split(',').filter(Boolean) + : [...DEFAULT_SPECIAL_PASTE_CATEGORIES], isAppReady: true, }) settingsStore.initConstants({ diff --git a/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts b/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts new file mode 100644 index 00000000..93e65fd6 --- /dev/null +++ b/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef } from 'react' +import { UniqueIdentifier } from '@dnd-kit/core/dist/types' +import { signal } from '@preact/signals-react' +import { invoke } from '@tauri-apps/api/tauri' +import { settingsStoreAtom } from '~/store' +import { useAtomValue } from 'jotai' + +import { applyTransform, TEXT_TRANSFORMS } from '~/lib/text-transforms' + +// Signals for tracking special copy/paste operations +export const specialCopiedItem = signal('') +export const specialPastedItem = signal('') +export const specialPastedItemCountDown = signal(0) + +interface UseSpecialCopyPasteOptions { + delay?: number +} + +export const useSpecialCopyPasteHistoryItem = ({ + delay = 800, +}: UseSpecialCopyPasteOptions = {}) => { + const { copyPasteDelay } = useAtomValue(settingsStoreAtom) + const countdownRef = useRef() + + // Special copy function - applies transformation and copies to clipboard + const specialCopy = async ( + historyId: UniqueIdentifier, + value: string, + transformId: string + ): Promise => { + try { + if (!value || !historyId) { + console.warn('No value or historyId to copy') + return + } + + // Set the signal to show UI feedback + specialCopiedItem.value = historyId + + // Apply the text transformation - this will throw if it fails + const transformedText = await applyTransform(value, transformId) + + // Only copy if transformation was successful + setTimeout(() => { + invoke('copy_text', { text: transformedText }) + .then(res => { + if (res === 'ok') { + requestAnimationFrame(() => { + specialCopiedItem.value = '' + }) + } else { + specialCopiedItem.value = '' + console.error('Failed to copy transformed text', res) + } + }) + .catch(err => { + specialCopiedItem.value = '' + console.error('Failed to copy transformed text', err) + }) + }, delay) + } catch (error) { + // Clear UI feedback immediately on transformation error + specialCopiedItem.value = '' + console.error('Failed to special copy - transformation error:', error) + // Don't copy anything to clipboard when transformation fails + throw error + } + } + + // Countdown helper for paste operations + const pasteCountdown = useCallback( + (initialCount: number, intervalMs = 1000): Promise => { + clearInterval(countdownRef.current) + return new Promise(resolve => { + specialPastedItemCountDown.value = initialCount + countdownRef.current = setInterval(() => { + if (specialPastedItemCountDown.value > 0) { + if (specialPastedItemCountDown.value === 1) { + resolve() + } + specialPastedItemCountDown.value -= 1 + } else { + clearInterval(countdownRef.current) + } + }, intervalMs) + }) + }, + [] + ) + + // Execute paste action with transformed text + const executePasteAction = (text: string, delay = 0): Promise => { + return new Promise((resolve, reject) => { + invoke('copy_paste', { text, delay }) + .then(res => { + if (res === 'ok') { + resolve() + } else { + console.error('Failed to paste transformed text', res) + reject() + } + }) + .catch(err => { + console.error('Failed to paste transformed text', err) + reject() + }) + }) + } + + // Special paste function - applies transformation and pastes directly + const specialPaste = async ( + historyId: UniqueIdentifier, + value: string, + transformId: string, + delaySeconds?: number + ): Promise => { + try { + delaySeconds = delaySeconds ?? copyPasteDelay + + if (!value || !historyId) { + console.warn('No value or historyId to paste') + return + } + + // Set the signal to show UI feedback + specialPastedItem.value = historyId + + // Apply the text transformation - this will throw if it fails + const transformedText = await applyTransform(value, transformId) + + // Handle countdown if delay is specified (only if transformation succeeded) + if (delaySeconds > 0) { + await pasteCountdown(delaySeconds) + } + + // Execute paste with transformed text (only if transformation succeeded) + await executePasteAction(transformedText, 0) + + // Clear the signal after a short delay + setTimeout(() => { + requestAnimationFrame(() => { + specialPastedItem.value = '' + specialPastedItemCountDown.value = 0 + }) + }, delay) + } catch (error) { + // Clear UI feedback immediately on transformation error + specialPastedItem.value = '' + specialPastedItemCountDown.value = 0 + console.error('Failed to special paste - transformation error:', error) + // Don't paste anything when transformation fails + throw error + } + } + + return { + specialCopy, + specialPaste, + availableTransforms: TEXT_TRANSFORMS, + specialCopiedItem: specialCopiedItem.value, + specialPastedItem: specialPastedItem.value, + specialPastedItemCountDown: specialPastedItemCountDown.value, + } +} diff --git a/packages/pastebar-app-ui/src/lib/text-transforms.ts b/packages/pastebar-app-ui/src/lib/text-transforms.ts new file mode 100644 index 00000000..4a83b4cc --- /dev/null +++ b/packages/pastebar-app-ui/src/lib/text-transforms.ts @@ -0,0 +1,468 @@ +/** + * Text transformation utilities for special copy/paste operations + * Organized by categories with enable/disable controls + */ + +export interface TextTransform { + id: string + label: string + transform: (text: string) => string | Promise +} + +export interface TransformCategory { + id: string + label: string + transforms?: TextTransform[] + subcategories?: TransformSubcategory[] +} + +export interface TransformSubcategory { + id: string + label: string + transforms: TextTransform[] +} + +// Transform functions for Text Case +const toUpperCase = (text: string): string => text.toUpperCase() +const toLowerCase = (text: string): string => text.toLowerCase() +const toTitleCase = (text: string): string => + text.replace(/\b\w/g, char => char.toUpperCase()) // Renamed from toCapitalize +const toSentenceCase = (text: string): string => + text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() // Keep as is, assumes single string capitalization +const toInvertCase = (text: string): string => + text.replace(/[a-zA-Z]/g, char => + char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase() + ) + +// Transform functions for Code Formatting +const toCamelCase = (text: string): string => { + const normalized = text.replace(/[^a-zA-Z0-9]+/g, ' ').trim() // Normalize spaces/delimiters + return normalized + .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => + index === 0 ? word.toLowerCase() : word.toUpperCase() + ) + .replace(/\s+/g, '') +} + +const toSnakeCase = (text: string): string => { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') // Add underscore before capital letters (camelCase to snake_case part) + .replace(/[^a-zA-Z0-9]+/g, '_') // Replace non-alphanumeric with underscore + .toLowerCase() + .replace(/^_|_$/g, '') // Remove leading/trailing underscores +} + +const toKebabCase = (text: string): string => { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // Add hyphen before capital letters + .replace(/[^a-zA-Z0-9]+/g, '-') // Replace non-alphanumeric with hyphen + .toLowerCase() + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens +} + +const toPascalCase = (text: string): string => { + const normalized = text.replace(/[^a-zA-Z0-9]+/g, ' ').trim() // Normalize spaces/delimiters + return normalized + .replace(/(?:^\w|[A-Z]|\b\w)/g, word => word.toUpperCase()) + .replace(/\s+/g, '') +} + +// Transform functions for Whitespace & Lines +const trimWhiteSpace = (text: string): string => text.trim() +const removeLineFeeds = ( + text: string +): string => // More aggressive removal of multiple line feeds to single space + text + .replace(/\r?\n|\r/g, ' ') + .replace(/\s+/g, ' ') + .trim() +const addOneLineFeed = (text: string): string => text + '\n' +const addTwoLineFeeds = (text: string): string => text + '\n\n' +const removeExtraSpaces = (text: string): string => text.replace(/\s+/g, ' ') +const sortLinesAlphabetically = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + return lines.sort((a, b) => a.localeCompare(b)).join('\n') +} +const removeDuplicateLines = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + const uniqueLines = [...new Set(lines)] + return uniqueLines.join('\n') +} +const addLineNumbers = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + return lines.map((line, index) => `${index + 1}. ${line}`).join('\n') +} + +// Transform functions for Encoding & Security +const toBase64Encode = (text: string): string => { + try { + // This is generally the most robust method for UTF-8 in browsers without polyfills + return btoa(String.fromCharCode(...new TextEncoder().encode(text))) + } catch (e) { + console.error('Base64 encode error:', e) + return text + } +} +const toBase64Decode = (text: string): string => { + try { + // This is generally the most robust method for UTF-8 in browsers without polyfills + return new TextDecoder().decode( + Uint8Array.from(atob(text), charCode => charCode.charCodeAt(0)) + ) + } catch (e) { + console.error('Base64 decode error:', e) + return text + } +} +const toUrlEncode = (text: string): string => encodeURIComponent(text) +const toUrlDecode = (text: string): string => { + try { + return decodeURIComponent(text) + } catch (e) { + console.error('URL decode error:', e) + return text + } +} +const toHtmlEncode = (text: string): string => { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} +const toHtmlDecode = (text: string): string => { + const div = document.createElement('div') + div.innerHTML = text + return div.textContent || '' +} + +// Transform functions for Text Tools +const reverseText = (text: string): string => text.split('').reverse().join('') +const addCurrentDateTime = (text: string): string => { + const now = new Date() + return text + '\n' + now.toLocaleString() +} +const countCharacters = (text: string): string => { + const count = text.length + return `Character count: ${count}` +} +const countWords = (text: string): string => { + const words = text + .trim() + .split(/\s+/) + .filter(word => word.length > 0) + const count = words.length + return `Word count: ${count}` +} +const countLines = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + const count = lines.length + return `Line count: ${count}` +} +const countSentences = (text: string): string => { + const sentences = text.split(/[.!?]+/).filter(sentence => sentence.trim().length > 0) + const count = sentences.length + return `Sentence count: ${count}` +} +const toJsonStringify = (text: string): string => { + try { + const parsed = JSON.parse(text) + return JSON.stringify(parsed, null, 2) + } catch { + return JSON.stringify(text) // If not valid JSON, stringify the text itself + } +} + +// Format Converter subcategories - organized by source format +const formatConverterSubcategories = [ + { + id: 'html', + label: 'HTML', + transforms: [ + { + id: 'htmlToMarkdown', + label: 'HTML to Markdown', + transform: (text: string) => convertFormat(text, 'html_to_markdown'), + }, + { + id: 'htmlToReact', + label: 'HTML to React JSX', + transform: (text: string) => convertFormat(text, 'html_to_react'), + }, + { + id: 'htmlToReactComponent', + label: 'HTML to React Component', + transform: (text: string) => convertFormat(text, 'html_to_react_components'), + }, + { + id: 'htmlToText', + label: 'HTML to Text', + transform: (text: string) => convertFormat(text, 'html_to_text'), + }, + ], + }, + { + id: 'markdown', + label: 'Markdown', + transforms: [ + { + id: 'markdownToHtml', + label: 'Markdown to HTML', + transform: (text: string) => convertFormat(text, 'markdown_to_html'), + }, + { + id: 'markdownToText', + label: 'Markdown to Text', + transform: (text: string) => convertFormat(text, 'markdown_to_text'), + }, + ], + }, + { + id: 'json', + label: 'JSON', + transforms: [ + { + id: 'jsonToCsv', + label: 'JSON to CSV', + transform: (text: string) => convertFormat(text, 'json_to_csv'), + }, + { + id: 'jsonToYaml', + label: 'JSON to YAML', + transform: (text: string) => convertFormat(text, 'json_to_yaml'), + }, + { + id: 'jsonToXml', + label: 'JSON to XML', + transform: (text: string) => convertFormat(text, 'json_to_xml'), + }, + { + id: 'jsonToToml', + label: 'JSON to TOML', + transform: (text: string) => convertFormat(text, 'json_to_toml'), + }, + { + id: 'jsonToTable', + label: 'JSON to Markdown Table', + transform: (text: string) => convertFormat(text, 'json_to_table'), + }, + ], + }, + { + id: 'csv', + label: 'CSV', + transforms: [ + { + id: 'csvToJson', + label: 'CSV to JSON', + transform: (text: string) => convertFormat(text, 'csv_to_json'), + }, + { + id: 'csvToTable', + label: 'CSV to Markdown Table', + transform: (text: string) => convertFormat(text, 'csv_to_table'), + }, + ], + }, + { + id: 'yaml', + label: 'YAML', + transforms: [ + { + id: 'yamlToJson', + label: 'YAML to JSON', + transform: (text: string) => convertFormat(text, 'yaml_to_json'), + }, + ], + }, + { + id: 'xml', + label: 'XML', + transforms: [ + { + id: 'xmlToJson', + label: 'XML to JSON', + transform: (text: string) => convertFormat(text, 'xml_to_json'), + }, + ], + }, + { + id: 'toml', + label: 'TOML', + transforms: [ + { + id: 'tomlToJson', + label: 'TOML to JSON', + transform: (text: string) => convertFormat(text, 'toml_to_json'), + }, + ], + }, +] + +// Helper function to call Rust format conversion +function convertFormat(text: string, conversionType: string): Promise { + return new Promise(async (resolve, reject) => { + try { + const { invoke } = await import('@tauri-apps/api/tauri') + const result = await invoke('format_convert', { text, conversionType }) + resolve(result as string) + } catch (error) { + console.error(`Format conversion failed for ${conversionType}:`, error) + + // Show error dialog to user + try { + const { message } = await import('@tauri-apps/api/dialog') + const errorMessage = error instanceof Error ? error.message : String(error) + + // Clean up the error message for better user experience + const cleanErrorMessage = errorMessage + .replace(/^Error: /, '') + .replace(/^format_convert returned an error: /, '') + + await message(`${cleanErrorMessage}`, { + title: 'Format Conversion Error', + type: 'error', + }) + } catch (dialogError) { + console.error('Failed to show error dialog:', dialogError) + } + + // Reject the promise so the error propagates and prevents copy/paste + reject(error) + } + }) +} + +// Special Convert category will be implemented in Rust later for better performance + +// Categorized transformations +export const TRANSFORM_CATEGORIES: TransformCategory[] = [ + { + id: 'textCase', + label: 'Text Case', + transforms: [ + { id: 'upperCase', label: 'UPPER CASE', transform: toUpperCase }, + { id: 'lowerCase', label: 'lower case', transform: toLowerCase }, + { id: 'titleCase', label: 'Title Case', transform: toTitleCase }, + { id: 'sentenceCase', label: 'Sentence case', transform: toSentenceCase }, + { id: 'invertCase', label: 'iNVERT cASE', transform: toInvertCase }, + ], + }, + { + id: 'codeFormatting', + label: 'Code Formatting', + transforms: [ + { id: 'camelCase', label: 'camelCase', transform: toCamelCase }, + { id: 'snakeCase', label: 'snake_case', transform: toSnakeCase }, + { id: 'kebabCase', label: 'kebab-case', transform: toKebabCase }, + { id: 'pascalCase', label: 'PascalCase', transform: toPascalCase }, + { id: 'jsonStringify', label: 'JSON Stringify', transform: toJsonStringify }, + ], + }, + { + id: 'whitespaceLines', + label: 'Whitespace & Lines', + transforms: [ + { id: 'trimWhiteSpace', label: 'Trim White Space', transform: trimWhiteSpace }, + { id: 'removeLineFeeds', label: 'Remove Line Feeds', transform: removeLineFeeds }, + { id: 'addOneLineFeed', label: 'Add One Line Feed', transform: addOneLineFeed }, + { id: 'addTwoLineFeeds', label: 'Add Two Line Feeds', transform: addTwoLineFeeds }, + { + id: 'removeExtraSpaces', + label: 'Remove Extra Spaces', + transform: removeExtraSpaces, + }, + { + id: 'sortLinesAlphabetically', + label: 'Sort Lines Alphabetically', + transform: sortLinesAlphabetically, + }, + { + id: 'removeDuplicateLines', + label: 'Remove Duplicate Lines', + transform: removeDuplicateLines, + }, + { id: 'addLineNumbers', label: 'Add Line Numbers', transform: addLineNumbers }, + ], + }, + { + id: 'encodingSecurity', + label: 'Encode/Decode', + transforms: [ + { id: 'base64Encode', label: 'Base64 Encode', transform: toBase64Encode }, + { id: 'base64Decode', label: 'Base64 Decode', transform: toBase64Decode }, + { id: 'urlEncode', label: 'URL Encode', transform: toUrlEncode }, + { id: 'urlDecode', label: 'URL Decode', transform: toUrlDecode }, + { id: 'htmlEncode', label: 'HTML Encode', transform: toHtmlEncode }, + { id: 'htmlDecode', label: 'HTML Decode', transform: toHtmlDecode }, + ], + }, + { + id: 'textTools', + label: 'Text Tools', + transforms: [ + { id: 'reverseText', label: 'Reverse Text', transform: reverseText }, + { + id: 'addCurrentDateTime', + label: 'Add Current Date/Time', + transform: addCurrentDateTime, + }, + { id: 'countCharacters', label: 'Count Characters', transform: countCharacters }, + { id: 'countWords', label: 'Count Words', transform: countWords }, + { id: 'countLines', label: 'Count Lines', transform: countLines }, + { id: 'countSentences', label: 'Count Sentences', transform: countSentences }, + ], + }, + { + id: 'formatConverter', + label: 'Format Converter', + subcategories: formatConverterSubcategories, + }, +] + +// Flat list of all transformations for backward compatibility +export const TEXT_TRANSFORMS: TextTransform[] = TRANSFORM_CATEGORIES.flatMap(category => { + if (category.subcategories) { + // For categories with subcategories, flatten all transforms from all subcategories + return category.subcategories.flatMap(subcategory => subcategory.transforms) + } else { + // For categories with direct transforms + return category.transforms || [] + } +}) + +// Helper to get a transform by ID +export const getTransformById = (id: string): TextTransform | undefined => + TEXT_TRANSFORMS.find(t => t.id === id) + +// Helper to get a category by ID +export const getCategoryById = (id: string): TransformCategory | undefined => + TRANSFORM_CATEGORIES.find(c => c.id === id) + +// Helper to apply a transform by ID +export const applyTransform = async ( + text: string, + transformId: string +): Promise => { + const transform = getTransformById(transformId) + if (!transform) { + throw new Error(`Transform not found: ${transformId}`) + } + + try { + const result = transform.transform(text) + // Handle both sync and async transforms + return await Promise.resolve(result) + } catch (error) { + console.error(`Transform failed for ${transformId}:`, error) + // Re-throw the error so calling functions can handle it + throw error + } +} + +// Helper to get all category IDs +export const getAllCategoryIds = (): string[] => TRANSFORM_CATEGORIES.map(c => c.id) + +// Helper to get all transform IDs in a category +export const getTransformIdsInCategory = (categoryId: string): string[] => { + const category = getCategoryById(categoryId) + return category ? category.transforms?.map(t => t.id) ?? [] : [] +} diff --git a/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml new file mode 100644 index 00000000..c5089b3d --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Add Current Date/Time +Add Line Numbers: Add Line Numbers +Add One Line Feed: Add One Line Feed +Add Two Line Feeds: Add Two Line Feeds +Base64 Decode: Base64 Decode +Base64 Encode: Base64 Encode +CSV: CSV +CSV to JSON: CSV to JSON +CSV to Markdown Table: CSV to Markdown Table +Capitalize Case: Capitalize Case +Code Formatting: Code Formatting +Count Characters: Count Characters +Count Lines: Count Lines +Count Sentences: Count Sentences +Count Words: Count Words +Data Conversion: Data Conversion +? Drag and drop to prioritize categories in the special copy/paste menu. The higher a category is in the list, the higher its menu priority. +: Drag and drop to prioritize categories in the special copy/paste menu. The higher a category is in the list, the higher its menu priority. +Enable All: Enable All +Enable special text transformation options for clipboard history items: Enable special text transformation options for clipboard history items +Enabled Operations: Enabled Operations +Encode/Decode: Encode/Decode +Format Converter: Format Converter +HTML: HTML +HTML Decode: HTML Decode +HTML Encode: HTML Encode +HTML to Markdown: HTML to Markdown +HTML to React Component: HTML to React Component +HTML to React JSX: HTML to React JSX +HTML to Text: HTML to Text +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON to CSV +JSON to Markdown Table: JSON to Markdown Table +JSON to TOML: JSON to TOML +JSON to XML: JSON to XML +JSON to YAML: JSON to YAML +Markdown: Markdown +Markdown to HTML: Markdown to HTML +Markdown to Text: Markdown to Text +Operations: Operations +PascalCase: PascalCase +Prioritize Category Order: Prioritize Category Order +Remove Duplicate Lines: Remove Duplicate Lines +Remove Extra Spaces: Remove Extra Spaces +Remove Line Feeds: Remove Line Feeds +Reverse Text: Reverse Text +Select Operations: Select Operations +Sentence case: Sentence case +Sort Lines Alphabetically: Sort Lines Alphabetically +Special Copy: Special Copy +Special Copy/Paste Operations: Special Copy/Paste Operations +Special Paste: Special Paste +Special Settings: Special Settings +TOML: TOML +TOML to JSON: TOML to JSON +Text Case: Text Case +Text Tools: Text Tools +Title Case: Title Case +Trim White Space: Trim White Space +UPPER CASE: UPPER CASE +URL Decode: URL Decode +URL Encode: URL Encode +Whitespace & Lines: Whitespace & Lines +XML: XML +XML to JSON: XML to JSON +YAML: YAML +YAML to JSON: YAML to JSON +camelCase: camelCase +iNVERT cASE: iNVERT cASE +kebab-case: kebab-case +lower case: lower case +snake_case: snake_case diff --git a/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml b/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml index 0c5631ed..b5cea561 100644 --- a/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml +++ b/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml @@ -15,7 +15,7 @@ confirmDeleteTemplateMessage: "'{{name}}' global şablonunu silmek istediğinizd confirmDeleteTemplateTitle: Silmeyi Onayla deleteTemplateButtonTooltip: Şablonu Sil enableGlobalTemplatesLabel: Global Şablonları Etkinleştir -globalTemplatesDescription: "{{template_name}} kullanarak herhangi bir klibe eklenebilen yeniden kullanılabilir metin parçacıklarını yönetin." +globalTemplatesDescription: '{{template_name}} kullanarak herhangi bir klibe eklenebilen yeniden kullanılabilir metin parçacıklarını yönetin.' globalTemplatesTitle: Global Şablonlar localTemplateConflictWarning: "'{{label}}' adlı bir global şablon da mevcut. Yerel şablon bu klibin formu içinde öncelik alacaktır." noGlobalTemplatesYet: Henüz global şablon tanımlanmamış. Bir tane oluşturmak için 'Şablon Ekle'ye tıklayın. diff --git a/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml b/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml index acd10e3a..b44e2731 100644 --- a/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml +++ b/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml @@ -122,7 +122,7 @@ Errors: No fields found in the template.: 模板中未找到字段。 Please fix output template or confirm to save as is.: 请修复输出模板或确认保持现状。 Please fix template fields or confirm to save as is.: 请修复模板字段或确认保持现状。 - Please fix the problem or confirm to save as is.: 请修复问题或确认保持现状。 + Please fix the problem or confirm to save as is.: 请修复问题或确认保持现状。 Please verify your link for any errors, or confirm to save as is.: 请检查链接是否有错误,或或确认保持现状。 Please verify your path for any errors, or confirm to save as is.: 请检查路径是否有错误,或确认保持现状。 Your command runs with errors, confirm you want to save as is.: 您的命令运行出错,确认是否保持现状。 diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index b7afc6e0..6b0534d1 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -42,7 +42,7 @@ import ImageWithFallback from '~/components/atoms/image/image-with-fallback-on-e import LinkCard from '~/components/atoms/link-card/link-card' import PlayButton from '~/components/atoms/play-button/PlayButton' import ToolTip from '~/components/atoms/tooltip' -import { Badge, Box, ContextMenu, ContextMenuTrigger, Flex, Text } from '~/components/ui' +import { Badge, Box, Flex, Text } from '~/components/ui' import YoutubeEmbed from '~/components/video-player/YoutubeEmbed' import { useSignal } from '~/hooks/use-signal' @@ -55,7 +55,7 @@ import { hyperlinkText, hyperlinkTextWithPreview, } from '../helpers' -import ClipboardHistoryRowContextMenu from './context-menu/ClipboardHistoryRowContextMenu' +import ContextMenuTrigger from './context-menu/ContextMenuTrigger' interface ClipboardHistoryRowProps { index?: number @@ -123,7 +123,7 @@ interface ClipboardHistoryRowProps { setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> isSingleClickToCopyPaste?: boolean - historyPreviewLineLimit?: number + historyPreviewLineLimit?: number | null } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -193,6 +193,7 @@ export function ClipboardHistoryRowComponent({ const contentElementRendered = useSignal(false) const contextMenuOpen = useSignal(false) + const isDeleteConfirmationFromContext = useSignal(false) const isHovering = !isPinnedTop ? (hoveringHistoryRowId.value === clipboard?.historyId && @@ -365,7 +366,7 @@ export function ClipboardHistoryRowComponent({ ? 'bg-blue-50 dark:bg-blue-950/80' : !isPinnedTop && isOverPinned && !isNowItem ? 'bg-orange-50 dark:!bg-transparent' - : isDeleting + : isDeleting || isDeleteConfirmationFromContext.value ? 'bg-red-50 dark:bg-red-950/80' : contextMenuOpen.value ? `bg-slate-100 dark:bg-slate-900 ${ @@ -387,7 +388,8 @@ export function ClipboardHistoryRowComponent({ style={{ ...style, opacity: - (isDeleting || isOverPinned) && !isDragPreview + (isDeleting || isDeleteConfirmationFromContext.value || isOverPinned) && + !isDragPreview ? 1 : isDragging ? 0.7 @@ -414,758 +416,753 @@ export function ClipboardHistoryRowComponent({ )} - { contextMenuOpen.value = isOpen showHistoryDeleteConfirmationId.value = null }} + historyId={clipboard.historyId} + copiedFromApp={clipboard.copiedFromApp} + isMasked={clipboard.isMasked} + setSavingItem={setSavingItem} + value={clipboard.value} + isImage={clipboard.isImage} + isMp3={isMp3} + isText={clipboard.isText} + isPinned={clipboard.isPinned} + isFavorite={clipboard.isFavorite} + isImageData={clipboard.isImageData} + detectedLanguage={clipboard.detectedLanguage} + setLargeViewItemId={setLargeViewItemId} + isLargeView={isLargeView} + arrLinks={clipboard.arrLinks} + hasLinkCard={hasLinkCard} + isSelected={isSelected} + invalidateClipboardHistoryQuery={invalidateClipboardHistoryQuery} + generateLinkMetaData={generateLinkMetaData} + removeLinkMetaData={removeLinkMetaData} + setSelectHistoryItem={setSelectHistoryItem} + setSelectedHistoryItems={setSelectedHistoryItems} + selectedHistoryItems={selectedHistoryItems} + onCopyPaste={onCopyPaste} + setHistoryFilters={setHistoryFilters} + setAppFilters={setAppFilters} + onDeleteConfirmationChange={(historyId, isMultiSelect) => { + if (isMultiSelect) { + // For multi-select, highlight this item if it's in the selected items + isDeleteConfirmationFromContext.value = selectedHistoryItems.includes( + clipboard.historyId + ) + } else { + // For single select, highlight only if this is the specific item + isDeleteConfirmationFromContext.value = historyId === clipboard.historyId + } + }} > - - Date.now() - MINUTE_IN_MS && - !isCopiedOrPasted && - !isDeleting && - !isKeyboardSelected && - !isSelected - ? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950' - : isKeyboardSelected - ? `bg-blue-50 ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ring-offset-white dark:ring-offset-gray-800 ${ - isPinnedTop ? ' dark:!bg-amber-950' : '' - }` - : isDeleting && !isDragPreview - ? 'border-red-400 bg-red-50 dark:bg-red-950/80 dark:border-red-900/80 dark:hover:border-red-800' - : contextMenuOpen.value - ? 'bg-slate-100 dark:bg-slate-950/80 border-slate-300 dark:border-slate-600' - : isSaved && !isDragPreview - ? 'bg-sky-50 border-sky-600 dark:bg-sky-950/80 dark:border-sky-900/80 dark:hover:border-sky-800' - : isCopiedOrPasted && !isDragPreview - ? `bg-green-50 border-green-600 dark:bg-green-950/80 dark:border-green-800` - : isSelected - ? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${ - isPinnedTop ? '!border dark:!bg-amber-950' : '' - }` - : `hover:bg-white dark:hover:bg-slate-950/80 ${ - isLargeView - ? 'border-slate-500 bg-white dark:bg-slate-950 hover:dark:border-slate-500' - : `${ - !isPinnedTop && isOverPinned - ? 'border-orange-300 dark:border-orange-400/80 dark:bg-orange-900/80 bg-orange-50' - : isPinnedTop - ? 'bg-slate-50 dark:!bg-slate-900 dark:hover:!bg-slate-950 hover:!border-orange-300/90 border-orange-300/50 dark:!border-orange-800/60 dark:hover:!border-orange-900' - : 'bg-slate-50 hover:border-slate-300 dark:border-slate-800' - }` - } dark:hover:border-slate-700 dark:bg-slate-900 ${ - isDragPreview ? 'dark:border-slate-700' : '' - }` - }`} - onClickCapture={e => { - if ( - (isSingleClickToCopyPaste && - !getSelectedText().text && - isWindows && - e.ctrlKey) || - (e.metaKey && !isWindows) - ) { - e.preventDefault() - e.stopPropagation() - onCopyPaste(clipboard.historyId) - } else if ((isWindows && e.ctrlKey) || (e.metaKey && !isWindows)) { - setSelectHistoryItem(clipboard.historyId) - } else if (e.ctrlKey || e.metaKey) { - e.preventDefault() - e.stopPropagation() - } else if (e.shiftKey) { - e.preventDefault() - e.stopPropagation() - window.getSelection()?.removeAllRanges() - setLargeViewItemId(isLargeView ? null : clipboard.historyId) - } else if (largeViewItemId && !isLargeView) { - window.getSelection()?.removeAllRanges() - setLargeViewItemId(clipboard.historyId) - } else if (isSingleClickToCopyPaste && !getSelectedText().text) { - // Check if click is on context menu button or its children - const isContextMenuClick = - contextMenuButtonRef.current && - (contextMenuButtonRef.current.contains(e.target as Node) || - contextMenuButtonRef.current === e.target) + className={`rounded-md justify-start duration-300 history-box relative px-3 py-1 hover:shadow-sm my-0.5 shadow-none border-2 flex flex-col ${ + index === 0 && + clipboard.updatedAt > Date.now() - MINUTE_IN_MS && + !isCopiedOrPasted && + !isDeleting && + !isKeyboardSelected && + !isSelected + ? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950' + : isKeyboardSelected + ? `bg-blue-50 ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ring-offset-white dark:ring-offset-gray-800 ${ + isPinnedTop ? ' dark:!bg-amber-950' : '' + }` + : (isDeleting || isDeleteConfirmationFromContext.value) && + !isDragPreview + ? 'border-red-400 bg-red-50 dark:bg-red-950/80 dark:border-red-900/80 dark:hover:border-red-800' + : contextMenuOpen.value + ? 'bg-slate-100 dark:bg-slate-950/80 border-slate-300 dark:border-slate-600' + : isSaved && !isDragPreview + ? 'bg-sky-50 border-sky-600 dark:bg-sky-950/80 dark:border-sky-900/80 dark:hover:border-sky-800' + : isCopiedOrPasted && !isDragPreview + ? `bg-green-50 border-green-600 dark:bg-green-950/80 dark:border-green-800` + : isSelected + ? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${ + isPinnedTop ? '!border dark:!bg-amber-950' : '' + }` + : `hover:bg-white dark:hover:bg-slate-950/80 ${ + isLargeView + ? 'border-slate-500 bg-white dark:bg-slate-950 hover:dark:border-slate-500' + : `${ + !isPinnedTop && isOverPinned + ? 'border-orange-300 dark:border-orange-400/80 dark:bg-orange-900/80 bg-orange-50' + : isPinnedTop + ? 'bg-slate-50 dark:!bg-slate-900 dark:hover:!bg-slate-950 hover:!border-orange-300/90 border-orange-300/50 dark:!border-orange-800/60 dark:hover:!border-orange-900' + : 'bg-slate-50 hover:border-slate-300 dark:border-slate-800' + }` + } dark:hover:border-slate-700 dark:bg-slate-900 ${ + isDragPreview ? 'dark:border-slate-700' : '' + }` + }`} + onClickCapture={e => { + if ( + (isSingleClickToCopyPaste && + !getSelectedText().text && + isWindows && + e.ctrlKey) || + (e.metaKey && !isWindows) + ) { + e.preventDefault() + e.stopPropagation() + onCopyPaste(clipboard.historyId) + } else if ((isWindows && e.ctrlKey) || (e.metaKey && !isWindows)) { + setSelectHistoryItem(clipboard.historyId) + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault() + e.stopPropagation() + } else if (e.shiftKey) { + e.preventDefault() + e.stopPropagation() + window.getSelection()?.removeAllRanges() + setLargeViewItemId(isLargeView ? null : clipboard.historyId) + } else if (largeViewItemId && !isLargeView) { + window.getSelection()?.removeAllRanges() + setLargeViewItemId(clipboard.historyId) + } else if (isSingleClickToCopyPaste && !getSelectedText().text) { + // Check if click is on context menu button or its children + const isContextMenuClick = + contextMenuButtonRef.current && + (contextMenuButtonRef.current.contains(e.target as Node) || + contextMenuButtonRef.current === e.target) - if (isContextMenuClick) { - return // Don't copy/paste if clicking on context menu - } - - if ( - e.altKey || - (e.metaKey && isWindows) || - (e.ctrlKey && !isWindows) - ) { - onCopyPaste(clipboard.historyId) - } else { - onCopy(clipboard.historyId) - } - } else { - hoveringHistoryRowId.value = !isPinnedTop - ? clipboard.historyId - : `${clipboard.historyId}::pinned` + if (isContextMenuClick) { + return // Don't copy/paste if clicking on context menu } - }} - onMouseEnter={() => { + + if (e.altKey || (e.metaKey && isWindows) || (e.ctrlKey && !isWindows)) { + onCopyPaste(clipboard.historyId) + } else { + onCopy(clipboard.historyId) + } + } else { hoveringHistoryRowId.value = !isPinnedTop ? clipboard.historyId : `${clipboard.historyId}::pinned` - }} - onMouseLeave={() => { - hoveringHistoryRowId.value = null - }} - onDoubleClickCapture={e => { - if (!isSingleClickToCopyPaste && !getSelectedText().text) { - if (e.altKey || e.metaKey) { - onCopyPaste(clipboard.historyId) - } else { - onCopy(clipboard.historyId) - } + } + }} + onMouseEnter={() => { + hoveringHistoryRowId.value = !isPinnedTop + ? clipboard.historyId + : `${clipboard.historyId}::pinned` + }} + onMouseLeave={() => { + hoveringHistoryRowId.value = null + }} + onDoubleClickCapture={e => { + if (!isSingleClickToCopyPaste && !getSelectedText().text) { + if (e.altKey || e.metaKey) { + onCopyPaste(clipboard.historyId) + } else { + onCopy(clipboard.historyId) } - }} + } + }} + > + - - {showSelectHistoryItems && !isDragPreview ? ( + {showSelectHistoryItems && !isDragPreview ? ( + + { + setSelectHistoryItem(clipboard.historyId) + }} + checked={isSelected} + /> + + ) : ( + showCopyPasteIndexNumber && ( - { - setSelectHistoryItem(clipboard.historyId) + + {index === 9 ? 0 : index + 1} + + + ) + )} + {clipboard.isImageData ? ( + + + { + setBrokenImageItem(clipboard.historyId) }} - checked={isSelected} - /> - - ) : ( - showCopyPasteIndexNumber && ( - - - {index === 9 ? 0 : index + 1} - - - ) - )} - {clipboard.isImageData ? ( - - - { - setBrokenImageItem(clipboard.historyId) - }} - draggable={false} - decoding="async" - onLoad={() => { - contentElementRendered.value = true - }} - className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" - /> - - - {searchTerm ? ( - highlightWithPreviewMatchedText(clipboard.value, searchTerm) - ) : ( - {valuePreview} - )} - - - ) : clipboard.isLink && clipboard.isImage ? ( - - - { - setBrokenImageItem(clipboard.historyId) - }} - draggable={false} - decoding="async" - onLoad={() => { - contentElementRendered.value = true - }} - className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" - /> - - - {searchTerm - ? highlightWithPreviewMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - - - ) : clipboard.isLink && clipboard.isVideo ? ( - - - - {searchTerm - ? highlightWithPreviewMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - - - ) : clipboard.isImage && clipboard.imageDataUrl ? ( - - { contentElementRendered.value = true }} className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" /> - ) : clipboard.detectedLanguage && valuePreview ? ( - { - if (ref) { - contentElementRendered.value = true - } - }} - className={`text-ellipsis self-start text-sm w-full overflow-hidden`} - > - - {({ className, style, tokens, getLineProps, getTokenProps }) => { - return ( - - {tokens.map((line, i) => { - return ( -
- {line.map((token, key) => ( - - {!searchTerm - ? token.content - : highlightMatchedText( - token.content, - searchTerm - )} - - ))} -
- ) - })} -
- ) - }} -
-
- ) : ( - { - if (ref) { - contentElementRendered.value = true - } - }} - className="text-ellipsis self-start text-sm w-full overflow-hidden" - > - {hasLinkCard && ( - - - - )} - {isExpanded ? ( - - {searchTerm - ? highlightMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - + + {searchTerm ? ( + highlightWithPreviewMatchedText(clipboard.value, searchTerm) ) : ( - - {searchTerm - ? highlightWithPreviewMatchedText( - stringValue ?? '', - searchTerm - ) - : hyperlinkTextWithPreview({ - previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, - isPreviewError: hasClipboardHistoryURLErrors, - value: valuePreview ?? '', - links: clipboard.arrLinks, - itemId: null, - historyId: clipboard.historyId, - })} - {isMp3 && ( - - )} - + {valuePreview} )} + + + ) : clipboard.isLink && clipboard.isImage ? ( + + + { + setBrokenImageItem(clipboard.historyId) + }} + draggable={false} + decoding="async" + onLoad={() => { + contentElementRendered.value = true + }} + className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" + /> - )} - {(valueMorePreviewLines || valueMorePreviewChars) && - !isCopiedOrPasted && ( + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + + ) : clipboard.isLink && clipboard.isVideo ? ( + + + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + + ) : clipboard.isImage && clipboard.imageDataUrl ? ( + + { + contentElementRendered.value = true + }} + className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" + /> + + ) : clipboard.detectedLanguage && valuePreview ? ( + { + if (ref) { + contentElementRendered.value = true + } + }} + className={`text-ellipsis self-start text-sm w-full overflow-hidden`} + > + + {({ className, style, tokens, getLineProps, getTokenProps }) => { + return ( + + {tokens.map((line, i) => { + return ( +
+ {line.map((token, key) => ( + + {!searchTerm + ? token.content + : highlightMatchedText(token.content, searchTerm)} + + ))} +
+ ) + })} +
+ ) + }} +
+
+ ) : ( + { + if (ref) { + contentElementRendered.value = true + } + }} + className="text-ellipsis self-start text-sm w-full overflow-hidden" + > + {hasLinkCard && ( + + + + )} + {isExpanded ? ( + + {searchTerm + ? highlightMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + ) : ( + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue ?? '', searchTerm) + : hyperlinkTextWithPreview({ + previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, + isPreviewError: hasClipboardHistoryURLErrors, + value: valuePreview ?? '', + links: clipboard.arrLinks, + itemId: null, + historyId: clipboard.historyId, + })} + {isMp3 && ( + + )} + + )} + + )} + {(valueMorePreviewLines || valueMorePreviewChars) && + !isCopiedOrPasted && ( + { + setExpanded(clipboard.historyId, !isExpanded) + }} > + + {!isExpanded ? ( + valueMorePreviewChars ? ( + <> + +{valueMorePreviewChars} {t('chars', { ns: 'common' })} + + ) : ( + <> + +{valueMorePreviewLines} {t('lines', { ns: 'common' })} + + ) + ) : ( + <>- {t('show less', { ns: 'common' })} + )} + + + {isExpanded && ( { - setExpanded(clipboard.historyId, !isExpanded) - }} + className={`text-xs text-muted-foreground px-1.5 cursor-pointer`} + onClick={() => setWrapText(clipboard.historyId, !isWrapText)} > - {!isExpanded ? ( - valueMorePreviewChars ? ( - <> - +{valueMorePreviewChars} {t('chars', { ns: 'common' })} - - ) : ( - <> - +{valueMorePreviewLines} {t('lines', { ns: 'common' })} - - ) + {!isWrapText ? ( + ) : ( - <>- {t('show less', { ns: 'common' })} + )} - {isExpanded && ( - setWrapText(clipboard.historyId, !isWrapText)} - > - - {!isWrapText ? ( - - ) : ( - - )} - - - )} - - )} - {clipboard.isImage && !clipboard.isLink && ( - - - {clipboard.imageWidth}x{clipboard.imageHeight} - + )} )} - <> - {clipboard.isFavorite && ( - - )} + {clipboard.isImage && !clipboard.isLink && ( + + + {clipboard.imageWidth}x{clipboard.imageHeight} + + + )} + <> + {clipboard.isFavorite && ( + + )} - {clipboard.isPinned && !clipboard.isFavorite && !isPinnedTop && ( - - )} - - - {isHovering || isSelected ? ( -
- {isPinnedTop ? ( - - + )} + + + {isHovering || isSelected ? ( +
+ {isPinnedTop ? ( + + + - - { - if (isDisabledPinnedMoveUp) { - return - } - onMovePinnedUpDown({ - historyId: clipboard.historyId, - moveUp: true, - }) - }} - /> - - - - - { - if (isDisabledPinnedMoveDown) { - return - } - onMovePinnedUpDown({ - historyId: clipboard.historyId, - moveDown: true, - }) - }} - /> - - - - ) : ( - clipboard.timeAgoShort && ( - - - {clipboard.timeAgoShort} - - - ) - )} - - - {isKeyAltPressed.value ? ( - { - onCopyPaste(clipboard.historyId) - }} - /> - ) : ( - { - onCopy(clipboard.historyId) - }} - /> - )} - - - - - - - - - - { - contextMenuTriggerRef?.current?.dispatchEvent( - new MouseEvent('contextmenu', { - bubbles: true, - clientX: - contextMenuButtonRef?.current?.getBoundingClientRect() - .x, - clientY: - contextMenuButtonRef?.current?.getBoundingClientRect() - .y, + if (isDisabledPinnedMoveUp) { + return + } + onMovePinnedUpDown({ + historyId: clipboard.historyId, + moveUp: true, }) - ) + }} + /> + + + + + { + if (isDisabledPinnedMoveDown) { + return + } + onMovePinnedUpDown({ + historyId: clipboard.historyId, + moveDown: true, + }) + }} + /> + + + + ) : ( + clipboard.timeAgoShort && ( + + + {clipboard.timeAgoShort} + + + ) + )} + + + {isKeyAltPressed.value ? ( + { + onCopyPaste(clipboard.historyId) }} /> - - -
- ) : ( -
- {clipboard.hasMaskedWords && ( - - - - )} + ) : ( + { + onCopy(clipboard.historyId) + }} + /> + )} + + + + + + + + + + { + contextMenuTriggerRef?.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + clientX: + contextMenuButtonRef?.current?.getBoundingClientRect() + .x, + clientY: + contextMenuButtonRef?.current?.getBoundingClientRect() + .y, + }) + ) + }} + /> + + +
+ ) : ( +
+ {clipboard.hasMaskedWords && ( + + + + )} - {clipboard.isMasked && ( - - {t('Type:::Secret', { ns: 'common' })} - - )} + {clipboard.isMasked && ( + + {t('Type:::Secret', { ns: 'common' })} + + )} - {clipboard.detectedLanguage && ( - - {clipboard.detectedLanguage} - - )} + {clipboard.detectedLanguage && ( + + {clipboard.detectedLanguage} + + )} - {clipboard.isLink && ( - - {clipboard.isVideo - ? t('Type:::Video', { ns: 'common' }) - : isEmailNotUrl(stringValue) - ? t('Type:::Email', { ns: 'common' }) - : isMp3 - ? t('Type:::Mp3', { ns: 'common' }) - : t('Type:::Link', { ns: 'common' })} - - )} + {clipboard.isLink && ( + + {clipboard.isVideo + ? t('Type:::Video', { ns: 'common' }) + : isEmailNotUrl(stringValue) + ? t('Type:::Email', { ns: 'common' }) + : isMp3 + ? t('Type:::Mp3', { ns: 'common' }) + : t('Type:::Link', { ns: 'common' })} + + )} - {clipboard.hasEmoji && ( - - {t('Type:::Emoji', { ns: 'common' })} - - )} + {clipboard.hasEmoji && ( + + {t('Type:::Emoji', { ns: 'common' })} + + )} - {clipboard.isImageData && ( - - {t('Type:::Image Base64', { ns: 'common' })} - - )} -
- )} -
- {selectedItemsCount > 1 && ( - - - {selectedItemsCount} - - + {clipboard.isImageData && ( + + {t('Type:::Image Base64', { ns: 'common' })} + + )} +
)}
-
- {isSaved ? ( - - - - {t('Saved', { ns: 'common' })} - - - ) : isCopiedOrPasted && !pastingCountDown ? ( - - - - {isCopied - ? t('Copied', { ns: 'common' }) - : isPasted - ? t('Pasted', { ns: 'common' }) - : ''} - - - ) : !isLargeView ? ( - pastingCountDown && - pastingCountDown > 0 && ( - - - {t('Paste in {{pastingCountDown}}...', { - ns: 'common', - pastingCountDown, - })} + {selectedItemsCount > 1 && ( + + + {selectedItemsCount} - ) - ) : ( + )} + +
+ {isSaved ? ( + + + + {t('Saved', { ns: 'common' })} + + + ) : isCopiedOrPasted && !pastingCountDown ? ( + + + + {isCopied + ? t('Copied', { ns: 'common' }) + : isPasted + ? t('Pasted', { ns: 'common' }) + : ''} + + + ) : !isLargeView ? ( + pastingCountDown && + pastingCountDown > 0 && ( - - {t('In View', { ns: 'common' })} - - { - setLargeViewItemId(null) - }} - /> + {t('Paste in {{pastingCountDown}}...', { + ns: 'common', + pastingCountDown, + })} - )} -
-
- -
+ ) + ) : ( + + + + {t('In View', { ns: 'common' })} + + { + setLargeViewItemId(null) + }} + /> + + + )} + + ) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx index 74654854..6d45d330 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx @@ -1,15 +1,16 @@ -import { Dispatch, SetStateAction } from 'react' +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { UniqueIdentifier } from '@dnd-kit/core' import { useQueryClient } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api' import { message } from '@tauri-apps/api/dialog' import { emit } from '@tauri-apps/api/event' import { - clipboardHistoryStoreAtom, createClipHistoryItemIds, createMenuItemFromHistoryId, + DEFAULT_SPECIAL_PASTE_CATEGORIES, hasDashboardItemCreate, isCreatingMenuItem, + isKeyAltPressed, settingsStoreAtom, } from '~/store' import { useAtomValue } from 'jotai' @@ -27,6 +28,7 @@ import { PanelTop, Pin, PinOff, + Settings, Shrink, SquareAsterisk, Star, @@ -35,6 +37,7 @@ import { import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' +import { TRANSFORM_CATEGORIES, type TransformCategory } from '~/lib/text-transforms' import { ensureUrlPrefix } from '~/lib/utils' import { @@ -58,6 +61,7 @@ import { useUpdateClipboardHistoryById, } from '~/hooks/queries/use-history-items' import { useSignal } from '~/hooks/use-signal' +import { useSpecialCopyPasteHistoryItem } from '~/hooks/use-special-copypaste-history-item' import { LinkMetadata } from '~/types/history' import { CreateDashboardItemType } from '~/types/menu' @@ -92,6 +96,10 @@ interface ClipboardHistoryRowContextMenuProps { onCopyPaste: (id: UniqueIdentifier, delay?: number) => void setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> + onDeleteConfirmationChange?: ( + historyId: UniqueIdentifier | null, + isMultiSelect?: boolean + ) => void } export default function ClipboardHistoryRowContextMenu({ @@ -121,8 +129,10 @@ export default function ClipboardHistoryRowContextMenu({ setSelectHistoryItem, selectedHistoryItems, onCopyPaste, + onDeleteConfirmationChange = () => {}, }: ClipboardHistoryRowContextMenuProps) { const { t } = useTranslation() + const navigate = useNavigate() const queryClient = useQueryClient() const { copyPasteDelay, @@ -130,14 +140,67 @@ export default function ClipboardHistoryRowContextMenu({ historyDetectLanguagesEnabledList, setIsExclusionAppListEnabled, addToHistoryExclusionAppList, + enabledSpecialPasteOperations, + specialPasteCategoriesOrder, + isSpecialCopyPasteHistoryEnabled, } = useAtomValue(settingsStoreAtom) - const showDeleteMenuItemsConfirmation = useSignal(false) + const [specialActionInProgress, setSpecialActionInProgress] = useState( + null + ) + const deleteTimerRef = useRef(null) + + // Moved hook declarations before useHotkeys to resolve TS errors const { updateClipboardHistoryById } = useUpdateClipboardHistoryById() const { deleteClipboardHistoryByIds } = useDeleteClipboardHistoryByIds() const { pinnedClipboardHistoryByIds } = usePinnedClipboardHistoryByIds() - const navigate = useNavigate() + // Track pending delete ID for two-step deletion + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + deleteTimerRef.current = null + } + } + }, []) + + const { specialCopy, specialPaste } = useSpecialCopyPasteHistoryItem() + + // Ensure we always have an array of categories + const categoriesOrder = specialPasteCategoriesOrder || [ + ...DEFAULT_SPECIAL_PASTE_CATEGORIES, + ] + + // Filter categories to only include those with enabled transforms + const categoriesWithTransforms = categoriesOrder + .map(categoryId => TRANSFORM_CATEGORIES.find(c => c.id === categoryId)) + .filter((category): category is TransformCategory => { + if (!category || !categoriesOrder.includes(category.id)) return false + + // Check if category has any enabled transforms + if (category.subcategories) { + // For categories with subcategories, check if any subcategory has enabled transforms + const hasEnabledSubcategories = category.subcategories.some(subcategory => + subcategory.transforms.some(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + ) + return hasEnabledSubcategories + } else { + // For categories with transforms, check if any transform is enabled + const enabledTransforms = + category.transforms?.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) || [] + return enabledTransforms.length > 0 + } + }) + + const hasEnabledCategories = categoriesWithTransforms.length > 0 const errorMessage = (err: string) => { message( @@ -155,7 +218,67 @@ export default function ClipboardHistoryRowContextMenu({ return ( - + { + // Prevent closing on interaction outside during deletion confirmation + if (pendingDeleteId) { + e.preventDefault() + } + }} + onEscapeKeyDown={e => { + // Allow escape to close even during confirmation + setPendingDeleteId(null) + }} + onKeyDown={e => { + // Handle Delete/Backspace keys + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + e.stopPropagation() + + if (isSelected && selectedHistoryItems && selectedHistoryItems.length > 1) { + // Multi-select delete + if (pendingDeleteId === 'multi') { + // Confirm multi-delete + deleteClipboardHistoryByIds({ historyIds: selectedHistoryItems }) + setTimeout(() => { + setSelectedHistoryItems([]) + }, 400) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } + } else { + // Start multi-delete confirmation + setPendingDeleteId('multi') + onDeleteConfirmationChange?.(null, true) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) + }, 3000) + } + } else { + // Single delete + if (pendingDeleteId === historyId) { + // Confirm single delete + deleteClipboardHistoryByIds({ historyIds: [historyId] }) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } + } else { + // Start single delete confirmation + setPendingDeleteId(historyId) + onDeleteConfirmationChange?.(historyId, false) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) + }, 3000) + } + } + } + }} + > { setSelectHistoryItem(historyId) @@ -247,6 +370,167 @@ export default function ClipboardHistoryRowContextMenu({ + + {/* Special Copy/Paste submenu - only show for text items when enabled */} + {isSpecialCopyPasteHistoryEnabled && !isImage && value && ( + <> + + + {isKeyAltPressed.value + ? t('Special Paste', { ns: 'specailCopyPaste' }) + : t('Special Copy', { ns: 'specailCopyPaste' })} + + + {categoriesWithTransforms.map(category => { + // Handle categories with subcategories (like Format Converter) + if (category.subcategories) { + const enabledSubcategories = category.subcategories.filter( + subcategory => + subcategory.transforms.some(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + ) + + return ( + + + {t(category.label, { + ns: 'specailCopyPaste', + })} + + + {enabledSubcategories.map(subcategory => { + const enabledTransforms = subcategory.transforms.filter( + transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + + return ( + + + {t(subcategory.label, { + ns: 'specailCopyPaste', + })} + + + {enabledTransforms.map(transform => ( + { + setSpecialActionInProgress(transform.id) + try { + if (isKeyAltPressed.value) { + await specialPaste( + historyId, + value, + transform.id + ) + } else { + await specialCopy( + historyId, + value, + transform.id + ) + } + setSpecialActionInProgress(null) + } catch (error) { + console.error( + 'Special copy/paste failed:', + error + ) + setSpecialActionInProgress(null) + } + }} + > + {t(transform.label, { + ns: 'specailCopyPaste', + })} + {specialActionInProgress === transform.id && ( +
+ + ... + +
+ )} +
+ ))} +
+
+ ) + })} +
+
+ ) + } else { + // Handle categories with direct transforms + const enabledTransforms = + category.transforms?.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) || [] + + return ( + + + {t(category.label, { + ns: 'specailCopyPaste', + })} + + + {enabledTransforms.map(transform => ( + { + setSpecialActionInProgress(transform.id) + try { + if (isKeyAltPressed.value) { + await specialPaste(historyId, value, transform.id) + } else { + await specialCopy(historyId, value, transform.id) + } + setSpecialActionInProgress(null) + } catch (error) { + console.error('Special copy/paste failed:', error) + setSpecialActionInProgress(null) + } + }} + > + {t(transform.label, { + ns: 'specailCopyPaste', + })} + {specialActionInProgress === transform.id && ( +
+ + ... + +
+ )} +
+ ))} +
+
+ ) + } + })} + {hasEnabledCategories && } + { + navigate('/app-settings/history#specialCopyPasteHistory', { + replace: true, + }) + }} + > + {t('Special Settings', { ns: 'specailCopyPaste' })} +
+ +
+
+
+
+ + )} + { @@ -552,25 +836,32 @@ export default function ClipboardHistoryRowContextMenu({ {isSelected && selectedHistoryItems && selectedHistoryItems.length > 1 ? ( { - if (showDeleteMenuItemsConfirmation.value) { + className={pendingDeleteId === 'multi' ? 'bg-red-500/20 dark:bg-red-600/20' : ''} + onSelect={async e => { + e.preventDefault() + + if (pendingDeleteId === 'multi') { await deleteClipboardHistoryByIds({ historyIds: selectedHistoryItems }) setTimeout(() => { setSelectedHistoryItems([]) }, 400) - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } } else { - e.preventDefault() - showDeleteMenuItemsConfirmation.value = true - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId('multi') + onDeleteConfirmationChange?.(null, true) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) }, 3000) } }} > - {!showDeleteMenuItemsConfirmation.value + {pendingDeleteId !== 'multi' ? t('Delete', { ns: 'common' }) : t('Click to Confirm', { ns: 'common' })} - {!showDeleteMenuItemsConfirmation.value && ( + {pendingDeleteId !== 'multi' && (
DEL @@ -591,29 +882,34 @@ export default function ClipboardHistoryRowContextMenu({ ) : ( { - if (showDeleteMenuItemsConfirmation.value) { + className={pendingDeleteId === historyId ? 'bg-red-500/20 dark:bg-red-600/20' : ''} + onSelect={async e => { + e.preventDefault() + + if (pendingDeleteId === historyId) { await deleteClipboardHistoryByIds({ historyIds: [historyId] }) - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false - }, 400) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } } else { - e.preventDefault() - showDeleteMenuItemsConfirmation.value = true - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId(historyId) + onDeleteConfirmationChange?.(historyId, false) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) }, 3000) } }} > - {!showDeleteMenuItemsConfirmation.value + {pendingDeleteId !== historyId ? t('Delete', { ns: 'common' }) : t('Click to Confirm', { ns: 'common' })} - {!showDeleteMenuItemsConfirmation.value && ( + {pendingDeleteId !== historyId && (
DEL diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx new file mode 100644 index 00000000..2bbbddbb --- /dev/null +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx @@ -0,0 +1,142 @@ +import { Dispatch, forwardRef, SetStateAction, useState } from 'react' +import { UniqueIdentifier } from '@dnd-kit/core' + +import { + ContextMenu, + ContextMenuTrigger as ContextMenuTriggerPrimitive, +} from '~/components/ui' + +import { LinkMetadata } from '~/types/history' + +import ClipboardHistoryRowContextMenu from './ClipboardHistoryRowContextMenu' + +// // Lazy load the heavy context menu component +// const ClipboardHistoryRowContextMenu = lazy( +// () => import('./ClipboardHistoryRowContextMenu') +// ) + +interface ContextMenuTriggerProps { + children: React.ReactNode + onOpenChange?: (isOpen: boolean) => void + historyId: UniqueIdentifier + value: string | null + arrLinks: string[] + isImage: boolean + isText: boolean + copiedFromApp?: string | null + isMasked: boolean + isImageData: boolean + isMp3: boolean | undefined + hasLinkCard: boolean | undefined | string | null + isSelected: boolean + isLargeView: boolean + isPinned: boolean + isFavorite: boolean + detectedLanguage: string | null + setLargeViewItemId: (historyId: UniqueIdentifier | null) => void + setSavingItem: (historyId: UniqueIdentifier | null) => void + invalidateClipboardHistoryQuery?: () => void + generateLinkMetaData?: ( + historyId: UniqueIdentifier, + url: string + ) => Promise + removeLinkMetaData?: (historyId: UniqueIdentifier) => Promise + setSelectHistoryItem: (id: UniqueIdentifier) => void + setSelectedHistoryItems?: (ids: UniqueIdentifier[]) => void + selectedHistoryItems?: UniqueIdentifier[] + onCopyPaste: (id: UniqueIdentifier, delay?: number) => void + setHistoryFilters?: Dispatch> + setAppFilters?: Dispatch> + onDeleteConfirmationChange?: ( + historyId: UniqueIdentifier | null, + isMultiSelect?: boolean + ) => void +} + +const ContextMenuTrigger = forwardRef( + ( + { + children, + onOpenChange, + historyId, + value, + arrLinks, + isImage, + isText, + copiedFromApp, + isMasked, + isImageData, + isMp3, + hasLinkCard, + isSelected, + isLargeView, + isPinned, + isFavorite, + detectedLanguage, + setLargeViewItemId, + setSavingItem, + invalidateClipboardHistoryQuery, + generateLinkMetaData, + removeLinkMetaData, + setSelectHistoryItem, + setSelectedHistoryItems, + selectedHistoryItems, + onCopyPaste, + setHistoryFilters, + setAppFilters, + onDeleteConfirmationChange, + }, + ref + ) => { + const [isOpen, setIsOpen] = useState(false) + + const handleOpenChange = (open: boolean) => { + setIsOpen(open) + onOpenChange?.(open) + } + + return ( + + + {children} + + + {isOpen && ( + + )} + + ) + } +) + +ContextMenuTrigger.displayName = 'ContextMenuTrigger' + +export default ContextMenuTrigger diff --git a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx index 34c663d8..abbfc78c 100644 --- a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx @@ -123,7 +123,12 @@ const renderWithBadges = ( : 'dark:!text-gray-300 text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-200/80 dark:border-gray-700/80' } text-normal pr-2.5`} > - + {field.label} } @@ -163,7 +168,12 @@ const renderWithBadges = ( : 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-200/80 dark:border-gray-700/80' } text-normal pr-2.5`} > - + {field.label} @@ -196,7 +206,10 @@ const renderWithBadges = ( variant="outline" className="!text-purple-700 dark:!text-purple-300 bg-purple-100 dark:bg-purple-800 hover:bg-purple-200 dark:hover:bg-purple-700 border-purple-200 dark:border-purple-800 text-normal pr-2.5" > - + {field.label} (Global) } @@ -223,7 +236,10 @@ const renderWithBadges = ( variant="outline" className="!text-purple-700 dark:!text-purple-300 bg-purple-100 dark:bg-purple-800 hover:bg-purple-200 dark:hover:bg-purple-700 border-purple-200 dark:border-purple-800 text-normal pr-2.5" > - + {field.label} @@ -407,17 +423,18 @@ export function ClipViewTemplate({ .filter(f => f.label !== undefined) .map(({ label, isEnable, value, isValueMasked, isGlobal }) => { // For global templates, get the current value from globalTemplates - const actualValue = isGlobal && globalTemplatesEnabled - ? globalTemplates.find(gt => gt.isEnabled && gt.name === label)?.value || '' - : value; - + const actualValue = + isGlobal && globalTemplatesEnabled + ? globalTemplates.find(gt => gt.isEnabled && gt.name === label)?.value || '' + : value + return { label, isValueMasked, value: actualValue, isEnable, isGlobal, - }; + } }), clipboardValueSignal.value, templateShowFormat.value === 'values', @@ -587,9 +604,7 @@ export function ClipViewTemplate({ {field.label} @@ -781,7 +796,10 @@ export function ClipViewTemplate({ title={`Global Template: ${field.label}`} /> - + {t('Global', { ns: 'templates' })} diff --git a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx index fd027fd5..6bbd4b05 100644 --- a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx @@ -90,7 +90,11 @@ export function MenuCardViewBody({ const { t } = useTranslation() const isWrapText = useSignal(false) - const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview(value, false, false) + const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview( + value, + false, + false + ) const textValue: string = value || '' const isBrokenImage = useSignal(false) const pathTypeCheck = useSignal('') diff --git a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx index bf585ad5..6b0b4b44 100644 --- a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx +++ b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx @@ -118,6 +118,11 @@ import { useCopyPasteHistoryItem, usePasteHistoryItem, } from '~/hooks/use-copypaste-history-item' +import { + specialCopiedItem, + specialPastedItem, + specialPastedItemCountDown, +} from '~/hooks/use-special-copypaste-history-item' import { useDebounce } from '~/hooks/use-debounce' import useDeleteConfirmationTimer from '~/hooks/use-delete-confirmation-items' import { useSignal } from '~/hooks/use-signal' @@ -339,6 +344,9 @@ export default function ClipboardHistoryPage() { const pastedItemValue = useMemo(() => pastedItem, [pastedItem]) const copiedItemValue = useMemo(() => copiedItem, [copiedItem]) + const specialCopiedItemValue = useMemo(() => specialCopiedItem.value, [specialCopiedItem.value]) + const specialPastedItemValue = useMemo(() => specialPastedItem.value, [specialPastedItem.value]) + const specialPastingCountDown = useMemo(() => specialPastedItemCountDown.value, [specialPastedItemCountDown.value]) const clipboardHistory = hasSearchOrFilter ? foundClipboardHistory : allClipboardHistory @@ -1480,10 +1488,12 @@ export default function ClipboardHistoryPage() { pastingCountDown={ historyId === pastedItemValue ? pastingCountDown + : historyId === specialPastedItemValue + ? specialPastingCountDown : undefined } - isPasted={historyId === pastedItemValue} - isCopied={historyId === copiedItemValue} + isPasted={historyId === pastedItemValue || historyId === specialPastedItemValue} + isCopied={historyId === copiedItemValue || historyId === specialCopiedItemValue} isSaved={historyId === savingItem} setSavingItem={setSavingItem} isDeleting={hasIsDeleting(historyId)} @@ -2090,9 +2100,11 @@ export default function ClipboardHistoryPage() { pastingCountDown={ historyId === pastedItemValue ? pastingCountDown + : historyId === specialPastedItemValue + ? specialPastingCountDown : undefined } - isPasted={historyId === pastedItemValue} + isPasted={historyId === pastedItemValue || historyId === specialPastedItemValue} isKeyboardSelected={ (currentNavigationContext.value === 'history' || @@ -2100,7 +2112,7 @@ export default function ClipboardHistoryPage() { null) && historyId === keyboardSelectedItemId.value } - isCopied={historyId === copiedItemValue} + isCopied={historyId === copiedItemValue || historyId === specialCopiedItemValue} isSaved={historyId === savingItem} setSavingItem={setSavingItem} key={historyId} @@ -2443,10 +2455,12 @@ export default function ClipboardHistoryPage() { pastingCountDown={ inLargeViewItem.historyId === pastedItemValue ? pastingCountDown + : inLargeViewItem.historyId === specialPastedItemValue + ? specialPastingCountDown : null } - isPasted={inLargeViewItem.historyId === pastedItemValue} - isCopied={inLargeViewItem.historyId === copiedItemValue} + isPasted={inLargeViewItem.historyId === pastedItemValue || inLargeViewItem.historyId === specialPastedItemValue} + isCopied={inLargeViewItem.historyId === copiedItemValue || inLargeViewItem.historyId === specialCopiedItemValue} isSaved={inLargeViewItem.historyId === savingItem} isMp3={ inLargeViewItem.isLink && diff --git a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx index 9385fe11..f5ae17f8 100644 --- a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx +++ b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx @@ -7,13 +7,19 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { settingsStoreAtom, uiStoreAtom } from '~/store' +import { + DEFAULT_SPECIAL_PASTE_CATEGORIES, + DEFAULT_SPECIAL_PASTE_OPERATIONS, + settingsStoreAtom, + uiStoreAtom, +} from '~/store' import { useAtomValue } from 'jotai' -import { Grip } from 'lucide-react' +import { ChevronDown, Grip } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import AutoSize from 'react-virtualized-auto-sizer' +import { TEXT_TRANSFORMS, TRANSFORM_CATEGORIES } from '~/lib/text-transforms' import { arraysEqual, isStringArrayEmpty, @@ -25,6 +31,7 @@ import Spacer from '~/components/atoms/spacer' import SimpleBar from '~/components/libs/simplebar-react' import InputField from '~/components/molecules/input' import { + Badge, Box, Button, Card, @@ -32,6 +39,12 @@ import { CardHeader, CardTitle, CheckBoxFilter, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, Flex, Select, SelectContent, @@ -79,6 +92,224 @@ function SortableItem({ id, language }: SortableItemProps) { ) } +interface SortableCategoryItemProps { + categoryId: string + localCategoriesOrder: string[] + setLocalCategoriesOrder: (categories: string[]) => void +} +function SortableCategoryItem({ + categoryId, + localCategoriesOrder, + setLocalCategoriesOrder, +}: SortableCategoryItemProps) { + const { t } = useTranslation() + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: categoryId }) + + const { + enabledSpecialPasteOperations, + setEnabledSpecialPasteOperations, + setSpecialPasteCategoriesOrder, + } = useAtomValue(settingsStoreAtom) + + const category = TRANSFORM_CATEGORIES.find(c => c.id === categoryId) + if (!category) return null + + const isCategoryEnabled = localCategoriesOrder.includes(category.id) + + // Get all transforms in category (including from subcategories) + const allTransformsInCategory = category.subcategories + ? category.subcategories.flatMap(subcategory => subcategory.transforms) + : category.transforms || [] + + const enabledTransformsInCategory = allTransformsInCategory.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ + {/* Category Header */} + + + + + {t(category.label, { ns: 'specailCopyPaste' })} + + + + + {enabledTransformsInCategory.length}/{allTransformsInCategory.length} + + { + if (checked) { + const newLocalOrder = localCategoriesOrder.includes(category.id) + ? localCategoriesOrder + : [...localCategoriesOrder, category.id] + setLocalCategoriesOrder(newLocalOrder) + setSpecialPasteCategoriesOrder(newLocalOrder) + + // Enable all transforms in the category (including from subcategories) + const allTransformIds = allTransformsInCategory.map(t => t.id) + const newOps = [ + ...new Set([...enabledSpecialPasteOperations, ...allTransformIds]), + ] + setEnabledSpecialPasteOperations(newOps) + } else { + const newLocalOrder = localCategoriesOrder.filter( + id => id !== category.id + ) + setLocalCategoriesOrder(newLocalOrder) + setSpecialPasteCategoriesOrder(newLocalOrder) + + const transformIds = allTransformsInCategory.map(t => t.id) + const newOps = enabledSpecialPasteOperations.filter( + op => !transformIds.includes(op) + ) + setEnabledSpecialPasteOperations(newOps) + } + }} + /> + + + + {/* Individual Transform Controls */} + {isCategoryEnabled && ( + + + + + + + + {t(category.label, { ns: 'specailCopyPaste' })}{' '} + {t('Operations', { ns: 'specailCopyPaste' })} + + + + {category.subcategories + ? // Handle categories with subcategories (like Format Converter) + category.subcategories.map(subcategory => ( +
+ + {t(subcategory.label, { ns: 'specailCopyPaste' })} + + {subcategory.transforms.map(transform => ( + { + e.preventDefault() + }} + onCheckedChange={checked => { + if (checked) { + setEnabledSpecialPasteOperations([ + ...enabledSpecialPasteOperations, + transform.id, + ]) + } else { + setEnabledSpecialPasteOperations( + enabledSpecialPasteOperations.filter( + op => op !== transform.id + ) + ) + } + }} + className="pl-6" + > + {t(transform.label, { ns: 'specailCopyPaste' })} + + ))} + {category.subcategories && + subcategory !== + category.subcategories[ + category.subcategories.length - 1 + ] && } +
+ )) + : // Handle categories with direct transforms + (category.transforms || []).map(transform => ( + { + e.preventDefault() + }} + onCheckedChange={checked => { + if (checked) { + setEnabledSpecialPasteOperations([ + ...enabledSpecialPasteOperations, + transform.id, + ]) + } else { + setEnabledSpecialPasteOperations( + enabledSpecialPasteOperations.filter( + op => op !== transform.id + ) + ) + } + }} + > + {t(transform.label, { ns: 'specailCopyPaste' })} + + ))} +
+
+
+
+ )} +
+
+ ) +} + export default function ClipboardHistorySettings() { const { isHistoryEnabled, @@ -131,6 +362,12 @@ export default function ClipboardHistorySettings() { setIsKeepPinnedOnClearEnabled, isKeepStarredOnClearEnabled, setIsKeepStarredOnClearEnabled, + isSpecialCopyPasteHistoryEnabled, + setIsSpecialCopyPasteHistoryEnabled, + enabledSpecialPasteOperations, + setEnabledSpecialPasteOperations, + specialPasteCategoriesOrder, + setSpecialPasteCategoriesOrder, isAppReady, CONST: { APP_DETECT_LANGUAGES_SUPPORTED: languageList }, } = useAtomValue(settingsStoreAtom) @@ -138,6 +375,19 @@ export default function ClipboardHistorySettings() { const { returnRoute } = useAtomValue(uiStoreAtom) const { t } = useTranslation() + useEffect(() => { + const id = window.location.hash.substring(1) + + if (id == null) { + return + } + + setTimeout(() => { + const releventDiv = document.getElementById(id) + releventDiv?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 400) + }, []) + const [exclusionListValue, setExclusionListValue] = useState('') const [exclusionAppListValue, setExclusionAppListValue] = useState('') const [autoMaskListValue, setAutoMaskListValue] = useState('') @@ -149,6 +399,7 @@ export default function ClipboardHistorySettings() { const debouncedAutoMaskListValue = useDebounce(autoMaskListValue, 300) const [prioritizedLanguages, setPrioritizedLanguages] = useState([]) + const [localCategoriesOrder, setLocalCategoriesOrder] = useState([]) useEffect(() => { if ( @@ -161,6 +412,45 @@ export default function ClipboardHistorySettings() { } }, [historyDetectLanguagesEnabledList, historyDetectLanguagesPrioritizedList]) + // Initialize local categories order from store + useEffect(() => { + if ( + Array.isArray(specialPasteCategoriesOrder) && + specialPasteCategoriesOrder.length > 0 + ) { + setLocalCategoriesOrder(specialPasteCategoriesOrder) + } else { + setLocalCategoriesOrder([...DEFAULT_SPECIAL_PASTE_CATEGORIES]) + } + }, [specialPasteCategoriesOrder]) + + // Show all categories, ordered by user preference with enabled ones first + const orderedCategories = (() => { + const enabled = + localCategoriesOrder.length > 0 + ? localCategoriesOrder + : [...DEFAULT_SPECIAL_PASTE_CATEGORIES] + + // Get all categories that exist but aren't in the enabled list + const allCategoryIds = [...DEFAULT_SPECIAL_PASTE_CATEGORIES] + const disabled = allCategoryIds.filter(id => !enabled.includes(id)) + + // Return enabled categories first, then disabled ones + return [...enabled, ...disabled] + })() + + console.log( + 'Component render - specialPasteCategoriesOrder:', + specialPasteCategoriesOrder, + typeof specialPasteCategoriesOrder, + Array.isArray(specialPasteCategoriesOrder) + ) + console.log( + 'Component render - orderedCategories:', + orderedCategories, + Array.isArray(orderedCategories) + ) + useEffect(() => { if (isAppReady) { setHistoryExclusionList(trimAndRemoveExtraNewlines(debouncedExclusionListValue)) @@ -1129,6 +1419,151 @@ export default function ClipboardHistorySettings() { + + + + + {t('Special Copy/Paste Operations', { ns: 'specailCopyPaste' })} + + { + setIsSpecialCopyPasteHistoryEnabled( + !isSpecialCopyPasteHistoryEnabled + ) + }} + /> + + + + {t( + 'Enable special text transformation options for clipboard history items', + { ns: 'specailCopyPaste' } + )} + + + {/* Category Controls - only show when enabled */} + {isSpecialCopyPasteHistoryEnabled && ( + <> + { + const { active, over } = event + if (over?.id && active.id !== over?.id) { + setLocalCategoriesOrder(items => { + const activeId = active.id.toString() + const overId = over.id.toString() + + // Check if both active and over items are in the enabled list + if ( + items.includes(activeId) && + items.includes(overId) + ) { + const oldIndex = items.indexOf(activeId) + const newIndex = items.indexOf(overId) + const newArray = arrayMove(items, oldIndex, newIndex) + + // Update the store if array changed + if (!arraysEqual(items, newArray)) { + setSpecialPasteCategoriesOrder(newArray) + } + return newArray + } + return items + }) + } + }} + > + + + {orderedCategories + .map(categoryId => + TRANSFORM_CATEGORIES.find(c => c.id === categoryId) + ) + .filter(category => category) + .map(category => { + if (!category) return null + + return ( + + ) + })} + + + + + {/* Summary */} + + + {t('Enabled Operations', { ns: 'specailCopyPaste' })} ( + {enabledSpecialPasteOperations.length}): + + {enabledSpecialPasteOperations.length > 0 ? ( + + {enabledSpecialPasteOperations.map(opId => { + const transform = TEXT_TRANSFORMS.find( + t => t.id === opId + ) + return transform ? ( + + {t(transform.label, { ns: 'specailCopyPaste' })} + + ) : null + })} + + ) : ( + + {t('None', { ns: 'specailCopyPaste' })} + + )} + + {/* Reset Button */} + + + + + )} + + + + diff --git a/packages/pastebar-app-ui/src/store/constants.ts b/packages/pastebar-app-ui/src/store/constants.ts index 23746b65..5442f5b4 100644 --- a/packages/pastebar-app-ui/src/store/constants.ts +++ b/packages/pastebar-app-ui/src/store/constants.ts @@ -61,6 +61,77 @@ export const SCREEN_AUTO_LOCK_TIMES_IN_MINUTES = [5, 10, 15, 20, 30, 45, 60] export const RESET_TIME_DELAY_SECONDS = 60 export const APP_NAME = 'PasteBar' +// Default special copy/paste settings +export const DEFAULT_SPECIAL_PASTE_OPERATIONS = [ + // Text Case + 'upperCase', + 'lowerCase', + 'titleCase', + 'sentenceCase', + 'invertCase', + // Code Formatting + 'camelCase', + 'snakeCase', + 'kebabCase', + 'pascalCase', + 'jsonStringify', + // Whitespace & Lines + 'trimWhiteSpace', + 'removeLineFeeds', + 'addOneLineFeed', + 'addTwoLineFeeds', + 'removeExtraSpaces', + 'sortLinesAlphabetically', + 'removeDuplicateLines', + 'addLineNumbers', + // Encode/Decode + 'base64Encode', + 'base64Decode', + 'urlEncode', + 'urlDecode', + 'htmlEncode', + 'htmlDecode', + // Text Tools + 'reverseText', + 'addCurrentDateTime', + 'countCharacters', + 'countWords', + 'countLines', + 'countSentences', + // Format Converter - HTML + 'htmlToMarkdown', + 'htmlToReact', + 'htmlToReactComponent', + 'htmlToText', + // Format Converter - Markdown + 'markdownToHtml', + 'markdownToText', + // Format Converter - JSON + 'jsonToCsv', + 'jsonToYaml', + 'jsonToXml', + 'jsonToToml', + 'jsonToTable', + // Format Converter - CSV + 'csvToJson', + 'csvToTable', + // Format Converter - YAML + 'yamlToJson', + // Format Converter - XML + 'xmlToJson', + // Format Converter - TOML + 'tomlToJson', +] as const + +export const DEFAULT_SPECIAL_PASTE_CATEGORIES = [ + 'textCase', + 'codeFormatting', + 'whitespaceLines', + 'encodingSecurity', + 'textTools', + 'formatConverter', +] as const + window['PasteBar'] = { APP_UI_VERSION: APP_UI_VERSION, APP_VERSION: APP_VERSION, diff --git a/packages/pastebar-app-ui/src/store/settingsStore.ts b/packages/pastebar-app-ui/src/store/settingsStore.ts index d0b591dd..7f870f13 100644 --- a/packages/pastebar-app-ui/src/store/settingsStore.ts +++ b/packages/pastebar-app-ui/src/store/settingsStore.ts @@ -13,6 +13,7 @@ import { atomWithStore } from 'jotai-zustand' import { createStore } from 'zustand/vanilla' import DOMPurify from '../components/libs/dompurify' +import { DEFAULT_SPECIAL_PASTE_OPERATIONS, DEFAULT_SPECIAL_PASTE_CATEGORIES } from './constants' import { availableVersionBody, availableVersionDate, @@ -111,6 +112,9 @@ type Settings = { isDoubleClickTrayToOpenEnabledOnWindows: boolean isLeftClickTrayToOpenEnabledOnWindows: boolean isLeftClickTrayDisabledOnWindows: boolean + isSpecialCopyPasteHistoryEnabled: boolean + enabledSpecialPasteOperations: string[] + specialPasteCategoriesOrder: string[] } type Constants = { @@ -235,6 +239,9 @@ export interface SettingsStoreState { deleteGlobalTemplate: (templateId: string) => void toggleGlobalTemplateEnabledState: (templateId: string) => void setIsDoubleClickTrayToOpenEnabledOnWindows: (isEnabled: boolean) => void + setIsSpecialCopyPasteHistoryEnabled: (isEnabled: boolean) => void + setEnabledSpecialPasteOperations: (operations: string[]) => void + setSpecialPasteCategoriesOrder: (categories: string[]) => void } const initialState: SettingsStoreState & Settings = { @@ -322,9 +329,15 @@ const initialState: SettingsStoreState & Settings = { isDoubleClickTrayToOpenEnabledOnWindows: false, isLeftClickTrayToOpenEnabledOnWindows: false, isLeftClickTrayDisabledOnWindows: false, + isSpecialCopyPasteHistoryEnabled: true, + enabledSpecialPasteOperations: [...DEFAULT_SPECIAL_PASTE_OPERATIONS], + specialPasteCategoriesOrder: [...DEFAULT_SPECIAL_PASTE_CATEGORIES], setIsDoubleClickTrayToOpenEnabledOnWindows: () => {}, setIsLeftClickTrayToOpenEnabledOnWindows: () => {}, setIsLeftClickTrayDisabledOnWindows: () => {}, + setIsSpecialCopyPasteHistoryEnabled: () => {}, + setEnabledSpecialPasteOperations: () => {}, + setSpecialPasteCategoriesOrder: () => {}, setHasPinProtectedCollections: async () => {}, CONST: { APP_DETECT_LANGUAGES_SUPPORTED: [], @@ -524,6 +537,19 @@ export const settingsStore = createStore()((set, } } + if (name === 'enabledSpecialPasteOperations' && typeof value === 'string') { + return set(() => ({ + enabledSpecialPasteOperations: value.split(',').filter(Boolean), + })) + } + + if (name === 'specialPasteCategoriesOrder' && typeof value === 'string') { + return set(() => ({ + specialPasteCategoriesOrder: value.split(',').filter(Boolean), + })) + } + + return set(() => ({ [name]: value })) } catch (e) { console.error(e) @@ -882,6 +908,15 @@ export const settingsStore = createStore()((set, setIsDoubleClickTrayToOpenEnabledOnWindows: async (isEnabled: boolean) => { return get().updateSetting('isDoubleClickTrayToOpenEnabledOnWindows', isEnabled) }, + setIsSpecialCopyPasteHistoryEnabled: async (isEnabled: boolean) => { + return get().updateSetting('isSpecialCopyPasteHistoryEnabled', isEnabled) + }, + setEnabledSpecialPasteOperations: async (operations: string[]) => { + return get().updateSetting('enabledSpecialPasteOperations', operations.join(',')) + }, + setSpecialPasteCategoriesOrder: async (categories: string[]) => { + return get().updateSetting('specialPasteCategoriesOrder', categories.join(',')) + }, setIsLeftClickTrayToOpenEnabledOnWindows: async (isEnabled: boolean) => { const result = await get().updateSetting( 'isLeftClickTrayToOpenEnabledOnWindows', diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d64e8307..e1a0b290 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -979,6 +979,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1174,6 +1180,27 @@ dependencies = [ "syn 2.0.102", ] +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa 1.0.15", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -1252,7 +1279,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -2035,7 +2062,7 @@ version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" dependencies = [ - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -2370,6 +2397,43 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "html-to-react" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962bea78a5dac58a7e0f08853264d4e96aa0c2e562bb71436240a661631445b4" +dependencies = [ + "convert_case 0.5.0", + "lazy_static", +] + +[[package]] +name = "html2md" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" +dependencies = [ + "html5ever 0.27.0", + "jni 0.19.0", + "lazy_static", + "markup5ever_rcdom", + "percent-encoding", + "regex", +] + +[[package]] +name = "html2text" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cda84f06c1cc83476f79ae8e2e892b626bdadafcb227baec54c918cadc18a0" +dependencies = [ + "html5ever 0.26.0", + "markup5ever 0.11.0", + "tendril", + "unicode-width 0.1.14", + "xml5ever 0.17.0", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -2968,6 +3032,20 @@ dependencies = [ "system-deps 5.0.0", ] +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "jni" version = "0.20.0" @@ -3349,6 +3427,18 @@ dependencies = [ "tendril", ] +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever 0.27.0", + "markup5ever 0.12.1", + "tendril", + "xml5ever 0.18.1", +] + [[package]] name = "matchers" version = "0.1.0" @@ -4180,6 +4270,7 @@ dependencies = [ "clokwerk", "cocoa 0.26.1", "colored_json", + "csv", "diesel", "diesel_migrations", "dirs 5.0.1", @@ -4187,6 +4278,9 @@ dependencies = [ "fns", "fs_extra", "html-escape", + "html-to-react", + "html2md", + "html2text", "http-cache-mokadeser", "http-cache-reqwest", "id3", @@ -4211,6 +4305,8 @@ dependencies = [ "once_cell", "opener", "platform-dirs", + "pulldown-cmark", + "quick-xml 0.31.0", "r2d2", "regex", "reqwest", @@ -4230,6 +4326,7 @@ dependencies = [ "tl", "tld", "tokio", + "toml 0.8.23", "tracing", "tracing-subscriber", "url", @@ -4668,6 +4765,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" +dependencies = [ + "bitflags 2.9.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" + [[package]] name = "qoi" version = "0.4.1" @@ -4695,6 +4811,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -5749,7 +5875,7 @@ dependencies = [ "gtk", "image 0.24.9", "instant", - "jni", + "jni 0.20.0", "lazy_static", "libappindicator", "libc", @@ -6509,6 +6635,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" @@ -7783,6 +7915,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml5ever" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" +dependencies = [ + "log", + "mac", + "markup5ever 0.11.0", +] + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", +] + [[package]] name = "yansi" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebbf6961..0e97991f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,14 @@ http-cache-mokadeser = "0.1.3" # log = "0.4" serde_yaml = "0.9.0" scraper = "0.19.0" +# Format conversion dependencies +csv = "1.3" +html2text = "0.6" +html2md = "0.2" +html-to-react = "0.5.2" +pulldown-cmark = "0.10" +quick-xml = { version = "0.31", features = ["serialize"] } +toml = "0.8" jsonpath-rust = "0.4.0" ajson = "0.3.1" diff --git a/src-tauri/src/commands/format_converter_commands.rs b/src-tauri/src/commands/format_converter_commands.rs new file mode 100644 index 00000000..27a16d8e --- /dev/null +++ b/src-tauri/src/commands/format_converter_commands.rs @@ -0,0 +1,375 @@ +use csv::{Reader, Writer}; +use html2text; +use pulldown_cmark::{html, Options, Parser}; +use quick_xml::de::from_str as xml_from_str; +use quick_xml::se::to_string as xml_to_string; +use serde_json::{from_str as json_from_str, to_string_pretty, Value as JsonValue}; +use serde_yaml::{from_str as yaml_from_str, to_string as yaml_to_string, Value as YamlValue}; +use std::collections::HashMap; +use toml::{from_str as toml_from_str, to_string as toml_to_string, Value as TomlValue}; +// Import html_to_react crate +extern crate html_to_react; + +/// Convert CSV to JSON +fn csv_to_json(text: &str) -> Result { + if !text.contains(',') && !text.contains('\t') && !text.contains(';') { + return Err("Input does not appear to be valid CSV format (no delimiters found)".to_string()); + } + + let mut reader = Reader::from_reader(text.as_bytes()); + let mut records = Vec::new(); + + // Get headers + let headers = reader + .headers() + .map_err(|e| { + format!( + "Failed to read CSV headers - ensure the text is properly formatted CSV: {}", + e + ) + })? + .clone(); + + if headers.is_empty() { + return Err("CSV file appears to have no headers".to_string()); + } + + // Read all records + for result in reader.records() { + let record = result.map_err(|e| format!("Failed to read CSV record: {}", e))?; + let mut map = HashMap::new(); + + for (i, field) in record.iter().enumerate() { + if let Some(header) = headers.get(i) { + map.insert(header.to_string(), field.to_string()); + } + } + records.push(map); + } + + to_string_pretty(&records).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to CSV +fn json_to_csv(text: &str) -> Result { + let json_data: JsonValue = + json_from_str(text).map_err(|e| format!("Invalid JSON format: {}", e))?; + + let mut output = Vec::new(); + { + let mut writer = Writer::from_writer(&mut output); + + match json_data { + JsonValue::Array(ref array) => { + if array.is_empty() { + return Ok(String::new()); + } + + // Extract headers from first object + if let Some(JsonValue::Object(first_obj)) = array.first() { + let headers: Vec = first_obj.keys().cloned().collect(); + writer + .write_record(&headers) + .map_err(|e| format!("Failed to write CSV headers: {}", e))?; + + // Write data rows + for item in array { + if let JsonValue::Object(obj) = item { + let row: Vec = headers + .iter() + .map(|header| { + obj + .get(header) + .map(|v| match v { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => String::new(), + _ => v.to_string(), + }) + .unwrap_or_default() + }) + .collect(); + writer + .write_record(&row) + .map_err(|e| format!("Failed to write CSV row: {}", e))?; + } + } + } + } + _ => return Err("JSON must be an array of objects for CSV conversion".to_string()), + } + + writer + .flush() + .map_err(|e| format!("Failed to flush CSV writer: {}", e))?; + } // writer is dropped here, releasing the borrow + + String::from_utf8(output).map_err(|e| format!("Failed to convert CSV to string: {}", e)) +} + +/// Convert YAML to JSON +fn yaml_to_json(text: &str) -> Result { + let yaml_data: YamlValue = yaml_from_str(text).map_err(|e| format!("Invalid YAML: {}", e))?; + + to_string_pretty(&yaml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to YAML +fn json_to_yaml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + yaml_to_string(&json_data).map_err(|e| format!("Failed to serialize to YAML: {}", e)) +} + +/// Convert Markdown to HTML +fn markdown_to_html(text: &str) -> Result { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = Parser::new_ext(text, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + Ok(html_output) +} + +/// Convert HTML to Markdown +fn html_to_markdown(text: &str) -> Result { + // Use html2md for better HTML to Markdown conversion + Ok(html2md::parse_html(text)) +} + +/// Convert HTML to plain text +fn html_to_text(text: &str) -> Result { + Ok(html2text::from_read(text.as_bytes(), text.len())) +} + +/// Convert Markdown to plain text +fn markdown_to_text(text: &str) -> Result { + // First convert markdown to HTML, then HTML to text + let html = markdown_to_html(text)?; + html_to_text(&html) +} + +// Convert HTML to React Component (JSX) +fn html_to_react_components(text: &str) -> Result { + // Use html_to_react crate to convert HTML to React JSX + let component = html_to_react::convert_to_react(text.to_string(), "MyComponent".to_string()); + Ok(component) +} + +/// Convert HTML to React JSX with comprehensive HTML to JSX conversion +fn convert_html_to_react_jsx(text: &str) -> Result { + // Comprehensive HTML to JSX converter with support for: + // - HTML attributes to JSX attributes (class -> className, for -> htmlFor, etc.) + // - Self-closing tags + // - HTML comments to JSX comments + // - Boolean attributes + // - CSS style properties to camelCase + let jsx = html_to_react::convert_props_react(text.to_string()); + + Ok(jsx) +} + +/// Convert XML to JSON +fn xml_to_json(text: &str) -> Result { + let xml_data: JsonValue = xml_from_str(text).map_err(|e| format!("Invalid XML: {}", e))?; + + to_string_pretty(&xml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to XML +fn json_to_xml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + xml_to_string(&json_data).map_err(|e| format!("Failed to serialize to XML: {}", e)) +} + +/// Convert TOML to JSON +fn toml_to_json(text: &str) -> Result { + let toml_data: TomlValue = toml_from_str(text).map_err(|e| format!("Invalid TOML: {}", e))?; + + to_string_pretty(&toml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to TOML +fn json_to_toml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Convert JsonValue to TomlValue + let toml_data = json_to_toml_value(json_data)?; + toml_to_string(&toml_data).map_err(|e| format!("Failed to serialize to TOML: {}", e)) +} + +/// Helper to convert JsonValue to TomlValue +fn json_to_toml_value(json: JsonValue) -> Result { + match json { + JsonValue::Null => Ok(TomlValue::String("".to_string())), + JsonValue::Bool(b) => Ok(TomlValue::Boolean(b)), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(TomlValue::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(TomlValue::Float(f)) + } else { + Err("Invalid number format".to_string()) + } + } + JsonValue::String(s) => Ok(TomlValue::String(s)), + JsonValue::Array(arr) => { + let mut toml_array = Vec::new(); + for item in arr { + toml_array.push(json_to_toml_value(item)?); + } + Ok(TomlValue::Array(toml_array)) + } + JsonValue::Object(obj) => { + let mut toml_table = toml::value::Table::new(); + for (key, value) in obj { + toml_table.insert(key, json_to_toml_value(value)?); + } + Ok(TomlValue::Table(toml_table)) + } + } +} + +/// Convert CSV to Markdown table +fn csv_to_table(text: &str) -> Result { + let mut reader = Reader::from_reader(text.as_bytes()); + let mut markdown = String::new(); + + // Get headers + let headers = reader + .headers() + .map_err(|e| format!("Failed to read CSV headers: {}", e))?; + + // Write header row + markdown.push('|'); + for header in headers.iter() { + markdown.push(' '); + markdown.push_str(header); + markdown.push_str(" |"); + } + markdown.push('\n'); + + // Write separator row + markdown.push('|'); + for _ in headers.iter() { + markdown.push_str(" --- |"); + } + markdown.push('\n'); + + // Write data rows + for result in reader.records() { + let record = result.map_err(|e| format!("Failed to read CSV record: {}", e))?; + markdown.push('|'); + for field in record.iter() { + markdown.push(' '); + markdown.push_str(field); + markdown.push_str(" |"); + } + markdown.push('\n'); + } + + Ok(markdown) +} + +/// Convert JSON to Markdown table +fn json_to_table(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + match json_data { + JsonValue::Array(ref array) => { + if array.is_empty() { + return Ok(String::new()); + } + + let mut markdown = String::new(); + + // Extract headers from first object + if let Some(JsonValue::Object(first_obj)) = array.first() { + let headers: Vec = first_obj.keys().cloned().collect(); + + // Write header row + markdown.push('|'); + for header in &headers { + markdown.push(' '); + markdown.push_str(header); + markdown.push_str(" |"); + } + markdown.push('\n'); + + // Write separator row + markdown.push('|'); + for _ in &headers { + markdown.push_str(" --- |"); + } + markdown.push('\n'); + + // Write data rows + for item in array { + if let JsonValue::Object(obj) = item { + markdown.push('|'); + for header in &headers { + markdown.push(' '); + if let Some(value) = obj.get(header) { + let cell_value = match value { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => String::new(), + _ => value.to_string(), + }; + markdown.push_str(&cell_value); + } + markdown.push_str(" |"); + } + markdown.push('\n'); + } + } + } + + Ok(markdown) + } + _ => Err("JSON must be an array of objects for table conversion".to_string()), + } +} + +/// Main format converter command +#[tauri::command] +pub async fn format_convert(text: String, conversion_type: String) -> Result { + // Validate input + if text.trim().is_empty() { + return Err("Input text cannot be empty".to_string()); + } + + // Log the conversion attempt for debugging + eprintln!( + "Converting {} with type: {}", + text.chars().take(50).collect::(), + conversion_type + ); + + match conversion_type.as_str() { + "csv_to_json" => csv_to_json(&text).map_err(|e| format!("CSV to JSON conversion failed: {}", e)), + "json_to_csv" => json_to_csv(&text).map_err(|e| format!("JSON to CSV conversion failed: {}", e)), + "yaml_to_json" => yaml_to_json(&text).map_err(|e| format!("YAML to JSON conversion failed: {}", e)), + "json_to_yaml" => json_to_yaml(&text).map_err(|e| format!("JSON to YAML conversion failed: {}", e)), + "markdown_to_html" => markdown_to_html(&text).map_err(|e| format!("Markdown to HTML conversion failed: {}", e)), + "html_to_markdown" => html_to_markdown(&text).map_err(|e| format!("HTML to Markdown conversion failed: {}", e)), + "html_to_react_components" => html_to_react_components(&text).map_err(|e| format!("HTML to React Component conversion failed: {}", e)), + "html_to_text" => html_to_text(&text).map_err(|e| format!("HTML to Text conversion failed: {}", e)), + "markdown_to_text" => markdown_to_text(&text).map_err(|e| format!("Markdown to Text conversion failed: {}", e)), + "html_to_react" => convert_html_to_react_jsx(&text).map_err(|e| format!("HTML to React JSX conversion failed: {}", e)), + "xml_to_json" => xml_to_json(&text).map_err(|e| format!("XML to JSON conversion failed: {}", e)), + "json_to_xml" => json_to_xml(&text).map_err(|e| format!("JSON to XML conversion failed: {}", e)), + "toml_to_json" => toml_to_json(&text).map_err(|e| format!("TOML to JSON conversion failed: {}", e)), + "json_to_toml" => json_to_toml(&text).map_err(|e| format!("JSON to TOML conversion failed: {}", e)), + "csv_to_table" => csv_to_table(&text).map_err(|e| format!("CSV to Table conversion failed: {}", e)), + "json_to_table" => json_to_table(&text).map_err(|e| format!("JSON to Table conversion failed: {}", e)), + _ => Err(format!("Unsupported conversion type: '{}'. Available types: csv_to_json, json_to_csv, yaml_to_json, json_to_yaml, markdown_to_html, html_to_markdown, html_to_text, markdown_to_text, html_to_react, xml_to_json, json_to_xml, toml_to_json, json_to_toml, csv_to_table, json_to_table", conversion_type)), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ef13766..903d81f0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod backup_restore_commands; pub(crate) mod clipboard_commands; pub(crate) mod collections_commands; pub(crate) mod download_update; +pub(crate) mod format_converter_commands; pub(crate) mod history_commands; pub(crate) mod items_commands; pub(crate) mod link_metadata_commands; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 204d573a..1640d9ce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,6 +56,7 @@ use commands::backup_restore_commands; use commands::clipboard_commands; use commands::collections_commands; use commands::download_update; +use commands::format_converter_commands; use commands::history_commands; use commands::items_commands; use commands::link_metadata_commands; @@ -188,6 +189,12 @@ fn update_left_click_tray_env(is_toggle_enabled: bool, is_disabled: bool) -> Res Ok(()) } +#[cfg(target_os = "macos")] +#[tauri::command] +fn update_left_click_tray_env(is_toggle_enabled: bool, is_disabled: bool) -> Result<(), String> { + Ok(()) +} + #[tauri::command] fn is_autostart_enabled() -> Result { let current_exe = current_exe().unwrap(); @@ -1380,6 +1387,7 @@ async fn main() { user_settings_command::cmd_get_setting, user_settings_command::cmd_set_setting, user_settings_command::cmd_remove_setting, + format_converter_commands::format_convert, open_osx_accessibility_preferences, check_osx_accessibility_preferences, open_path_or_app,