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:
Gustavo Carvalho
2025-12-26 01:50:30 -03:00
committed by GitHub
parent 21bd1fea25
commit a3da3eacb5
5 changed files with 94 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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