feat: implement special copy/paste operations with customizable settings and transformation options

This commit is contained in:
Sergey Kurdin 2025-06-22 17:24:54 -04:00
parent b34ace03b7
commit a3c82b714b
20 changed files with 3016 additions and 743 deletions

View File

@ -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({

View File

@ -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<UniqueIdentifier>('')
export const specialPastedItem = signal<UniqueIdentifier>('')
export const specialPastedItemCountDown = signal<number>(0)
interface UseSpecialCopyPasteOptions {
delay?: number
}
export const useSpecialCopyPasteHistoryItem = ({
delay = 800,
}: UseSpecialCopyPasteOptions = {}) => {
const { copyPasteDelay } = useAtomValue(settingsStoreAtom)
const countdownRef = useRef<NodeJS.Timeout>()
// Special copy function - applies transformation and copies to clipboard
const specialCopy = async (
historyId: UniqueIdentifier,
value: string,
transformId: string
): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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,
}
}

View File

@ -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<string>
}
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<string> {
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<string> => {
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) ?? [] : []
}

View File

@ -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

View File

@ -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.

View File

@ -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.: 您的命令运行出错,确认是否保持现状。

View File

@ -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<SetStateAction<string[]>>
setAppFilters?: Dispatch<SetStateAction<string[]>>
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<string | null>(
null
)
const deleteTimerRef = useRef<NodeJS.Timeout | null>(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<UniqueIdentifier | null>(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 (
<ContextMenuPortal>
<ContextMenuContent className="max-w-[210px]">
<ContextMenuContent
className="max-w-[210px]"
onInteractOutside={e => {
// 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)
}
}
}
}}
>
<ContextMenuItem
onClick={() => {
setSelectHistoryItem(historyId)
@ -247,6 +370,167 @@ export default function ClipboardHistoryRowContextMenu({
</ContextMenuCheckboxItem>
</ContextMenuSubContent>
</ContextMenuSub>
{/* Special Copy/Paste submenu - only show for text items when enabled */}
{isSpecialCopyPasteHistoryEnabled && !isImage && value && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
{isKeyAltPressed.value
? t('Special Paste', { ns: 'specailCopyPaste' })
: t('Special Copy', { ns: 'specailCopyPaste' })}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{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 (
<ContextMenuSub key={category.id}>
<ContextMenuSubTrigger>
{t(category.label, {
ns: 'specailCopyPaste',
})}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{enabledSubcategories.map(subcategory => {
const enabledTransforms = subcategory.transforms.filter(
transform =>
enabledSpecialPasteOperations.includes(transform.id)
)
return (
<ContextMenuSub key={subcategory.id}>
<ContextMenuSubTrigger>
{t(subcategory.label, {
ns: 'specailCopyPaste',
})}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
{enabledTransforms.map(transform => (
<ContextMenuItem
key={transform.id}
disabled={specialActionInProgress === transform.id}
onClick={async () => {
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 && (
<div className="ml-auto">
<Text className="text-xs text-muted-foreground">
...
</Text>
</div>
)}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)
})}
</ContextMenuSubContent>
</ContextMenuSub>
)
} else {
// Handle categories with direct transforms
const enabledTransforms =
category.transforms?.filter(transform =>
enabledSpecialPasteOperations.includes(transform.id)
) || []
return (
<ContextMenuSub key={category.id}>
<ContextMenuSubTrigger>
{t(category.label, {
ns: 'specailCopyPaste',
})}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
{enabledTransforms.map(transform => (
<ContextMenuItem
key={transform.id}
disabled={specialActionInProgress === transform.id}
onClick={async () => {
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 && (
<div className="ml-auto">
<Text className="text-xs text-muted-foreground">
...
</Text>
</div>
)}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)
}
})}
{hasEnabledCategories && <ContextMenuSeparator />}
<ContextMenuItem
onClick={() => {
navigate('/app-settings/history#specialCopyPasteHistory', {
replace: true,
})
}}
>
{t('Special Settings', { ns: 'specailCopyPaste' })}
<div className="ml-auto">
<Settings size={15} />
</div>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@ -552,25 +836,32 @@ export default function ClipboardHistoryRowContextMenu({
<ContextMenuSeparator />
{isSelected && selectedHistoryItems && selectedHistoryItems.length > 1 ? (
<ContextMenuItem
onClick={async e => {
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)
}
}}
>
<Flex>
<Text className="!text-red-500 dark:!text-red-600">
{!showDeleteMenuItemsConfirmation.value
{pendingDeleteId !== 'multi'
? t('Delete', { ns: 'common' })
: t('Click to Confirm', { ns: 'common' })}
<Badge
@ -581,7 +872,7 @@ export default function ClipboardHistoryRowContextMenu({
</Badge>
</Text>
</Flex>
{!showDeleteMenuItemsConfirmation.value && (
{pendingDeleteId !== 'multi' && (
<div className="ml-auto">
<Badge variant="default" className="ml-1 py-0 font-semibold">
DEL
@ -591,29 +882,34 @@ export default function ClipboardHistoryRowContextMenu({
</ContextMenuItem>
) : (
<ContextMenuItem
onClick={async e => {
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)
}
}}
>
<Flex>
<Text className="!text-red-500 dark:!text-red-600">
{!showDeleteMenuItemsConfirmation.value
{pendingDeleteId !== historyId
? t('Delete', { ns: 'common' })
: t('Click to Confirm', { ns: 'common' })}
</Text>
</Flex>
{!showDeleteMenuItemsConfirmation.value && (
{pendingDeleteId !== historyId && (
<div className="ml-auto">
<Badge variant="default" className="ml-1 py-0 font-semibold">
DEL

View File

@ -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<LinkMetadata | void>
removeLinkMetaData?: (historyId: UniqueIdentifier) => Promise<void>
setSelectHistoryItem: (id: UniqueIdentifier) => void
setSelectedHistoryItems?: (ids: UniqueIdentifier[]) => void
selectedHistoryItems?: UniqueIdentifier[]
onCopyPaste: (id: UniqueIdentifier, delay?: number) => void
setHistoryFilters?: Dispatch<SetStateAction<string[]>>
setAppFilters?: Dispatch<SetStateAction<string[]>>
onDeleteConfirmationChange?: (
historyId: UniqueIdentifier | null,
isMultiSelect?: boolean
) => void
}
const ContextMenuTrigger = forwardRef<HTMLElement, ContextMenuTriggerProps>(
(
{
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 (
<ContextMenu onOpenChange={handleOpenChange}>
<ContextMenuTriggerPrimitive ref={ref} asChild>
{children}
</ContextMenuTriggerPrimitive>
{isOpen && (
<ClipboardHistoryRowContextMenu
historyId={historyId}
value={value}
arrLinks={arrLinks}
isImage={isImage}
isText={isText}
copiedFromApp={copiedFromApp}
isMasked={isMasked}
isImageData={isImageData}
isMp3={isMp3}
hasLinkCard={hasLinkCard}
isSelected={isSelected}
isLargeView={isLargeView}
isPinned={isPinned}
isFavorite={isFavorite}
detectedLanguage={detectedLanguage}
setLargeViewItemId={setLargeViewItemId}
setSavingItem={setSavingItem}
invalidateClipboardHistoryQuery={invalidateClipboardHistoryQuery}
generateLinkMetaData={generateLinkMetaData}
removeLinkMetaData={removeLinkMetaData}
setSelectHistoryItem={setSelectHistoryItem}
setSelectedHistoryItems={setSelectedHistoryItems}
selectedHistoryItems={selectedHistoryItems}
onCopyPaste={onCopyPaste}
setHistoryFilters={setHistoryFilters}
setAppFilters={setAppFilters}
onDeleteConfirmationChange={onDeleteConfirmationChange}
/>
)}
</ContextMenu>
)
}
)
ContextMenuTrigger.displayName = 'ContextMenuTrigger'
export default ContextMenuTrigger

View File

@ -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`}
>
<Check size={12} className={`mr-0.5 ${field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''}`} />
<Check
size={12}
className={`mr-0.5 ${
field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''
}`}
/>
{field.label}
</Badge>
}
@ -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`}
>
<Check size={12} className={`mr-0.5 ${field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''}`} />
<Check
size={12}
className={`mr-0.5 ${
field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''
}`}
/>
{field.label}
</Badge>
</ToolTip>
@ -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"
>
<Check size={12} className="mr-0.5 text-purple-600 dark:text-purple-400" />
<Check
size={12}
className="mr-0.5 text-purple-600 dark:text-purple-400"
/>
{field.label} (Global)
</Badge>
}
@ -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"
>
<Check size={12} className="mr-0.5 text-purple-600 dark:text-purple-400" />
<Check
size={12}
className="mr-0.5 text-purple-600 dark:text-purple-400"
/>
{field.label}
</Badge>
</ToolTip>
@ -407,9 +423,10 @@ 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,
@ -417,7 +434,7 @@ export function ClipViewTemplate({
value: actualValue,
isEnable,
isGlobal,
};
}
}),
clipboardValueSignal.value,
templateShowFormat.value === 'values',
@ -587,9 +604,7 @@ export function ClipViewTemplate({
<span
className={`whitespace-nowrap pr-1 min-w-[80px] overflow-hidden text-ellipsis block ${
isLabelOnTop ? 'text-left' : 'text-right max-w-[160px]'
} ${
field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''
}`}
} ${field.isGlobal ? 'text-purple-600 dark:text-purple-400' : ''}`}
>
{field.label}
</span>
@ -781,7 +796,10 @@ export function ClipViewTemplate({
title={`Global Template: ${field.label}`}
/>
<Badge className="inline-flex items-center gap-1 bg-purple-100 text-purple-700 dark:bg-purple-800 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-700 cursor-default text-xs py-0.5 px-1.5">
<Check size={12} className="text-purple-600 dark:text-purple-400" />
<Check
size={12}
className="text-purple-600 dark:text-purple-400"
/>
{t('Global', { ns: 'templates' })}
</Badge>
</Flex>

View File

@ -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<string | null | undefined>('')

View File

@ -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 &&

View File

@ -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 (
<div
ref={setNodeRef}
style={style}
key={category.id}
{...attributes}
className={
isDragging
? 'z-100 opacity-70 bg-slate-50 dark:bg-slate-900'
: 'z-auto opacity-100'
}
>
<Box
className={`border rounded-lg p-4 ${
!isCategoryEnabled ? 'opacity-60 bg-gray-50 dark:bg-gray-900/50' : ''
}`}
>
{/* Category Header */}
<Flex className="items-center justify-between">
<Flex className="items-center gap-2">
<Button
variant="ghost"
size="sm"
className={`opacity-40 hover:opacity-90 p-1 ${
isCategoryEnabled
? 'cursor-grab active:cursor-grabbing'
: 'cursor-not-allowed'
}`}
{...(isCategoryEnabled ? listeners : {})}
disabled={!isCategoryEnabled}
>
<Grip size={18} />
</Button>
<Text className="text-[14px] font-semibold">
{t(category.label, { ns: 'specailCopyPaste' })}
</Text>
</Flex>
<Flex className="items-center gap-2">
<Badge variant="outline" className="text-xs">
{enabledTransformsInCategory.length}/{allTransformsInCategory.length}
</Badge>
<Switch
className="scale-[.95]"
checked={isCategoryEnabled}
onCheckedChange={checked => {
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)
}
}}
/>
</Flex>
</Flex>
{/* Individual Transform Controls */}
{isCategoryEnabled && (
<Box className="mt-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{t('Select Operations', { ns: 'specailCopyPaste' })}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
<DropdownMenuLabel>
{t(category.label, { ns: 'specailCopyPaste' })}{' '}
{t('Operations', { ns: 'specailCopyPaste' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SimpleBar
className="code-filter"
style={{
height: 'auto',
maxHeight: '400px',
overflowX: 'hidden',
}}
autoHide={false}
>
{category.subcategories
? // Handle categories with subcategories (like Format Converter)
category.subcategories.map(subcategory => (
<div key={subcategory.id}>
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
{t(subcategory.label, { ns: 'specailCopyPaste' })}
</DropdownMenuLabel>
{subcategory.transforms.map(transform => (
<DropdownMenuCheckboxItem
key={transform.id}
checked={enabledSpecialPasteOperations.includes(
transform.id
)}
onSelect={e => {
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' })}
</DropdownMenuCheckboxItem>
))}
{category.subcategories &&
subcategory !==
category.subcategories[
category.subcategories.length - 1
] && <DropdownMenuSeparator />}
</div>
))
: // Handle categories with direct transforms
(category.transforms || []).map(transform => (
<DropdownMenuCheckboxItem
key={transform.id}
checked={enabledSpecialPasteOperations.includes(transform.id)}
onSelect={e => {
e.preventDefault()
}}
onCheckedChange={checked => {
if (checked) {
setEnabledSpecialPasteOperations([
...enabledSpecialPasteOperations,
transform.id,
])
} else {
setEnabledSpecialPasteOperations(
enabledSpecialPasteOperations.filter(
op => op !== transform.id
)
)
}
}}
>
{t(transform.label, { ns: 'specailCopyPaste' })}
</DropdownMenuCheckboxItem>
))}
</SimpleBar>
</DropdownMenuContent>
</DropdownMenu>
</Box>
)}
</Box>
</div>
)
}
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<string[]>([])
const [localCategoriesOrder, setLocalCategoriesOrder] = useState<string[]>([])
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() {
</Card>
</Box>
<Box
className="mt-4 max-w-xl animate-in fade-in"
id="specialCopyPasteHistory"
>
<Card
className={`${
!isSpecialCopyPasteHistoryEnabled &&
'opacity-80 bg-gray-100 dark:bg-gray-900/80'
}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="animate-in fade-in text-md font-medium w-full">
{t('Special Copy/Paste Operations', { ns: 'specailCopyPaste' })}
</CardTitle>
<Switch
checked={isSpecialCopyPasteHistoryEnabled}
className="ml-auto"
onCheckedChange={() => {
setIsSpecialCopyPasteHistoryEnabled(
!isSpecialCopyPasteHistoryEnabled
)
}}
/>
</CardHeader>
<CardContent>
<Text className="text-sm text-muted-foreground mb-4">
{t(
'Enable special text transformation options for clipboard history items',
{ ns: 'specailCopyPaste' }
)}
</Text>
{/* Category Controls - only show when enabled */}
{isSpecialCopyPasteHistoryEnabled && (
<>
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => {
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
})
}
}}
>
<SortableContext
items={localCategoriesOrder}
strategy={verticalListSortingStrategy}
>
<Box className="space-y-4">
{orderedCategories
.map(categoryId =>
TRANSFORM_CATEGORIES.find(c => c.id === categoryId)
)
.filter(category => category)
.map(category => {
if (!category) return null
return (
<SortableCategoryItem
key={category.id}
categoryId={category.id}
localCategoriesOrder={localCategoriesOrder}
setLocalCategoriesOrder={setLocalCategoriesOrder}
/>
)
})}
</Box>
</SortableContext>
</DndContext>
{/* Summary */}
<Box className="mt-4 pt-4">
<Text className="text-sm font-medium mb-2">
{t('Enabled Operations', { ns: 'specailCopyPaste' })} (
{enabledSpecialPasteOperations.length}):
</Text>
{enabledSpecialPasteOperations.length > 0 ? (
<Flex className="flex-wrap gap-1 justify-start">
{enabledSpecialPasteOperations.map(opId => {
const transform = TEXT_TRANSFORMS.find(
t => t.id === opId
)
return transform ? (
<Badge
key={opId}
variant="graySecondary"
className="font-normal text-xs"
>
{t(transform.label, { ns: 'specailCopyPaste' })}
</Badge>
) : null
})}
</Flex>
) : (
<Text className="text-sm text-muted-foreground">
{t('None', { ns: 'specailCopyPaste' })}
</Text>
)}
</Box>
{/* Reset Button */}
<Box className="mt-6">
<Button
variant="outline"
size="sm"
onClick={() => {
// Enable all categories and operations using constants
const defaultCategories = [
...DEFAULT_SPECIAL_PASTE_CATEGORIES,
]
setLocalCategoriesOrder(defaultCategories)
setSpecialPasteCategoriesOrder(defaultCategories)
setEnabledSpecialPasteOperations([
...DEFAULT_SPECIAL_PASTE_OPERATIONS,
])
}}
>
{t('Enable All', { ns: 'specailCopyPaste' })}
</Button>
</Box>
</>
)}
</CardContent>
</Card>
</Box>
<Spacer h={6} />
<Link to={returnRoute} replace>

View File

@ -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,

View File

@ -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<SettingsStoreState & Settings>()((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<SettingsStoreState & Settings>()((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',

160
src-tauri/Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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<String, String> {
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<String, String> {
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<String> = 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<String> = 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
// 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<String, String> {
Ok(html2text::from_read(text.as_bytes(), text.len()))
}
/// Convert Markdown to plain text
fn markdown_to_text(text: &str) -> Result<String, String> {
// 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<String, String> {
// 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<String, String> {
// 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<TomlValue, String> {
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<String, String> {
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<String, String> {
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<String> = 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<String, String> {
// 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::<String>(),
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)),
}
}

View File

@ -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;

View File

@ -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<bool, bool> {
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,