mirror of
https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux
synced 2026-04-25 17:15:35 +02:00
feat: rich text support (#65)
* feat: add support for rich text content in clipboard history * fix: change get_current_html method to be non-mutating
This commit is contained in:
@@ -42,6 +42,8 @@ fn get_system_clipboard() -> Result<Clipboard, String> {
|
||||
pub enum ClipboardContent {
|
||||
/// Plain text content
|
||||
Text(String),
|
||||
/// Rich text with HTML formatting (plain text + optional HTML)
|
||||
RichText { plain: String, html: String },
|
||||
/// Image as base64 encoded PNG
|
||||
Image {
|
||||
base64: String,
|
||||
@@ -79,6 +81,19 @@ impl ClipboardItem {
|
||||
Self::create(ClipboardContent::Text(text), preview)
|
||||
}
|
||||
|
||||
pub fn new_rich_text(plain: String, html: String) -> Self {
|
||||
let preview = if plain.chars().count() > PREVIEW_TEXT_MAX_LEN {
|
||||
format!(
|
||||
"{}...",
|
||||
&plain.chars().take(PREVIEW_TEXT_MAX_LEN).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
plain.clone()
|
||||
};
|
||||
|
||||
Self::create(ClipboardContent::RichText { plain, html }, preview)
|
||||
}
|
||||
|
||||
pub fn new_image(base64: String, width: u32, height: u32, hash: u64) -> Self {
|
||||
// We store the hash in the preview string to persist it across sessions
|
||||
// without breaking the serialization schema of existing data.
|
||||
@@ -153,6 +168,12 @@ impl ClipboardManager {
|
||||
Clipboard::new()?.get_text()
|
||||
}
|
||||
|
||||
/// Try to get HTML content from clipboard. Returns None if not available.
|
||||
pub fn get_current_html(&self) -> Option<String> {
|
||||
let mut clipboard = get_system_clipboard().ok()?;
|
||||
clipboard.get().html().ok()
|
||||
}
|
||||
|
||||
pub fn get_current_image(
|
||||
&mut self,
|
||||
) -> Result<Option<(ImageData<'static>, u64)>, arboard::Error> {
|
||||
@@ -175,7 +196,8 @@ impl ClipboardManager {
|
||||
|
||||
// --- Adding Items ---
|
||||
|
||||
pub fn add_text(&mut self, text: String) -> Option<ClipboardItem> {
|
||||
/// Add text content to history, with optional HTML for rich text
|
||||
pub fn add_text(&mut self, text: String, html: Option<String>) -> Option<ClipboardItem> {
|
||||
if self.should_skip_text(&text) {
|
||||
return None;
|
||||
}
|
||||
@@ -198,8 +220,13 @@ impl ClipboardManager {
|
||||
// If so, remove the old entry so we can add fresh at top
|
||||
self.remove_duplicate_text_from_history(&text);
|
||||
|
||||
// Create new item and add to history
|
||||
let item = ClipboardItem::new_text(text);
|
||||
// Create new item - use RichText if HTML is available, otherwise plain Text
|
||||
let item = match html {
|
||||
Some(html_content) if !html_content.trim().is_empty() => {
|
||||
ClipboardItem::new_rich_text(text, html_content)
|
||||
}
|
||||
_ => ClipboardItem::new_text(text),
|
||||
};
|
||||
self.insert_item(item.clone());
|
||||
|
||||
self.last_added_text_hash = Some(text_hash);
|
||||
@@ -275,8 +302,10 @@ impl ClipboardManager {
|
||||
// Check only the very first non-pinned item for exact match logic
|
||||
// used in rapid detection
|
||||
if let Some(item) = self.history.iter().find(|item| !item.pinned) {
|
||||
if matches!(&item.content, ClipboardContent::Text(t) if t == text) {
|
||||
return true;
|
||||
match &item.content {
|
||||
ClipboardContent::Text(t) if t == text => return true,
|
||||
ClipboardContent::RichText { plain, .. } if plain == text => return true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -284,7 +313,14 @@ impl ClipboardManager {
|
||||
|
||||
fn remove_duplicate_text_from_history(&mut self, text: &str) {
|
||||
if let Some(pos) = self.history.iter().position(|item| {
|
||||
!item.pinned && matches!(&item.content, ClipboardContent::Text(t) if t == text)
|
||||
if item.pinned {
|
||||
return false;
|
||||
}
|
||||
match &item.content {
|
||||
ClipboardContent::Text(t) => t == text,
|
||||
ClipboardContent::RichText { plain, .. } => plain == text,
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
self.history.remove(pos);
|
||||
}
|
||||
@@ -357,6 +393,10 @@ impl ClipboardManager {
|
||||
self.last_pasted_text = Some(text.clone());
|
||||
self.last_pasted_image_hash = None;
|
||||
}
|
||||
ClipboardContent::RichText { plain, .. } => {
|
||||
self.last_pasted_text = Some(plain.clone());
|
||||
self.last_pasted_image_hash = None;
|
||||
}
|
||||
ClipboardContent::Image { .. } => {
|
||||
if let Some(hash) = item.extract_image_hash() {
|
||||
self.last_pasted_image_hash = Some(hash);
|
||||
@@ -384,6 +424,12 @@ impl ClipboardManager {
|
||||
ClipboardContent::Text(text) => {
|
||||
clipboard.set_text(text).map_err(|e| e.to_string())?;
|
||||
}
|
||||
ClipboardContent::RichText { plain, html } => {
|
||||
// Set HTML with plain text as fallback - this preserves formatting
|
||||
clipboard
|
||||
.set_html(html, Some(plain))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
ClipboardContent::Image {
|
||||
base64,
|
||||
width,
|
||||
|
||||
@@ -539,7 +539,11 @@ fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc<Mutex<Clipboar
|
||||
if Some(text_hash) != last_text_hash {
|
||||
last_text_hash = Some(text_hash);
|
||||
last_image_hash = None;
|
||||
if let Some(item) = manager.add_text(text) {
|
||||
|
||||
// Try to get HTML content for rich text support
|
||||
let html = manager.get_current_html();
|
||||
|
||||
if let Some(item) = manager.add_text(text, html) {
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const HistoryItem = forwardRef<HTMLDivElement, HistoryItemProps>(function
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const isText = item.content.type === 'Text'
|
||||
const isText = item.content.type === 'Text' || item.content.type === 'RichText'
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = useCallback((timestamp: string) => {
|
||||
@@ -139,6 +139,17 @@ export const HistoryItem = forwardRef<HTMLDivElement, HistoryItemProps>(function
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.content.type === 'RichText' && (
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm line-clamp-3 break-words whitespace-pre-wrap',
|
||||
isDark ? 'text-win11-text-primary' : 'text-win11Light-text-primary'
|
||||
)}
|
||||
>
|
||||
{item.content.data.plain}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.content.type === 'Image' && (
|
||||
<div className="relative">
|
||||
<img
|
||||
|
||||
@@ -101,15 +101,23 @@ export function useClipboardHistory() {
|
||||
return prev
|
||||
}
|
||||
|
||||
// Helper to get plain text from any text-based content
|
||||
const getPlainText = (content: ClipboardItem['content']): string | null => {
|
||||
if (content.type === 'Text') return content.data
|
||||
if (content.type === 'RichText') return content.data.plain
|
||||
return null
|
||||
}
|
||||
|
||||
// Also check for content duplicates in the first few unpinned items
|
||||
// This handles race conditions between fetchHistory and events
|
||||
const unpinnedItems = prev.filter((i) => !i.pinned)
|
||||
const isDuplicate = unpinnedItems.slice(0, 5).some((i) => {
|
||||
if (i.content.type === 'Text' && newItem.content.type === 'Text') {
|
||||
return i.content.data === newItem.content.data
|
||||
}
|
||||
return false
|
||||
})
|
||||
const newPlainText = getPlainText(newItem.content)
|
||||
const isDuplicate =
|
||||
newPlainText !== null &&
|
||||
unpinnedItems.slice(0, 5).some((i) => {
|
||||
const existingPlainText = getPlainText(i.content)
|
||||
return existingPlainText === newPlainText
|
||||
})
|
||||
|
||||
if (isDuplicate) {
|
||||
return prev
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Clipboard content types */
|
||||
export type ClipboardContentType = 'text' | 'image'
|
||||
export type ClipboardContentType = 'text' | 'RichText' | 'image'
|
||||
|
||||
/** Text content */
|
||||
export interface TextContent {
|
||||
@@ -7,6 +7,15 @@ export interface TextContent {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Rich text content with HTML formatting */
|
||||
export interface RichTextContent {
|
||||
type: 'RichText'
|
||||
data: {
|
||||
plain: string
|
||||
html: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Image content */
|
||||
export interface ImageContent {
|
||||
type: 'Image'
|
||||
@@ -18,7 +27,7 @@ export interface ImageContent {
|
||||
}
|
||||
|
||||
/** Union of all content types */
|
||||
export type ClipboardContent = TextContent | ImageContent
|
||||
export type ClipboardContent = TextContent | RichTextContent | ImageContent
|
||||
|
||||
/** A single clipboard history item */
|
||||
export interface ClipboardItem {
|
||||
|
||||
Reference in New Issue
Block a user