#import "CHTextView.h" @implementation CHTextView #pragma mark #pragma mark Modify the Contextual Menu /* Modify the pre-existing contextual menu to offer more table commands. */ - (NSMenu *) menuForEvent:(NSEvent *)theEvent { // Store this so we know where the click happened if (lastMenuEvent != nil) { [lastMenuEvent release]; } lastMenuEvent = [theEvent retain]; // Get the menu that would normally exist without our meddling NSMenu *originalMenu = [super menuForEvent:theEvent]; // Determine if the mouse click is above a table NSTextTableBlock *overCell = [self clickedCell]; if (overCell != nil) { [originalMenu insertItem:[NSMenuItem separatorItem] atIndex:2]; if ([overCell columnSpan] > 1) { [originalMenu insertItemWithTitle:@"Delete Columns" action:@selector(deleteColumn:) keyEquivalent:@"" atIndex:2]; } else { [originalMenu insertItemWithTitle:@"Delete Column" action:@selector(deleteColumn:) keyEquivalent:@"" atIndex:2]; } [originalMenu insertItemWithTitle:@"Delete Row" action:@selector(deleteRow:) keyEquivalent:@"" atIndex:2]; [originalMenu insertItem:[NSMenuItem separatorItem] atIndex:2]; [originalMenu insertItemWithTitle:@"Add Column After" action:@selector(addColumnAfter:) keyEquivalent:@"" atIndex:2]; [originalMenu insertItemWithTitle:@"Add Column Before" action:@selector(addColumnBefore:) keyEquivalent:@"" atIndex:2]; [originalMenu insertItem:[NSMenuItem separatorItem] atIndex:2]; [originalMenu insertItemWithTitle:@"Add Row Below" action:@selector(addRowBelow:) keyEquivalent:@"" atIndex:2]; [originalMenu insertItemWithTitle:@"Add Row Above" action:@selector(addRowAbove:) keyEquivalent:@"" atIndex:2]; } return originalMenu; } #pragma mark #pragma mark Finding The Clicked Area /* Return the NSTextTableBlock that was clicked during the last right click. */ - (NSTextTableBlock *) clickedCell { NSTextTableBlock *clickedCell = [self tableCellAtIndex:[self clickedIndex] forAttributedString:[self textStorage]]; return clickedCell; } /* Returns the index of the textview that's nearest to the last right click. */ - (int) clickedIndex { NSPoint originalPoint = [lastMenuEvent locationInWindow]; NSPoint translatedPoint = [self convertPoint:originalPoint fromView:nil]; int clickedIndex = [self characterIndexForInsertionAtPoint:translatedPoint]; return clickedIndex; } /* Returns the first NSTextTableBlock attached to the character at a given index in a given attributed string. */ - (NSTextTableBlock *) tableCellAtIndex:(int)index forAttributedString:(NSAttributedString *)string { if ([string length] <= index) { return nil; } NSDictionary *rulerAttributesAtIndex = [string rulerAttributesInRange:NSMakeRange(index, 0)]; NSParagraphStyle *paragraphStyleAtIndex = [rulerAttributesAtIndex objectForKey:@"NSParagraphStyle"]; if ([paragraphStyleAtIndex textBlocks] != nil) { if ([[paragraphStyleAtIndex textBlocks] objectAtIndex:0] != nil) { if ([[[paragraphStyleAtIndex textBlocks] objectAtIndex:0] isMemberOfClass:[NSTextTableBlock class]]) { NSTextTableBlock *cell = [[paragraphStyleAtIndex textBlocks] objectAtIndex:0]; return cell; } } } return nil; } #pragma mark #pragma mark Adding and Deleting Rows /* Creates a new empty row above the clicked row. Uses the clicked row as a template for the new row. */ - (IBAction) addRowAbove:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; int clickedRow = [[self clickedCell] startingRow]; NSMutableArray *duplicatedRow = [NSMutableArray array]; for (NSMutableAttributedString *cell in [table objectAtIndex:clickedRow]) { NSMutableAttributedString *newCell = [[[NSMutableAttributedString alloc] initWithAttributedString:cell] autorelease]; [newCell replaceCharactersInRange:NSMakeRange(0, [[newCell string] length]) withString:@"\n"]; [duplicatedRow addObject:newCell]; } [table insertObject:duplicatedRow atIndex:clickedRow]; [self replaceClickedArrayWithTableFromArray:table]; } /* Creates a new empty row below the clicked row. Uses the clicked row as a template for the new row. */ - (IBAction) addRowBelow:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; int clickedRow = [[self clickedCell] startingRow]; NSMutableArray *duplicatedRow = [NSMutableArray array]; for (NSMutableAttributedString *cell in [table objectAtIndex:clickedRow]) { NSMutableAttributedString *newCell = [[[NSMutableAttributedString alloc] initWithAttributedString:cell] autorelease]; [newCell replaceCharactersInRange:NSMakeRange(0, [[newCell string] length]) withString:@"\n"]; [duplicatedRow addObject:newCell]; } [table insertObject:duplicatedRow atIndex:clickedRow+1]; [self replaceClickedArrayWithTableFromArray:table]; } /* Removes the clicked row from the table. */ - (IBAction) deleteRow:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; int clickedRow = [[self clickedCell] startingRow]; [table removeObjectAtIndex:clickedRow]; [self replaceClickedArrayWithTableFromArray:table]; } #pragma mark #pragma mark Adding and Deleting Columns /* Creates a new empty column to the left of the clicked column. Uses the clicked column as a template for the new column. */ - (IBAction) addColumnBefore:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; int clickedColumn = [[self clickedCell] startingColumn]; NSAttributedString *cellMatch = nil; for (NSMutableArray *row in table) { NSAttributedString *newString = nil; for (NSMutableAttributedString *cell in row) { NSTextTableBlock *tableCell = [self tableCellAtIndex:0 forAttributedString:cell]; if ([tableCell startingColumn] == clickedColumn) { // Add in a new cell right before this one cellMatch = cell; NSMutableAttributedString *newCell = [[[NSMutableAttributedString alloc] initWithString:@"\n"] autorelease]; NSTextTableBlock *newBlock = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:1 columnSpan:1] autorelease]; newBlock = [self textTableBlock:newBlock cloningAttributesFrom:tableCell]; [newBlock setContentWidth:75 type:NSTextBlockAbsoluteValueType]; // Arbitrary default width newString = [self setTableCell:newBlock forAttributedString:newCell]; } else { if (clickedColumn > [tableCell startingColumn] && clickedColumn < [tableCell startingColumn] + [tableCell columnSpan]) { // If the column should be added _within_ this column, just increment it's column span NSTextTableBlock *modifiedCell = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:[tableCell startingColumn] columnSpan:[tableCell columnSpan] + 1] autorelease]; modifiedCell = [self textTableBlock:modifiedCell cloningAttributesFrom:tableCell]; double newContentWidth = [modifiedCell contentWidth]; newContentWidth += 75; // Arbitrary default width newContentWidth += [modifiedCell widthForLayer:NSTextBlockPadding edge:NSMinXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockPadding edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthValueTypeForLayer:NSTextBlockBorder edge:NSMinXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockMargin edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockMargin edge:NSMinXEdge]; [modifiedCell setContentWidth:newContentWidth type:[modifiedCell contentWidthValueType]]; NSAttributedString *modifiedCellString = [self setTableCell:modifiedCell forAttributedString:cell]; [cell setAttributedString:modifiedCellString]; } } } if (newString != nil) { [row insertObject:newString atIndex:[row indexOfObject:cellMatch]]; } } [self replaceClickedArrayWithTableFromArray:table]; } /* Creates a new empty column to the right of the clicked column. Uses the clicked column as a template for the new column. */ - (IBAction) addColumnAfter:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; int clickedColumn = [[self clickedCell] startingColumn]; NSAttributedString *cellMatch = nil; for (NSMutableArray *row in table) { NSAttributedString *newString = nil; for (NSMutableAttributedString *cell in row) { NSTextTableBlock *tableCell = [self tableCellAtIndex:0 forAttributedString:cell]; if ([tableCell startingColumn]+[tableCell columnSpan] == clickedColumn+[[self clickedCell] columnSpan]) { // Add in a new cell right after this one cellMatch = cell; NSMutableAttributedString *newCell = [[[NSMutableAttributedString alloc] initWithString:@"\n"] autorelease]; NSTextTableBlock *newBlock = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:1 columnSpan:1] autorelease]; newBlock = [self textTableBlock:newBlock cloningAttributesFrom:tableCell]; [newBlock setContentWidth:75 type:NSTextBlockAbsoluteValueType]; // Arbitrary default width newString = [self setTableCell:newBlock forAttributedString:newCell]; } else { if (clickedColumn+[[self clickedCell] columnSpan] > [tableCell startingColumn] && clickedColumn+[[self clickedCell] columnSpan] < [tableCell startingColumn] + [tableCell columnSpan]) { // If the column should be added _within_ this column, just increment it's column span NSTextTableBlock *modifiedCell = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:[tableCell startingColumn] columnSpan:[tableCell columnSpan] + 1] autorelease]; modifiedCell = [self textTableBlock:modifiedCell cloningAttributesFrom:tableCell]; double newContentWidth = [modifiedCell contentWidth]; newContentWidth += 75; // Arbitrary default width newContentWidth += [modifiedCell widthForLayer:NSTextBlockPadding edge:NSMinXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockPadding edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthValueTypeForLayer:NSTextBlockBorder edge:NSMinXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockMargin edge:NSMaxXEdge]; newContentWidth += [modifiedCell widthForLayer:NSTextBlockMargin edge:NSMinXEdge]; [modifiedCell setContentWidth:newContentWidth type:[modifiedCell contentWidthValueType]]; NSAttributedString *modifiedCellString = [self setTableCell:modifiedCell forAttributedString:cell]; [cell setAttributedString:modifiedCellString]; } } } if (newString != nil) { [row insertObject:newString atIndex:[row indexOfObject:cellMatch]+1]; } } [self replaceClickedArrayWithTableFromArray:table]; } /* Removes the clicked column from the table. */ - (IBAction) deleteColumn:(id)sender { NSMutableArray *table = [self clickedTableAsArray]; NSMutableArray *clickedColumns = [NSMutableArray array]; int i=0; for (i=0; i<[[self clickedCell] columnSpan]; i++) { [clickedColumns addObject:[NSNumber numberWithInt:(i+[[self clickedCell] startingColumn])]]; } for (NSMutableArray *row in table) { NSMutableArray *removableCells = [NSMutableArray array]; for (NSMutableAttributedString *cell in row) { NSTextTableBlock *tableCell = [self tableCellAtIndex:0 forAttributedString:cell]; NSMutableArray *cellColumns = [NSMutableArray array]; int j=0; for (j=0; j<[tableCell columnSpan]; j++) { [cellColumns addObject:[NSNumber numberWithInt:(j+[tableCell startingColumn])]]; } int numberOfIntersectingColumns = 0; for (NSNumber *cellColumn in cellColumns) { if ([clickedColumns containsObject:cellColumn]) { numberOfIntersectingColumns++; } } if (numberOfIntersectingColumns > 0) { // Does this cell intersect the clicked columns? if ([tableCell startingColumn] < [[self clickedCell] startingColumn]) { // Intersection caused by extension of a cell into the intersecting area // The starting column is still valid, but the column span must be adjusted to accomodate the removed columns NSTextTableBlock *modifiedCell = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:[tableCell startingColumn] columnSpan:[tableCell columnSpan] - numberOfIntersectingColumns] autorelease]; modifiedCell = [self textTableBlock:modifiedCell cloningAttributesFrom:tableCell]; double newContentWidth = [modifiedCell contentWidth]; newContentWidth -= [[self clickedCell] contentWidth]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockPadding edge:NSMinXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockPadding edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthValueTypeForLayer:NSTextBlockBorder edge:NSMinXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockMargin edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockMargin edge:NSMinXEdge]; [modifiedCell setContentWidth:newContentWidth type:[modifiedCell contentWidthValueType]]; NSAttributedString *modifiedCellString = [self setTableCell:modifiedCell forAttributedString:cell]; [cell setAttributedString:modifiedCellString]; } else { // Intersection caused by a cell in the area if (numberOfIntersectingColumns == [tableCell columnSpan]) { // is the cell completely within the clicked columns? Remove it [removableCells addObject:cell]; } else { // otherwise move the start of the cell to the end of the clicked columns and shrink it appropriately NSTextTableBlock *modifiedCell = [[[NSTextTableBlock alloc] initWithTable:[tableCell table] startingRow:[tableCell startingRow] rowSpan:[tableCell rowSpan] startingColumn:[tableCell startingColumn] + numberOfIntersectingColumns columnSpan:[tableCell columnSpan] - numberOfIntersectingColumns] autorelease]; modifiedCell = [self textTableBlock:modifiedCell cloningAttributesFrom:tableCell]; double newContentWidth = [modifiedCell contentWidth]; newContentWidth -= [[self clickedCell] contentWidth]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockPadding edge:NSMinXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockPadding edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthValueTypeForLayer:NSTextBlockBorder edge:NSMinXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockMargin edge:NSMaxXEdge]; newContentWidth -= [[self clickedCell] widthForLayer:NSTextBlockMargin edge:NSMinXEdge]; [modifiedCell setContentWidth:newContentWidth type:[modifiedCell contentWidthValueType]]; NSAttributedString *modifiedCellString = [self setTableCell:modifiedCell forAttributedString:cell]; [cell setAttributedString:modifiedCellString]; } } } } [row removeObjectsInArray:removableCells]; } [self replaceClickedArrayWithTableFromArray:table]; } #pragma mark #pragma mark Modeling the Table as an Array /* Creates a two dimensional array that mirrors the structure of the table. It's far easier to just manipulate this and then rebuild an equivalent table instead of kicking around the NSAttributedString that is the table. */ - (NSMutableArray *) clickedTableAsArray { NSMutableArray *translatedArray = [NSMutableArray array]; // Find the complete attributed string which represents the table int clickedIndex = [self clickedIndex]; NSTextTableBlock *overCell = [self clickedCell]; NSRange originalTableRange = [[self textStorage] rangeOfTextTable:[overCell table] atIndex:clickedIndex]; NSAttributedString *originalTable = [[self textStorage] attributedSubstringFromRange:originalTableRange]; int searchIndex = 0; int currentRow = -1; int currentColumn = -1; while (searchIndex < [originalTable length]) { // Row Change if (currentRow != [[self tableCellAtIndex:searchIndex forAttributedString:originalTable] startingRow]) { // We've entered a new row [translatedArray addObject:[NSMutableArray array]]; currentColumn = -1; } currentRow = [[self tableCellAtIndex:searchIndex forAttributedString:originalTable] startingRow]; // Column Change if (currentColumn != [[self tableCellAtIndex:searchIndex forAttributedString:originalTable] startingColumn]) { // We've entered a new row [[translatedArray lastObject] addObject:[[[NSMutableAttributedString alloc] initWithString:@""] autorelease]]; } currentColumn = [[self tableCellAtIndex:searchIndex forAttributedString:originalTable] startingColumn]; NSAttributedString *searchCharacter = [originalTable attributedSubstringFromRange:NSMakeRange(searchIndex, 1)]; [[[translatedArray lastObject] lastObject] appendAttributedString:searchCharacter]; searchIndex++; } return translatedArray; } /* Rebuilds a table using the tableArray and replaces the table that we clicked in using the rebuilt table. */ - (void) replaceClickedArrayWithTableFromArray:(NSArray *)tableArray { // Generate an attributed string representing the table int clickedIndex = [self clickedIndex]; NSTextTableBlock *overCell = [self clickedCell]; NSRange originalTableRange = [[self textStorage] rangeOfTextTable:[overCell table] atIndex:clickedIndex]; NSMutableAttributedString *newTable = [[[NSMutableAttributedString alloc] initWithString:@""] autorelease]; int rowNumber = 0; int columnNumber = 0; for (NSArray *row in tableArray) { for (NSAttributedString *cell in row) { NSTextTableBlock *originalBlock = [self tableCellAtIndex:0 forAttributedString:cell]; NSTextTableBlock *modifiedBlock = [[[NSTextTableBlock alloc] initWithTable:[overCell table] startingRow:rowNumber rowSpan:[originalBlock rowSpan] startingColumn:columnNumber columnSpan:[originalBlock columnSpan]] autorelease]; modifiedBlock = [self textTableBlock:modifiedBlock cloningAttributesFrom:originalBlock]; NSAttributedString *newCell = [self setTableCell:modifiedBlock forAttributedString:cell]; [newTable appendAttributedString:newCell]; columnNumber = columnNumber + [originalBlock columnSpan]; } rowNumber++; [[overCell table] setNumberOfColumns:columnNumber]; columnNumber = 0; } // Replace the original table with the new table [[self textStorage] replaceCharactersInRange:originalTableRange withAttributedString:newTable]; } #pragma mark #pragma mark Working with NSTextTableBlocks /* I can't help but think there has to be a better way to do this. */ - (NSTextTableBlock *) textTableBlock:(NSTextTableBlock *)block cloningAttributesFrom:(NSTextTableBlock *)otherBlock { [block setVerticalAlignment:[otherBlock verticalAlignment]]; [block setBackgroundColor:[otherBlock backgroundColor]]; [block setContentWidth:[otherBlock contentWidth] type:[otherBlock contentWidthValueType]]; [block setBorderColor:[otherBlock borderColorForEdge:NSMinXEdge] forEdge:NSMinXEdge]; [block setBorderColor:[otherBlock borderColorForEdge:NSMinYEdge] forEdge:NSMinYEdge]; [block setBorderColor:[otherBlock borderColorForEdge:NSMaxXEdge] forEdge:NSMaxXEdge]; [block setBorderColor:[otherBlock borderColorForEdge:NSMaxYEdge] forEdge:NSMaxYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockPadding edge:NSMinXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockPadding edge:NSMinXEdge] forLayer:NSTextBlockPadding edge:NSMinXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockPadding edge:NSMinYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockPadding edge:NSMinYEdge] forLayer:NSTextBlockPadding edge:NSMinYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockPadding edge:NSMaxXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockPadding edge:NSMaxXEdge] forLayer:NSTextBlockPadding edge:NSMaxXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockPadding edge:NSMaxYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockPadding edge:NSMaxYEdge] forLayer:NSTextBlockPadding edge:NSMaxYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockBorder edge:NSMinXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockBorder edge:NSMinXEdge] forLayer:NSTextBlockBorder edge:NSMinXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockBorder edge:NSMinYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockBorder edge:NSMinYEdge] forLayer:NSTextBlockBorder edge:NSMinYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockBorder edge:NSMaxXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxXEdge] forLayer:NSTextBlockBorder edge:NSMaxXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockBorder edge:NSMaxYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockBorder edge:NSMaxYEdge] forLayer:NSTextBlockBorder edge:NSMaxYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockMargin edge:NSMinXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockMargin edge:NSMinXEdge] forLayer:NSTextBlockMargin edge:NSMinXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockMargin edge:NSMinYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockMargin edge:NSMinYEdge] forLayer:NSTextBlockMargin edge:NSMinYEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockMargin edge:NSMaxXEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockMargin edge:NSMaxXEdge] forLayer:NSTextBlockMargin edge:NSMaxXEdge]; [block setWidth:[otherBlock widthForLayer:NSTextBlockMargin edge:NSMaxYEdge] type:[otherBlock widthValueTypeForLayer:NSTextBlockMargin edge:NSMaxYEdge] forLayer:NSTextBlockMargin edge:NSMaxYEdge]; return block; } /* Sets an NSTextTableBlock as the text block for a given string. */ - (NSAttributedString *) setTableCell:(NSTextTableBlock *)cell forAttributedString:(NSAttributedString *)string { NSMutableAttributedString *updatedString = [string mutableCopy]; int index = 0; while (index < [updatedString length]) { NSMutableParagraphStyle *style = [[updatedString attribute:@"NSParagraphStyle" atIndex:index effectiveRange:NULL] mutableCopy]; if (style == nil) { style = [[[NSMutableParagraphStyle alloc] init] autorelease]; } [style setTextBlocks:[NSArray arrayWithObject:cell]]; [updatedString removeAttribute:@"NSParagraphStyle" range:NSMakeRange(index, 1)]; [updatedString addAttribute:@"NSParagraphStyle" value:style range:NSMakeRange(index, 1)]; index++; } return updatedString; } @end