From ba9b3796c9c04bb9c5a47bcd21866fa4ef944303 Mon Sep 17 00:00:00 2001 From: Milan Hoppe Date: Fri, 3 Jul 2026 04:19:46 +0200 Subject: [PATCH] Preserve text formatting (RTF/HTML) when pasting older clippings Flycut previously stored and pasted plain text only, so pasting any clipping through the bezel, menu, or search window stripped all formatting. Capture now also reads the RTF and HTML representations of text clippings (after the concealed/transient skip checks, capped at 1 MB per type) and stores them alongside the plain text. Pasting declares the rich types in addition to the plain string, so rich text editors keep bold, fonts, links, and colors while plain-text targets are unaffected. Rich data persists through the existing NSUserDefaults store and can be disabled with the new preserveTextFormatting default (documented in help.md). Co-Authored-By: Claude Fable 5 --- AppController.h | 1 + AppController.m | 47 +++++++++++++++++++++++++------- FlycutEngine/FlycutClipping.h | 4 +++ FlycutEngine/FlycutClipping.m | 14 ++++++++++ FlycutEngine/FlycutStore.h | 2 ++ FlycutEngine/FlycutStore.m | 18 +++++++++++-- FlycutOperator.h | 3 +++ FlycutOperator.m | 50 ++++++++++++++++++++++++++++++++--- help.md | 8 ++++++ 9 files changed, 132 insertions(+), 15 deletions(-) diff --git a/AppController.h b/AppController.h index 669bed8..cef54ca 100755 --- a/AppController.h +++ b/AppController.h @@ -91,6 +91,7 @@ // Basic functionality -(void) pollPB:(NSTimer *)timer; -(void) addClipToPasteboard:(NSString*)pbFullText; +-(void) addClipToPasteboard:(NSString*)pbFullText withRichData:(NSDictionary *)richData; -(void) setPBBlockCount:(NSNumber *)newPBBlockCount; -(void) hideApp; -(void) fakeCommandV; diff --git a/AppController.m b/AppController.m index 3898ab0..7883d54 100755 --- a/AppController.m +++ b/AppController.m @@ -883,10 +883,11 @@ - (void)restoreStashedStoreAndUpdate - (void)pasteFromStack { NSLog(@"pasteFromStack called"); + NSDictionary *richData = [flycutOperator getRichDataFromStackPosition]; NSString *content = [flycutOperator getPasteFromStackPosition]; if ( nil != content ) { NSLog(@"Content found, adding to pasteboard and preparing to paste: %@", [content substringToIndex:MIN(content.length, 50)]); - [self addClipToPasteboard:content]; + [self addClipToPasteboard:content withRichData:richData]; [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.5]; } else { @@ -915,10 +916,11 @@ - (void)pasteIndexAndUpdate:(int) position { position = [mapping[position] intValue]; } + NSDictionary *richData = [flycutOperator getRichDataFromIndex: position]; NSString *content = [flycutOperator getPasteFromIndex: position]; if ( nil != content ) { - [self addClipToPasteboard:content]; + [self addClipToPasteboard:content withRichData:richData]; [self updateMenu]; } } @@ -1097,12 +1099,30 @@ -(void)pollPB:(NSTimer *)timer if ( contents == nil || [flycutOperator shouldSkip:contents ofType:[jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:NSPasteboardTypeString]] fromAvailableTypes:[jcPasteboard types]] ) { DLog(@"Contents: Empty or skipped"); } else { + // Capture rich representations of the text so pasting an older clipping + // can keep its formatting. Only done after the skip checks above so + // concealed/transient clippings never have their rich data read. + NSMutableDictionary *richData = nil; + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"preserveTextFormatting"] ) { + for ( NSString *richType in [NSArray arrayWithObjects:NSPasteboardTypeRTF, NSPasteboardTypeHTML, nil] ) { + if ( nil != [jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:richType]] ) { + NSData *data = [jcPasteboard dataForType:richType]; + // Cap the size so one giant rich clipping can't bloat the saved store. + if ( nil != data && [data length] > 0 && [data length] <= 1048576 ) { + if ( nil == richData ) + richData = [NSMutableDictionary dictionaryWithCapacity:2]; + [richData setObject:data forKey:richType]; + } + } + } + } + // Dispatch back to main queue to safely modify the clipping store and update UI. // jcList (NSMutableArray) is not thread-safe, and concurrent access from this // background queue and the main thread (e.g. showing the bezel) causes crashes. dispatch_async(dispatch_get_main_queue(), ^{ if ( ! [pbCount isEqualTo:pbBlockCount] ) { - [flycutOperator addClipping:contents ofType:type fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; + [flycutOperator addClipping:contents ofType:type withRichData:richData fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; } }); } @@ -1554,12 +1574,20 @@ -(void) setPBBlockCount:(NSNumber *)newPBBlockCount -(void)addClipToPasteboard:(NSString*)pbFullText { - NSArray *pbTypes; - pbTypes = [NSArray arrayWithObjects:@"NSStringPboardType",NULL]; - + [self addClipToPasteboard:pbFullText withRichData:nil]; +} + +-(void)addClipToPasteboard:(NSString*)pbFullText withRichData:(NSDictionary *)richData +{ + NSMutableArray *pbTypes = [NSMutableArray arrayWithObject:NSPasteboardTypeString]; + if ( nil != richData ) + [pbTypes addObjectsFromArray:[richData allKeys]]; + [jcPasteboard declareTypes:pbTypes owner:NULL]; - - [jcPasteboard setString:pbFullText forType:@"NSStringPboardType"]; + + [jcPasteboard setString:pbFullText forType:NSPasteboardTypeString]; + for ( NSString *richType in richData ) + [jcPasteboard setData:[richData objectForKey:richType] forType:richType]; [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; } @@ -1910,9 +1938,10 @@ - (IBAction)searchWindowItemSelected:(id)sender position = [mapping[selectedRow] intValue]; } + NSDictionary *richData = [flycutOperator getRichDataFromIndex:position]; NSString *content = [flycutOperator getPasteFromIndex:position]; if (content) { - [self addClipToPasteboard:content]; + [self addClipToPasteboard:content withRichData:richData]; [self updateMenu]; // Update menu like bezel does [self hideSearchWindow]; diff --git a/FlycutEngine/FlycutClipping.h b/FlycutEngine/FlycutClipping.h index 81316b2..744273f 100755 --- a/FlycutEngine/FlycutClipping.h +++ b/FlycutEngine/FlycutClipping.h @@ -29,6 +29,8 @@ NSString * appBundleURL; // The time NSInteger clipTimestamp; +// Optional rich representations of the text (pasteboard type -> NSData), e.g. RTF or HTML + NSDictionary * clipRichData; } -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; @@ -42,6 +44,7 @@ -(void) setType:(NSString *)newType; -(void) setDisplayLength:(int)newDisplayLength; -(void) setHasName:(BOOL)newHasName; +-(void) setRichData:(NSDictionary *)newRichData; // Retrieve values -(FlycutClipping *) clipping; @@ -53,6 +56,7 @@ -(NSString *) appBundleURL; -(NSInteger) timestamp; -(BOOL) hasName; +-(NSDictionary *) richData; // Additional functions -(void) resetDisplayString; diff --git a/FlycutEngine/FlycutClipping.m b/FlycutEngine/FlycutClipping.m index 5997530..23f765b 100755 --- a/FlycutEngine/FlycutClipping.m +++ b/FlycutEngine/FlycutClipping.m @@ -134,6 +134,19 @@ -(void) setHasName:(BOOL)newHasName clipHasName = newHasName; } +-(void) setRichData:(NSDictionary *)newRichData +{ + id old = clipRichData; + [newRichData retain]; + clipRichData = newRichData; + [old release]; +} + +-(NSDictionary *) richData +{ + return clipRichData; +} + -(void) resetDisplayString { NSString *newDisplayString, *firstLineOfClipping, *trimmedString; @@ -234,6 +247,7 @@ -(void) dealloc [clipType release]; [appLocalizedName release]; [appBundleURL release]; + [clipRichData release]; clipDisplayLength = 0; [clipDisplayString release]; clipHasName = 0; diff --git a/FlycutEngine/FlycutStore.h b/FlycutEngine/FlycutStore.h index f0d1bdc..e948718 100755 --- a/FlycutEngine/FlycutStore.h +++ b/FlycutEngine/FlycutStore.h @@ -77,6 +77,7 @@ -(NSString *) clippingContentsAtPosition:(int)index; -(NSString *) clippingDisplayStringAtPosition:(int)index; -(NSString *) clippingTypeAtPosition:(int)index; +-(NSDictionary *) clippingRichDataAtPosition:(int)index; -(NSArray *) previousContents:(int)howMany; -(NSArray *) previousDisplayStrings:(int)howMany; -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search; @@ -87,6 +88,7 @@ // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp; +-(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp withRichData:(NSDictionary *)richData; -(void) addClipping:(FlycutClipping*) clipping; -(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index; diff --git a/FlycutEngine/FlycutStore.m b/FlycutEngine/FlycutStore.m index 68aab09..7cca1a5 100755 --- a/FlycutEngine/FlycutStore.m +++ b/FlycutEngine/FlycutStore.m @@ -76,6 +76,10 @@ -(int) indexOfClipping:(FlycutClipping*) clipping afterIndex:(int) after{ // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(int) timestamp{ + return [self addClipping:clipping ofType:type fromAppLocalizedName:appLocalizedName fromAppBundleURL:bundleURL atTimestamp:timestamp withRichData:nil]; +} + +-(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(int) timestamp withRichData:(NSDictionary *)richData{ if ([clipping length] == 0) { return NO; } @@ -88,9 +92,10 @@ -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalize withAppLocalizedName:appLocalizedName withAppBundleURL:bundleURL withTimestamp:timestamp]; - + [newClipping setRichData:richData]; + [self addClipping:newClipping]; - + [newClipping release]; return YES; } @@ -311,6 +316,15 @@ -(NSString *) clippingContentsAtPosition:(int)index } } +-(NSDictionary *) clippingRichDataAtPosition:(int)index +{ + if ( index >= [jcList count] ) { + return nil; + } else { + return [[jcList objectAtIndex:index] richData]; + } +} + -(NSString *) clippingDisplayStringAtPosition:(int)index { return [[jcList objectAtIndex:index] displayString]; diff --git a/FlycutOperator.h b/FlycutOperator.h index 362db60..1349766 100644 --- a/FlycutOperator.h +++ b/FlycutOperator.h @@ -50,9 +50,12 @@ // Basic functionality -(int)indexOfClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL; -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelectorclippingAddedSelector; +-(bool)addClipping:(NSString*)contents ofType:(NSString*)type withRichData:(NSDictionary *)richData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector; -(int)stackPosition; -(NSString*)getPasteFromStackPosition; -(NSString*)getPasteFromIndex:(int) position; +-(NSDictionary*)getRichDataFromStackPosition; +-(NSDictionary*)getRichDataFromIndex:(int) position; -(bool) saveFromStack; -(bool)clearItemAtStackPosition; -(void)clearList; diff --git a/FlycutOperator.m b/FlycutOperator.m index ca5c581..0fb2ce7 100644 --- a/FlycutOperator.m +++ b/FlycutOperator.m @@ -51,6 +51,8 @@ - (id)init @"removeDuplicates", [NSNumber numberWithBool:NO], @"pasteMovesToTop", + [NSNumber numberWithBool:YES], + @"preserveTextFormatting", [NSNumber numberWithBool:NO], @"syncSettingsViaICloud", [NSNumber numberWithBool:NO], @@ -66,7 +68,8 @@ - (id)init @"skipPasswordLengths", @"skipPasswordLengthsList", @"removeDuplicates", - @"pasteMovesToTop"]; + @"pasteMovesToTop", + @"preserveTextFormatting"]; [settingsSyncList retain]; return self; @@ -227,6 +230,21 @@ - (NSString*)getPasteFromStackPosition return nil; } +// Must be called before getPasteFromIndex:/getPasteFromStackPosition, which may +// reorder the list when pasteMovesToTop is enabled. +- (NSDictionary*)getRichDataFromStackPosition +{ + if ( [clippingStore jcListCount] > stackPosition ) { + return [self getRichDataFromIndex: stackPosition]; + } + return nil; +} + +- (NSDictionary*)getRichDataFromIndex:(int) position +{ + return [clippingStore clippingRichDataAtPosition:position]; +} + - (bool)saveFromStack { return [self saveFromStackWithPrefix:@""]; @@ -460,13 +478,19 @@ -(int)indexOfClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSStri } -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector +{ + return [self addClipping:contents ofType:type withRichData:nil fromApp:appName withAppBundleURL:bundleURL target:selectorTarget clippingAddedSelector:clippingAddedSelector]; +} + +-(bool)addClipping:(NSString*)contents ofType:(NSString*)type withRichData:(NSDictionary *)richData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector { if ( [clippingStore jcListCount] == 0 || ! [contents isEqualToString:[clippingStore clippingContentsAtPosition:0]]) { bool success = [clippingStore addClipping:contents ofType:type fromAppLocalizedName:appName fromAppBundleURL:bundleURL - atTimestamp:[[NSDate date] timeIntervalSince1970]]; + atTimestamp:[[NSDate date] timeIntervalSince1970] + withRichData:richData]; // The below tracks our position down down down... Maybe as an option? // if ( [clippingStore jcListCount] > 1 ) stackPosition++; @@ -985,12 +1009,26 @@ -(bool) loadEngineFrom:(NSDictionary*)loadDict key:(NSString*)listKey into:(Flyc int rangeCap = [savedJCList count] < [store rememberNum] ? [savedJCList count] : [store rememberNum]; NSRange loadRange = NSMakeRange(0, rangeCap); NSArray *toBeRestoredClips = [[[savedJCList subarrayWithRange:loadRange] reverseObjectEnumerator] allObjects]; - for( NSDictionary *aSavedClipping in toBeRestoredClips) + for( NSDictionary *aSavedClipping in toBeRestoredClips) { + // Only restore rich data that has the expected shape (type string -> data), + // since the plist could have been written by other versions or edited. + NSDictionary *richData = [aSavedClipping objectForKey:@"RichData"]; + if ( [richData isKindOfClass:[NSDictionary class]] ) { + for ( id key in richData ) + if ( ! [key isKindOfClass:[NSString class]] || ! [[richData objectForKey:key] isKindOfClass:[NSData class]] ) { + richData = nil; + break; + } + } else + richData = nil; + [store addClipping:[aSavedClipping objectForKey:@"Contents"] ofType:[aSavedClipping objectForKey:@"Type"] fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] - atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue] + withRichData:richData]; + } return YES; } else DLog(@"Not array"); return NO; @@ -1071,6 +1109,10 @@ - (void)saveStore:(FlycutStore *)store toKey:(NSString *)key onDict:(NSMutableDi if ( timestamp > 0 ) [dict setObject:[NSNumber numberWithInt:timestamp] forKey:@"Timestamp"]; + NSDictionary *richData = [clipping richData]; + if ( nil != richData && [richData count] > 0 ) + [dict setObject:richData forKey:@"RichData"]; + [jcListArray addObject:dict]; } [saveDict setObject:jcListArray forKey:key]; diff --git a/help.md b/help.md index d985e05..37cf40f 100644 --- a/help.md +++ b/help.md @@ -68,6 +68,14 @@ Here's a complete list of keys you can use in the Bezel. Press the Search Hotkey (Shift-Command-B, by default) to open the search window. Type to filter your clipboard history. Use the arrow keys to navigate results and press Return to paste the selected item. +**Text Formatting** + +Flycut preserves the formatting (RTF and HTML) of copied text, so pasting an older clipping into a rich text editor keeps its bold, fonts, links, and colors. Plain-text-only targets are unaffected — they always receive the plain text. Rich data larger than 1 MB per clipping is not kept. + +To disable this and store plain text only, run: + + defaults write com.generalarcade.flycut preserveTextFormatting -bool NO + **Menu Features** The Flycut menu allows you to select from the most recent items in the main clipboard history store, clear all of them, merge them all into one entry, or access the preferences panel. You can also Option-Click the Flycut menu icon to disable or reenable clipboard tracking, in case you are copying sensitive information such as passwords.