chat: custom message selection
Allow user to drag-select multiple messages
Change-Id: I1fcf115822dab791d3df35b29696be3af3800204
Tuleap: #202
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c0793f6..bf93b7f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -144,6 +144,8 @@
src/AppDelegate.mm
src/AppDelegate.h
src/Constants.h
+ src/INDSequentialTextSelectionManager.mm
+ src/INDSequentialTextSelectionManager.h
src/delegates/ImageManipulationDelegate.mm
src/delegates/ImageManipulationDelegate.h)
diff --git a/src/ConversationVC.mm b/src/ConversationVC.mm
index 142da89..24ae5d9 100644
--- a/src/ConversationVC.mm
+++ b/src/ConversationVC.mm
@@ -37,6 +37,7 @@
#import "views/IMTableCellView.h"
#import "views/NSColor+RingTheme.h"
#import "QNSTreeController.h"
+#import "INDSequentialTextSelectionManager.h"
#import "delegates/ImageManipulationDelegate.h"
#import <QuartzCore/QuartzCore.h>
@@ -58,6 +59,8 @@
__unsafe_unretained IBOutlet NSPopUpButton* contactMethodsPopupButton;
}
+@property (nonatomic, strong, readonly) INDSequentialTextSelectionManager* selectionManager;
+
@end
@implementation ConversationVC
@@ -72,6 +75,7 @@
[sendPanel setWantsLayer:YES];
[sendPanel setLayer:[CALayer layer]];
+ _selectionManager = [[INDSequentialTextSelectionManager alloc] init];
[self setupChat];
@@ -95,6 +99,8 @@
return ;
}
+ [self.selectionManager unregisterAllTextViews];
+
[contactMethodsPopupButton removeAllItems];
for (auto cm : contactMethods) {
[contactMethodsPopupButton addItemWithTitle:cm->uri().toNSString()];
@@ -231,6 +237,10 @@
- (void)outlineView:(NSOutlineView *)outlineView didAddRowView:(NSTableRowView *)rowView forRow:(NSInteger)row
{
+ if (IMTableCellView* cellView = [outlineView viewAtColumn:0 row:row makeIfNecessary:NO]) {
+ [self.selectionManager registerTextView:cellView.msgView withUniqueIdentifier:@(row).stringValue];
+ }
+
if (auto txtRecording = contactMethods.at([contactMethodsPopupButton indexOfSelectedItem])->textRecording()) {
[emptyConversationPlaceHolder setHidden:txtRecording->instantMessagingModel()->rowCount() > 0];
txtRecording->setAllRead();
diff --git a/src/INDSequentialTextSelectionManager.h b/src/INDSequentialTextSelectionManager.h
new file mode 100644
index 0000000..005b52c
--- /dev/null
+++ b/src/INDSequentialTextSelectionManager.h
@@ -0,0 +1,48 @@
+//
+// INDSequentialTextSelectionManager.h
+// INDSequentialTextSelectionManager
+//
+// Created by Indragie Karunaratne on 2014-03-02.
+// Copyright (c) 2014 Indragie Karunaratne. All rights reserved.
+//
+#import <Cocoa/Cocoa.h>
+#import <Foundation/Foundation.h>
+
+typedef NSAttributedString * (^INDAttributedTextTransformationBlock)(NSAttributedString *);
+
+/**
+ * Coordinates sequential text selection among an arbitrary set of `NSTextView`s
+ */
+@interface INDSequentialTextSelectionManager : NSResponder
+
+/**
+ * Registers a text view to participate in sequential selection.
+ *
+ * @param textView The `NSTextView` instance to register.
+ * @param identifier The unique identifier to associate with the text view instance.
+ */
+- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier;
+
+/**
+ * Registers a text view to participate in sequential selection.
+ *
+ * @param textView The `NSTextView` instance to register.
+ * @param identifier The unique identifier to associate with the text view instance.
+ * @param block A transformation block to apply to the contents of the text view
+ * before copying the text.
+ */
+- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier transformationBlock:(INDAttributedTextTransformationBlock)block;
+
+/**
+ * Unregisters a text view for sequential text selection.
+ *
+ * @param textView The text view to unregister.
+ */
+- (void)unregisterTextView:(NSTextView *)textView;
+
+/**
+ * Unregisters all text views.
+ */
+- (void)unregisterAllTextViews;
+
+@end
\ No newline at end of file
diff --git a/src/INDSequentialTextSelectionManager.mm b/src/INDSequentialTextSelectionManager.mm
new file mode 100644
index 0000000..c9ff427
--- /dev/null
+++ b/src/INDSequentialTextSelectionManager.mm
@@ -0,0 +1,623 @@
+//
+// INDSequentialTextSelectionManager.m
+// INDSequentialTextSelectionManager
+//
+// Created by Indragie Karunaratne on 2014-03-02.
+// Copyright (c) 2014 Indragie Karunaratne. All rights reserved.
+//
+
+#import "INDSequentialTextSelectionManager.h"
+#import <objc/runtime.h>
+
+static NSUInteger INDCharacterIndexForTextViewEvent(NSEvent *event, NSTextView *textView)
+{
+ NSView *contentView = event.window.contentView;
+ NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
+ NSPoint textPoint = [textView convertPoint:point fromView:contentView];
+ return [textView characterIndexForInsertionAtPoint:textPoint];
+}
+
+static NSRange INDForwardRangeForIndices(NSUInteger idx1, NSUInteger idx2) {
+ NSRange range;
+ if (idx2 >= idx1) {
+ range = NSMakeRange(idx1, idx2 - idx1);
+ } else if (idx2 < idx1) {
+ range = NSMakeRange(idx2, idx1 - idx2);
+ } else {
+ range = NSMakeRange(NSNotFound, 0);
+ }
+ return range;
+}
+
+static void * INDUniqueIdentifierKey = &INDUniqueIdentifierKey;
+
+@interface NSTextView (INDUniqueIdentifiers)
+@property (nonatomic, copy) NSString *ind_uniqueIdentifier;
+@end
+
+@implementation NSTextView (INDUniqueIdentifiers)
+
+- (void)setInd_uniqueIdentifier:(NSString *)ind_uniqueIdentifier
+{
+ objc_setAssociatedObject(self, INDUniqueIdentifierKey, ind_uniqueIdentifier, OBJC_ASSOCIATION_COPY_NONATOMIC);
+}
+
+- (NSString *)ind_uniqueIdentifier
+{
+ return objc_getAssociatedObject(self, INDUniqueIdentifierKey);
+}
+
+@end
+
+@interface INDAttributeRange : NSObject
+@property (nonatomic, copy, readonly) NSString *attribute;
+@property (nonatomic, strong, readonly) id value;
+@property (nonatomic, assign, readonly) NSRange range;
+- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range;
+@end
+
+@implementation INDAttributeRange
+
+- (id)initWithAttribute:(NSString *)attribute value:(id)value range:(NSRange)range
+{
+ if ((self = [super init])) {
+ _attribute = [attribute copy];
+ _value = value;
+ _range = range;
+ }
+ return self;
+}
+
+@end
+
+static void * INDBackgroundColorRangesKey = &INDBackgroundColorRangesKey;
+static void * INDHighlightedRangeKey = &INDHighlightedRangeKey;
+
+#define IND_DISABLED_SELECTED_TEXT_BG_COLOR [NSColor colorWithDeviceRed:0.83 green:0.83 blue:0.83 alpha:1.0]
+
+@interface NSTextView (INDSelectionHighlight)
+@property (nonatomic, strong) NSArray *ind_backgroundColorRanges;
+@property (nonatomic, assign) NSRange ind_highlightedRange;
+@end
+
+@implementation NSTextView (INDSelectionHighlight)
+
+- (void)ind_highlightSelectedTextInRange:(NSRange)range drawActive:(BOOL)active
+{
+ if (self.ind_backgroundColorRanges == nil) {
+ [self ind_backupBackgroundColorState];
+ }
+ self.ind_highlightedRange = range;
+
+ NSColor *selectedColor = nil;
+ if (active) {
+ selectedColor = self.selectedTextAttributes[NSBackgroundColorAttributeName] ?: NSColor.selectedTextBackgroundColor;
+ } else {
+ selectedColor = IND_DISABLED_SELECTED_TEXT_BG_COLOR;
+ }
+ [self.textStorage beginEditing];
+ [self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.textStorage.length)];
+ [self.textStorage addAttribute:NSBackgroundColorAttributeName value:selectedColor range:range];
+ [self.textStorage endEditing];
+ [self setNeedsDisplay:YES];
+}
+
+- (void)ind_backupBackgroundColorState
+{
+ NSMutableArray *ranges = [NSMutableArray array];
+ NSString *attribute = NSBackgroundColorAttributeName;
+ [self.textStorage enumerateAttribute:attribute inRange:NSMakeRange(0, self.textStorage.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
+ if (value == nil) return;
+ INDAttributeRange *attrRange = [[INDAttributeRange alloc] initWithAttribute:attribute value:value range:range];
+ [ranges addObject:attrRange];
+ }];
+ self.ind_backgroundColorRanges = ranges;
+}
+
+- (void)ind_deselectHighlightedText
+{
+ [self.textStorage beginEditing];
+ [self.textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, self.string.length)];
+ NSArray *ranges = self.ind_backgroundColorRanges;
+ for (INDAttributeRange *range in ranges) {
+ [self.textStorage addAttribute:range.attribute value:range.value range:range.range];
+ }
+ [self.textStorage endEditing];
+ [self setNeedsDisplay:YES];
+
+ self.ind_backgroundColorRanges = nil;
+ self.ind_highlightedRange = NSMakeRange(0, 0);
+}
+
+- (void)setInd_backgroundColorRanges:(NSArray *)ind_backgroundColorRanges
+{
+ objc_setAssociatedObject(self, INDBackgroundColorRangesKey, ind_backgroundColorRanges, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+}
+
+- (NSArray *)ind_backgroundColorRanges
+{
+ return objc_getAssociatedObject(self, INDBackgroundColorRangesKey);
+}
+
+- (void)setInd_highlightedRange:(NSRange)ind_highlightedRange
+{
+ objc_setAssociatedObject(self, INDHighlightedRangeKey, [NSValue valueWithRange:ind_highlightedRange], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+}
+
+- (NSRange)ind_highlightedRange
+{
+ return [objc_getAssociatedObject(self, INDHighlightedRangeKey) rangeValue];
+}
+
+@end
+
+@interface INDTextViewSelectionRange : NSObject
+@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
+@property (nonatomic, assign, readonly) NSRange range;
+@property (nonatomic, copy, readonly) NSAttributedString *attributedText;
+- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range;
+@end
+
+@implementation INDTextViewSelectionRange
+
+- (id)initWithTextView:(NSTextView *)textView selectedRange:(NSRange)range
+{
+ if ((self = [super init])) {
+ _textViewIdentifier = [textView.ind_uniqueIdentifier copy];
+ _range = range;
+ _attributedText = [[textView.attributedString attributedSubstringFromRange:range] copy];
+ }
+ return self;
+}
+
+@end
+
+@interface INDTextViewSelectionSession : NSObject
+@property (nonatomic, copy, readonly) NSString *textViewIdentifier;
+@property (nonatomic, assign, readonly) NSUInteger characterIndex;
+@property (nonatomic, strong, readonly) NSDictionary *selectionRanges;
+@property (nonatomic, assign) NSPoint windowPoint;
+- (void)addSelectionRange:(INDTextViewSelectionRange *)range;
+- (void)removeSelectionRangeForTextView:(NSTextView *)textView;
+@end
+
+@implementation INDTextViewSelectionSession {
+ NSMutableDictionary *_selectionRanges;
+}
+@synthesize selectionRanges = _selectionRanges;
+
+- (id)initWithTextView:(NSTextView *)textView event:(NSEvent *)event
+{
+ if ((self = [super init])) {
+ _textViewIdentifier = [textView.ind_uniqueIdentifier copy];
+ _characterIndex = INDCharacterIndexForTextViewEvent(event, textView);
+ _selectionRanges = [NSMutableDictionary dictionary];
+ _windowPoint = event.locationInWindow;
+ }
+ return self;
+}
+
+- (void)addSelectionRange:(INDTextViewSelectionRange *)range
+{
+ NSParameterAssert(range.textViewIdentifier);
+ _selectionRanges[range.textViewIdentifier] = range;
+}
+
+- (void)removeSelectionRangeForTextView:(NSTextView *)textView
+{
+ NSParameterAssert(textView.ind_uniqueIdentifier);
+ [_selectionRanges removeObjectForKey:textView.ind_uniqueIdentifier];
+}
+
+@end
+
+@interface INDTextViewMetadata : NSObject
+@property (nonatomic, strong, readonly) NSTextView *textView;
+@property (nonatomic, copy, readonly) INDAttributedTextTransformationBlock transformationBlock;
+- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock;
+@end
+
+@implementation INDTextViewMetadata
+
+- (id)initWithTextView:(NSTextView *)textView transformationBlock:(INDAttributedTextTransformationBlock)transformationBlock
+{
+ if ((self = [super init])) {
+ _textView = textView;
+ _transformationBlock = [transformationBlock copy];
+ }
+ return self;
+}
+
+@end
+
+@interface INDSequentialTextSelectionManager ()
+@property (nonatomic, strong, readonly) NSMutableDictionary *textViews;
+@property (nonatomic, strong, readonly) NSMutableOrderedSet *sortedTextViews;
+@property (nonatomic, strong) INDTextViewSelectionSession *currentSession;
+@property (nonatomic, strong) NSAttributedString *cachedAttributedText;
+@property (nonatomic, strong) id eventMonitor;
+@property (nonatomic, assign, getter = isFirstResponder) BOOL firstResponder;
+@end
+
+@implementation INDSequentialTextSelectionManager
+
+#pragma mark - Iniialization
+
+- (id)init
+{
+ if ((self = [super init])) {
+ _textViews = [NSMutableDictionary dictionary];
+ _sortedTextViews = [NSMutableOrderedSet orderedSet];
+ _eventMonitor = [self addLocalEventMonitor];
+ }
+ return self;
+}
+
+#pragma mark - Cleanup
+
+- (void)dealloc
+{
+ [NSEvent removeMonitor:_eventMonitor];
+}
+
+#pragma mark - Events
+
+- (BOOL)handleLeftMouseDown:(NSEvent *)event
+{
+ // Allow for correct handling of double clicks on text views.
+ if (event.clickCount == 1) {
+ [self endSession];
+
+ NSTextView *textView = [self validTextViewForEvent:event];
+ // Ignore if the text view is not "owned" by this manager, or if it is being
+ // edited at the time of this event.
+ if (textView && textView.window.firstResponder != textView) {
+ self.currentSession = [[INDTextViewSelectionSession alloc] initWithTextView:textView event:event];
+ return YES;
+ }
+ }
+ return NO;
+}
+
+- (BOOL)handleLeftMouseUp:(NSEvent *)event
+{
+ if (self.currentSession == nil) return NO;
+
+ [event.window makeFirstResponder:self];
+ NSTextView *textView = [self validTextViewForEvent:event];
+ if (textView != nil) {
+ // Handle link clicks properly.
+ NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
+ if (index < textView.string.length) {
+ NSDictionary *attributes = [textView.attributedString attributesAtIndex:index effectiveRange:NULL];
+ id link = attributes[NSLinkAttributeName];
+
+ // From documentation, NSLinkAttributeName could be either an NSString * or NSURL *
+ if (link != nil) {
+ [textView clickedOnLink:link atIndex:index];
+ }
+ }
+ }
+ return YES;
+}
+
+- (BOOL)handleLeftMouseDragged:(NSEvent *)event
+{
+ if (self.currentSession == nil) return NO;
+
+ NSTextView *textView = [self validTextViewForEvent:event];
+ if (textView != nil) {
+ [textView.window makeFirstResponder:textView];
+ NSSelectionAffinity affinity = (event.locationInWindow.y < self.currentSession.windowPoint.y) ? NSSelectionAffinityDownstream : NSSelectionAffinityUpstream;
+ self.currentSession.windowPoint = event.locationInWindow;
+
+ NSUInteger current;
+ NSString *identifier = self.currentSession.textViewIdentifier;
+ if ([textView.ind_uniqueIdentifier isEqualTo:identifier]) {
+ current = self.currentSession.characterIndex;
+ } else {
+ INDTextViewMetadata *meta = self.textViews[identifier];
+ NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
+ NSUInteger end = [self.sortedTextViews indexOfObject:textView];
+ current = (end >= start) ? 0 : textView.string.length;
+ }
+ NSUInteger index = INDCharacterIndexForTextViewEvent(event, textView);
+ NSRange range = INDForwardRangeForIndices(index, current);
+ [self setSelectionRangeForTextView:textView withRange:range];
+ [self processCompleteSelectionsForTargetTextView:textView affinity:affinity];
+ }
+ return YES;
+}
+
+- (BOOL)handleRightMouseDown:(NSEvent *)event
+{
+ if (self.currentSession == nil) return NO;
+
+ [event.window makeFirstResponder:self];
+ NSTextView *textView = [self validTextViewForEvent:event];
+ if (textView != nil) {
+ NSMenu *menu = [self menuForEvent:event];
+ [NSMenu popUpContextMenu:menu withEvent:event forView:textView];
+ }
+ return YES;
+}
+
+- (id)addLocalEventMonitor
+{
+ return [NSEvent addLocalMonitorForEventsMatchingMask:NSLeftMouseDownMask | NSLeftMouseDraggedMask | NSLeftMouseUpMask | NSRightMouseDownMask handler:^NSEvent *(NSEvent *event) {
+ switch (event.type) {
+ case NSLeftMouseDown:
+ return [self handleLeftMouseDown:event] ? nil : event;
+ case NSLeftMouseDragged:
+ return [self handleLeftMouseDragged:event] ? nil : event;
+ case NSLeftMouseUp:
+ return [self handleLeftMouseUp:event] ? nil : event;
+ case NSRightMouseDown:
+ return [self handleRightMouseDown:event] ? nil : event;
+ default:
+ return event;
+ }
+ }];
+}
+
+- (NSTextView *)validTextViewForEvent:(NSEvent *)event
+{
+ NSView *contentView = event.window.contentView;
+ NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil];
+ NSView *view = [contentView hitTest:point];
+ if ([view isKindOfClass:NSTextView.class]) {
+ NSTextView *textView = (NSTextView *)view;
+ NSString *identifier = textView.ind_uniqueIdentifier;
+ return (textView.isSelectable && identifier && self.textViews[identifier]) ? textView : nil;
+ }
+ return nil;
+}
+
+#pragma mark - NSResponder
+
+- (NSAttributedString *)cachedAttributedText
+{
+ if (_cachedAttributedText == nil) {
+ _cachedAttributedText = [self buildAttributedStringForCurrentSession];
+ }
+ return _cachedAttributedText;
+}
+
+- (void)copy:(id)sender
+{
+ NSPasteboard *pboard = NSPasteboard.generalPasteboard;
+ [pboard clearContents];
+ [pboard writeObjects:@[self.cachedAttributedText]];
+}
+
+- (NSMenu *)buildSharingMenu
+{
+ NSMenu *shareMenu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Share", nil)];
+ NSArray *services = [NSSharingService sharingServicesForItems:@[self.cachedAttributedText]];
+ for (NSSharingService *service in services) {
+ NSMenuItem *item = [shareMenu addItemWithTitle:service.title action:@selector(share:) keyEquivalent:@""];
+ item.target = self;
+ item.image = service.image;
+ item.representedObject = service;
+ }
+ return shareMenu;
+}
+
+- (NSMenu *)menuForEvent:(NSEvent *)theEvent
+{
+ NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Text Actions", nil)];
+ NSMenuItem *copy = [menu addItemWithTitle:NSLocalizedString(@"Copy", nil) action:@selector(copy:) keyEquivalent:@""];
+ copy.target = self;
+ [menu addItem:NSMenuItem.separatorItem];
+
+ NSMenuItem *share = [menu addItemWithTitle:NSLocalizedString(@"Share", nil) action:nil keyEquivalent:@""];
+ share.submenu = [self buildSharingMenu];
+
+ return menu;
+}
+
+- (void)share:(NSMenuItem *)item
+{
+ NSSharingService *service = item.representedObject;
+ [service performWithItems:@[self.cachedAttributedText]];
+}
+
+- (void)rehighlightSelectedRangesAsActive:(BOOL)active
+{
+ NSArray *ranges = self.currentSession.selectionRanges.allValues;
+ for (INDTextViewSelectionRange *range in ranges) {
+ INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
+ [meta.textView ind_highlightSelectedTextInRange:range.range drawActive:active];
+ }
+}
+
+- (BOOL)resignFirstResponder
+{
+ [self rehighlightSelectedRangesAsActive:NO];
+ self.firstResponder = NO;
+ return YES;
+}
+
+- (BOOL)becomeFirstResponder
+{
+ [self rehighlightSelectedRangesAsActive:YES];
+ self.firstResponder = YES;
+ return YES;
+}
+
+#pragma mark - Selection
+
+- (void)setSelectionRangeForTextView:(NSTextView *)textView withRange:(NSRange)range
+{
+ if (range.location == NSNotFound || NSMaxRange(range) == 0) {
+ [textView ind_deselectHighlightedText];
+ [self.currentSession removeSelectionRangeForTextView:textView];
+ } else {
+ INDTextViewSelectionRange *selRange = [[INDTextViewSelectionRange alloc] initWithTextView:textView selectedRange:range];
+ [self.currentSession addSelectionRange:selRange];
+ [textView ind_highlightSelectedTextInRange:range drawActive:YES];
+ }
+}
+
+- (void)processCompleteSelectionsForTargetTextView:(NSTextView *)textView affinity:(NSSelectionAffinity)affinity
+{
+ if (self.currentSession == nil) return;
+
+ INDTextViewMetadata *meta = self.textViews[self.currentSession.textViewIdentifier];
+ NSUInteger start = [self.sortedTextViews indexOfObject:meta.textView];
+ NSUInteger end = [self.sortedTextViews indexOfObject:textView];
+ if (start == NSNotFound || end == NSNotFound) return;
+
+ NSRange subrange = NSMakeRange(NSNotFound, 0);
+ BOOL select = NO;
+ NSUInteger count = self.sortedTextViews.count;
+ if (end > start) {
+ if (affinity == NSSelectionAffinityDownstream) {
+ subrange = NSMakeRange(start, end - start);
+ select = YES;
+ } else if (count > end + 1) {
+ subrange = NSMakeRange(end + 1, count - end - 1);
+ }
+ } else if (end < start) {
+ if (affinity == NSSelectionAffinityUpstream) {
+ subrange = NSMakeRange(end + 1, start - end);
+ select = YES;
+ } else {
+ subrange = NSMakeRange(0, end);
+ }
+ }
+ NSArray *subarray = nil;
+ if (subrange.location == NSNotFound) {
+ NSMutableOrderedSet *views = [self.sortedTextViews mutableCopy];
+ [views removeObject:textView];
+ subarray = views.array;
+ } else {
+ subarray = [self.sortedTextViews.array subarrayWithRange:subrange];
+ }
+ for (NSTextView *tv in subarray) {
+ NSRange range;
+ if (select) {
+ NSRange currentRange = tv.ind_highlightedRange;
+ if (affinity == NSSelectionAffinityDownstream) {
+ range = NSMakeRange(currentRange.location, tv.string.length - currentRange.location);
+ } else {
+ range = NSMakeRange(0, NSMaxRange(currentRange) ?: tv.string.length);
+ }
+ } else {
+ range = NSMakeRange(0, 0);
+ }
+ [self setSelectionRangeForTextView:tv withRange:range];
+ }
+}
+
+- (void)endSession
+{
+ for (INDTextViewMetadata *meta in self.textViews.allValues) {
+ [meta.textView ind_deselectHighlightedText];
+ }
+ self.currentSession = nil;
+ self.cachedAttributedText = nil;
+}
+
+#pragma mark - Text
+
+- (NSAttributedString *)buildAttributedStringForCurrentSession
+{
+ if (self.currentSession == nil) return nil;
+
+ NSDictionary *ranges = self.currentSession.selectionRanges;
+ NSMutableArray *keys = [ranges.allKeys mutableCopy];
+ NSComparator textViewComparator = self.textViewComparator;
+ [keys sortUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) {
+ INDTextViewMetadata *meta1 = self.textViews[obj1];
+ INDTextViewMetadata *meta2 = self.textViews[obj2];
+ return textViewComparator(meta1.textView, meta2.textView);
+ }];
+ NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
+ [string beginEditing];
+ [keys enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) {
+ INDTextViewSelectionRange *range = ranges[key];
+ INDTextViewMetadata *meta = self.textViews[range.textViewIdentifier];
+ NSAttributedString *fragment = range.attributedText;
+ if (meta.transformationBlock != nil) {
+ fragment = meta.transformationBlock(fragment);
+ }
+ [string appendAttributedString:fragment];
+ if (string.length && idx != keys.count - 1) {
+ NSDictionary *attributes = [string attributesAtIndex:string.length - 1 effectiveRange:NULL];
+ NSAttributedString *newline = [[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
+ [string appendAttributedString:newline];
+ }
+ }];
+ [string endEditing];
+ return string;
+}
+
+#pragma mark - Registration
+
+- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier
+{
+ [self registerTextView:textView withUniqueIdentifier:identifier transformationBlock:nil];
+}
+
+- (void)registerTextView:(NSTextView *)textView withUniqueIdentifier:(NSString *)identifier transformationBlock:(INDAttributedTextTransformationBlock)block
+{
+ NSParameterAssert(identifier);
+ NSParameterAssert(textView);
+
+ [self unregisterTextView:textView];
+ textView.ind_uniqueIdentifier = identifier;
+ if (self.currentSession) {
+ INDTextViewSelectionRange *range = self.currentSession.selectionRanges[identifier];
+ if (range) {
+ [textView ind_highlightSelectedTextInRange:range.range drawActive:self.firstResponder];
+ }
+ }
+ self.textViews[identifier] = [[INDTextViewMetadata alloc] initWithTextView:textView transformationBlock:block];
+
+ [self.sortedTextViews addObject:textView];
+ [self sortTextViews];
+}
+
+- (NSComparator)textViewComparator
+{
+ return ^NSComparisonResult(NSTextView *obj1, NSTextView *obj2) {
+ // Convert to window coordinates to normalize coordinate flipped-ness
+ NSRect frame1 = [obj1 convertRect:obj1.bounds toView:nil];
+ NSRect frame2 = [obj2 convertRect:obj2.bounds toView:nil];
+
+ CGFloat y1 = NSMinY(frame1);
+ CGFloat y2 = NSMinY(frame2);
+
+ if (y1 > y2) {
+ return NSOrderedAscending;
+ } else if (y1 < y2) {
+ return NSOrderedDescending;
+ } else {
+ return NSOrderedSame;
+ }
+ };
+}
+
+- (void)sortTextViews
+{
+ [self.sortedTextViews sortUsingComparator:self.textViewComparator];
+}
+
+- (void)unregisterTextView:(NSTextView *)textView
+{
+ if (textView.ind_uniqueIdentifier == nil) return;
+ [self.textViews removeObjectForKey:textView.ind_uniqueIdentifier];
+ [self.sortedTextViews removeObject:textView];
+ [self sortTextViews];
+
+ textView.ind_uniqueIdentifier = nil;
+}
+
+- (void)unregisterAllTextViews
+{
+ [self.textViews removeAllObjects];
+ [self.sortedTextViews removeAllObjects];
+}
+
+@end
\ No newline at end of file