/* DSClickableURLTextField Copyright (c) 2006 - 2007 Night Productions, by Darkshadow. All Rights Reserved. http://www.nightproductions.net/developer.htm darkshadow@nightproductions.net May be used freely, but keep my name/copyright in the header. There is NO warranty of any kind, express or implied; use at your own risk. Responsibility for damages (if any) to anyone resulting from the use of this code rests entirely with the user. ------------------------------------ * August 25, 2006 - initial release * August 30, 2006 • Fixed a bug where cursor rects would be enabled even if the textfield wasn't visible. i.e. it's in a scrollview, but the textfield isn't scrolled to where it's visible. • Fixed an issue where mouseUp wouldn't be called and so clicking on the URL would have no effect when the textfield is a subview of a splitview (and maybe some other certain views). I did this by NOT calling super in -mouseDown:. Since the textfield is non-editable and non-selectable, I don't believe this will cause any problems. • Fixed the fact that it was using the textfield's bounds rather than the cell's bounds to calculate rects. * May 25, 2007 Contributed by Jens Miltner: • Fixed a problem with the text storage and the text field's attributed string value having different lengths, causing range exceptions. • Added a delegate method allowing custom handling of URLs. • Tracks initially clicked URL at -mouseDown: to avoid situations where dragging would end up in a different URL at -mouseUp:, opening that URL. This includes situations where the user clicks on an empty area of the text field, drags the mouse, and ends up on top of a link, which would then erroneously open that link. • Fixed to allow string links to work as well as URL links. Changes by Darkshadow: • Overrode -initWithCoder:, -initWithFrame:, and -awakeFromNib to explicitly set the text field to be non-editable and non-selectable. Now you don't need to remember to set this up, and the class will work correctly regardless. • Added in the ability for the user to copy URLs to the clipboard. Note that this is off by default. • Some code clean up. * December 6, 2011 Changes by Salvatore Ansani: • Added 64-bit support. * January 21, 2012 Changes by Barijaona Ramholimihaso: • Fixed build warnings. * August 23, 2015 Changes by Barijaona Ramholimihaso: • Converted code to ARC. * March 14, 2016 Changes by Jan Weiß: • Converted code to modern Objective-C. * February 3, 2019 Changes by Eitot: • Replaced deprecated APIs. * April 28, 2019 Changes by Eitot: • Fixed build warnings. * October 22, 2021 Changes by Eitot: • Replaced deprecated APIs. • Fixed analyzer warning. */ #import "DSClickableURLTextField.h" @implementation DSClickableURLTextField { NSTextStorage *URLStorage; NSLayoutManager *URLManager; NSTextContainer *URLContainer; NSURL *clickedURL; BOOL canCopyURLs; } /* Set the text field to be non-editable and non-selectable. */ - (instancetype)initWithCoder:(NSCoder *)coder { if ( (self = [super initWithCoder:coder]) ) { [self setEditable:NO]; [self setSelectable:NO]; canCopyURLs = NO; } return self; } /* Set the text field to be non-editable and non-selectable. */ - (instancetype)initWithFrame:(NSRect)frameRect { if ( (self = [super initWithFrame:frameRect]) ) { [self setEditable:NO]; [self setSelectable:NO]; canCopyURLs = NO; } return self; } /* Enforces that the text field be non-editable and non-selectable. Probably not needed, but I always like to be cautious. */ - (void)awakeFromNib { [self setEditable:NO]; [self setSelectable:NO]; } - (void)setAttributedStringValue:(NSAttributedString *)aStr { [URLStorage setAttributedString:aStr]; [self.window invalidateCursorRectsForView:self]; super.attributedStringValue = aStr; } - (void)setStringValue:(NSString *)aStr { NSAttributedString *attrString = [[NSAttributedString alloc] initWithString:aStr attributes:nil]; self.attributedStringValue = attrString; } - (void)setCanCopyURLs:(BOOL)aFlag { canCopyURLs = aFlag; } - (BOOL)canCopyURLs { return canCopyURLs; } - (void)resetCursorRects { if ( self.attributedStringValue.length == 0 ) { [super resetCursorRects]; return; } NSRect cellBounds = [self.cell drawingRectForBounds:self.bounds]; if ( URLStorage == nil ) { BOOL cellWraps = !self.cell.scrollable; NSSize containerSize = NSMakeSize( cellWraps ? cellBounds.size.width : MAXFLOAT, cellWraps ? MAXFLOAT : cellBounds.size.height ); URLContainer = [[NSTextContainer alloc] initWithContainerSize:containerSize]; URLManager = [[NSLayoutManager alloc] init]; URLStorage = [[NSTextStorage alloc] init]; [URLStorage addLayoutManager:URLManager]; [URLManager addTextContainer:URLContainer]; URLContainer.lineFragmentPadding = 2.f; [URLStorage setAttributedString:self.attributedStringValue]; } NSUInteger myLength = URLStorage.length; NSRange returnRange = { NSNotFound, 0 }, stringRange = { 0, myLength }, glyphRange = { NSNotFound, 0 }; NSCursor *pointingCursor = nil; /* Here mainly for 10.2 compatibility (in case anyone even tries for that anymore) */ if ( [NSCursor respondsToSelector:@selector(pointingHandCursor)] ) { pointingCursor = [NSCursor performSelector:@selector(pointingHandCursor)]; } else { [super resetCursorRects]; return; } /* Moved out of the while and for loops as there's no need to recalculate it every time through */ NSRect superVisRect = [self convertRect:self.superview.visibleRect fromView:self.superview]; while ( stringRange.location < myLength ) { id aVal = [URLStorage attribute:NSLinkAttributeName atIndex:stringRange.location longestEffectiveRange:&returnRange inRange:stringRange]; if ( aVal != nil ) { NSRectArray aRectArray = NULL; NSUInteger numRects = 0, j = 0; glyphRange = [URLManager glyphRangeForCharacterRange:returnRange actualCharacterRange:nil]; aRectArray = [URLManager rectArrayForGlyphRange:glyphRange withinSelectedGlyphRange:glyphRange inTextContainer:URLContainer rectCount:&numRects]; for ( j = 0; j < numRects; j++ ) { /* Check to make sure the rect is visible before setting the cursor */ NSRect glyphRect = aRectArray[j]; glyphRect.origin.x += cellBounds.origin.x; glyphRect.origin.y += cellBounds.origin.y; NSRect textRect = NSIntersectionRect(glyphRect, cellBounds); NSRect cursorRect = NSIntersectionRect(textRect, superVisRect); if ( NSIntersectsRect( textRect, superVisRect ) ) [self addCursorRect:cursorRect cursor:pointingCursor]; } } stringRange.location = NSMaxRange(returnRange); stringRange.length = myLength - stringRange.location; } } - (NSURL*)urlAtMouse:(NSEvent *)mouseEvent { NSURL* urlAtMouse = nil; NSPoint mousePoint = [self convertPoint:mouseEvent.locationInWindow fromView:nil]; NSRect cellBounds = [self.cell drawingRectForBounds:self.bounds]; if ( (URLStorage.length > 0 ) && [self mouse:mousePoint inRect:cellBounds] ) { id aVal = nil; NSRange returnRange = { NSNotFound, 0 }, glyphRange = { NSNotFound, 0 }; NSRectArray linkRect = NULL; NSUInteger glyphIndex = [URLManager glyphIndexForPoint:mousePoint inTextContainer:URLContainer]; NSUInteger charIndex = [URLManager characterIndexForGlyphAtIndex:glyphIndex]; NSUInteger numRects = 0, j = 0; aVal = [URLStorage attribute:NSLinkAttributeName atIndex:charIndex longestEffectiveRange:&returnRange inRange:NSMakeRange(charIndex, URLStorage.length - charIndex)]; if ( (aVal != nil) ) { glyphRange = [URLManager glyphRangeForCharacterRange:returnRange actualCharacterRange:nil]; linkRect = [URLManager rectArrayForGlyphRange:glyphRange withinSelectedGlyphRange:glyphRange inTextContainer:URLContainer rectCount:&numRects]; for ( j = 0; j < numRects; j++ ) { NSRect testHit = linkRect[j]; testHit.origin.x += cellBounds.origin.x; testHit.origin.x += cellBounds.origin.y; if ( [self mouse:mousePoint inRect:NSIntersectionRect(testHit, cellBounds)] ) { // be smart about links stored as strings if ( [aVal isKindOfClass:[NSString class]] ) aVal = [NSURL URLWithString:aVal]; urlAtMouse = aVal; break; } } } } return urlAtMouse; } - (NSMenu *)menuForEvent:(NSEvent *)aEvent { if ( !canCopyURLs ) return nil; NSURL *anURL = [self urlAtMouse:aEvent]; if ( anURL != nil ) { NSString *title = NSLocalizedString(@"Copy URL", @"Copy URL"); NSMenu *aMenu = [[NSMenu alloc] initWithTitle:title]; NSMenuItem *anItem = [[NSMenuItem alloc] initWithTitle:title action:@selector(copyURL:) keyEquivalent:@""]; anItem.target = self; anItem.representedObject = anURL; [aMenu addItem:anItem]; return aMenu; } return nil; } - (void)copyURL:(id)sender { NSPasteboard *copyBoard = [NSPasteboard pasteboardWithName:NSPasteboardNameGeneral]; [copyBoard declareTypes:@[NSPasteboardTypeURL, NSPasteboardTypeString] owner:nil]; NSURL *copyURL = [sender representedObject]; [copyURL writeToPasteboard:copyBoard]; [copyBoard setString:copyURL.absoluteString forType:NSPasteboardTypeString]; } - (void)mouseDown:(NSEvent *)mouseEvent { /* Not calling [super mouseDown:] because there are some situations where the mouse tracking is ignored otherwise. */ /* Remember which URL was clicked originally, so we don't end up opening the wrong URL accidentally. */ clickedURL = [self urlAtMouse:mouseEvent]; } - (void)mouseUp:(NSEvent *)mouseEvent { NSURL* urlAtMouse = [self urlAtMouse:mouseEvent]; if ( (urlAtMouse != nil) && [urlAtMouse isEqualTo:clickedURL] ) { // check if delegate wants to open the URL itself, if not, let the workspace open the URL if ( (self.delegate == nil) || ![self.delegate respondsToSelector:@selector(textField:openURL:)] || ![(id)self.delegate textField:self openURL:urlAtMouse] ) [[NSWorkspace sharedWorkspace] openURL:urlAtMouse]; } clickedURL = nil; [super mouseUp:mouseEvent]; } @end