diff --git a/AppController.h b/AppController.h index 669bed8..85f1b4c 100755 --- a/AppController.h +++ b/AppController.h @@ -21,6 +21,7 @@ #import "SGHotKey.h" @class SGHotKey; +@class FlycutClipping; @interface AppController : NSResponder { BezelWindow *bezel; @@ -91,6 +92,7 @@ // Basic functionality -(void) pollPB:(NSTimer *)timer; -(void) addClipToPasteboard:(NSString*)pbFullText; +-(void) addImageClipToPasteboard:(FlycutClipping *)clipping; -(void) setPBBlockCount:(NSNumber *)newPBBlockCount; -(void) hideApp; -(void) fakeCommandV; diff --git a/AppController.m b/AppController.m index 3898ab0..ef7eeef 100755 --- a/AppController.m +++ b/AppController.m @@ -18,6 +18,7 @@ #import "SRRecorderCell.h" #import "NSWindow+TrueCenter.h" #import "NSWindow+ULIZoomEffect.h" +#import "FlycutImageStore.h" //#import "MJCloudKitUserDefaultsSync/MJCloudKitUserDefaultsSync.h" #import #import @@ -53,6 +54,14 @@ - (BOOL)performKeyEquivalent:(NSEvent *)theEvent { @end +@interface AppController () +// Returns the source GIF URL embedded in the clipboard HTML (e.g. Chrome "Copy Image"), +// but only for https URLs that point at a .gif. nil otherwise. +-(NSURL *)animatedGifURLFromPasteboard; +// Asynchronously downloads url and, if it is an animated GIF, adds it as a clipping. +-(void)downloadAndCaptureGIFFromURL:(NSURL *)url fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL; +@end + @implementation AppController @@ -94,6 +103,15 @@ - (id)init @"saveForgottenFavorites", [NSNumber numberWithBool:NO], @"suppressAccessibilityAlert", + [NSNumber numberWithBool:YES], + @"captureImages", + [NSNumber numberWithInt:10240], + @"maxImageSizeKB", + // Off by default: this feature issues a network request to a URL taken + // from copied HTML, which must be an explicit opt-in for a clipboard + // manager that otherwise never touches the network. + [NSNumber numberWithBool:NO], + @"downloadAnimatedGIFs", nil]]; /* For testing, the ability to force initial values of the sync settings: @@ -765,7 +783,21 @@ -(void) buildAppearancesPreferencePanel action:@selector(setupBezel:)]; [appearancePanel addSubview:row]; nextYMax = row.frame.origin.y; - + + row = [self preferencePanelCheckboxRowForText:@"Capture images from clipboard" + frameMaxY:nextYMax + binding:@"captureImages" + action:nil]; + [appearancePanel addSubview:row]; + nextYMax = row.frame.origin.y; + + row = [self preferencePanelCheckboxRowForText:@"Download animated GIFs from web" + frameMaxY:nextYMax + binding:@"downloadAnimatedGIFs" + action:nil]; + [appearancePanel addSubview:row]; + nextYMax = row.frame.origin.y; + // Add Accessibility Check button NSRect panelFrame = [appearancePanel frame]; int height = 40; @@ -882,17 +914,21 @@ - (void)restoreStashedStoreAndUpdate - (void)pasteFromStack { - NSLog(@"pasteFromStack called"); - 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 performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; - [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.5]; - } else { - NSLog(@"No content found in stack position"); - [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; - } + FlycutClipping *clipping = [flycutOperator clippingAtStackPosition]; + if (clipping) { + if ([clipping isImageClipping]) { + [self addImageClipToPasteboard:clipping]; + } else { + NSString *content = [flycutOperator getPasteFromStackPosition]; + if (content) { + [self addClipToPasteboard:content]; + } + } + [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; + [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.5]; + } else { + [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; + } [self restoreStashedStoreAndUpdate]; } @@ -907,7 +943,6 @@ - (void)moveItemAtStackPositionToTopOfStack } - (void)pasteIndexAndUpdate:(int) position { - // If there is an active search, we need to map the menu index to the stack position. NSString* search = [searchBox stringValue]; if ( nil != search && 0 != search.length ) { @@ -915,12 +950,21 @@ - (void)pasteIndexAndUpdate:(int) position { position = [mapping[position] intValue]; } - NSString *content = [flycutOperator getPasteFromIndex: position]; - if ( nil != content ) - { - [self addClipToPasteboard:content]; + FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; + if (clipping && [clipping isImageClipping]) { + [self addImageClipToPasteboard:clipping]; + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { + [flycutOperator getPasteFromIndex:position]; + } [self updateMenu]; - } + } else { + NSString *content = [flycutOperator getPasteFromIndex: position]; + if ( nil != content ) + { + [self addClipToPasteboard:content]; + [self updateMenu]; + } + } } - (void)metaKeysReleased @@ -1066,48 +1110,81 @@ -(BOOL)control:(NSControl *)control textView:(NSTextView *)fieldEditor doCommand -(void)pollPB:(NSTimer *)timer { - NSString *type = [jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:NSPasteboardTypeString]]; + NSString *textType = [jcPasteboard availableTypeFromArray:@[NSPasteboardTypeString]]; + NSString *imageType = [jcPasteboard availableTypeFromArray:@[@"com.compuserve.gif", NSPasteboardTypeTIFF, NSPasteboardTypePNG]]; + if ( [pbCount intValue] != [jcPasteboard changeCount] && ![flycutOperator storeDisabled] ) { - // Reload pbCount with the current changeCount - // Probably poor coding technique, but pollPB should be the only thing messing with pbCount, so it should be okay [pbCount release]; pbCount = [[NSNumber numberWithInt:[jcPasteboard changeCount]] retain]; - if ( type != nil ) { - NSRunningApplication *currRunningApp = nil; - for (NSRunningApplication *currApp in [[NSWorkspace sharedWorkspace] runningApplications]) - if ([currApp isActive]) - currRunningApp = currApp; - bool largeCopyRisk = nil != currRunningApp && [[currRunningApp localizedName] rangeOfString:@"Remote Desktop Connection"].location != NSNotFound; - // Microsoft's Remote Desktop Connection has an issue with large copy actions, which appears to be in the time it takes to transer them over the network. The copy starts being registered with OS X prior to completion of the transfer, and if the active application changes during the transfer the copy will be lost. Indicate this time period by toggling the menu icon at the beginning of all RDC trasfers and back at the end. Apple's Screen Sharing does not demonstrate this problem. + NSRunningApplication *currRunningApp = nil; + for (NSRunningApplication *currApp in [[NSWorkspace sharedWorkspace] runningApplications]) + if ([currApp isActive]) + currRunningApp = currApp; + + if ( textType != nil ) { + bool largeCopyRisk = nil != currRunningApp && [[currRunningApp localizedName] rangeOfString:@"Remote Desktop Connection"].location != NSNotFound; if (largeCopyRisk) [self toggleMenuIconDisabled]; - // In case we need to do a status visual, this will be dispatched out so our thread isn't blocked. dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ - - // This operation blocks until the transfer is complete, though it was was here before the RDC issue was discovered. Convenient. - NSString *contents = [jcPasteboard stringForType:type]; - - // Toggle back if dealing with the RDC issue. + NSString *contents = [jcPasteboard stringForType:textType]; if (largeCopyRisk) [self toggleMenuIconDisabled]; - if ( contents == nil || [flycutOperator shouldSkip:contents ofType:[jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:NSPasteboardTypeString]] fromAvailableTypes:[jcPasteboard types]] ) { + if ( contents == nil || [flycutOperator shouldSkip:contents ofType:[jcPasteboard availableTypeFromArray:@[NSPasteboardTypeString]] fromAvailableTypes:[jcPasteboard types]] ) { DLog(@"Contents: Empty or skipped"); } else { - // 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:textType fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; } }); } }); - } + } + + if ( imageType != nil && [[NSUserDefaults standardUserDefaults] boolForKey:@"captureImages"] ) { + if ( [flycutOperator shouldSkip:@"" ofType:imageType fromAvailableTypes:[jcPasteboard types]] ) { + DLog(@"Image: skipped due to pasteboard type filter"); + } else { + // When the clipboard carries only a flattened still but embeds the source + // GIF's URL (e.g. Chrome "Copy Image" on an animated GIF), fetch the real + // animated file instead of capturing the still. + BOOL haveDirectGIF = [imageType isEqualToString:@"com.compuserve.gif"]; + NSURL *gifURL = nil; + if ( !haveDirectGIF && [[NSUserDefaults standardUserDefaults] boolForKey:@"downloadAnimatedGIFs"] ) + gifURL = [self animatedGifURLFromPasteboard]; + + if ( gifURL != nil ) { + // Skip the still entirely; the async download adds the animated GIF on success. + [self downloadAndCaptureGIFFromURL:gifURL fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path]; + } else { + NSString *captureType = imageType; + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_async(queue, ^{ + NSData *imageData = [[jcPasteboard dataForType:captureType] copy]; + if (imageData && [imageData length] > 0) { + int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; + if (maxSizeKB <= 0) maxSizeKB = 10240; + if ([imageData length] <= (NSUInteger)maxSizeKB * 1024) { + dispatch_async(dispatch_get_main_queue(), ^{ + if ( ! [pbCount isEqualTo:pbBlockCount] ) { + [flycutOperator addImageClipping:imageData fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path imageType:captureType target:self clippingAddedSelector:@selector(updateMenu)]; + } + [imageData release]; + }); + } else { + [imageData release]; + } + } else { + [imageData release]; + } + }); + } + } + } } } @@ -1527,8 +1604,12 @@ - (void)updateMenuContaining:(NSString*)search { keyEquivalent:@""]; [item setTarget:self]; [item setEnabled:YES]; + if ([[clipStrings objectAtIndex:i] hasPrefix:@"[Image"]) { + NSImage *imgIcon = [NSImage imageNamed:NSImageNameQuickLookTemplate]; + [imgIcon setSize:NSMakeSize(16, 16)]; + [item setImage:imgIcon]; + } [jcMenu insertItem:item atIndex:0]; - // Way back in 0.2, failure to release the new item here was causing a quite atrocious memory leak. [item release]; } }); @@ -1554,15 +1635,117 @@ -(void) setPBBlockCount:(NSNumber *)newPBBlockCount -(void)addClipToPasteboard:(NSString*)pbFullText { - NSArray *pbTypes; - pbTypes = [NSArray arrayWithObjects:@"NSStringPboardType",NULL]; - - [jcPasteboard declareTypes:pbTypes owner:NULL]; - - [jcPasteboard setString:pbFullText forType:@"NSStringPboardType"]; + [jcPasteboard declareTypes:@[NSPasteboardTypeString] owner:nil]; + [jcPasteboard setString:pbFullText forType:NSPasteboardTypeString]; [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; } +-(void)addImageClipToPasteboard:(FlycutClipping *)clipping +{ + NSString *type = [clipping type]; + if (!type || [type length] == 0) + type = NSPasteboardTypeTIFF; + + // Animated GIFs must be offered as a FILE reference ONLY. Apps like Slack/Discord + // upload the actual .gif file (preserving animation); if raw image data is also on + // the pasteboard they grab that instead and flatten it to a single frame. Pointing + // at the persistent store file is safe — it lives as long as the clipping does. + if ([type isEqualToString:@"com.compuserve.gif"]) { + NSString *path = [[FlycutImageStore sharedStore] imageFilePathForHash:[clipping imageHash]]; + if (path) { + [jcPasteboard clearContents]; + [jcPasteboard writeObjects:@[[NSURL fileURLWithPath:path]]]; + [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; + return; + } + } + + NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; + if (imageData) { + [jcPasteboard declareTypes:@[type] owner:nil]; + [jcPasteboard setData:imageData forType:type]; + [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; + } +} + +-(NSURL *)animatedGifURLFromPasteboard +{ + NSData *htmlData = [jcPasteboard dataForType:@"public.html"]; + if (htmlData == nil || [htmlData length] == 0) + return nil; + + NSString *html = [[[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding] autorelease]; + if (html == nil || [html length] == 0) + return nil; + + // Pull the first URL out of the clipboard HTML. + NSRegularExpression *re = [NSRegularExpression regularExpressionWithPattern:@"]+src=[\"']([^\"']+)[\"']" + options:NSRegularExpressionCaseInsensitive + error:nil]; + if (re == nil) + return nil; + NSTextCheckingResult *match = [re firstMatchInString:html options:0 range:NSMakeRange(0, [html length])]; + if (match == nil || [match numberOfRanges] < 2) + return nil; + + NSString *src = [html substringWithRange:[match rangeAtIndex:1]]; + NSURL *url = [NSURL URLWithString:src]; + if (url == nil) + return nil; + + // Only fetch over HTTPS, and only when the URL actually points at a GIF. + if (![[[url scheme] lowercaseString] isEqualToString:@"https"]) + return nil; + if (![[[url path] lowercaseString] hasSuffix:@".gif"]) + return nil; + + return url; +} + +-(void)downloadAndCaptureGIFFromURL:(NSURL *)url fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL +{ + int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; + if (maxSizeKB <= 0) maxSizeKB = 10240; + NSUInteger maxBytes = (NSUInteger)maxSizeKB * 1024; + + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.timeoutIntervalForRequest = 5.0; + config.timeoutIntervalForResource = 10.0; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; + + NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // On any failure (offline, timeout, non-200, too big, not an animated GIF) do + // nothing — per design we skip the entry rather than fall back to a still. + if (error != nil || data == nil || [data length] == 0) + return; + if ([response isKindOfClass:[NSHTTPURLResponse class]] && [(NSHTTPURLResponse *)response statusCode] != 200) + return; + if ([data length] > maxBytes || [data length] < 6) + return; + + // Verify GIF magic bytes ("GIF87a"/"GIF89a")... + const unsigned char *bytes = (const unsigned char *)[data bytes]; + if (!(bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F')) + return; + + // ...and that it is actually animated (more than one frame). + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:data]; + NSNumber *frames = rep ? [rep valueForProperty:NSImageFrameCount] : nil; + if (frames == nil || [frames integerValue] <= 1) + return; + + NSData *gifData = [data retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + if ( ! [pbCount isEqualTo:pbBlockCount] ) { + [flycutOperator addImageClipping:gifData fromApp:appName withAppBundleURL:bundleURL imageType:@"com.compuserve.gif" target:self clippingAddedSelector:@selector(updateMenu)]; + } + [gifData release]; + }); + }]; + [task resume]; + [session finishTasksAndInvalidate]; +} + -(void) stackDown { NSLog(@"stackDown: current position=%d, total count=%d", [flycutOperator stackPosition], [flycutOperator jcListCount]); @@ -1577,15 +1760,24 @@ -(void) stackDown -(void) fillBezel { FlycutClipping* clipping = [flycutOperator clippingAtStackPosition]; - [bezel setText:[NSString stringWithFormat:@"%@", [clipping contents]]]; - + + if ([clipping isImageClipping]) { + NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; + if (imageData) { + NSImage *image = [[NSImage alloc] initWithData:imageData]; + [bezel setImage:image]; + [image release]; + } else { + [bezel setText:@"[Image not found]"]; + } + } else { + [bezel setText:[NSString stringWithFormat:@"%@", [clipping contents]]]; + } + int currentPos = [flycutOperator stackPosition] + 1; int totalCount = [flycutOperator jcListCount]; - int displayNum = [[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"]; - - NSLog(@"fillBezel: showing %d of %d (displayNum pref=%d)", currentPos, totalCount, displayNum); [bezel setCharString:[NSString stringWithFormat:@"%d of %d", currentPos, totalCount]]; - + NSString *localizedName = [clipping appLocalizedName]; if ( nil == localizedName ) localizedName = @""; @@ -1896,29 +2088,34 @@ - (IBAction)searchWindowItemSelected:(id)sender { NSInteger selectedRow = [searchWindowTableView selectedRow]; if (selectedRow < 0) { - selectedRow = 0; // Default to first item if none selected + selectedRow = 0; } - + if (selectedRow < [searchResults count]) { - // Get the content and paste it like bezel does NSString* searchText = [searchWindowSearchField stringValue]; NSArray *mapping = nil; int position = (int)selectedRow; - + if (searchText && [searchText length] > 0) { mapping = [flycutOperator previousIndexes:[[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"] containing:searchText]; position = [mapping[selectedRow] intValue]; } - - NSString *content = [flycutOperator getPasteFromIndex:position]; - if (content) { - [self addClipToPasteboard:content]; - [self updateMenu]; // Update menu like bezel does - [self hideSearchWindow]; - - // Always paste immediately (like bezel behavior), ignore menuSelectionPastes preference - [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.3]; + + FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; + if (clipping && [clipping isImageClipping]) { + [self addImageClipToPasteboard:clipping]; + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { + [flycutOperator getPasteFromIndex:position]; + } + } else { + NSString *content = [flycutOperator getPasteFromIndex:position]; + if (content) { + [self addClipToPasteboard:content]; + } } + [self updateMenu]; + [self hideSearchWindow]; + [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.3]; } } diff --git a/Flycut.xcodeproj/project.pbxproj b/Flycut.xcodeproj/project.pbxproj index defebf3..5bead28 100755 --- a/Flycut.xcodeproj/project.pbxproj +++ b/Flycut.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ E5334CA52DBF0A1D00AAEAEE /* FlycutClipping.m in Sources */ = {isa = PBXBuildFile; fileRef = E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */; }; E5334CA62DBF0A1D00AAEAEE /* FlycutStore.h in Headers */ = {isa = PBXBuildFile; fileRef = E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */; }; E5334CA72DBF0A1D00AAEAEE /* FlycutClipping.h in Headers */ = {isa = PBXBuildFile; fileRef = E5334C9F2DBF0A1D00AAEAEE /* FlycutClipping.h */; }; + F1000001AAAA000100000001 /* FlycutImageStore.m in Sources */ = {isa = PBXBuildFile; fileRef = F1000001AAAA000100000003 /* FlycutImageStore.m */; }; + F1000001AAAA000100000002 /* FlycutImageStore.h in Headers */ = {isa = PBXBuildFile; fileRef = F1000001AAAA000100000004 /* FlycutImageStore.h */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -172,6 +174,8 @@ E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutClipping.m; sourceTree = ""; }; E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlycutStore.h; sourceTree = ""; }; E5334CA22DBF0A1D00AAEAEE /* FlycutStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutStore.m; sourceTree = ""; }; + F1000001AAAA000100000003 /* FlycutImageStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutImageStore.m; sourceTree = ""; }; + F1000001AAAA000100000004 /* FlycutImageStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlycutImageStore.h; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -376,6 +380,8 @@ E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */, E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */, E5334CA22DBF0A1D00AAEAEE /* FlycutStore.m */, + F1000001AAAA000100000004 /* FlycutImageStore.h */, + F1000001AAAA000100000003 /* FlycutImageStore.m */, ); path = FlycutEngine; sourceTree = ""; @@ -399,6 +405,7 @@ 7761C8AC139BDF12000FB3AB /* SRKeyCodeTransformer.h in Headers */, E5334CA62DBF0A1D00AAEAEE /* FlycutStore.h in Headers */, E5334CA72DBF0A1D00AAEAEE /* FlycutClipping.h in Headers */, + F1000001AAAA000100000002 /* FlycutImageStore.h in Headers */, 7761C8AE139BDF12000FB3AB /* SRRecorderCell.h in Headers */, 7761C8B0139BDF12000FB3AB /* SRRecorderControl.h in Headers */, 7761C8B2139BDF12000FB3AB /* SRValidator.h in Headers */, @@ -516,6 +523,7 @@ 77A4F3B0139BD72300F39666 /* SGHotKeyCenter.m in Sources */, E5334CA42DBF0A1D00AAEAEE /* FlycutStore.m in Sources */, E5334CA52DBF0A1D00AAEAEE /* FlycutClipping.m in Sources */, + F1000001AAAA000100000001 /* FlycutImageStore.m in Sources */, 77A4F3B2139BD72300F39666 /* SGKeyCodeTranslator.m in Sources */, 77A4F3B4139BD72300F39666 /* SGKeyCombo.m in Sources */, 7761C891139BDEAF000FB3AB /* BezelWindow.m in Sources */, diff --git a/FlycutEngine/FlycutClipping.h b/FlycutEngine/FlycutClipping.h index 81316b2..d12ad0a 100755 --- a/FlycutEngine/FlycutClipping.h +++ b/FlycutEngine/FlycutClipping.h @@ -12,26 +12,20 @@ #import @interface FlycutClipping : NSObject { -// What must a clipping hold? -// The text NSString * clipContents; -// The text type NSString * clipType; -// The display length int clipDisplayLength; -// The display string NSString * clipDisplayString; -// Does it have a name? BOOL clipHasName; -// The app name it came from NSString * appLocalizedName; -// The the bunle URL of the app it came from NSString * appBundleURL; -// The time NSInteger clipTimestamp; + NSString * imageHash; + NSSize imageSize; } -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withImageType:(NSString *)imageType withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; /* -(id) initWithCoder:(NSCoder *)coder; -(void) decodeWithCoder:(NSCoder *)coder; */ -(NSString *) description; @@ -53,6 +47,11 @@ -(NSString *) appBundleURL; -(NSInteger) timestamp; -(BOOL) hasName; +-(BOOL) isImageClipping; +-(NSString *) imageHash; +-(NSSize) imageSize; +-(void) setImageHash:(NSString *)hash; +-(void) setImageSize:(NSSize)size; // Additional functions -(void) resetDisplayString; diff --git a/FlycutEngine/FlycutClipping.m b/FlycutEngine/FlycutClipping.m index 5997530..250f364 100755 --- a/FlycutEngine/FlycutClipping.m +++ b/FlycutEngine/FlycutClipping.m @@ -11,6 +11,7 @@ #import "FlycutClipping.h" +#import @implementation FlycutClipping @@ -31,6 +32,8 @@ -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDispla clipContents = [[[NSString alloc] init] retain]; clipDisplayString = [[[NSString alloc] init] retain]; clipType = [[[NSString alloc] init] retain]; + imageHash = nil; + imageSize = NSZeroSize; [self setContents:contents setDisplayLength:displayLength]; [self setType:type]; @@ -38,7 +41,26 @@ -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDispla [self setAppBundleURL:bundleURL]; [self setTimestamp:timestamp]; [self setHasName:false]; - + + return self; +} + +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withImageType:(NSString *)imageType withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString*)bundleURL withTimestamp:(NSInteger)timestamp +{ + [super init]; + clipContents = [@"" retain]; + clipDisplayString = [[[NSString alloc] init] retain]; + clipType = [(imageType != nil ? imageType : NSPasteboardTypeTIFF) retain]; + clipDisplayLength = displayLength > 0 ? displayLength : 40; + + [self setImageHash:hash]; + imageSize = size; + [self setAppLocalizedName:localizedName]; + [self setAppBundleURL:bundleURL]; + [self setTimestamp:timestamp]; + [self setHasName:false]; + [self resetDisplayString]; + return self; } @@ -136,19 +158,32 @@ -(void) setHasName:(BOOL)newHasName -(void) resetDisplayString { + [clipDisplayString release]; + + if (imageHash != nil) { + NSString *label = [clipType isEqualToString:@"com.compuserve.gif"] ? @"GIF" : @"Image"; + NSString *newDisplayString; + if (imageSize.width > 0 && imageSize.height > 0) { + newDisplayString = [NSString stringWithFormat:@"[%@ %dx%d]", + label, (int)imageSize.width, (int)imageSize.height]; + } else { + newDisplayString = [NSString stringWithFormat:@"[%@]", label]; + } + [newDisplayString retain]; + clipDisplayString = newDisplayString; + return; + } + NSString *newDisplayString, *firstLineOfClipping, *trimmedString; NSUInteger start, lineEnd, contentsEnd; NSRange startRange = NSMakeRange(0,0); NSRange contentsRange; - // We're resetting the display string, so release the old one. - [clipDisplayString release]; - // We want to restrict the display string to the clipping contents through the first line break. trimmedString = [clipContents stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; [trimmedString getLineStart:&start end:&lineEnd contentsEnd:&contentsEnd forRange:startRange]; contentsRange = NSMakeRange(0, contentsEnd); firstLineOfClipping = [trimmedString substringWithRange:contentsRange]; if ( [firstLineOfClipping length] > clipDisplayLength ) { - newDisplayString = [[NSString stringWithString:[firstLineOfClipping substringToIndex:clipDisplayLength]] stringByAppendingString:@"…"]; + newDisplayString = [[NSString stringWithString:[firstLineOfClipping substringToIndex:clipDisplayLength]] stringByAppendingString:@"…"]; } else { newDisplayString = [NSString stringWithString:firstLineOfClipping]; } @@ -216,14 +251,45 @@ -(BOOL) hasName return clipHasName; } +-(BOOL) isImageClipping +{ + return imageHash != nil; +} + +-(NSString *) imageHash +{ + return imageHash; +} + +-(NSSize) imageSize +{ + return imageSize; +} + +-(void) setImageHash:(NSString *)hash +{ + id old = imageHash; + [hash retain]; + imageHash = hash; + [old release]; +} + +-(void) setImageSize:(NSSize)size +{ + imageSize = size; +} + - (BOOL)isEqual:(id)other { if (other == self) return YES; if (!other || ![other isKindOfClass:[self class]]) return NO; FlycutClipping * otherClip = (FlycutClipping *)other; - return (/*[self.type isEqualToString:otherClip.type] &&*/ // Type is under-utilized a this time and will mismatch on cross-device (macOS <-> iOS) usage. This should be revisited once we have support for more than just raw text clippings. - [self.contents isEqualToString:otherClip.contents]); + if ([self isImageClipping] && [otherClip isImageClipping]) + return [self.imageHash isEqualToString:otherClip.imageHash]; + if ([self isImageClipping] != [otherClip isImageClipping]) + return NO; + return [self.contents isEqualToString:otherClip.contents]; } @@ -234,6 +300,7 @@ -(void) dealloc [clipType release]; [appLocalizedName release]; [appBundleURL release]; + [imageHash release]; clipDisplayLength = 0; [clipDisplayString release]; clipHasName = 0; diff --git a/FlycutEngine/FlycutImageStore.h b/FlycutEngine/FlycutImageStore.h new file mode 100644 index 0000000..8c61a00 --- /dev/null +++ b/FlycutEngine/FlycutImageStore.h @@ -0,0 +1,31 @@ +// +// FlycutImageStore.h +// Flycut +// +// File-based image storage for clipboard image clippings. +// + +#import +#import + +@interface FlycutImageStore : NSObject { + NSString *imagesDirectoryPath; +} + ++(FlycutImageStore *)sharedStore; + +// Maps a pasteboard UTI (e.g. com.compuserve.gif) to the on-disk file extension. ++(NSString *)fileExtensionForType:(NSString *)uti; + +-(NSString *)hashForData:(NSData *)data; +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash; +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash extension:(NSString *)ext; +-(NSData *)imageDataForHash:(NSString *)hash; +// Absolute path to the stored image file for a hash (whatever extension), or nil. +-(NSString *)imageFilePathForHash:(NSString *)hash; +-(void)deleteImageForHash:(NSString *)hash; +-(BOOL)hasImageForHash:(NSString *)hash; +-(NSString *)imagesDirectoryPath; +-(NSArray *)allStoredHashes; + +@end diff --git a/FlycutEngine/FlycutImageStore.m b/FlycutEngine/FlycutImageStore.m new file mode 100644 index 0000000..c9a256c --- /dev/null +++ b/FlycutEngine/FlycutImageStore.m @@ -0,0 +1,157 @@ +// +// FlycutImageStore.m +// Flycut +// +// File-based image storage for clipboard image clippings. +// + +#import "FlycutImageStore.h" + +static FlycutImageStore *sharedInstance = nil; + +@implementation FlycutImageStore + ++(FlycutImageStore *)sharedStore +{ + if (nil == sharedInstance) { + sharedInstance = [[FlycutImageStore alloc] init]; + } + return sharedInstance; +} + ++(NSString *)fileExtensionForType:(NSString *)uti +{ + if ([uti isEqualToString:@"com.compuserve.gif"]) + return @"gif"; + if ([uti isEqualToString:@"public.png"]) + return @"png"; + if ([uti isEqualToString:@"public.jpeg"]) + return @"jpg"; + return @"tiff"; +} + +-(id)init +{ + self = [super init]; + if (self) { + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); + NSString *appSupportDir = [paths firstObject]; + imagesDirectoryPath = [[appSupportDir stringByAppendingPathComponent:@"Flycut/Images"] retain]; + + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:imagesDirectoryPath]) { + [fm createDirectoryAtPath:imagesDirectoryPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + } + return self; +} + +-(NSString *)hashForData:(NSData *)data +{ + if (!data || [data length] == 0) + return nil; + + unsigned char hash[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256([data bytes], (CC_LONG)[data length], hash); + NSMutableString *hashString = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) + [hashString appendFormat:@"%02x", hash[i]]; + return hashString; +} + +-(NSString *)filePathForHash:(NSString *)hash extension:(NSString *)ext +{ + if (!ext || [ext length] == 0) + ext = @"tiff"; + return [imagesDirectoryPath stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%@", hash, ext]]; +} + +// Resolves a hash to whatever extension it was actually stored under (e.g. .gif, +// .png, or legacy .tiff). Returns nil if no matching file exists. +-(NSString *)resolvedPathForHash:(NSString *)hash +{ + if (!hash) + return nil; + + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *prefix = [hash stringByAppendingString:@"."]; + for (NSString *file in [fm contentsOfDirectoryAtPath:imagesDirectoryPath error:nil]) { + if ([file hasPrefix:prefix]) + return [imagesDirectoryPath stringByAppendingPathComponent:file]; + } + return nil; +} + +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash +{ + return [self saveImageData:data forHash:hash extension:@"tiff"]; +} + +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash extension:(NSString *)ext +{ + if (!data || !hash) + return NO; + + // Content-addressed: if any representation of this hash is already stored, keep it. + if ([self resolvedPathForHash:hash]) + return YES; + + NSString *filePath = [self filePathForHash:hash extension:ext]; + return [data writeToFile:filePath atomically:YES]; +} + +-(NSData *)imageDataForHash:(NSString *)hash +{ + NSString *filePath = [self resolvedPathForHash:hash]; + if (!filePath) + return nil; + + return [NSData dataWithContentsOfFile:filePath]; +} + +-(NSString *)imageFilePathForHash:(NSString *)hash +{ + return [self resolvedPathForHash:hash]; +} + +-(void)deleteImageForHash:(NSString *)hash +{ + NSString *filePath = [self resolvedPathForHash:hash]; + if (filePath) + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; +} + +-(BOOL)hasImageForHash:(NSString *)hash +{ + return [self resolvedPathForHash:hash] != nil; +} + +-(NSString *)imagesDirectoryPath +{ + return imagesDirectoryPath; +} + +-(NSArray *)allStoredHashes +{ + NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:imagesDirectoryPath error:nil]; + NSMutableArray *hashes = [NSMutableArray arrayWithCapacity:[files count]]; + for (NSString *file in files) { + // Any stored image representation (.tiff/.gif/.png/.jpg) maps back to its hash. + if ([[file pathExtension] length] > 0 && ![file hasPrefix:@"."]) { + [hashes addObject:[file stringByDeletingPathExtension]]; + } + } + return hashes; +} + +-(void)dealloc +{ + [imagesDirectoryPath release]; + [super dealloc]; +} + +@end diff --git a/FlycutEngine/FlycutStore.h b/FlycutEngine/FlycutStore.h index f0d1bdc..d99a61c 100755 --- a/FlycutEngine/FlycutStore.h +++ b/FlycutEngine/FlycutStore.h @@ -87,6 +87,7 @@ // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp; +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size imageType:(NSString *)imageType fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp; -(void) addClipping:(FlycutClipping*) clipping; -(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index; diff --git a/FlycutEngine/FlycutStore.m b/FlycutEngine/FlycutStore.m index 68aab09..a6c5dd4 100755 --- a/FlycutEngine/FlycutStore.m +++ b/FlycutEngine/FlycutStore.m @@ -95,6 +95,23 @@ -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalize return YES; } +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size imageType:(NSString *)imageType fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp +{ + if (!hash || [hash length] == 0) + return NO; + + FlycutClipping *newClipping = [[FlycutClipping alloc] initWithImageHash:hash + withImageSize:size + withImageType:imageType + withDisplayLength:[self displayLen] + withAppLocalizedName:appLocalizedName + withAppBundleURL:bundleURL + withTimestamp:timestamp]; + [self addClipping:newClipping]; + [newClipping release]; + return YES; +} + -(bool) removeDuplicates{ return [[[NSUserDefaults standardUserDefaults] valueForKey:@"removeDuplicates"] boolValue]; } @@ -358,13 +375,23 @@ -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search NSEnumerator *enumerator; FlycutClipping *aClipping; - // If we have a search, do that. Pretty much a mix of the other two paths below, but separated out to avoid extra processing. if (nil != search && search.length > 0) { subArray = [jcList copy]; enumerator = [subArray objectEnumerator]; int index = 0; - while ( aClipping = [enumerator nextObject] ) { // Forward enumerator so we find the most recent N matches - if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) { + while ( aClipping = [enumerator nextObject] ) { + BOOL matches = NO; + if ([aClipping isImageClipping]) { + NSString *appName = [aClipping appLocalizedName]; + if (appName && [appName rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + if ([[aClipping displayString] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } else { + if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } + if (matches) { [returnArray insertObject:[aClipping displayString] atIndex:0]; howMany--; if (0 == howMany) @@ -372,7 +399,7 @@ -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search } index++; } - return [[returnArray reverseObjectEnumerator] allObjects]; // Reverse the results since the caller expects the most recent to be last. + return [[returnArray reverseObjectEnumerator] allObjects]; } theRange.location = 0; @@ -396,13 +423,23 @@ -(NSArray *) previousIndexes:(int)howMany containing:(NSString*)search // This m NSEnumerator *enumerator; FlycutClipping *aClipping; - // If we have a search, do that. if (nil != search && search.length > 0) { subArray = [jcList copy]; enumerator = [subArray objectEnumerator]; int index = 0; - while ( aClipping = [enumerator nextObject] ) { // Forward enumerator so we find the most recent N matches - if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) { + while ( aClipping = [enumerator nextObject] ) { + BOOL matches = NO; + if ([aClipping isImageClipping]) { + NSString *appName = [aClipping appLocalizedName]; + if (appName && [appName rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + if ([[aClipping displayString] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } else { + if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } + if (matches) { [returnArray addObject:[NSNumber numberWithInt:index]]; howMany--; if (0 == howMany) diff --git a/FlycutOperator.h b/FlycutOperator.h index 362db60..031593c 100644 --- a/FlycutOperator.h +++ b/FlycutOperator.h @@ -50,6 +50,8 @@ // 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)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL imageType:(NSString *)imageType target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector; +-(FlycutClipping *)clippingAtIndex:(int)index; -(int)stackPosition; -(NSString*)getPasteFromStackPosition; -(NSString*)getPasteFromIndex:(int) position; diff --git a/FlycutOperator.m b/FlycutOperator.m index ca5c581..589bd04 100644 --- a/FlycutOperator.m +++ b/FlycutOperator.m @@ -13,7 +13,9 @@ // manipulation of the stores. #import +#import #import "FlycutOperator.h" +#import "FlycutImageStore.h" #ifdef FLYCUT_MAC #import "AppController.h" @@ -240,9 +242,18 @@ - (bool)saveFromStackWithPrefix:(NSString*) prefix - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSString*) prefix { if ( [store jcListCount] > index ) { - // Get text from clipping store. - NSString *pbFullText = [self clippingStringWithCount:index inStore:store]; - pbFullText = [pbFullText stringByReplacingOccurrencesOfString:@"\r" withString:@"\r\n"]; + FlycutClipping *clipping = [store clippingAtPosition:index]; + BOOL isImage = [clipping isImageClipping]; + + NSString *pbFullText = nil; + NSData *imageData = nil; + if (isImage) { + imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; + if (!imageData) return NO; + } else { + pbFullText = [self clippingStringWithCount:index inStore:store]; + pbFullText = [pbFullText stringByReplacingOccurrencesOfString:@"\r" withString:@"\r\n"]; + } if (!dateFormatterForFilename) { // Date formatters are time-expensive to create, so create once and reuse. @@ -254,9 +265,9 @@ - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSStrin NSDate *currentDate = [NSDate date]; NSString *dateString = [dateFormatterForFilename stringFromDate:currentDate]; - // Make a file name - NSString *fileName = [NSString stringWithFormat:@"%@%@Clipping %@.txt", - prefix, store == favoritesStore ? @"Favorite " : @"", dateString]; + NSString *fileExt = isImage ? [FlycutImageStore fileExtensionForType:[clipping type]] : @"txt"; + NSString *fileName = [NSString stringWithFormat:@"%@%@Clipping %@.%@", + prefix, store == favoritesStore ? @"Favorite " : @"", dateString, fileExt]; // Make a subdirectory, if doing autosave, to avoid a directory with too many files in it as that can make Finder effectively hang on launch if that directory were the desktop. NSString *subdirectoryString = nil; @@ -314,11 +325,14 @@ - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSStrin baseDirectoryString, fileName]; } - // Save content to the file - [pbFullText writeToFile:fileNameWithPath - atomically:NO - encoding:NSNonLossyASCIIStringEncoding - error:nil]; + if (isImage) { + [imageData writeToFile:fileNameWithPath atomically:YES]; + } else { + [pbFullText writeToFile:fileNameWithPath + atomically:NO + encoding:NSNonLossyASCIIStringEncoding + error:nil]; + } return YES; } return NO; @@ -479,14 +493,84 @@ -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString return NO; } +-(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL imageType:(NSString *)imageType target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector +{ + if (!imageData || [imageData length] == 0) + return NO; + + if (!imageType) + imageType = NSPasteboardTypeTIFF; + + FlycutImageStore *imgStore = [FlycutImageStore sharedStore]; + NSString *hash = [imgStore hashForData:imageData]; + if (!hash) + return NO; + + if ([clippingStore jcListCount] > 0) { + FlycutClipping *topClipping = [clippingStore clippingAtPosition:0]; + if ([topClipping isImageClipping] && [[topClipping imageHash] isEqualToString:hash]) + return NO; + } + + [imgStore saveImageData:imageData forHash:hash extension:[FlycutImageStore fileExtensionForType:imageType]]; + + NSSize pixelSize = NSZeroSize; + NSImage *tempImage = [[NSImage alloc] initWithData:imageData]; + if (tempImage) { + NSImageRep *rep = [[tempImage representations] firstObject]; + if (rep) { + pixelSize = NSMakeSize([rep pixelsWide], [rep pixelsHigh]); + } + [tempImage release]; + } + + bool success = [clippingStore addImageClippingWithHash:hash + imageSize:pixelSize + imageType:imageType + fromAppLocalizedName:appName + fromAppBundleURL:bundleURL + atTimestamp:[[NSDate date] timeIntervalSince1970]]; + stackPosition = 0; + [selectorTarget performSelector:clippingAddedSelector]; + [self actionAfterListModification]; + return success; +} + +-(FlycutClipping *)clippingAtIndex:(int)index +{ + return [clippingStore clippingAtPosition:index]; +} + +-(BOOL)isImageHashReferencedElsewhere:(NSString *)hash excludingStore:(FlycutStore *)excludeStore atIndex:(int)excludeIndex +{ + FlycutStore *stores[] = { clippingStore, favoritesStore }; + for (int s = 0; s < 2; s++) { + FlycutStore *store = stores[s]; + if (!store) continue; + for (int i = 0; i < [store jcListCount]; i++) { + if (store == excludeStore && i == excludeIndex) + continue; + FlycutClipping *c = [store clippingAtPosition:i]; + if ([c isImageClipping] && [[c imageHash] isEqualToString:hash]) + return YES; + } + } + return NO; +} + - (void)willDeleteClippingFromStore:(id)store AtIndex:(int)index { - if ( (!inhibitAutosaveClippings) // Avoid saving things that the user explicitly deletes. + FlycutClipping *clipping = [(FlycutStore *)store clippingAtPosition:index]; + if ([clipping isImageClipping]) { + if (![self isImageHashReferencedElsewhere:[clipping imageHash] excludingStore:(FlycutStore *)store atIndex:index]) { + [[FlycutImageStore sharedStore] deleteImageForHash:[clipping imageHash]]; + } + } + + if ( (!inhibitAutosaveClippings) && ( store == favoritesStore ? [[[NSUserDefaults standardUserDefaults] valueForKey:@"saveForgottenFavorites"] boolValue] : [[[NSUserDefaults standardUserDefaults] valueForKey:@"saveForgottenClippings"] boolValue] ) ) { - // clipping is being removed, so save it before it gets lost. - // Set to last item, save, and restore position. [self saveFromStore:store atIndex:index withPrefix:@"Autosave "]; } } @@ -981,16 +1065,31 @@ -(bool) loadEngineFrom:(NSDictionary*)loadDict key:(NSString*)listKey into:(Flyc { NSArray *savedJCList = [loadDict objectForKey:listKey]; if ( [savedJCList isKindOfClass:[NSArray class]] ) { - // There's probably a nicer way to prevent the range from going out of bounds, but this works. 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) - [store addClipping:[aSavedClipping objectForKey:@"Contents"] - ofType:[aSavedClipping objectForKey:@"Type"] - fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] - fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] - atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + for( NSDictionary *aSavedClipping in toBeRestoredClips) { + NSString *imgHash = [aSavedClipping objectForKey:@"ImageHash"]; + if (imgHash && [imgHash length] > 0) { + if ([[FlycutImageStore sharedStore] hasImageForHash:imgHash]) { + NSSize imgSize = NSMakeSize( + [[aSavedClipping objectForKey:@"ImageWidth"] doubleValue], + [[aSavedClipping objectForKey:@"ImageHeight"] doubleValue]); + [store addImageClippingWithHash:imgHash + imageSize:imgSize + imageType:[aSavedClipping objectForKey:@"Type"] + fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] + fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + } + } else { + [store addClipping:[aSavedClipping objectForKey:@"Contents"] + ofType:[aSavedClipping objectForKey:@"Type"] + fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] + fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + } + } return YES; } else DLog(@"Not array"); return NO; @@ -1059,6 +1158,12 @@ - (void)saveStore:(FlycutStore *)store toKey:(NSString *)key onDict:(NSMutableDi [clipping type], @"Type", [NSNumber numberWithInt:i], @"Position",nil]; + if ([clipping isImageClipping]) { + [dict setObject:[clipping imageHash] forKey:@"ImageHash"]; + [dict setObject:[NSNumber numberWithDouble:[clipping imageSize].width] forKey:@"ImageWidth"]; + [dict setObject:[NSNumber numberWithDouble:[clipping imageSize].height] forKey:@"ImageHeight"]; + } + NSString *val = [clipping appLocalizedName]; if ( nil != val ) [dict setObject:val forKey:@"AppLocalizedName"]; @@ -1085,7 +1190,7 @@ -(void) saveEngine { NSMutableDictionary *saveDict; saveDict = [NSMutableDictionary dictionaryWithCapacity:3]; - [saveDict setObject:@"0.7" forKey:@"version"]; + [saveDict setObject:@"0.8" forKey:@"version"]; [saveDict setObject:[NSNumber numberWithInt:[[NSUserDefaults standardUserDefaults] integerForKey:@"rememberNum"]] forKey:@"rememberNum"]; [saveDict setObject:[NSNumber numberWithInt:[[NSUserDefaults standardUserDefaults] integerForKey:@"favoritesRememberNum"]] diff --git a/UI/BezelWindow.h b/UI/BezelWindow.h index 0a6f9a6..8c8eecb 100755 --- a/UI/BezelWindow.h +++ b/UI/BezelWindow.h @@ -40,6 +40,8 @@ RoundRecTextField *textField; RoundRecTextField *charField; NSImageView *iconView; + NSImageView *imagePreview; + BOOL isShowingImage; id delegate; Boolean color; } @@ -63,6 +65,8 @@ - (void)setSource:(NSString *)newSource; - (void)setDate:(NSString *)newDate; - (void)setSourceIcon:(NSImage *)newSourceIcon; +- (void)setImage:(NSImage *)image; +- (void)clearImage; - (id)delegate; - (void)setDelegate:(id)newDelegate; diff --git a/UI/BezelWindow.m b/UI/BezelWindow.m index 5696100..67ff287 100755 --- a/UI/BezelWindow.m +++ b/UI/BezelWindow.m @@ -195,7 +195,16 @@ - (id)initWithContentRect:(NSRect)contentRect [charField.heightAnchor constraintEqualToConstant:charFrame.size.height], ]]; - [self setInitialFirstResponder:textField]; + imagePreview = [[NSImageView alloc] initWithFrame:textFrame]; + [imagePreview setImageScaling:NSImageScaleProportionallyUpOrDown]; + [imagePreview setImageAlignment:NSImageAlignCenter]; + [imagePreview setImageFrameStyle:NSImageFrameNone]; + [imagePreview setAnimates:YES]; // play multi-frame GIFs in the preview + [imagePreview setHidden:YES]; + [[self contentView] addSubview:imagePreview]; + isShowingImage = NO; + + [self setInitialFirstResponder:textField]; return self; } return nil; @@ -210,10 +219,10 @@ - (void) update { if (nil == sourceText || 0 == sourceText.length) showSourceField = false; - // Defer frame updates to avoid layout recursion dispatch_async(dispatch_get_main_queue(), ^{ NSRect textFrame = [self textFrame]; [textField setFrame:textFrame]; + [imagePreview setFrame:textFrame]; NSRect charFrame = [self charFrame]; [charField setFrame:charFrame]; }); @@ -313,8 +322,7 @@ - (void)setCharString:(NSString *)newChar - (void)setText:(NSString *)newText { - // The Bezel gets slow when newText is huge. Probably the retain. - // Since we can't see that much of it anyway, trim to 2000 characters. + [self clearImage]; if ([newText length] > 2000) newText = [newText substringToIndex:2000]; [newText retain]; @@ -323,6 +331,28 @@ - (void)setText:(NSString *)newText [textField.textField setStringValue:bezelText]; } +- (void)setImage:(NSImage *)image +{ + if (!image) { + [self clearImage]; + return; + } + [textField setHidden:YES]; + [imagePreview setImage:image]; + [imagePreview setHidden:NO]; + isShowingImage = YES; +} + +- (void)clearImage +{ + if (isShowingImage) { + [imagePreview setHidden:YES]; + [imagePreview setImage:nil]; + [textField setHidden:NO]; + isShowingImage = NO; + } +} + - (void)setSourceIcon:(NSImage *)newSourceIcon { if (!showSourceField) @@ -408,6 +438,7 @@ - (void)dealloc [textField release]; [charField release]; [iconView release]; + [imagePreview release]; [super dealloc]; } diff --git a/acknowledgements.txt b/acknowledgements.txt index 4be1a51..da2ae02 100644 --- a/acknowledgements.txt +++ b/acknowledgements.txt @@ -8,6 +8,8 @@ This fork was updated by haad to compile on Tahoe 26.0 release of Mac OS. © General Arcade, 2011 - 2020. © Adam Hamsik, 2025 - 2026. +Image clipboard support contributed by Ankush Vangari (), merged with gratitude under the MIT License. + Flycut incorporates the following libraries, used with gratitude: • ShortcutRecorder by Jesper et al. () • a modified version of UKPrefsPanel by Uli Kusterer ()