4
votes

I have a fairly complicated NSTextView subclass in one of my projects. I'm currently working on getting find/replace to work with an inline find bar (e.g. Safari, Xcode) and want to properly support undo/redo for the replace operations.

I want the Replace All command to support undo as a single command (i.e. if there are 8 replacements to be made in the text view, it should also undo those 8 replacements at once).

I'm wondering if there is a counterpart to shouldChangeTextInRanges:replaceStrings: that I can call after checking to do the replacement. I expected there would be a replaceCharactersInRanges:withStrings: or something similar, but there doesn't seem to be.

The only way I can think to do this at the moment is to check with a call to shouldChangeTextInRanges:replaceStrings: first, then call replaceCharactersInRange:withString: with the entire range of the text view and the new string (with the replacements made) as the second argument.

This just seems unnecessary, I don't really want to replace the entire string if I don't have to. Any ideas?

2

2 Answers

4
votes

After some tinkering I think I've got this figured out. Josh, I used your suggestion to get started. I'm not sure if you edited your suggestion or just deleted it, but it's gone so I can't quote it in my answer.

Anyways, you have to shift the ranges you are going to replace after each invocation of replaceCharactersInRange:withString: or else bad things happen as the ranges don't match up. Here is what I ended up with:

// array of NSValue objects storing an NSRange
NSArray *replaceRanges = [self replaceRanges];
NSString *replaceString = [self replaceString];
// array of NSString objects you are going to use for the replace operation, just replaceString repeated [replaceRanges count] times
NSArray *replaceStrings = [self replaceStrings];
NSTextView *textView = [self textView];
// the amount we have to shift subequent replace ranges after each call to replaceCharactersInRange:withString:
NSInteger locationShift = 0;

// check to makes sure the replace can occur
if ([textView shouldChangeTextInRanges:replaceRanges replacementStrings:replaceStrings]) {
    // we want to treat all these replacements as a single replacement for undo purposes
    [[textView textStorage] beginEditing];

    for (NSValue *rangeValue in replaceRanges) {
        NSRange range = [rangeValue rangeValue];

        // replace the range shifted by locationShift with our replaceString
        [[textView textStorage] replaceCharactersInRange:NSMakeRange(range.location + locationShift, range.length) withString:replaceString];

        // update the shift amount, which is the difference between our replaced string length and the original match length
        locationShift += [replaceString length] - range.length;
    }
    // end the grouping operation
    [[textView textStorage] endEditing];
}

This works great and supports undo as expected, undoing this operation results in all the replacements being undone at once.

Anyways thanks to Josh, as his answer got me pointed in the right direction.

0
votes

I'm surprised the undo for that isn't grouped automatically. However, you can do manual undo grouping; you'll have to set up the inverse actions yourself. Hopefully this will point you in the right direction:

- (BOOL)shouldChangeTextInRanges:(NSArray *)affectedRanges replacementStrings:(NSArray *)replacementStrings {

    NSUndoManager * undoMan = [self undoManager];
    [undoMan beginUndoGrouping];
    NSEnumerator stringEnumerator = [replacementStrings objectEnumerator];
    for( NSRange thisRange in affectedRanges ){
        NSString * thisString = [stringEnumerator nextObject];
        NSTextStorage * textStore = [self textStorage];
        [[undoMan prepareWithInvocationTarget:textStore] 
                 replaceCharactersInRange:thisRange 
                               withString:[textStore attributedSubstringFromRange:thisRange]];

        [textStore replaceCharactersInRange:thisRange withString:thisString];
    }
    [undoMan endUndoGrouping];
    [undoMan setActionName:@"Replace All"];

    return NO; // because we just did it by hand
}