Files
macOS-Rich-Text-Editor/Library/macOSRichTextEditor/Source/RichTextEditor.m
T
2020-12-08 09:28:48 -05:00

1418 lines
66 KiB
Objective-C

//
// RichTextEditor.h
// RichTextEdtor
//
// Created by Aryan Gh on 7/21/13.
// Copyright (c) 2013 Aryan Ghassemi. All rights reserved.
// Heavily modified for macOS by Deadpikle
// Copyright (c) 2016 Deadpikle. All rights reserved.
//
// https://github.com/aryaxt/iOS-Rich-Text-Editor -- Original
// https://github.com/Deadpikle/macOS-Rich-Text-Editor -- Fork
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// Text editing architecture guide: https://developer.apple.com/library/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html#//apple_ref/doc/uid/TP40009459-CH3-SW1
#import "RichTextEditor.h"
#import <QuartzCore/QuartzCore.h>
#import "NSFont+RichTextEditor.h"
#import "NSAttributedString+RichTextEditor.h"
#import "WZProtocolInterceptor.h"
#import <objc/runtime.h>
typedef NS_ENUM(NSInteger, ParagraphIndentation) {
ParagraphIndentationIncrease,
ParagraphIndentationDecrease
};
@interface RichTextEditor () <NSTextViewDelegate> {
}
// Gets set to YES when the user starts changing attributes when there is no text selection (selecting bold, italic, etc)
// Gets set to NO when the user changes selection or starts typing
@property (nonatomic, assign) BOOL typingAttributesInProgress;
@property float currSysVersion;
@property NSInteger MAX_INDENT;
@property BOOL isInTextDidChange;
@property NSString *BULLET_STRING;
@property NSUInteger levelsOfUndo;
@property NSUInteger previousCursorPosition;
@property BOOL inBulletedList;
@property BOOL justDeletedBackward;
@property NSString *latestReplacementString;
@property NSString *latestStringReplaced;
@property (nonatomic) NSRange lastAnchorPoint;
@property BOOL shouldEndColorChangeOnLeft;
@property WZProtocolInterceptor *delegate_interceptor;
@end
@implementation RichTextEditor
+(NSString*)pasteboardDataType {
return @"macOSRichTextEditor57";
}
#pragma mark - Initialization -
- (id)init {
if (self = [super init]) {
[self commonInitialization];
}
return self;
}
- (id)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self commonInitialization];
}
return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
[self commonInitialization];
}
return self;
}
- (id)delegate {
return self.delegate_interceptor;
}
- (void)setDelegate:(id)newDelegate {
[super setDelegate:nil];
self.delegate_interceptor.receiver = newDelegate;
[super setDelegate:(id)self.delegate_interceptor];
}
- (void)commonInitialization {
// Prevent the use of self.delegate = self
// http://stackoverflow.com/questions/3498158/intercept-objective-c-delegate-messages-within-a-subclass
Protocol *p = objc_getProtocol("NSTextViewDelegate");
self.delegate_interceptor = [[WZProtocolInterceptor alloc] initWithInterceptedProtocol:p];
[self.delegate_interceptor setMiddleMan:self];
[super setDelegate:(id)self.delegate_interceptor];
self.allowsRichTextPasteOnlyFromThisClass = YES;
self.borderColor = [NSColor lightGrayColor];
self.borderWidth = 1.0;
self.shouldEndColorChangeOnLeft = NO;
self.typingAttributesInProgress = NO;
self.isInTextDidChange = NO;
self.fontSizeChangeAmount = 6.0f;
self.maxFontSize = 128.0f;
self.minFontSize = 10.0f;
self.levelsOfUndo = 10;
self.BULLET_STRING = @"\u00A0"; // bullet is \u2022
self.latestReplacementString = @"";
self.latestStringReplaced = @"";
// Instead of hard-coding the default indentation size, which can make bulleted lists look a little
// odd when increasing/decreasing their indent, use a \t character width instead
// The old defaultIndentationSize was 15
// TODO: readjust this defaultIndentationSize when font size changes? Might make things weird.
NSDictionary *dictionary = [self dictionaryAtIndex:self.selectedRange.location];
CGSize expectedStringSize = [@"\t" sizeWithAttributes:dictionary];
self.defaultIndentationSize = expectedStringSize.width;
self.MAX_INDENT = self.defaultIndentationSize * 10;
if (self.rteDataSource && [self.rteDataSource respondsToSelector:@selector(levelsOfUndo)]) {
[[self undoManager] setLevelsOfUndo:[self.rteDataSource levelsOfUndo]];
}
else {
[[self undoManager] setLevelsOfUndo:self.levelsOfUndo];
}
// http://stackoverflow.com/questions/26454037/uitextview-text-selection-and-highlight-jumping-in-ios-8
self.layoutManager.allowsNonContiguousLayout = NO;
self.selectedRange = NSMakeRange(0, 0);
if ([[self.string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] isEqualToString:@""]) {
[self.textStorage setAttributedString:[[NSAttributedString alloc] initWithString:@""]];
}
}
- (BOOL)rangeExists:(NSRange)range {
return range.location != NSNotFound && range.location + range.length <= self.attributedString.length;
}
- (BOOL)textView:(NSTextView *)textView shouldChangeTextInRange:(NSRange)affectedCharRange replacementString:(NSString *)replacementString {
self.latestReplacementString = replacementString;
if (affectedCharRange.length > 0 && [self rangeExists:affectedCharRange]) {
self.latestStringReplaced = [self.string substringWithRange:affectedCharRange];
}
else {
self.latestStringReplaced = @"";
}
if ([replacementString isEqualToString:@"\n"]) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeEnter];
self.inBulletedList = [self isInBulletedList];
}
if ([replacementString isEqualToString:@" "]) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeSpace];
}
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementString:)]) {
return [self.delegate_interceptor.receiver textView:textView shouldChangeTextInRange:affectedCharRange replacementString:replacementString];
}
if (self.tabKeyAlwaysIndentsOutdents && [replacementString isEqualToString:@"\t"] && affectedCharRange.length == 0) {
//[self userSelectedIncreaseIndent];
//return NO;
}
return YES;
}
// http://stackoverflow.com/questions/2484072/how-can-i-make-the-tab-key-move-focus-out-of-a-nstextview
- (BOOL)textView:(NSTextView *)aTextView doCommandBySelector:(SEL)aSelector {
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textView:doCommandBySelector:)]) {
return [self.delegate_interceptor.receiver textView:aTextView doCommandBySelector:aSelector];
}
if (aSelector == @selector(insertTab:)) {
if ([self isInEmptyBulletedListItem]) {
[self userSelectedIncreaseIndent];
return YES;
}
}
else if (aSelector == @selector(insertBacktab:)) {
if ([self isInEmptyBulletedListItem]) {
[self userSelectedDecreaseIndent];
return YES;
}
}
else if (aSelector == @selector(deleteForward:)) {
// Do something against DELETE key
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeDelete];
}
else if (aSelector == @selector(deleteBackward:)) {
// Do something against BACKSPACE key
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeDelete];
}
return NO;
}
-(void)deleteBackward:(id)sender {
self.justDeletedBackward = YES;
[super deleteBackward:sender];
}
// https://stackoverflow.com/a/23667851/3938401
- (void)setSelectedRange:(NSRange)charRange affinity:(NSSelectionAffinity)affinity stillSelecting:(BOOL)stillSelectingFlag {
if (charRange.length == 0) {
self.lastAnchorPoint = charRange;
}
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:charRange];
charRange = [self adjustSelectedRangeForBulletsWithStart:rangeOfCurrentParagraph Previous:NSMakeRange(NSNotFound, 0) andCurrent:charRange isMouseClick:YES];
[super setSelectedRange:charRange affinity:affinity stillSelecting:stillSelectingFlag];
}
// https://stackoverflow.com/a/23667851/3938401
- (NSRange)textView:(NSTextView *)textView willChangeSelectionFromCharacterRange:(NSRange)oldSelectedCharRange toCharacterRange:(NSRange)newSelectedCharRange {
if (newSelectedCharRange.length != 0) {
int anchorStart = (int)self.lastAnchorPoint.location;
int selectionStart = (int)newSelectedCharRange.location;
int selectionLength = (int)newSelectedCharRange.length;
/*
If mouse selects left, and then a user arrows right, or the opposite, anchor point flips.
*/
int difference = anchorStart - selectionStart;
if (difference > 0 && difference != selectionLength) {
if (oldSelectedCharRange.location == newSelectedCharRange.location) {
// We were selecting left via mouse, but now we are selecting to the right via arrows
anchorStart = selectionStart;
}
else {
// We were selecting right via mouse, but now we are selecting to the left via arrows
anchorStart = selectionStart + selectionLength;
}
self.lastAnchorPoint = NSMakeRange(anchorStart, 0);
}
// Evaluate Selection Direction
if (anchorStart == selectionStart) {
if (oldSelectedCharRange.length < newSelectedCharRange.length) {
// Bigger
//NSLog(@"Will select right in overall right selection");
}
else {
// Smaller
//NSLog(@"Will select left in overall right selection");
}
self.shouldEndColorChangeOnLeft = NO;
}
else {
self.shouldEndColorChangeOnLeft = YES;
if (oldSelectedCharRange.length < newSelectedCharRange.length) {
// Bigger
//NSLog(@"Will select left in overall left selection");
}
else {
// Smaller
//NSLog(@"Will select right in overall left selection");
}
}
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:oldSelectedCharRange];
newSelectedCharRange = [self adjustSelectedRangeForBulletsWithStart:rangeOfCurrentParagraph Previous:oldSelectedCharRange andCurrent:newSelectedCharRange isMouseClick:NO];
}
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textView:willChangeSelectionFromCharacterRange:toCharacterRange:)]) {
return [self.delegate_interceptor.receiver textView:textView willChangeSelectionFromCharacterRange:oldSelectedCharRange toCharacterRange:newSelectedCharRange];
}
return newSelectedCharRange;
}
- (void)textViewDidChangeSelection:(NSNotification *)notification {
[self setNeedsLayout:YES];
[self scrollRangeToVisible:self.selectedRange]; // fixes issue with cursor moving to top via keyboard and RTE not scrolling
[self sendDelegateTypingAttrsUpdate];
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textViewDidChangeSelection:)]) {
[self.delegate_interceptor.receiver textViewDidChangeSelection:notification];
}
}
- (void)textDidChange:(NSNotification *)notification {
if (!self.isInTextDidChange) {
self.isInTextDidChange = YES;
[self applyBulletListIfApplicable];
[self deleteBulletListWhenApplicable];
if ([self.latestStringReplaced hasSuffix:@"\n"]) {
// get rest of paragraph as they just deleted a newline
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
NSInteger rangeDiff = self.selectedRange.location - rangeOfCurrentParagraph.location;
if (rangeDiff >= 0) {
NSRange restOfLineRange = NSMakeRange(rangeOfCurrentParagraph.location + rangeDiff, rangeOfCurrentParagraph.length - rangeDiff);
NSString *restOfLine = [self.string substringWithRange:restOfLineRange];
if ([restOfLine hasPrefix:self.BULLET_STRING]) {
// we must have deleted a newline under a previous list! Get rid of the bullet!
[self.textStorage replaceCharactersInRange:NSMakeRange(restOfLineRange.location, self.BULLET_STRING.length) withString:@""];
}
}
}
self.isInTextDidChange = NO;
}
self.justDeletedBackward = NO;
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textDidChange:)]) {
[self.delegate_interceptor.receiver textDidChange:notification];
}
}
- (BOOL)isInBulletedList {
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
return [[[self.attributedString string] substringFromIndex:rangeOfCurrentParagraph.location] hasPrefix:self.BULLET_STRING];
}
-(BOOL)isInEmptyBulletedListItem {
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
return [[[self.attributedString string] substringFromIndex:rangeOfCurrentParagraph.location] isEqualToString:self.BULLET_STRING];
}
- (void)paste:(id)sender {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangePaste];
if (self.allowsRichTextPasteOnlyFromThisClass) {
if ([[NSPasteboard generalPasteboard] dataForType:[RichTextEditor pasteboardDataType]]) {
[super paste:sender]; // just call paste so we don't have to bother doing the check again
}
else {
[self pasteAsPlainText:self];
}
}
else {
[super paste:sender];
}
}
- (void)pasteAsRichText:(id)sender {
BOOL hasCopyDataFromThisClass = [[NSPasteboard generalPasteboard] dataForType:[RichTextEditor pasteboardDataType]] != nil;
if (self.allowsRichTextPasteOnlyFromThisClass) {
if (hasCopyDataFromThisClass) {
[super pasteAsRichText:sender];
}
else {
[self pasteAsPlainText:sender];
}
}
else {
[super pasteAsRichText:sender];
}
}
- (void)pasteAsPlainText:(id)sender {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangePaste];
// Apparently paste as "plain" text doesn't ignore background and foreground colors...
NSMutableDictionary *typingAttributes = [self.typingAttributes mutableCopy];
[typingAttributes removeObjectForKey:NSBackgroundColorAttributeName];
[typingAttributes removeObjectForKey:NSForegroundColorAttributeName];
self.typingAttributes = typingAttributes;
[super pasteAsPlainText:sender];
}
- (void)cut:(id)sender {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeCut];
[super cut:sender];
}
-(void)copy:(id)sender {
[super copy:sender];
NSPasteboard *currentPasteboard = [NSPasteboard generalPasteboard];
[currentPasteboard setData:[@"" dataUsingEncoding:NSUTF8StringEncoding] forType:[RichTextEditor pasteboardDataType]];
}
#pragma mark -
- (void)sendDelegateTypingAttrsUpdate {
if (self.rteDelegate) {
NSDictionary *attributes = [self typingAttributes];
NSFont *font = [attributes objectForKey:NSFontAttributeName];
NSColor *fontColor = [attributes objectForKey:NSForegroundColorAttributeName];
NSColor *backgroundColor = [attributes objectForKey:NSBackgroundColorAttributeName]; // may want NSBackgroundColorAttributeName
BOOL isInBulletedList = [self isInBulletedList];
[self.rteDelegate selectionForEditor:self changedTo:[self selectedRange] isBold:[font isBold] isItalic:[font isItalic] isUnderline:[self isCurrentFontUnderlined] isInBulletedList:isInBulletedList textBackgroundColor:backgroundColor textColor:fontColor];
}
}
-(void)sendDelegateTVChanged {
if (self.delegate_interceptor.receiver && [self.delegate_interceptor.receiver respondsToSelector:@selector(textDidChange:)]) {
[self.delegate_interceptor.receiver textDidChange:[NSNotification notificationWithName:@"textDidChange:" object:self]];
}
}
-(void)sendDelegatePreviewChangeOfType:(RichTextEditorPreviewChange)type {
if (self.rteDelegate && [self.rteDelegate respondsToSelector:@selector(richTextEditor:changeAboutToOccurOfType:)]) {
[self.rteDelegate richTextEditor:self changeAboutToOccurOfType:type];
}
}
-(void)userSelectedBold {
NSFont *font = [[self typingAttributes] objectForKey:NSFontAttributeName];
if (!font) {
font = [NSFont systemFontOfSize:12.0f];
}
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeBold];
[self applyFontAttributesToSelectedRangeWithBoldTrait:[NSNumber numberWithBool:![font isBold]] italicTrait:nil fontName:nil fontSize:nil];
[self sendDelegateTypingAttrsUpdate];
[self sendDelegateTVChanged];
}
-(void)userSelectedItalic {
NSFont *font = [[self typingAttributes] objectForKey:NSFontAttributeName];
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeItalic];
[self applyFontAttributesToSelectedRangeWithBoldTrait:nil italicTrait:[NSNumber numberWithBool:![font isItalic]] fontName:nil fontSize:nil];
[self sendDelegateTypingAttrsUpdate];
[self sendDelegateTVChanged];
}
-(void)userSelectedUnderline {
NSNumber *existingUnderlineStyle;
if (![self isCurrentFontUnderlined]) {
existingUnderlineStyle = [NSNumber numberWithInteger:NSUnderlineStyleSingle];
}
else {
existingUnderlineStyle = [NSNumber numberWithInteger:NSUnderlineStyleNone];
}
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeUnderline];
[self applyAttributesToSelectedRange:existingUnderlineStyle forKey:NSUnderlineStyleAttributeName];
[self sendDelegateTypingAttrsUpdate];
[self sendDelegateTVChanged];
}
-(void)userSelectedIncreaseIndent {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeIndentIncrease];
[self userSelectedParagraphIndentation:ParagraphIndentationIncrease];
[self sendDelegateTVChanged];
}
-(void)userSelectedDecreaseIndent {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeIndentDecrease];
[self userSelectedParagraphIndentation:ParagraphIndentationDecrease];
[self sendDelegateTVChanged];
}
-(void)userSelectedTextBackgroundColor:(NSColor*)color {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeHighlight];
NSRange selectedRange = [self selectedRange];
if (color) {
[self applyAttributesToSelectedRange:color forKey:NSBackgroundColorAttributeName];
}
else {
[self removeAttributeForKeyFromSelectedRange:NSBackgroundColorAttributeName];
}
if (self.shouldEndColorChangeOnLeft) {
[self setSelectedRange:NSMakeRange(selectedRange.location, 0)];
}
else {
[self setSelectedRange:NSMakeRange(selectedRange.location + selectedRange.length, 0)];
}
[self sendDelegateTVChanged];
}
-(void)userSelectedTextColor:(NSColor*)color {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeFontColor];
if (color) {
[self applyAttributesToSelectedRange:color forKey:NSForegroundColorAttributeName];
}
else {
[self removeAttributeForKeyFromSelectedRange:NSForegroundColorAttributeName];
}
[self sendDelegateTVChanged];
}
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (void)setFont:(NSFont *)font {
[super setFont:font];
}
#pragma mark - Public Methods -
- (void)setHtmlString:(NSString *)htmlString {
NSMutableAttributedString *attr = [[RichTextEditor attributedStringFromHTMLString:htmlString] mutableCopy];
if (attr) {
if ([attr.string hasSuffix:@"\n"]) {
[attr replaceCharactersInRange:NSMakeRange(attr.length - 1, 1) withString:@""];
}
[self setAttributedString:attr];
}
}
- (NSString *)htmlString {
return [RichTextEditor htmlStringFromAttributedText:self.attributedString];
}
- (void)changeToAttributedString:(NSAttributedString*)string {
[self setAttributedString:string];
}
+(NSString *)htmlStringFromAttributedText:(NSAttributedString*)text {
NSData *data = [text dataFromRange:NSMakeRange(0, text.length)
documentAttributes:
@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType,
NSCharacterEncodingDocumentAttribute: [NSNumber numberWithInt:NSUTF8StringEncoding]}
error:nil];
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
+(NSAttributedString*)attributedStringFromHTMLString:(NSString *)htmlString {
@try {
NSError *error;
NSData *data = [htmlString dataUsingEncoding:NSUTF8StringEncoding];
NSAttributedString *str =
[[NSAttributedString alloc] initWithData:data
options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType,
NSCharacterEncodingDocumentAttribute: [NSNumber numberWithInt:NSUTF8StringEncoding]}
documentAttributes:nil error:&error];
if (!error)
return str;
return nil;
}
@catch (NSException *e) {
return nil;
}
}
- (void)setBorderColor:(NSColor *)borderColor {
self.layer.borderColor = borderColor.CGColor;
}
- (void)setBorderWidth:(CGFloat)borderWidth {
self.layer.borderWidth = borderWidth;
}
- (void)userChangedToFontSize:(NSNumber*)fontSize {
[self applyFontAttributesToSelectedRangeWithBoldTrait:nil italicTrait:nil fontName:nil fontSize:fontSize];
}
- (void)userChangedToFontName:(NSString*)fontName {
[self applyFontAttributesToSelectedRangeWithBoldTrait:nil italicTrait:nil fontName:fontName fontSize:nil];
}
- (BOOL)isCurrentFontUnderlined {
NSDictionary *dictionary = [self typingAttributes];
NSNumber *existingUnderlineStyle = [dictionary objectForKey:NSUnderlineStyleAttributeName];
if (!existingUnderlineStyle || existingUnderlineStyle.intValue == NSUnderlineStyleNone) {
return NO;
}
return YES;
}
// try/catch blocks on undo/redo because it doesn't work right with bulleted lists when BULLET_STRING has more than 1 character
- (void)undo {
@try {
BOOL shouldUseUndoManager = YES;
if ([self.rteDelegate respondsToSelector:@selector(handlesUndoRedoForText)] &&
[self.rteDelegate respondsToSelector:@selector(userPerformedUndo)]) {
if ([self.rteDelegate handlesUndoRedoForText]) {
[self.rteDelegate userPerformedUndo];
shouldUseUndoManager = NO;
}
}
if (shouldUseUndoManager && [[self undoManager] canUndo]) {
[[self undoManager] undo];
}
}
@catch (NSException *e) {
[[self undoManager] removeAllActions];
}
}
- (void)redo {
@try {
BOOL shouldUseUndoManager = YES;
if ([self.rteDelegate respondsToSelector:@selector(handlesUndoRedoForText)] &&
[self.rteDelegate respondsToSelector:@selector(userPerformedRedo)]) {
if ([self.rteDelegate handlesUndoRedoForText]) {
[self.rteDelegate userPerformedRedo];
shouldUseUndoManager = NO;
}
}
if (shouldUseUndoManager && [[self undoManager] canRedo])
[[self undoManager] redo];
}
@catch (NSException *e) {
[[self undoManager] removeAllActions];
}
}
- (void)userSelectedParagraphIndentation:(ParagraphIndentation)paragraphIndentation {
self.isInTextDidChange = YES;
__block NSDictionary *dictionary;
__block NSMutableParagraphStyle *paragraphStyle;
NSRange currSelectedRange = self.selectedRange;
[self enumarateThroughParagraphsInRange:self.selectedRange withBlock:^(NSRange paragraphRange){
dictionary = [self dictionaryAtIndex:paragraphRange.location];
paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
if (!paragraphStyle) {
paragraphStyle = [[NSMutableParagraphStyle alloc] init];
}
if (paragraphIndentation == ParagraphIndentationIncrease &&
paragraphStyle.headIndent < self.MAX_INDENT && paragraphStyle.firstLineHeadIndent < self.MAX_INDENT) {
paragraphStyle.headIndent += self.defaultIndentationSize;
paragraphStyle.firstLineHeadIndent += self.defaultIndentationSize;
}
else if (paragraphIndentation == ParagraphIndentationDecrease) {
paragraphStyle.headIndent -= self.defaultIndentationSize;
paragraphStyle.firstLineHeadIndent -= self.defaultIndentationSize;
if (paragraphStyle.headIndent < 0) {
paragraphStyle.headIndent = 0; // this is the right cursor placement
}
if (paragraphStyle.firstLineHeadIndent < 0) {
paragraphStyle.firstLineHeadIndent = 0; // this affects left cursor placement
}
}
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:paragraphRange];
}];
[self setSelectedRange:currSelectedRange];
self.isInTextDidChange = NO;
// Old iOS code
// Following 2 lines allow the user to insta-type after indenting in a bulleted list
//NSRange range = NSMakeRange(self.selectedRange.location+self.selectedRange.length, 0);
//[self setSelectedRange:range];
// Check to see if the current paragraph is blank. If it is, manually get the cursor to move with a weird hack.
// After NSTextStorage changes, these don't seem necessary
/* NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
BOOL currParagraphIsBlank = [[self.string substringWithRange:rangeOfCurrentParagraph] isEqualToString:@""] ? YES: NO;
if (currParagraphIsBlank)
{
// [self setIndentationWithAttributes:dictionary paragraphStyle:paragraphStyle atRange:rangeOfCurrentParagraph];
} */
}
// Manually ensures that the cursor is shown in the correct location. Ugly work around and weird but it works (at least in iOS 7 / OS X 10.11.2).
// Basically what I do is add a " " with the correct indentation then delete it. For some reason with that
// and applying that attribute to the current typing attributes it moves the cursor to the right place.
// Would updating the typing attributes also work instead? That'd certainly be cleaner...
-(void)setIndentationWithAttributes:(NSDictionary*)attributes paragraphStyle:(NSMutableParagraphStyle*)paragraphStyle atRange:(NSRange)range {
NSMutableAttributedString *space = [[NSMutableAttributedString alloc] initWithString:@" " attributes:attributes];
[space addAttributes:[NSDictionary dictionaryWithObject:paragraphStyle forKey:NSParagraphStyleAttributeName] range:NSMakeRange(0, 1)];
[self.textStorage insertAttributedString:space atIndex:range.location];
[self setSelectedRange:NSMakeRange(range.location, 1)];
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:NSMakeRange(self.selectedRange.location+self.selectedRange.length-1, 1)];
[self setSelectedRange:NSMakeRange(range.location, 0)];
[self.textStorage deleteCharactersInRange:NSMakeRange(range.location, 1)];
[self applyAttributeToTypingAttribute:paragraphStyle forKey:NSParagraphStyleAttributeName];
}
- (void)userSelectedParagraphFirstLineHeadIndent {
[self enumarateThroughParagraphsInRange:self.selectedRange withBlock:^(NSRange paragraphRange){
NSDictionary *dictionary = [self dictionaryAtIndex:paragraphRange.location];
NSMutableParagraphStyle *paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
if (!paragraphStyle) {
paragraphStyle = [[NSMutableParagraphStyle alloc] init];
}
if (paragraphStyle.headIndent == paragraphStyle.firstLineHeadIndent) {
paragraphStyle.firstLineHeadIndent += self.defaultIndentationSize;
}
else {
paragraphStyle.firstLineHeadIndent = paragraphStyle.headIndent;
}
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:paragraphRange];
}];
}
- (void)userSelectedTextAlignment:(NSTextAlignment)textAlignment {
[self enumarateThroughParagraphsInRange:self.selectedRange withBlock:^(NSRange paragraphRange){
NSDictionary *dictionary = [self dictionaryAtIndex:paragraphRange.location];
NSMutableParagraphStyle *paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
if (!paragraphStyle) {
paragraphStyle = [[NSMutableParagraphStyle alloc] init];
}
paragraphStyle.alignment = textAlignment;
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:paragraphRange];
[self setIndentationWithAttributes:dictionary paragraphStyle:paragraphStyle atRange:paragraphRange];
}];
}
-(void)setAttributedString:(NSAttributedString*)attributedString {
[self.textStorage setAttributedString:attributedString];
}
// http://stackoverflow.com/questions/5810706/how-to-programmatically-add-bullet-list-to-nstextview might be useful to look at some day (or maybe not)
- (void)userSelectedBullet {
//NSLog(@"[RTE] Bullet code called");
if (!self.isEditable) {
return;
}
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeBullet];
NSRange initialSelectedRange = self.selectedRange;
NSArray *rangeOfParagraphsInSelectedText = [self.attributedString rangeOfParagraphsFromTextRange:self.selectedRange];
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
BOOL firstParagraphHasBullet = [[self.string substringFromIndex:rangeOfCurrentParagraph.location] hasPrefix:self.BULLET_STRING];
NSRange rangeOfPreviousParagraph = [self.attributedString firstParagraphRangeFromTextRange:NSMakeRange(rangeOfCurrentParagraph.location-1, 0)];
NSDictionary *prevParaDict = [self dictionaryAtIndex:rangeOfPreviousParagraph.location];
NSMutableParagraphStyle *prevParaStyle = [prevParaDict objectForKey:NSParagraphStyleAttributeName];
__block NSInteger rangeOffset = 0;
__block BOOL mustDecreaseIndentAfterRemovingBullet = NO;
__block BOOL isInBulletedList = self.inBulletedList;
[self enumarateThroughParagraphsInRange:self.selectedRange withBlock:^(NSRange paragraphRange){
NSRange range = NSMakeRange(paragraphRange.location + rangeOffset, paragraphRange.length);
NSDictionary *dictionary = [self dictionaryAtIndex:MAX((int)range.location-1, 0)];
NSMutableParagraphStyle *paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
if (!paragraphStyle) {
paragraphStyle = [[NSMutableParagraphStyle alloc] init];
}
BOOL currentParagraphHasBullet = [[self.string substringFromIndex:range.location] hasPrefix:self.BULLET_STRING];
if (firstParagraphHasBullet != currentParagraphHasBullet) {
return;
}
if (currentParagraphHasBullet) {
// User hit the bullet button and is in a bulleted list so we should get rid of the bullet
range = NSMakeRange(range.location, range.length - self.BULLET_STRING.length);
[self.textStorage deleteCharactersInRange:NSMakeRange(range.location, self.BULLET_STRING.length)];
paragraphStyle.firstLineHeadIndent = 0;
paragraphStyle.headIndent = 0;
rangeOffset = rangeOffset - self.BULLET_STRING.length;
mustDecreaseIndentAfterRemovingBullet = YES;
isInBulletedList = NO;
}
else {
// We are adding a bullet
range = NSMakeRange(range.location, range.length + self.BULLET_STRING.length);
NSMutableAttributedString *bulletAttributedString = [[NSMutableAttributedString alloc] initWithString:self.BULLET_STRING attributes:nil];
// The following code attempts to remove any underline from the bullet string, but it doesn't work right. I don't know why.
/* NSFont *prevFont = [dictionary objectForKey:NSFontAttributeName];
NSFont *bulletFont = [NSFont fontWithName:[prevFont familyName] size:[prevFont pointSize]];
NSMutableDictionary *bulletDict = [dictionary mutableCopy];
[bulletDict setObject:bulletFont forKey:NSFontAttributeName];
[bulletDict removeObjectForKey:NSStrikethroughStyleAttributeName];
[bulletDict setValue:NSUnderlineStyleNone forKey:NSUnderlineStyleAttributeName];
[bulletDict removeObjectForKey:NSStrokeColorAttributeName];
[bulletDict removeObjectForKey:NSStrokeWidthAttributeName];
dictionary = bulletDict;*/
[bulletAttributedString setAttributes:dictionary range:NSMakeRange(0, self.BULLET_STRING.length)];
[self.textStorage insertAttributedString:bulletAttributedString atIndex:range.location];
CGSize expectedStringSize = [self.BULLET_STRING sizeWithAttributes:dictionary];
// See if the previous paragraph has a bullet
NSString *previousParagraph = [self.string substringWithRange:rangeOfPreviousParagraph];
BOOL doesPrefixWithBullet = [previousParagraph hasPrefix:self.BULLET_STRING];
// Look at the previous paragraph to see what the firstLineHeadIndent should be for the
// current bullet
// if the previous paragraph has a bullet, use that paragraph's indent
// if not, then use defaultIndentation size
if (!doesPrefixWithBullet) {
paragraphStyle.firstLineHeadIndent = self.defaultIndentationSize;
}
else {
paragraphStyle.firstLineHeadIndent = prevParaStyle.firstLineHeadIndent;
}
paragraphStyle.headIndent = expectedStringSize.width + paragraphStyle.firstLineHeadIndent;
rangeOffset = rangeOffset + self.BULLET_STRING.length;
isInBulletedList = YES;
}
[self.textStorage addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range];
}];
// If paragraph is empty move cursor to front of bullet, so the user can start typing right away
NSRange rangeForSelection;
if (rangeOfParagraphsInSelectedText.count == 1 && rangeOfCurrentParagraph.length == 0 && isInBulletedList) {
rangeForSelection = NSMakeRange(rangeOfCurrentParagraph.location + self.BULLET_STRING.length, 0);
}
else {
if (initialSelectedRange.length == 0) {
rangeForSelection = NSMakeRange(initialSelectedRange.location+rangeOffset, 0);
}
else {
NSRange fullRange = [self fullRangeFromArrayOfParagraphRanges:rangeOfParagraphsInSelectedText];
rangeForSelection = NSMakeRange(fullRange.location, fullRange.length+rangeOffset);
}
}
if (mustDecreaseIndentAfterRemovingBullet) {
// remove the extra indentation added by the bullet
[self userSelectedParagraphIndentation:ParagraphIndentationDecrease];
}
self.selectedRange = rangeForSelection;
if (!self.isInTextDidChange) {
[self sendDelegateTVChanged];
}
}
// modified from https://stackoverflow.com/a/4833778/3938401
- (void)changeToFont:(NSFont*)font {
NSTextStorage *textStorage = self.textStorage;
[textStorage beginEditing];
[textStorage enumerateAttributesInRange: NSMakeRange(0, textStorage.length)
options: 0
usingBlock: ^(NSDictionary *attributesDictionary,
NSRange range,
BOOL *stop) {
NSFont *currFont = [attributesDictionary objectForKey:NSFontAttributeName];
if (currFont) {
NSFont *fontToChangeTo = [font fontWithBoldTrait:currFont.isBold andItalicTrait:currFont.isItalic];
if (fontToChangeTo) {
[textStorage removeAttribute:NSFontAttributeName range:range];
[textStorage addAttribute:NSFontAttributeName value:fontToChangeTo range:range];
}
}
}];
[textStorage endEditing];
}
#pragma mark - Private Methods -
- (void)enumarateThroughParagraphsInRange:(NSRange)range withBlock:(void (^)(NSRange paragraphRange))block{
NSArray *rangeOfParagraphsInSelectedText = [self.attributedString rangeOfParagraphsFromTextRange:self.selectedRange];
for (int i = 0; i < rangeOfParagraphsInSelectedText.count; i++) {
NSValue *value = rangeOfParagraphsInSelectedText[i];
NSRange paragraphRange = [value rangeValue];
block(paragraphRange);
}
rangeOfParagraphsInSelectedText = [self.attributedString rangeOfParagraphsFromTextRange:self.selectedRange];
NSRange fullRange = [self fullRangeFromArrayOfParagraphRanges:rangeOfParagraphsInSelectedText];
if (fullRange.location + fullRange.length > [self.attributedString length]) {
fullRange.length = 0;
fullRange.location = [self.attributedString length]-1;
}
[self setSelectedRange:fullRange];
}
- (NSRange)fullRangeFromArrayOfParagraphRanges:(NSArray *)paragraphRanges {
if (!paragraphRanges.count) {
return NSMakeRange(0, 0);
}
NSRange firstRange = [paragraphRanges[0] rangeValue];
NSRange lastRange = [[paragraphRanges lastObject] rangeValue];
return NSMakeRange(firstRange.location, lastRange.location + lastRange.length - firstRange.location);
}
- (NSFont *)fontAtIndex:(NSInteger)index {
return [[self dictionaryAtIndex:index] objectForKey:NSFontAttributeName];
}
- (BOOL)hasText {
return self.string.length > 0;
}
- (NSDictionary *)dictionaryAtIndex:(NSInteger)index {
if (![self hasText] || index == self.string.length) {
return self.typingAttributes; // end of string, use whatever we're currently using
}
else {
return [self.attributedString attributesAtIndex:index effectiveRange:nil];
}
}
- (void)updateTypingAttributes {
// http://stackoverflow.com/questions/11835497/nstextview-not-applying-attributes-to-newly-inserted-text
NSArray *selectedRanges = self.selectedRanges;
if (selectedRanges && selectedRanges.count > 0 && [self hasText]) {
NSValue *firstSelectionRangeValue = selectedRanges[0];
if (firstSelectionRangeValue) {
NSRange firstCharacterOfSelectedRange = [firstSelectionRangeValue rangeValue];
if (firstCharacterOfSelectedRange.location >= self.textStorage.length) {
firstCharacterOfSelectedRange.location = self.textStorage.length - 1;
}
NSDictionary *attributesDictionary = [self.textStorage attributesAtIndex:firstCharacterOfSelectedRange.location effectiveRange: NULL];
[self setTypingAttributes: attributesDictionary];
}
}
}
- (void)applyAttributeToTypingAttribute:(id)attribute forKey:(NSString *)key {
NSMutableDictionary *dictionary = [self.typingAttributes mutableCopy];
[dictionary setObject:attribute forKey:key];
[self setTypingAttributes:dictionary];
}
- (void)applyAttributes:(id)attribute forKey:(NSString *)key atRange:(NSRange)range {
// If any text selected apply attributes to text
if (range.length > 0) {
// Workaround for when there is only one paragraph,
// sometimes the attributedString is actually longer by one then the displayed text,
// and this results in not being able to set to lef align anymore.
if (range.length == self.textStorage.length - 1 && range.length == self.string.length) {
++range.length;
}
[self.textStorage addAttributes:[NSDictionary dictionaryWithObject:attribute forKey:key] range:range];
// Have to update typing attributes because the selection won't change after these attributes have changed.
[self updateTypingAttributes];
}
else {
// If no text is selected apply attributes to typingAttribute
self.typingAttributesInProgress = YES;
[self applyAttributeToTypingAttribute:attribute forKey:key];
}
}
- (void)removeAttributeForKey:(NSString *)key atRange:(NSRange)range {
NSRange initialRange = self.selectedRange;
[self.textStorage removeAttribute:key range:range];
[self setSelectedRange:initialRange];
}
- (void)removeAttributeForKeyFromSelectedRange:(NSString *)key {
[self removeAttributeForKey:key atRange:self.selectedRange];
}
- (void)applyAttributesToSelectedRange:(id)attribute forKey:(NSString *)key {
[self applyAttributes:attribute forKey:key atRange:self.selectedRange];
}
- (void)applyFontAttributesToSelectedRangeWithBoldTrait:(NSNumber *)isBold italicTrait:(NSNumber *)isItalic fontName:(NSString *)fontName fontSize:(NSNumber *)fontSize {
[self applyFontAttributesWithBoldTrait:isBold italicTrait:isItalic fontName:fontName fontSize:fontSize toTextAtRange:self.selectedRange];
}
- (void)applyFontAttributesWithBoldTrait:(NSNumber *)isBold italicTrait:(NSNumber *)isItalic fontName:(NSString *)fontName fontSize:(NSNumber *)fontSize toTextAtRange:(NSRange)range {
// If any text selected apply attributes to text
if (range.length > 0) {
[self.textStorage beginEditing];
[self.textStorage enumerateAttributesInRange:range
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(NSDictionary *dictionary, NSRange range, BOOL *stop){
NSFont *newFont = [self fontwithBoldTrait:isBold
italicTrait:isItalic
fontName:fontName
fontSize:fontSize
fromDictionary:dictionary];
if (newFont) {
[self.textStorage addAttributes:[NSDictionary dictionaryWithObject:newFont forKey:NSFontAttributeName] range:range];
}
}];
[self.textStorage endEditing];
[self setSelectedRange:range];
[self updateTypingAttributes];
}
else {
// If no text is selected apply attributes to typingAttribute
self.typingAttributesInProgress = YES;
NSFont *newFont = [self fontwithBoldTrait:isBold
italicTrait:isItalic
fontName:fontName
fontSize:fontSize
fromDictionary:self.typingAttributes];
if (newFont) {
[self applyAttributeToTypingAttribute:newFont forKey:NSFontAttributeName];
}
}
}
-(BOOL)hasSelection {
return self.selectedRange.length > 0;
}
// By default, if this function is called with nothing selected, it will resize all text.
-(void)changeFontSizeWithOperation:(CGFloat(^)(CGFloat currFontSize))operation {
[self.textStorage beginEditing];
NSRange range = self.selectedRange;
if (range.length == 0) {
range = NSMakeRange(0, self.textStorage.length);
}
[self.textStorage enumerateAttributesInRange:range
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(NSDictionary *dictionary, NSRange range, BOOL *stop){
// Get current font size
NSFont *currFont = [dictionary objectForKey:NSFontAttributeName];
if (currFont) {
CGFloat currFontSize = currFont.pointSize;
CGFloat nextFontSize = operation(currFontSize);
if ((currFontSize < nextFontSize && nextFontSize <= self.maxFontSize) || // sizing up
(currFontSize > nextFontSize && self.minFontSize <= nextFontSize)) { // sizing down
NSFont *newFont = [self fontwithBoldTrait:[NSNumber numberWithBool:[currFont isBold]]
italicTrait:[NSNumber numberWithBool:[currFont isItalic]]
fontName:currFont.fontName
fontSize:[NSNumber numberWithFloat:nextFontSize]
fromDictionary:dictionary];
if (newFont) {
[self.textStorage addAttributes:[NSDictionary dictionaryWithObject:newFont forKey:NSFontAttributeName] range:range];
}
}
}
}];
[self.textStorage endEditing];
[self updateTypingAttributes];
}
- (void)decreaseFontSize {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeFontSize];
if (self.selectedRange.length == 0) {
NSMutableDictionary *typingAttributes = [self.typingAttributes mutableCopy];
NSFont *font = [typingAttributes valueForKey:NSFontAttributeName];
CGFloat nextFontSize = font.pointSize - self.fontSizeChangeAmount;
if (nextFontSize < self.minFontSize)
nextFontSize = self.minFontSize;
NSFont *nextFont = [[NSFontManager sharedFontManager] convertFont:font toSize:nextFontSize];
[typingAttributes setValue:nextFont forKey:NSFontAttributeName];
self.typingAttributes = typingAttributes;
}
else {
[self changeFontSizeWithOperation:^CGFloat (CGFloat currFontSize) {
return currFontSize - self.fontSizeChangeAmount;
}];
[self sendDelegateTVChanged]; // only send if the actual text changes -- if no text selected, no text has actually changed
}
}
- (void)increaseFontSize {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeFontSize];
if (self.selectedRange.length == 0) {
NSMutableDictionary *typingAttributes = [self.typingAttributes mutableCopy];
NSFont *font = [typingAttributes valueForKey:NSFontAttributeName];
CGFloat nextFontSize = font.pointSize + self.fontSizeChangeAmount;
if (nextFontSize > self.maxFontSize) {
nextFontSize = self.maxFontSize;
}
NSFont *nextFont = [[NSFontManager sharedFontManager] convertFont:font toSize:nextFontSize];
[typingAttributes setValue:nextFont forKey:NSFontAttributeName];
self.typingAttributes = typingAttributes;
}
else {
[self changeFontSizeWithOperation:^CGFloat (CGFloat currFontSize) {
return currFontSize + self.fontSizeChangeAmount;
}];
[self sendDelegateTVChanged]; // only send if the actual text changes -- if no text selected, no text has actually changed
}
}
// TODO: Fix this function. You can't create a font that isn't bold from a dictionary that has a bold attribute currently, since if you send isBold 0 [nil], it'll use the dictionary, which is bold!
// In other words, this function has logical errors
// Returns a font with given attributes. For any missing parameter takes the attribute from a given dictionary
- (NSFont *)fontwithBoldTrait:(NSNumber *)isBold italicTrait:(NSNumber *)isItalic fontName:(NSString *)fontName fontSize:(NSNumber *)fontSize fromDictionary:(NSDictionary *)dictionary {
NSFont *newFont = nil;
NSFont *font = [dictionary objectForKey:NSFontAttributeName];
BOOL newBold = (isBold) ? isBold.intValue : [font isBold];
BOOL newItalic = (isItalic) ? isItalic.intValue : [font isItalic];
CGFloat newFontSize = (fontSize) ? fontSize.floatValue : font.pointSize;
if (fontName) {
newFont = [NSFont fontWithName:fontName size:newFontSize boldTrait:newBold italicTrait:newItalic];
}
else {
newFont = [font fontWithBoldTrait:newBold italicTrait:newItalic andSize:newFontSize];
}
return newFont;
}
/**
* Does not allow cursor to be right beside the bullet point. This method also does not allow selection of the bullet point itself.
* It uses the previousCursorPosition property to save the previous cursor location.
*
* @param beginRange The beginning position of the paragraph
* @param previousRange The previous cursor position before the new change. Only used for keyboard change events
* @param currentRange The current cursor position after the new change
* @param isMouseClick A boolean to check whether the requested change is a mouse event or a keyboard event
*/
- (NSRange)adjustSelectedRangeForBulletsWithStart:(NSRange)beginRange Previous:(NSRange)previousRange andCurrent:(NSRange)currentRange isMouseClick:(BOOL)isMouseClick {
NSUInteger previous = self.previousCursorPosition;
NSUInteger begin = beginRange.location;
NSUInteger current = currentRange.location;
NSRange finalRange = currentRange;
if (self.justDeletedBackward) {
return finalRange;
}
BOOL currentParagraphHasBulletInFront = [[self.string substringFromIndex:begin] hasPrefix:self.BULLET_STRING];
if (currentParagraphHasBulletInFront) {
if (!isMouseClick && (current == begin + 1)) { // select bullet point when using keyboard arrow keys
if (previousRange.location > current) {
finalRange = NSMakeRange(begin, currentRange.length + 1);
}
else if (previousRange.location < current) {
finalRange = NSMakeRange(current + 1, currentRange.length - 1);
}
}
else {
if ((current == begin && (previous > current || previous < current)) ||
(current == (begin + 1) && (previous < current || current == previous))) { // cursor moved from in bullet to front of bullet
finalRange = currentRange.length >= 1 ? NSMakeRange(begin, finalRange.length + 1) : NSMakeRange(begin + 2, 0);
}
else if (current == (begin + 1) && previous > current) { // cursor moved from in bullet to beside of bullet
BOOL isNewLocationValid = (begin - 1) > [self.string length] ? NO : YES;
finalRange = currentRange.length >= 1 ? NSMakeRange(begin, finalRange.length + 1) : NSMakeRange(isNewLocationValid ? begin - 1 : begin + 2, 0);
}
else if ((current == begin) && (begin == previous) && isMouseClick) {
finalRange = currentRange.length >= 1 ? NSMakeRange(begin, finalRange.length + 1) : NSMakeRange(begin + 2, 0);
}
}
}
if (currentRange.location > self.string.length || currentRange.location + currentRange.length > self.string.length) {
// select the very end of the string.
// there was a crash report that had an out of range error. Couldn't replicate, so trying
// to avoid future crashes.
return NSMakeRange(self.string.length, 0);
}
NSRange endingStringRange = [[self.string substringWithRange:currentRange] rangeOfString:@"\n\u2022" options:NSBackwardsSearch];
NSUInteger currentRangeAddedProperties = currentRange.location + currentRange.length;
NSUInteger previousRangeAddedProperties = previousRange.location + previousRange.length;
BOOL currentParagraphHasBulletAtTheEnd = (endingStringRange.length + endingStringRange.location + currentRange.location) == currentRangeAddedProperties;
if (currentParagraphHasBulletAtTheEnd) {
if (isMouseClick) {
if (previousRange.length > current) {
finalRange = NSMakeRange(current, currentRange.length + 1);
}
else if (previousRange.length < current) {
finalRange = NSMakeRange(current, currentRange.length - 1);
}
}
else {
if (previousRangeAddedProperties < currentRangeAddedProperties) {
finalRange = NSMakeRange(current, currentRange.length + 1);
}
else if (previousRangeAddedProperties > currentRangeAddedProperties) {
finalRange = NSMakeRange(current, currentRange.length - 1);
}
}
}
self.previousCursorPosition = finalRange.location;
return finalRange;
}
- (void)applyBulletListIfApplicable {
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
if (rangeOfCurrentParagraph.location == 0) {
return; // there isn't a previous paragraph, so forget it. The user isn't in a bulleted list.
}
NSRange rangeOfPreviousParagraph = [self.attributedString firstParagraphRangeFromTextRange:NSMakeRange(rangeOfCurrentParagraph.location - 1, 0)];
//self.replacementString
BOOL previousParagraphHasBullet = [[self.string
substringFromIndex:rangeOfPreviousParagraph.location] hasPrefix:self.BULLET_STRING];
if (!self.inBulletedList) { // fixes issue with backspacing into bullet list adding a bullet
//NSLog(@"[RTE] NOT in a bulleted list.");
BOOL currentParagraphHasBullet = [[self.string substringFromIndex:rangeOfCurrentParagraph.location]
hasPrefix:self.BULLET_STRING];
BOOL isCurrParaBlank = [[self.string substringWithRange:rangeOfCurrentParagraph] isEqualToString:@""];
// if we don't check to see if the current paragraph is blank, bad bugs happen with
// the current paragraph where the selected range doesn't let the user type O_o
if (previousParagraphHasBullet && !currentParagraphHasBullet && isCurrParaBlank) {
// Fix the indentation. Here is the use case for this code:
/*
---
• bullet
|
---
Where | is the cursor on a blank line. User hits backspace. Without fixing the
indentation, the cursor ends up indented at the same indentation as the bullet.
*/
NSDictionary *dictionary = [self dictionaryAtIndex:rangeOfCurrentParagraph.location];
NSMutableParagraphStyle *paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
paragraphStyle.firstLineHeadIndent = 0;
paragraphStyle.headIndent = 0;
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:rangeOfCurrentParagraph];
[self setIndentationWithAttributes:dictionary paragraphStyle:paragraphStyle atRange:rangeOfCurrentParagraph];
}
return;
}
if (rangeOfCurrentParagraph.length != 0 && !(previousParagraphHasBullet && [self.latestReplacementString isEqualToString:@"\n"])) {
return;
}
if (!self.justDeletedBackward && [[self.string substringFromIndex:rangeOfPreviousParagraph.location] hasPrefix:self.BULLET_STRING]) {
[self userSelectedBullet];
}
}
- (void)removeBulletIndentation:(NSRange)firstParagraphRange {
NSRange rangeOfParagraph = [self.attributedString firstParagraphRangeFromTextRange:firstParagraphRange];
NSDictionary *dictionary = [self dictionaryAtIndex:rangeOfParagraph.location];
NSMutableParagraphStyle *paragraphStyle = [[dictionary objectForKey:NSParagraphStyleAttributeName] mutableCopy];
paragraphStyle.firstLineHeadIndent = 0;
paragraphStyle.headIndent = 0;
[self applyAttributes:paragraphStyle forKey:NSParagraphStyleAttributeName atRange:rangeOfParagraph];
[self setIndentationWithAttributes:dictionary paragraphStyle:paragraphStyle atRange:firstParagraphRange];
}
- (void)deleteBulletListWhenApplicable {
NSRange range = self.selectedRange;
// TODO: Clean up this code since a lot of it is "repeated"
if (range.location > 0) {
NSString *checkString = self.BULLET_STRING;
if (checkString.length > 1) {
// chop off last letter and use that
checkString = [checkString substringToIndex:checkString.length - 1];
}
//else return;
NSUInteger checkStringLength = [checkString length];
if (![self.string isEqualToString:self.BULLET_STRING]) {
if (((int)(range.location-checkStringLength) >= 0 &&
[[self.string substringFromIndex:range.location-checkStringLength] hasPrefix:checkString])) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeBullet];
//NSLog(@"[RTE] Getting rid of a bullet due to backspace while in empty bullet paragraph.");
// Get rid of bullet string
[self.textStorage deleteCharactersInRange:NSMakeRange(range.location-checkStringLength, checkStringLength)];
NSRange newRange = NSMakeRange(range.location-checkStringLength, 0);
self.selectedRange = newRange;
// Get rid of bullet indentation
[self removeBulletIndentation:newRange];
}
else {
// User may be needing to get out of a bulleted list due to hitting enter (return)
NSRange rangeOfCurrentParagraph = [self.attributedString firstParagraphRangeFromTextRange:self.selectedRange];
NSString *currentParagraphString = [self.string substringWithRange:rangeOfCurrentParagraph];
NSInteger prevParaLocation = rangeOfCurrentParagraph.location-1;
// [currentParagraphString isEqualToString:self.BULLET_STRING] ==> "is the current paragraph an empty bulleted list item?"
if (prevParaLocation >= 0 && [currentParagraphString isEqualToString:self.BULLET_STRING]) {
NSRange rangeOfPreviousParagraph = [self.attributedString firstParagraphRangeFromTextRange:NSMakeRange(rangeOfCurrentParagraph.location-1, 0)];
// If the following if statement is true, the user hit enter on a blank bullet list
// Basically, there is now a bullet ' ' \n bullet ' ' that we need to delete (' ' == space)
// Since it gets here AFTER it adds a new bullet
if ([[self.string substringWithRange:rangeOfPreviousParagraph] hasSuffix:self.BULLET_STRING]) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeBullet];
//NSLog(@"[RTE] Getting rid of bullets due to user hitting enter.");
NSRange rangeToDelete = NSMakeRange(rangeOfPreviousParagraph.location, rangeOfPreviousParagraph.length+rangeOfCurrentParagraph.length+1);
[self.textStorage deleteCharactersInRange:rangeToDelete];
NSRange newRange = NSMakeRange(rangeOfPreviousParagraph.location, 0);
self.selectedRange = newRange;
// Get rid of bullet indentation
[self removeBulletIndentation:newRange];
}
}
}
}
}
}
- (void)mouseDown:(NSEvent *)theEvent {
_lastSingleKeyPressed = 0;
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeMouseDown];
[super mouseDown:theEvent];
}
- (NSString*)bulletString {
return self.BULLET_STRING;
}
+ (NSString *)convertPreviewChangeTypeToString:(RichTextEditorPreviewChange)changeType withNonSpecialChangeText:(BOOL)shouldReturnStringForNonSpecialType {
switch (changeType) {
case RichTextEditorPreviewChangeBold:
return NSLocalizedString(@"Bold", @"");
case RichTextEditorPreviewChangeCut:
return NSLocalizedString(@"Cut", @"");
case RichTextEditorPreviewChangePaste:
return NSLocalizedString(@"Paste", @"");
case RichTextEditorPreviewChangeBullet:
return NSLocalizedString(@"Bulleted List", @"");
case RichTextEditorPreviewChangeItalic:
return NSLocalizedString(@"Italic", @"");
case RichTextEditorPreviewChangeFontResize:
case RichTextEditorPreviewChangeFontSize:
return NSLocalizedString(@"Font Resize", @"");
case RichTextEditorPreviewChangeFontColor:
return NSLocalizedString(@"Font Color", @"");
case RichTextEditorPreviewChangeHighlight:
return NSLocalizedString(@"Text Highlight", @"");
case RichTextEditorPreviewChangeUnderline:
return NSLocalizedString(@"Underline", @"");
case RichTextEditorPreviewChangeIndentDecrease:
case RichTextEditorPreviewChangeIndentIncrease:
return NSLocalizedString(@"Text Indent", @"");
case RichTextEditorPreviewChangeKeyDown:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Key Down", @"");
break;
case RichTextEditorPreviewChangeEnter:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Enter [Return] Key", @"");
break;
case RichTextEditorPreviewChangeSpace:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Space", @"");
break;
case RichTextEditorPreviewChangeDelete:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Delete", @"");
break;
case RichTextEditorPreviewChangeArrowKey:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Arrow Key Movement", @"");
break;
case RichTextEditorPreviewChangeMouseDown:
if (shouldReturnStringForNonSpecialType)
return NSLocalizedString(@"Mouse Down", @"");
break;
case RichTextEditorPreviewChangeFindReplace:
return NSLocalizedString(@"Find & Replace", @"");
default:
break;
}
return @"";
}
#pragma mark - Keyboard Shortcuts
// http://stackoverflow.com/questions/970707/cocoa-keyboard-shortcuts-in-dialog-without-an-edit-menu
- (void)keyDown:(NSEvent*)event {
NSString *key = event.charactersIgnoringModifiers;
if (key.length > 0) {
NSUInteger enabledShortcuts = RichTextEditorShortcutAll;
if (self.rteDataSource && [self.rteDataSource respondsToSelector:@selector(enabledKeyboardShortcuts)]) {
enabledShortcuts = [self.rteDataSource enabledKeyboardShortcuts];
}
unichar keyChar = 0;
bool shiftKeyDown = event.modifierFlags & NSShiftKeyMask;
bool commandKeyDown = event.modifierFlags & NSCommandKeyMask;
keyChar = [key characterAtIndex:0];
_lastSingleKeyPressed = keyChar;
if (keyChar == NSLeftArrowFunctionKey || keyChar == NSRightArrowFunctionKey ||
keyChar == NSUpArrowFunctionKey || keyChar == NSDownArrowFunctionKey) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeArrowKey];
[super keyDown:event];
}
else if ((keyChar == 'b' || keyChar == 'B') && commandKeyDown && !shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutBold)) {
[self userSelectedBold];
}
else if ((keyChar == 'i' || keyChar == 'I') && commandKeyDown && !shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutItalic)) {
[self userSelectedItalic];
}
else if ((keyChar == 'u' || keyChar == 'U') && commandKeyDown && !shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutUnderline)) {
[self userSelectedUnderline];
}
else if (keyChar == '>' && shiftKeyDown && commandKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutIncreaseFontSize)) {
[self increaseFontSize];
}
else if (keyChar == '<' && shiftKeyDown && commandKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutDecreaseFontSize)) {
[self decreaseFontSize];
}
else if (keyChar == 'L' && shiftKeyDown && commandKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutBulletedList)) {
[self userSelectedBullet];
}
else if (keyChar == 'N' && shiftKeyDown && commandKeyDown && [self isInBulletedList] &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutLeaveBulletedList)) {
[self userSelectedBullet];
}
else if (keyChar == 'T' && shiftKeyDown && commandKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutDecreaseIndent)) {
[self userSelectedDecreaseIndent];
}
else if (keyChar == 't' && commandKeyDown && !shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutIncreaseIndent)) {
[self userSelectedIncreaseIndent];
}
else if (self.tabKeyAlwaysIndentsOutdents && keyChar == '\t' && !commandKeyDown && !shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutIncreaseIndent)) {
[self userSelectedIncreaseIndent];
}
else if (self.tabKeyAlwaysIndentsOutdents && (keyChar == '\t' || keyChar == 25) && !commandKeyDown && shiftKeyDown &&
(enabledShortcuts == RichTextEditorShortcutAll || enabledShortcuts & RichTextEditorShortcutIncreaseIndent)) {
[self userSelectedDecreaseIndent];
}
else if (!([self.rteDelegate respondsToSelector:@selector(richTextEditor:keyDownEvent:)] && [self.rteDelegate richTextEditor:self keyDownEvent:event])) {
[self sendDelegatePreviewChangeOfType:RichTextEditorPreviewChangeKeyDown];
[super keyDown:event];
}
}
else {
[super keyDown:event];
}
}
@end