You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1155 lines
36 KiB
1155 lines
36 KiB
//
|
|
// SlackTextViewController
|
|
// https://github.com/slackhq/SlackTextViewController
|
|
//
|
|
// Copyright 2014-2016 Slack Technologies, Inc.
|
|
// Licence: MIT-Licence
|
|
//
|
|
|
|
#import "SLKTextView.h"
|
|
#import "SLKTextView+SLKAdditions.h"
|
|
|
|
#import "SLKUIConstants.h"
|
|
|
|
NSString * const SLKTextViewTextWillChangeNotification = @"SLKTextViewTextWillChangeNotification";
|
|
NSString * const SLKTextViewContentSizeDidChangeNotification = @"SLKTextViewContentSizeDidChangeNotification";
|
|
NSString * const SLKTextViewSelectedRangeDidChangeNotification = @"SLKTextViewSelectedRangeDidChangeNotification";
|
|
NSString * const SLKTextViewDidPasteItemNotification = @"SLKTextViewDidPasteItemNotification";
|
|
NSString * const SLKTextViewDidShakeNotification = @"SLKTextViewDidShakeNotification";
|
|
|
|
NSString * const SLKTextViewPastedItemContentType = @"SLKTextViewPastedItemContentType";
|
|
NSString * const SLKTextViewPastedItemMediaType = @"SLKTextViewPastedItemMediaType";
|
|
NSString * const SLKTextViewPastedItemData = @"SLKTextViewPastedItemData";
|
|
|
|
static NSString *const SLKTextViewGenericFormattingSelectorPrefix = @"slk_format_";
|
|
|
|
@interface SLKTextView ()
|
|
|
|
// The label used as placeholder
|
|
@property (nonatomic, strong) UILabel *placeholderLabel;
|
|
|
|
// The initial font point size, used for dynamic type calculations
|
|
@property (nonatomic) CGFloat initialFontSize;
|
|
|
|
// Used for moving the caret up/down
|
|
@property (nonatomic) UITextLayoutDirection verticalMoveDirection;
|
|
@property (nonatomic) CGRect verticalMoveStartCaretRect;
|
|
@property (nonatomic) CGRect verticalMoveLastCaretRect;
|
|
|
|
// Used for detecting if the scroll indicator was previously flashed
|
|
@property (nonatomic) BOOL didFlashScrollIndicators;
|
|
|
|
@property (nonatomic, strong) NSMutableArray *registeredFormattingTitles;
|
|
@property (nonatomic, strong) NSMutableArray *registeredFormattingSymbols;
|
|
@property (nonatomic, getter=isFormatting) BOOL formatting;
|
|
|
|
// The keyboard commands available for external keyboards
|
|
@property (nonatomic, strong) NSMutableDictionary *registeredKeyCommands;
|
|
@property (nonatomic, strong) NSMutableDictionary *registeredKeyCallbacks;
|
|
|
|
@end
|
|
|
|
@implementation SLKTextView
|
|
@dynamic delegate;
|
|
|
|
#pragma mark - Initialization
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer
|
|
{
|
|
if (self = [super initWithFrame:frame textContainer:textContainer]) {
|
|
[self slk_commonInit];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (instancetype)initWithCoder:(NSCoder *)coder
|
|
{
|
|
if (self = [super initWithCoder:coder]) {
|
|
[self slk_commonInit];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)slk_commonInit
|
|
{
|
|
_pastableMediaTypes = SLKPastableMediaTypeNone;
|
|
_dynamicTypeEnabled = YES;
|
|
|
|
self.undoManagerEnabled = YES;
|
|
|
|
self.editable = YES;
|
|
self.selectable = YES;
|
|
self.scrollEnabled = YES;
|
|
self.scrollsToTop = NO;
|
|
self.directionalLockEnabled = YES;
|
|
self.dataDetectorTypes = UIDataDetectorTypeNone;
|
|
|
|
[self slk_registerNotifications];
|
|
|
|
[self addObserver:self forKeyPath:NSStringFromSelector(@selector(contentSize)) options:NSKeyValueObservingOptionNew context:NULL];
|
|
}
|
|
|
|
|
|
#pragma mark - UIView Overrides
|
|
|
|
- (CGSize)intrinsicContentSize
|
|
{
|
|
CGFloat height = self.font.lineHeight;
|
|
height += self.textContainerInset.top + self.textContainerInset.bottom;
|
|
|
|
return CGSizeMake(UIViewNoIntrinsicMetric, height);
|
|
}
|
|
|
|
+ (BOOL)requiresConstraintBasedLayout
|
|
{
|
|
return YES;
|
|
}
|
|
|
|
- (void)layoutIfNeeded
|
|
{
|
|
if (!self.window) {
|
|
return;
|
|
}
|
|
|
|
[super layoutIfNeeded];
|
|
}
|
|
|
|
- (void)layoutSubviews
|
|
{
|
|
[super layoutSubviews];
|
|
|
|
self.placeholderLabel.hidden = [self slk_shouldHidePlaceholder];
|
|
|
|
if (!self.placeholderLabel.hidden) {
|
|
|
|
[UIView performWithoutAnimation:^{
|
|
self.placeholderLabel.frame = [self slk_placeholderRectThatFits:self.bounds];
|
|
[self sendSubviewToBack:self.placeholderLabel];
|
|
}];
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - Getters
|
|
|
|
- (UILabel *)placeholderLabel
|
|
{
|
|
if (!_placeholderLabel) {
|
|
_placeholderLabel = [UILabel new];
|
|
_placeholderLabel.clipsToBounds = NO;
|
|
_placeholderLabel.numberOfLines = 1;
|
|
_placeholderLabel.autoresizesSubviews = NO;
|
|
_placeholderLabel.font = self.font;
|
|
_placeholderLabel.backgroundColor = [UIColor clearColor];
|
|
_placeholderLabel.textColor = [UIColor lightGrayColor];
|
|
_placeholderLabel.hidden = YES;
|
|
_placeholderLabel.isAccessibilityElement = NO;
|
|
|
|
[self addSubview:_placeholderLabel];
|
|
}
|
|
return _placeholderLabel;
|
|
}
|
|
|
|
- (NSString *)placeholder
|
|
{
|
|
return self.placeholderLabel.text;
|
|
}
|
|
|
|
- (UIColor *)placeholderColor
|
|
{
|
|
return self.placeholderLabel.textColor;
|
|
}
|
|
|
|
- (UIFont *)placeholderFont
|
|
{
|
|
return self.placeholderLabel.font;
|
|
}
|
|
|
|
- (NSUInteger)numberOfLines
|
|
{
|
|
CGSize contentSize = self.contentSize;
|
|
|
|
CGFloat contentHeight = contentSize.height;
|
|
contentHeight -= self.textContainerInset.top + self.textContainerInset.bottom;
|
|
|
|
NSUInteger lines = fabs(contentHeight/self.font.lineHeight);
|
|
|
|
// This helps preventing the content's height to be larger that the bounds' height
|
|
// Avoiding this way to have unnecessary scrolling in the text view when there is only 1 line of content
|
|
if (lines == 1 && contentSize.height > self.bounds.size.height) {
|
|
contentSize.height = self.bounds.size.height;
|
|
self.contentSize = contentSize;
|
|
}
|
|
|
|
// Let's fallback to the minimum line count
|
|
if (lines == 0) {
|
|
lines = 1;
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
- (NSUInteger)maxNumberOfLines
|
|
{
|
|
NSUInteger numberOfLines = _maxNumberOfLines;
|
|
|
|
if (SLK_IS_LANDSCAPE) {
|
|
if ((SLK_IS_IPHONE4 || SLK_IS_IPHONE5)) {
|
|
numberOfLines = 2.0; // 2 lines max on smaller iPhones
|
|
}
|
|
else if (SLK_IS_IPHONE) {
|
|
numberOfLines /= 2.0; // Half size on larger iPhone
|
|
}
|
|
}
|
|
|
|
if (self.isDynamicTypeEnabled) {
|
|
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
|
|
CGFloat pointSizeDifference = SLKPointSizeDifferenceForCategory(contentSizeCategory);
|
|
|
|
CGFloat factor = pointSizeDifference/self.initialFontSize;
|
|
|
|
if (fabs(factor) > 0.75) {
|
|
factor = 0.75;
|
|
}
|
|
|
|
numberOfLines -= floorf(numberOfLines * factor); // Calculates a dynamic number of lines depending of the user preferred font size
|
|
}
|
|
|
|
return numberOfLines;
|
|
}
|
|
|
|
- (BOOL)isTypingSuggestionEnabled
|
|
{
|
|
return (self.autocorrectionType == UITextAutocorrectionTypeNo) ? NO : YES;
|
|
}
|
|
|
|
- (BOOL)isFormattingEnabled
|
|
{
|
|
return (self.registeredFormattingSymbols.count > 0) ? YES : NO;
|
|
}
|
|
|
|
// Returns only a supported pasted item
|
|
- (id)slk_pastedItem
|
|
{
|
|
NSString *contentType = [self slk_pasteboardContentType];
|
|
NSData *data = [[UIPasteboard generalPasteboard] dataForPasteboardType:contentType];
|
|
|
|
if (data && [data isKindOfClass:[NSData class]])
|
|
{
|
|
SLKPastableMediaType mediaType = SLKPastableMediaTypeFromNSString(contentType);
|
|
|
|
NSDictionary *userInfo = @{SLKTextViewPastedItemContentType: contentType,
|
|
SLKTextViewPastedItemMediaType: @(mediaType),
|
|
SLKTextViewPastedItemData: data};
|
|
return userInfo;
|
|
}
|
|
if ([[UIPasteboard generalPasteboard] URL]) {
|
|
return [[[UIPasteboard generalPasteboard] URL] absoluteString];
|
|
}
|
|
if ([[UIPasteboard generalPasteboard] string]) {
|
|
return [[UIPasteboard generalPasteboard] string];
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
// Checks if any supported media found in the general pasteboard
|
|
- (BOOL)slk_isPasteboardItemSupported
|
|
{
|
|
if ([self slk_pasteboardContentType].length > 0) {
|
|
return YES;
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
- (NSString *)slk_pasteboardContentType
|
|
{
|
|
NSArray *pasteboardTypes = [[UIPasteboard generalPasteboard] pasteboardTypes];
|
|
NSMutableArray *subpredicates = [NSMutableArray new];
|
|
|
|
for (NSString *type in [self slk_supportedMediaTypes]) {
|
|
[subpredicates addObject:[NSPredicate predicateWithFormat:@"SELF == %@", type]];
|
|
}
|
|
|
|
return [[pasteboardTypes filteredArrayUsingPredicate:[NSCompoundPredicate orPredicateWithSubpredicates:subpredicates]] firstObject];
|
|
}
|
|
|
|
- (NSArray *)slk_supportedMediaTypes
|
|
{
|
|
if (self.pastableMediaTypes == SLKPastableMediaTypeNone) {
|
|
return nil;
|
|
}
|
|
|
|
NSMutableArray *types = [NSMutableArray new];
|
|
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypePNG) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePNG)];
|
|
}
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypeJPEG) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeJPEG)];
|
|
}
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypeTIFF) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeTIFF)];
|
|
}
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypeGIF) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeGIF)];
|
|
}
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypeMOV) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeMOV)];
|
|
}
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypePassbook) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePassbook)];
|
|
}
|
|
|
|
if (self.pastableMediaTypes & SLKPastableMediaTypeImages) {
|
|
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeImages)];
|
|
}
|
|
|
|
return types;
|
|
}
|
|
|
|
NSString *NSStringFromSLKPastableMediaType(SLKPastableMediaType type)
|
|
{
|
|
if (type == SLKPastableMediaTypePNG) {
|
|
return @"public.png";
|
|
}
|
|
if (type == SLKPastableMediaTypeJPEG) {
|
|
return @"public.jpeg";
|
|
}
|
|
if (type == SLKPastableMediaTypeTIFF) {
|
|
return @"public.tiff";
|
|
}
|
|
if (type == SLKPastableMediaTypeGIF) {
|
|
return @"com.compuserve.gif";
|
|
}
|
|
if (type == SLKPastableMediaTypeMOV) {
|
|
return @"com.apple.quicktime";
|
|
}
|
|
if (type == SLKPastableMediaTypePassbook) {
|
|
return @"com.apple.pkpass";
|
|
}
|
|
if (type == SLKPastableMediaTypeImages) {
|
|
return @"com.apple.uikit.image";
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
SLKPastableMediaType SLKPastableMediaTypeFromNSString(NSString *string)
|
|
{
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePNG)]) {
|
|
return SLKPastableMediaTypePNG;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeJPEG)]) {
|
|
return SLKPastableMediaTypeJPEG;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeTIFF)]) {
|
|
return SLKPastableMediaTypeTIFF;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeGIF)]) {
|
|
return SLKPastableMediaTypeGIF;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeMOV)]) {
|
|
return SLKPastableMediaTypeMOV;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePassbook)]) {
|
|
return SLKPastableMediaTypePassbook;
|
|
}
|
|
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeImages)]) {
|
|
return SLKPastableMediaTypeImages;
|
|
}
|
|
return SLKPastableMediaTypeNone;
|
|
}
|
|
|
|
- (BOOL)isExpanding
|
|
{
|
|
if (self.numberOfLines >= self.maxNumberOfLines) {
|
|
return YES;
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
- (BOOL)slk_shouldHidePlaceholder
|
|
{
|
|
if (self.placeholder.length == 0 || self.text.length > 0) {
|
|
return YES;
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
- (CGRect)slk_placeholderRectThatFits:(CGRect)bounds
|
|
{
|
|
CGFloat padding = self.textContainer.lineFragmentPadding;
|
|
|
|
CGRect rect = CGRectZero;
|
|
rect.size.height = [self.placeholderLabel sizeThatFits:bounds.size].height;
|
|
rect.size.width = self.textContainer.size.width - padding*2.0;
|
|
rect.origin = UIEdgeInsetsInsetRect(bounds, self.textContainerInset).origin;
|
|
rect.origin.x += padding;
|
|
|
|
return rect;
|
|
}
|
|
|
|
|
|
#pragma mark - Setters
|
|
|
|
- (void)setPlaceholder:(NSString *)placeholder
|
|
{
|
|
self.placeholderLabel.text = placeholder;
|
|
self.accessibilityLabel = placeholder;
|
|
|
|
[self setNeedsLayout];
|
|
}
|
|
|
|
- (void)setAttributedPlaceholder:(NSAttributedString *)attributedPlaceholder
|
|
{
|
|
self.placeholderLabel.attributedText = attributedPlaceholder;
|
|
self.accessibilityLabel = attributedPlaceholder.string;
|
|
|
|
[self setNeedsLayout];
|
|
}
|
|
|
|
- (void)setPlaceholderColor:(UIColor *)color
|
|
{
|
|
self.placeholderLabel.textColor = color;
|
|
}
|
|
|
|
- (void)setPlaceholderNumberOfLines:(NSInteger)numberOfLines
|
|
{
|
|
self.placeholderLabel.numberOfLines = numberOfLines;
|
|
|
|
[self setNeedsLayout];
|
|
}
|
|
|
|
- (void)setPlaceholderFont:(UIFont *)placeholderFont
|
|
{
|
|
if (!placeholderFont) {
|
|
self.placeholderLabel.font = self.font;
|
|
}
|
|
else {
|
|
self.placeholderLabel.font = placeholderFont;
|
|
}
|
|
}
|
|
|
|
- (void)setUndoManagerEnabled:(BOOL)enabled
|
|
{
|
|
if (self.undoManagerEnabled == enabled) {
|
|
return;
|
|
}
|
|
|
|
self.undoManager.levelsOfUndo = 10;
|
|
[self.undoManager removeAllActions];
|
|
[self.undoManager setActionIsDiscardable:YES];
|
|
|
|
_undoManagerEnabled = enabled;
|
|
}
|
|
|
|
- (void)setTypingSuggestionEnabled:(BOOL)enabled
|
|
{
|
|
if (self.isTypingSuggestionEnabled == enabled) {
|
|
return;
|
|
}
|
|
|
|
self.autocorrectionType = enabled ? UITextAutocorrectionTypeDefault : UITextAutocorrectionTypeNo;
|
|
self.spellCheckingType = enabled ? UITextSpellCheckingTypeDefault : UITextSpellCheckingTypeNo;
|
|
|
|
//NOTE: this cause to resign textview responder which we don't want to
|
|
//[self refreshFirstResponder];
|
|
}
|
|
|
|
- (void)setContentOffset:(CGPoint)contentOffset
|
|
{
|
|
// At times during a layout pass, the content offset's x value may change.
|
|
// Since we only care about vertical offset, let's override its horizontal value to avoid other layout issues.
|
|
[super setContentOffset:CGPointMake(0.0, contentOffset.y)];
|
|
}
|
|
|
|
|
|
#pragma mark - UITextView Overrides
|
|
|
|
- (void)setSelectedRange:(NSRange)selectedRange
|
|
{
|
|
[super setSelectedRange:selectedRange];
|
|
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
|
|
}
|
|
|
|
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange
|
|
{
|
|
[super setSelectedTextRange:selectedTextRange];
|
|
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
|
|
}
|
|
|
|
- (void)setText:(NSString *)text
|
|
{
|
|
// Registers for undo management
|
|
[self slk_prepareForUndo:@"Text Set"];
|
|
|
|
if (text) {
|
|
[self setAttributedText:[self slk_defaultAttributedStringForText:text]];
|
|
}
|
|
else {
|
|
[self setAttributedText:nil];
|
|
}
|
|
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:UITextViewTextDidChangeNotification object:self];
|
|
}
|
|
|
|
- (NSString *)text
|
|
{
|
|
return self.attributedText.string;
|
|
}
|
|
|
|
- (void)setAttributedText:(NSAttributedString *)attributedText
|
|
{
|
|
// Registers for undo management
|
|
[self slk_prepareForUndo:@"Attributed Text Set"];
|
|
|
|
[super setAttributedText:attributedText];
|
|
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:UITextViewTextDidChangeNotification object:self];
|
|
}
|
|
|
|
- (void)setFont:(UIFont *)font
|
|
{
|
|
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
|
|
|
|
[self setFontName:font.fontName pointSize:font.pointSize withContentSizeCategory:contentSizeCategory];
|
|
|
|
self.initialFontSize = font.pointSize;
|
|
}
|
|
|
|
- (void)setFontName:(NSString *)fontName pointSize:(CGFloat)pointSize withContentSizeCategory:(NSString *)contentSizeCategory
|
|
{
|
|
if (self.isDynamicTypeEnabled) {
|
|
pointSize += SLKPointSizeDifferenceForCategory(contentSizeCategory);
|
|
}
|
|
|
|
UIFont *dynamicFont = [UIFont fontWithName:fontName size:pointSize];
|
|
|
|
[super setFont:dynamicFont];
|
|
|
|
// Updates the placeholder font too
|
|
self.placeholderLabel.font = dynamicFont;
|
|
}
|
|
|
|
- (void)setDynamicTypeEnabled:(BOOL)dynamicTypeEnabled
|
|
{
|
|
if (self.isDynamicTypeEnabled == dynamicTypeEnabled) {
|
|
return;
|
|
}
|
|
|
|
_dynamicTypeEnabled = dynamicTypeEnabled;
|
|
|
|
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
|
|
|
|
[self setFontName:self.font.fontName pointSize:self.initialFontSize withContentSizeCategory:contentSizeCategory];
|
|
}
|
|
|
|
- (void)setTextAlignment:(NSTextAlignment)textAlignment
|
|
{
|
|
[super setTextAlignment:textAlignment];
|
|
|
|
// Updates the placeholder text alignment too
|
|
self.placeholderLabel.textAlignment = textAlignment;
|
|
}
|
|
|
|
|
|
#pragma mark - UITextInput Overrides
|
|
|
|
#ifdef __IPHONE_9_0
|
|
- (void)beginFloatingCursorAtPoint:(CGPoint)point
|
|
{
|
|
[super beginFloatingCursorAtPoint:point];
|
|
|
|
_trackpadEnabled = YES;
|
|
}
|
|
|
|
- (void)updateFloatingCursorAtPoint:(CGPoint)point
|
|
{
|
|
[super updateFloatingCursorAtPoint:point];
|
|
}
|
|
|
|
- (void)endFloatingCursor
|
|
{
|
|
[super endFloatingCursor];
|
|
|
|
_trackpadEnabled = NO;
|
|
|
|
// We still need to notify a selection change in the textview after the trackpad is disabled
|
|
if (self.delegate && [self.delegate respondsToSelector:@selector(textViewDidChangeSelection:)]) {
|
|
[self.delegate textViewDidChangeSelection:self];
|
|
}
|
|
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
|
|
}
|
|
#endif
|
|
|
|
#pragma mark - UIResponder Overrides
|
|
|
|
- (BOOL)canBecomeFirstResponder
|
|
{
|
|
[self slk_addCustomMenuControllerItems];
|
|
|
|
return [super canBecomeFirstResponder];
|
|
}
|
|
|
|
- (BOOL)becomeFirstResponder
|
|
{
|
|
return [super becomeFirstResponder];
|
|
}
|
|
|
|
- (BOOL)canResignFirstResponder
|
|
{
|
|
// Removes undo/redo items
|
|
if (self.undoManagerEnabled) {
|
|
[self.undoManager removeAllActions];
|
|
}
|
|
|
|
return [super canResignFirstResponder];
|
|
}
|
|
|
|
- (BOOL)resignFirstResponder
|
|
{
|
|
return [super resignFirstResponder];
|
|
}
|
|
|
|
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
{
|
|
if (self.isFormatting) {
|
|
NSString *title = [self slk_formattingTitleFromSelector:action];
|
|
NSString *symbol = [self slk_formattingSymbolWithTitle:title];
|
|
|
|
if (symbol.length > 0) {
|
|
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldOfferFormattingForSymbol:)]) {
|
|
return [self.delegate textView:self shouldOfferFormattingForSymbol:symbol];
|
|
}
|
|
else {
|
|
return YES;
|
|
}
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
if (action == @selector(delete:)) {
|
|
return NO;
|
|
}
|
|
|
|
if (action == @selector(slk_presentFormattingMenu:)) {
|
|
return self.selectedRange.length > 0 ? YES : NO;
|
|
}
|
|
|
|
if (action == @selector(paste:) && [self slk_isPasteboardItemSupported]) {
|
|
return YES;
|
|
}
|
|
|
|
if (self.undoManagerEnabled) {
|
|
if (action == @selector(slk_undo:)) {
|
|
if (self.undoManager.undoActionIsDiscardable) {
|
|
return NO;
|
|
}
|
|
return [self.undoManager canUndo];
|
|
}
|
|
if (action == @selector(slk_redo:)) {
|
|
if (self.undoManager.redoActionIsDiscardable) {
|
|
return NO;
|
|
}
|
|
return [self.undoManager canRedo];
|
|
}
|
|
}
|
|
|
|
return [super canPerformAction:action withSender:sender];
|
|
}
|
|
|
|
- (void)paste:(id)sender
|
|
{
|
|
id pastedItem = [self slk_pastedItem];
|
|
|
|
if ([pastedItem isKindOfClass:[NSDictionary class]]) {
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewDidPasteItemNotification object:nil userInfo:pastedItem];
|
|
}
|
|
else if ([pastedItem isKindOfClass:[NSString class]]) {
|
|
// Respect the delegate yo!
|
|
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:)]) {
|
|
if (![self.delegate textView:self shouldChangeTextInRange:self.selectedRange replacementText:pastedItem]) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Inserting the text fixes a UITextView bug whitch automatically scrolls to the bottom
|
|
// and beyond scroll content size sometimes when the text is too long
|
|
[self slk_insertTextAtCaretRange:pastedItem];
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - NSObject Overrides
|
|
|
|
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
|
|
{
|
|
if ([super methodSignatureForSelector:sel]) {
|
|
return [super methodSignatureForSelector:sel];
|
|
}
|
|
return [super methodSignatureForSelector:@selector(slk_format:)];
|
|
}
|
|
|
|
- (void)forwardInvocation:(NSInvocation *)invocation
|
|
{
|
|
NSString *title = [self slk_formattingTitleFromSelector:[invocation selector]];
|
|
|
|
if (title.length > 0) {
|
|
[self slk_format:title];
|
|
}
|
|
else {
|
|
[super forwardInvocation:invocation];
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - Custom Actions
|
|
|
|
- (void)slk_flashScrollIndicatorsIfNeeded
|
|
{
|
|
if (self.numberOfLines == self.maxNumberOfLines+1) {
|
|
if (!_didFlashScrollIndicators) {
|
|
_didFlashScrollIndicators = YES;
|
|
[super flashScrollIndicators];
|
|
}
|
|
}
|
|
else if (_didFlashScrollIndicators) {
|
|
_didFlashScrollIndicators = NO;
|
|
}
|
|
}
|
|
|
|
- (void)refreshFirstResponder
|
|
{
|
|
if (!self.isFirstResponder) {
|
|
return;
|
|
}
|
|
|
|
_didNotResignFirstResponder = YES;
|
|
[self resignFirstResponder];
|
|
|
|
_didNotResignFirstResponder = NO;
|
|
[self becomeFirstResponder];
|
|
}
|
|
|
|
- (void)refreshInputViews
|
|
{
|
|
_didNotResignFirstResponder = YES;
|
|
|
|
[super reloadInputViews];
|
|
|
|
_didNotResignFirstResponder = NO;
|
|
}
|
|
|
|
- (void)slk_addCustomMenuControllerItems
|
|
{
|
|
UIMenuItem *undo = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Undo", nil) action:@selector(slk_undo:)];
|
|
UIMenuItem *redo = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Redo", nil) action:@selector(slk_redo:)];
|
|
|
|
NSMutableArray *items = [NSMutableArray arrayWithObjects:undo, redo, nil];
|
|
|
|
if (self.registeredFormattingTitles.count > 0) {
|
|
UIMenuItem *format = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Format", nil) action:@selector(slk_presentFormattingMenu:)];
|
|
[items addObject:format];
|
|
}
|
|
|
|
[[UIMenuController sharedMenuController] setMenuItems:items];
|
|
}
|
|
|
|
- (void)slk_undo:(id)sender
|
|
{
|
|
[self.undoManager undo];
|
|
}
|
|
|
|
- (void)slk_redo:(id)sender
|
|
{
|
|
[self.undoManager redo];
|
|
}
|
|
|
|
- (void)slk_presentFormattingMenu:(id)sender
|
|
{
|
|
NSMutableArray *items = [NSMutableArray arrayWithCapacity:self.registeredFormattingTitles.count];
|
|
|
|
for (NSString *name in self.registeredFormattingTitles) {
|
|
|
|
NSString *sel = [NSString stringWithFormat:@"%@%@", SLKTextViewGenericFormattingSelectorPrefix, name];
|
|
|
|
UIMenuItem *item = [[UIMenuItem alloc] initWithTitle:name action:NSSelectorFromString(sel)];
|
|
[items addObject:item];
|
|
}
|
|
|
|
self.formatting = YES;
|
|
|
|
UIMenuController *menu = [UIMenuController sharedMenuController];
|
|
[menu setMenuItems:items];
|
|
|
|
NSLayoutManager *manager = self.layoutManager;
|
|
CGRect targetRect = [manager boundingRectForGlyphRange:self.selectedRange inTextContainer:self.textContainer];
|
|
|
|
[menu setTargetRect:targetRect inView:self];
|
|
|
|
[menu setMenuVisible:YES animated:YES];
|
|
}
|
|
|
|
- (NSString *)slk_formattingTitleFromSelector:(SEL)selector
|
|
{
|
|
NSString *selectorString = NSStringFromSelector(selector);
|
|
NSRange match = [selectorString rangeOfString:SLKTextViewGenericFormattingSelectorPrefix];
|
|
|
|
if (match.location != NSNotFound) {
|
|
return [selectorString substringFromIndex:SLKTextViewGenericFormattingSelectorPrefix.length];
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
- (NSString *)slk_formattingSymbolWithTitle:(NSString *)title
|
|
{
|
|
NSUInteger idx = [self.registeredFormattingTitles indexOfObject:title];
|
|
|
|
if (idx <= self.registeredFormattingSymbols.count -1) {
|
|
return self.registeredFormattingSymbols[idx];
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
- (void)slk_format:(NSString *)titles
|
|
{
|
|
NSString *symbol = [self slk_formattingSymbolWithTitle:titles];
|
|
|
|
if (symbol.length > 0) {
|
|
NSRange selection = self.selectedRange;
|
|
|
|
NSRange range = [self slk_insertText:symbol inRange:NSMakeRange(selection.location, 0)];
|
|
range.location += selection.length;
|
|
range.length = 0;
|
|
|
|
// The default behavior is to add a closure
|
|
BOOL addClosure = YES;
|
|
|
|
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldInsertSuffixForFormattingWithSymbol:prefixRange:)]) {
|
|
addClosure = [self.delegate textView:self shouldInsertSuffixForFormattingWithSymbol:symbol prefixRange:selection];
|
|
}
|
|
|
|
if (addClosure) {
|
|
self.selectedRange = [self slk_insertText:symbol inRange:range];
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - Markdown Formatting
|
|
|
|
- (void)registerMarkdownFormattingSymbol:(NSString *)symbol withTitle:(NSString *)title
|
|
{
|
|
if (!symbol || !title) {
|
|
return;
|
|
}
|
|
|
|
if (!_registeredFormattingTitles) {
|
|
_registeredFormattingTitles = [NSMutableArray new];
|
|
_registeredFormattingSymbols = [NSMutableArray new];
|
|
}
|
|
|
|
// Adds the symbol if not contained already
|
|
if (![self.registeredSymbols containsObject:symbol]) {
|
|
[self.registeredFormattingTitles addObject:title];
|
|
[self.registeredFormattingSymbols addObject:symbol];
|
|
}
|
|
}
|
|
|
|
- (NSArray *)registeredSymbols
|
|
{
|
|
return self.registeredFormattingSymbols;
|
|
}
|
|
|
|
|
|
#pragma mark - Notification Events
|
|
|
|
- (void)slk_didBeginEditing:(NSNotification *)notification
|
|
{
|
|
if (![notification.object isEqual:self]) {
|
|
return;
|
|
}
|
|
|
|
// Do something
|
|
}
|
|
|
|
- (void)slk_didChangeText:(NSNotification *)notification
|
|
{
|
|
if (![notification.object isEqual:self]) {
|
|
return;
|
|
}
|
|
|
|
if (self.placeholderLabel.hidden != [self slk_shouldHidePlaceholder]) {
|
|
[self setNeedsLayout];
|
|
}
|
|
|
|
[self slk_flashScrollIndicatorsIfNeeded];
|
|
}
|
|
|
|
- (void)slk_didEndEditing:(NSNotification *)notification
|
|
{
|
|
if (![notification.object isEqual:self]) {
|
|
return;
|
|
}
|
|
|
|
// Do something
|
|
}
|
|
|
|
- (void)slk_didChangeTextInputMode:(NSNotification *)notification
|
|
{
|
|
// Do something
|
|
}
|
|
|
|
- (void)slk_didChangeContentSizeCategory:(NSNotification *)notification
|
|
{
|
|
if (!self.isDynamicTypeEnabled) {
|
|
return;
|
|
}
|
|
|
|
NSString *contentSizeCategory = notification.userInfo[UIContentSizeCategoryNewValueKey];
|
|
|
|
[self setFontName:self.font.fontName pointSize:self.initialFontSize withContentSizeCategory:contentSizeCategory];
|
|
|
|
NSString *text = [self.text copy];
|
|
|
|
// Reloads the content size of the text view
|
|
[self setText:@" "];
|
|
[self setText:text];
|
|
}
|
|
|
|
- (void)slk_willShowMenuController:(NSNotification *)notification
|
|
{
|
|
// Do something
|
|
}
|
|
|
|
- (void)slk_didHideMenuController:(NSNotification *)notification
|
|
{
|
|
self.formatting = NO;
|
|
|
|
[self slk_addCustomMenuControllerItems];
|
|
}
|
|
|
|
|
|
#pragma mark - KVO Listener
|
|
|
|
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
|
|
{
|
|
if ([object isEqual:self] && [keyPath isEqualToString:NSStringFromSelector(@selector(contentSize))]) {
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewContentSizeDidChangeNotification object:self userInfo:nil];
|
|
}
|
|
else {
|
|
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - Motion Events
|
|
|
|
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event
|
|
{
|
|
if (event.type == UIEventTypeMotion && event.subtype == UIEventSubtypeMotionShake) {
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewDidShakeNotification object:self];
|
|
}
|
|
}
|
|
|
|
|
|
#pragma mark - External Keyboard Support
|
|
|
|
typedef void (^SLKKeyCommandHandler)(UIKeyCommand *keyCommand);
|
|
|
|
- (void)observeKeyInput:(NSString *)input modifiers:(UIKeyModifierFlags)modifiers title:(NSString *_Nullable)title completion:(void (^)(UIKeyCommand *keyCommand))completion
|
|
{
|
|
NSAssert([input isKindOfClass:[NSString class]], @"You must provide a string with one or more characters corresponding to the keys to observe.");
|
|
NSAssert(completion != nil, @"You must provide a non-nil completion block.");
|
|
|
|
if (!input || !completion) {
|
|
return;
|
|
}
|
|
|
|
UIKeyCommand *keyCommand = [UIKeyCommand keyCommandWithInput:input modifierFlags:modifiers action:@selector(didDetectKeyCommand:)];
|
|
|
|
#ifdef __IPHONE_9_0
|
|
if ([UIKeyCommand respondsToSelector:@selector(keyCommandWithInput:modifierFlags:action:discoverabilityTitle:)] ) {
|
|
keyCommand.discoverabilityTitle = title;
|
|
}
|
|
#endif
|
|
|
|
if (!_registeredKeyCommands) {
|
|
_registeredKeyCommands = [NSMutableDictionary new];
|
|
_registeredKeyCallbacks = [NSMutableDictionary new];
|
|
}
|
|
|
|
NSString *key = [self keyForKeyCommand:keyCommand];
|
|
|
|
self.registeredKeyCommands[key] = keyCommand;
|
|
self.registeredKeyCallbacks[key] = completion;
|
|
}
|
|
|
|
- (void)didDetectKeyCommand:(UIKeyCommand *)keyCommand
|
|
{
|
|
NSString *key = [self keyForKeyCommand:keyCommand];
|
|
|
|
SLKKeyCommandHandler completion = self.registeredKeyCallbacks[key];
|
|
|
|
if (completion) {
|
|
completion(keyCommand);
|
|
}
|
|
}
|
|
|
|
- (NSString *)keyForKeyCommand:(UIKeyCommand *)keyCommand
|
|
{
|
|
return [NSString stringWithFormat:@"%@_%ld", keyCommand.input, (long)keyCommand.modifierFlags];
|
|
}
|
|
|
|
- (NSArray *)keyCommands
|
|
{
|
|
if (self.registeredKeyCommands) {
|
|
return [self.registeredKeyCommands allValues];
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
|
|
#pragma mark Up/Down Cursor Movement
|
|
|
|
- (void)didPressArrowKey:(UIKeyCommand *)keyCommand
|
|
{
|
|
if (![keyCommand isKindOfClass:[UIKeyCommand class]] || self.text.length == 0 || self.numberOfLines < 2) {
|
|
return;
|
|
}
|
|
|
|
if ([keyCommand.input isEqualToString:UIKeyInputUpArrow]) {
|
|
[self slk_moveCursorTodirection:UITextLayoutDirectionUp];
|
|
}
|
|
else if ([keyCommand.input isEqualToString:UIKeyInputDownArrow]) {
|
|
[self slk_moveCursorTodirection:UITextLayoutDirectionDown];
|
|
}
|
|
}
|
|
|
|
- (void)slk_moveCursorTodirection:(UITextLayoutDirection)direction
|
|
{
|
|
UITextPosition *start = (direction == UITextLayoutDirectionUp) ? self.selectedTextRange.start : self.selectedTextRange.end;
|
|
|
|
if ([self slk_isNewVerticalMovementForPosition:start inDirection:direction]) {
|
|
self.verticalMoveDirection = direction;
|
|
self.verticalMoveStartCaretRect = [self caretRectForPosition:start];
|
|
}
|
|
|
|
if (start) {
|
|
UITextPosition *end = [self slk_closestPositionToPosition:start inDirection:direction];
|
|
|
|
if (end) {
|
|
self.verticalMoveLastCaretRect = [self caretRectForPosition:end];
|
|
self.selectedTextRange = [self textRangeFromPosition:end toPosition:end];
|
|
|
|
[self slk_scrollToCaretPositonAnimated:NO];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Based on code from Ruben Cabaco
|
|
// https://gist.github.com/rcabaco/6765778
|
|
|
|
- (UITextPosition *)slk_closestPositionToPosition:(UITextPosition *)position inDirection:(UITextLayoutDirection)direction
|
|
{
|
|
// Only up/down are implemented. No real need for left/right since that is native to UITextInput.
|
|
NSParameterAssert(direction == UITextLayoutDirectionUp || direction == UITextLayoutDirectionDown);
|
|
|
|
// Translate the vertical direction to a horizontal direction.
|
|
UITextLayoutDirection lookupDirection = (direction == UITextLayoutDirectionUp) ? UITextLayoutDirectionLeft : UITextLayoutDirectionRight;
|
|
|
|
// Walk one character at a time in `lookupDirection` until the next line is reached.
|
|
UITextPosition *checkPosition = position;
|
|
UITextPosition *closestPosition = position;
|
|
CGRect startingCaretRect = [self caretRectForPosition:position];
|
|
CGRect nextLineCaretRect = CGRectZero;
|
|
BOOL isInNextLine = NO;
|
|
|
|
while (YES) {
|
|
UITextPosition *nextPosition = [self positionFromPosition:checkPosition inDirection:lookupDirection offset:1];
|
|
|
|
// End of line.
|
|
if (!nextPosition || [self comparePosition:checkPosition toPosition:nextPosition] == NSOrderedSame) {
|
|
break;
|
|
}
|
|
|
|
checkPosition = nextPosition;
|
|
CGRect checkRect = [self caretRectForPosition:checkPosition];
|
|
if (CGRectGetMidY(startingCaretRect) != CGRectGetMidY(checkRect)) {
|
|
// While on the next line stop just above/below the starting position.
|
|
if (lookupDirection == UITextLayoutDirectionLeft && CGRectGetMidX(checkRect) <= CGRectGetMidX(self.verticalMoveStartCaretRect)) {
|
|
closestPosition = checkPosition;
|
|
break;
|
|
}
|
|
if (lookupDirection == UITextLayoutDirectionRight && CGRectGetMidX(checkRect) >= CGRectGetMidX(self.verticalMoveStartCaretRect)) {
|
|
closestPosition = checkPosition;
|
|
break;
|
|
}
|
|
// But don't skip lines.
|
|
if (isInNextLine && CGRectGetMidY(checkRect) != CGRectGetMidY(nextLineCaretRect)) {
|
|
break;
|
|
}
|
|
|
|
isInNextLine = YES;
|
|
nextLineCaretRect = checkRect;
|
|
closestPosition = checkPosition;
|
|
}
|
|
}
|
|
return closestPosition;
|
|
}
|
|
|
|
- (BOOL)slk_isNewVerticalMovementForPosition:(UITextPosition *)position inDirection:(UITextLayoutDirection)direction
|
|
{
|
|
CGRect caretRect = [self caretRectForPosition:position];
|
|
BOOL noPreviousStartPosition = CGRectEqualToRect(self.verticalMoveStartCaretRect, CGRectZero);
|
|
BOOL caretMovedSinceLastPosition = !CGRectEqualToRect(caretRect, self.verticalMoveLastCaretRect);
|
|
BOOL directionChanged = self.verticalMoveDirection != direction;
|
|
|
|
BOOL newMovement = noPreviousStartPosition || caretMovedSinceLastPosition || directionChanged;
|
|
return newMovement;
|
|
}
|
|
|
|
|
|
#pragma mark - NSNotificationCenter registration
|
|
|
|
- (void)slk_registerNotifications
|
|
{
|
|
[self slk_unregisterNotifications];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didBeginEditing:) name:UITextViewTextDidBeginEditingNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeText:) name:UITextViewTextDidChangeNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didEndEditing:) name:UITextViewTextDidEndEditingNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeTextInputMode:) name:UITextInputCurrentInputModeDidChangeNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeContentSizeCategory:) name:UIContentSizeCategoryDidChangeNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_willShowMenuController:) name:UIMenuControllerWillShowMenuNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didHideMenuController:) name:UIMenuControllerDidHideMenuNotification object:nil];
|
|
}
|
|
|
|
- (void)slk_unregisterNotifications
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidBeginEditingNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidChangeNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidEndEditingNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextInputCurrentInputModeDidChangeNotification object:nil];
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIContentSizeCategoryDidChangeNotification object:nil];
|
|
}
|
|
|
|
|
|
#pragma mark - Lifeterm
|
|
|
|
- (void)dealloc
|
|
{
|
|
[self slk_unregisterNotifications];
|
|
|
|
[self removeObserver:self forKeyPath:NSStringFromSelector(@selector(contentSize))];
|
|
}
|
|
|
|
@end
|