├── rsrc ├── InfoPlist.strings ├── url map.plist ├── Info └── Info.plist ├── src ├── Edit in TextMate.h ├── NSTextView: Edit in TextMate.mm ├── Edit in TextMate.mm └── WebView: Edit in TextMate.mm └── Makefile /rsrc/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/textmate/edit-in-textmate/master/rsrc/InfoPlist.strings -------------------------------------------------------------------------------- /rsrc/url map.plist: -------------------------------------------------------------------------------- 1 | { 2 | 'macromates.com/blog/' = 'markdown'; 3 | 'blacktree.cocoaforge.com/forums/' = 'bbcode'; 4 | 'mail.google.com/' = 'mail'; 5 | } -------------------------------------------------------------------------------- /rsrc/Info: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BundleName 6 | Edit in TextMate.bundle 7 | LoadBundleOnLaunch 8 | YES 9 | LocalizedNames 10 | 11 | English 12 | Edit in TextMate 13 | 14 | NoMenuEntry 15 | YES 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Edit in TextMate.h: -------------------------------------------------------------------------------- 1 | // 2 | // Edit in TextMate.h 3 | // 4 | // Created by Allan Odgaard on 2005-11-26. 5 | // See /trunk/LICENSE for license details 6 | // 7 | 8 | #import 9 | 10 | bool debug_enabled (); 11 | 12 | #define D(format, args...) if(debug_enabled()) NSLog(format, ##args); 13 | 14 | @interface EditInTextMate : NSObject 15 | { 16 | } 17 | + (void)externalEditString:(NSString*)aString startingAtLine:(int)aLine forView:(NSView*)aView; 18 | + (void)externalEditString:(NSString*)aString startingAtLine:(int)aLine forView:(NSView*)aView withObject:(NSObject*)anObject; 19 | @end 20 | -------------------------------------------------------------------------------- /rsrc/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleExecutable 8 | Edit in TextMate 9 | CFBundleName 10 | Edit in TextMate 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | com.macromates.edit_in_textmate 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundlePackageType 18 | BNDL 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | NSPrincipalClass 24 | EditInTextMate 25 | 26 | 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME = Edit\ in\ TextMate 2 | DST = /tmp/$(NAME) 3 | DST_BUNDLE = $(DST)/$(NAME).bundle 4 | DST_CONTENTS = $(DST_BUNDLE)/Contents 5 | DST_BIN = $(DST_CONTENTS)/MacOS 6 | DST_RSRC = $(DST_CONTENTS)/Resources 7 | DST_LANG = $(DST_RSRC)/English.lproj 8 | 9 | CFLAGS = -pipe -fPIC -Os -DNDEBUG 10 | CFLAGS += -m32 -mmacosx-version-min=10.5 -isysroot /Developer/SDKs/MacOSX10.5.sdk 11 | CFLAGS += -funsigned-char -fvisibility=hidden 12 | CFLAGS += -DNS_BUILD_32_LIKE_64 13 | CFLAGS += -Wall -Wwrite-strings -Wformat=2 -Winit-self -Wmissing-include-dirs -Wno-parentheses -Wno-sign-compare -Wno-switch 14 | 15 | all: $(DST)/Info $(DST_CONTENTS)/Info.plist $(DST_BIN)/$(NAME) $(DST_LANG)/InfoPlist.strings $(DST_RSRC)/url\ map.plist 16 | 17 | $(DST): ; mkdir '$@' 18 | $(DST_BUNDLE): $(DST); mkdir '$@' 19 | $(DST_CONTENTS): $(DST_BUNDLE); mkdir '$@' 20 | $(DST_BIN): $(DST_CONTENTS); mkdir '$@' 21 | $(DST_RSRC): $(DST_CONTENTS); mkdir '$@' 22 | $(DST_LANG): $(DST_RSRC); mkdir '$@' 23 | 24 | $(DST)/Info: rsrc/Info $(DST); cp '$<' '$@' 25 | $(DST_CONTENTS)/Info.plist: rsrc/Info.plist $(DST_CONTENTS); cp '$<' '$@' 26 | $(DST_LANG)/InfoPlist.strings: rsrc/InfoPlist.strings $(DST_LANG); cp '$<' '$@' 27 | $(DST_RSRC)/url\ map.plist: rsrc/url\ map.plist $(DST_RSRC); cp '$<' '$@' 28 | 29 | $(DST_BIN)/$(NAME): src/Edit\ in\ TextMate.mm $(DST_BIN) 30 | g++ -bundle $(CFLAGS) -o '$@' src/*.mm -framework Cocoa -framework Carbon -framework WebKit 31 | 32 | install: $(DST_BIN)/$(NAME) 33 | cp -pR $(DST) /Library/InputManagers/$(NAME) && chown -R root /Library/InputManagers/$(NAME) 34 | 35 | uninstall: 36 | rm -rf /Library/InputManagers/$(NAME) 37 | 38 | clean: 39 | rm -rf $(DST) 40 | 41 | .PHONY: all clean install uninstall 42 | -------------------------------------------------------------------------------- /src/NSTextView: Edit in TextMate.mm: -------------------------------------------------------------------------------- 1 | // 2 | // NSTextView: Edit in TextMate.mm 3 | // 4 | // Created by Allan Odgaard on 2005-11-27. 5 | // See /trunk/LICENSE for license details 6 | // 7 | 8 | #import "Edit in TextMate.h" 9 | 10 | @interface NSTextView (EditInTextMate) 11 | - (void)editInTextMate:(id)sender; 12 | @end 13 | 14 | @implementation NSTextView (EditInTextMate) 15 | - (void)editInTextMate:(id)sender 16 | { 17 | D(@"editInTextMate: view: %@", self); 18 | if(![self isEditable]) 19 | return (void)NSBeep(); 20 | 21 | NSString* str = [[self textStorage] string]; 22 | NSRange selectedRange = [self selectedRange]; 23 | int lineNumber = 0; 24 | if(selectedRange.length == 0) 25 | { 26 | NSRange range = NSMakeRange(0, 0); 27 | do { 28 | NSRange oldRange = range; 29 | range = [str lineRangeForRange:NSMakeRange(NSMaxRange(range), 0)]; 30 | if(NSMaxRange(oldRange) == NSMaxRange(range) || selectedRange.location < NSMaxRange(range)) 31 | break; 32 | lineNumber++; 33 | } while(true); 34 | selectedRange = NSMakeRange(0, [str length]); 35 | } 36 | D(@"%s editing %u bytes from view: %@", _cmd, [[str substringWithRange:selectedRange] length], self); 37 | [EditInTextMate externalEditString:[str substringWithRange:selectedRange] startingAtLine:lineNumber forView:self]; 38 | } 39 | 40 | - (void)textMateDidModifyString:(NSString*)newString 41 | { 42 | NSLog(@"[%@ textMateDidModifyString:%@]", [self class], newString); 43 | NSRange selectedRange = [self selectedRange]; 44 | BOOL hadSelection = selectedRange.length != 0; 45 | selectedRange = hadSelection ? selectedRange : NSMakeRange(0, [[self textStorage] length]); 46 | if([self shouldChangeTextInRange:selectedRange replacementString:newString]) 47 | { 48 | if(!hadSelection) 49 | [self setSelectedRange:NSMakeRange(0, [[self textStorage] length])]; 50 | [self insertText:newString]; 51 | if(hadSelection) 52 | [self setSelectedRange:NSMakeRange(selectedRange.location, [newString length])]; 53 | [self didChangeText]; 54 | } 55 | else 56 | { 57 | NSBeep(); 58 | NSLog(@"%s couldn't edit text", SELNAME(_cmd)); 59 | } 60 | } 61 | @end 62 | -------------------------------------------------------------------------------- /src/Edit in TextMate.mm: -------------------------------------------------------------------------------- 1 | // 2 | // Edit in TextMate.mm 3 | // 4 | // Created by Allan Odgaard on 2005-11-26. 5 | // See /trunk/LICENSE for license details 6 | // 7 | 8 | #import 9 | #import 10 | #import 11 | #import "Edit in TextMate.h" 12 | 13 | // from ODBEditorSuite.h 14 | #define keyFileSender 'FSnd' 15 | #define kODBEditorSuite 'R*ch' 16 | #define kAEModifiedFile 'FMod' 17 | #define kAEClosedFile 'FCls' 18 | 19 | static NSMutableDictionary* OpenFiles; 20 | static NSMutableSet* FailedFiles; 21 | static NSString* TextMateBundleIdentifier = @"com.macromates.TextMate.preview"; 22 | 23 | #pragma options align=mac68k 24 | struct PBX_SelectionRange 25 | { 26 | short unused1; // 0 (not used) 27 | short lineNum; // line to select (<0 to specify range) 28 | long startRange; // start of selection range (if line < 0) 29 | long endRange; // end of selection range (if line < 0) 30 | long unused2; // 0 (not used) 31 | long theDate; // modification date/time 32 | }; 33 | #pragma options align=reset 34 | 35 | static bool DebugEnabled = false; 36 | bool debug_enabled () { return DebugEnabled; } 37 | 38 | @implementation EditInTextMate 39 | + (void)setODBEventHandlers 40 | { 41 | NSAppleEventManager* eventManager = [NSAppleEventManager sharedAppleEventManager]; 42 | [eventManager setEventHandler:self andSelector:@selector(handleModifiedFileEvent:withReplyEvent:) forEventClass:kODBEditorSuite andEventID:kAEModifiedFile]; 43 | [eventManager setEventHandler:self andSelector:@selector(handleClosedFileEvent:withReplyEvent:) forEventClass:kODBEditorSuite andEventID:kAEClosedFile]; 44 | } 45 | 46 | + (void)removeODBEventHandlers 47 | { 48 | NSAppleEventManager* eventManager = [NSAppleEventManager sharedAppleEventManager]; 49 | [eventManager removeEventHandlerForEventClass:kODBEditorSuite andEventID:kAEModifiedFile]; 50 | [eventManager removeEventHandlerForEventClass:kODBEditorSuite andEventID:kAEClosedFile]; 51 | } 52 | 53 | + (BOOL)launchTextMate 54 | { 55 | NSArray* array = [[NSWorkspace sharedWorkspace] launchedApplications]; 56 | for(unsigned i = [array count]; --i; ) 57 | { 58 | if([[[array objectAtIndex:i] objectForKey:@"NSApplicationBundleIdentifier"] isEqualToString:TextMateBundleIdentifier]) 59 | { 60 | D(@"TextMate already running"); 61 | return YES; 62 | } 63 | } 64 | D(@"TextMate not running, launching it"); 65 | return [[NSWorkspace sharedWorkspace] launchAppWithBundleIdentifier:TextMateBundleIdentifier options:0L additionalEventParamDescriptor:nil launchIdentifier:nil]; 66 | } 67 | 68 | + (void)asyncEditStringWithOptions:(NSDictionary*)someOptions 69 | { 70 | NSAutoreleasePool* pool = [NSAutoreleasePool new]; 71 | D(@"asyncEditStringWithOptions: %@", someOptions); 72 | 73 | if(![self launchTextMate]) 74 | { 75 | D(@"Failed to launch TextMate"); 76 | return; 77 | } 78 | 79 | /* =========== */ 80 | 81 | NSData* targetBundleID = [TextMateBundleIdentifier dataUsingEncoding:NSUTF8StringEncoding]; 82 | NSAppleEventDescriptor* targetDescriptor = [NSAppleEventDescriptor descriptorWithDescriptorType:typeApplicationBundleID data:targetBundleID]; 83 | NSAppleEventDescriptor* appleEvent = [NSAppleEventDescriptor appleEventWithEventClass:kCoreEventClass eventID:kAEOpenDocuments targetDescriptor:targetDescriptor returnID:kAutoGenerateReturnID transactionID:kAnyTransactionID]; 84 | NSAppleEventDescriptor* replyDescriptor = nil; 85 | NSAppleEventDescriptor* errorDescriptor = nil; 86 | AEDesc reply = { typeNull, NULL }; 87 | 88 | NSString* fileName = [someOptions objectForKey:@"fileName"]; 89 | [appleEvent setParamDescriptor:[NSAppleEventDescriptor descriptorWithDescriptorType:typeFileURL data:[[[NSURL fileURLWithPath:fileName] absoluteString] dataUsingEncoding:NSUTF8StringEncoding]] forKeyword:keyDirectObject]; 90 | 91 | UInt32 packageType = 0, packageCreator = 0; 92 | CFBundleGetPackageInfo(CFBundleGetMainBundle(), &packageType, &packageCreator); 93 | if(packageCreator == kUnknownType) 94 | [appleEvent setParamDescriptor:[NSAppleEventDescriptor descriptorWithDescriptorType:typeApplicationBundleID data:[[[NSBundle mainBundle] bundleIdentifier] dataUsingEncoding:NSUTF8StringEncoding]] forKeyword:keyFileSender]; 95 | else [appleEvent setParamDescriptor:[NSAppleEventDescriptor descriptorWithTypeCode:packageCreator] forKeyword:keyFileSender]; 96 | 97 | if(int line = [[someOptions objectForKey:@"line"] intValue]) 98 | { 99 | PBX_SelectionRange pos = { }; 100 | pos.lineNum = line; 101 | [appleEvent setParamDescriptor:[NSAppleEventDescriptor descriptorWithDescriptorType:kUnknownType bytes:&pos length:sizeof(pos)] forKeyword:keyAEPosition]; 102 | } 103 | 104 | OSStatus status = AESend([appleEvent aeDesc], &reply, kAEWaitReply, kAENormalPriority, kAEDefaultTimeout, NULL, NULL); 105 | if(status == noErr) 106 | { 107 | replyDescriptor = [[[NSAppleEventDescriptor alloc] initWithAEDescNoCopy:&reply] autorelease]; 108 | errorDescriptor = [replyDescriptor paramDescriptorForKeyword:keyErrorNumber]; 109 | if(errorDescriptor != nil) 110 | status = [errorDescriptor int32Value]; 111 | 112 | if(status != noErr) 113 | NSLog(@"%s error %d", SELNAME(_cmd), status), NSBeep(); 114 | } 115 | 116 | [pool release]; 117 | } 118 | 119 | + (NSString*)extensionForURL:(NSURL*)anURL 120 | { 121 | NSString* res = nil; 122 | if(NSString* urlString = [anURL absoluteString]) 123 | { 124 | NSString* path = [[NSBundle bundleForClass:[self class]] pathForResource:@"url map" ofType:@"plist"]; 125 | NSMutableDictionary* map = [NSMutableDictionary dictionaryWithContentsOfFile:path]; 126 | 127 | NSString* customBindingsPath = [[NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:@"Preferences/com.macromates.edit_in_textmate.plist"]; 128 | if(NSDictionary* associations = [[NSDictionary dictionaryWithContentsOfFile:customBindingsPath] objectForKey:@"URLAssociations"]) 129 | [map addEntriesFromDictionary:associations]; 130 | 131 | unsigned longestMatch = 0; 132 | NSEnumerator* enumerator = [map keyEnumerator]; 133 | while(NSString* key = [enumerator nextObject]) 134 | { 135 | if([urlString rangeOfString:key].location != NSNotFound && [key length] > longestMatch) 136 | { 137 | res = [map objectForKey:key]; 138 | longestMatch = [key length]; 139 | } 140 | } 141 | } 142 | return res; 143 | } 144 | 145 | + (void)externalEditString:(NSString*)aString startingAtLine:(int)aLine forView:(NSView*)aView 146 | { 147 | [self externalEditString:aString startingAtLine:aLine forView:aView withObject:nil]; 148 | } 149 | 150 | + (void)externalEditString:(NSString*)aString startingAtLine:(int)aLine forView:(NSView*)aView withObject:(NSObject*)anObject 151 | { 152 | Class cl = NSClassFromString(@"WebFrameView"); 153 | 154 | NSURL* url = nil; 155 | for(NSView* view = aView; view && !url && cl; view = [view superview]) 156 | { 157 | if([view isKindOfClass:cl]) 158 | url = [[[[(WebFrameView*)view webFrame] dataSource] mainResource] URL]; 159 | } 160 | 161 | NSString* basename = [[[[aView window] title] componentsSeparatedByString:@"/"] componentsJoinedByString:@"-"] ?: @"untitled"; 162 | NSString* extension = [self extensionForURL:url] ?: [[[[NSWorkspace sharedWorkspace] activeApplication] objectForKey:@"NSApplicationName"] lowercaseString]; 163 | NSString* fileName = [NSString stringWithFormat:@"%@/%@.%@", NSTemporaryDirectory(), basename, extension]; 164 | for(unsigned i = 2; [[NSFileManager defaultManager] fileExistsAtPath:fileName]; i++) 165 | fileName = [NSString stringWithFormat:@"%@/%@ %u.%@", NSTemporaryDirectory(), basename, i, extension]; 166 | 167 | [[aString dataUsingEncoding:NSUTF8StringEncoding] writeToFile:fileName atomically:NO]; 168 | fileName = [fileName stringByStandardizingPath]; 169 | 170 | NSDictionary* options = [NSDictionary dictionaryWithObjectsAndKeys: 171 | aString, @"string", 172 | aView, @"view", 173 | fileName, @"fileName", 174 | [NSNumber numberWithInt:aLine], @"line", 175 | anObject, @"object", /* last since anObject might be nil */ 176 | nil]; 177 | 178 | [OpenFiles setObject:options forKey:[fileName precomposedStringWithCanonicalMapping]]; 179 | if([OpenFiles count] == 1) 180 | [self setODBEventHandlers]; 181 | D(@"detached request to %@: %@", self, options); 182 | [NSThread detachNewThreadSelector:@selector(asyncEditStringWithOptions:) toTarget:self withObject:options]; 183 | } 184 | 185 | + (void)handleModifiedFileEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEventDescriptor*)replyEvent 186 | { 187 | NSAppleEventDescriptor* fileURL = [[event paramDescriptorForKeyword:keyDirectObject] coerceToDescriptorType:typeFileURL]; 188 | NSString* urlString = [[[NSString alloc] initWithData:[fileURL data] encoding:NSUTF8StringEncoding] autorelease]; 189 | NSString* fileName = [[[NSURL URLWithString:urlString] path] stringByStandardizingPath]; 190 | NSDictionary* options = [OpenFiles objectForKey:[fileName precomposedStringWithCanonicalMapping]]; 191 | NSView* view = [options objectForKey:@"view"]; 192 | 193 | if([view window]) 194 | { 195 | if ([view respondsToSelector:@selector(textMateDidModifyString:withObject:)]) 196 | { 197 | NSString* newString = [[[NSString alloc] initWithData:[NSData dataWithContentsOfFile:fileName] encoding:NSUTF8StringEncoding] autorelease]; 198 | NSObject* anObject = [options objectForKey:@"object"]; 199 | [view performSelector:@selector(textMateDidModifyString:withObject:) withObject:newString withObject:anObject]; 200 | [FailedFiles removeObject:fileName]; 201 | fileName = nil; 202 | } 203 | else if([view respondsToSelector:@selector(textMateDidModifyString:)]) 204 | { 205 | NSString* newString = [[[NSString alloc] initWithData:[NSData dataWithContentsOfFile:fileName] encoding:NSUTF8StringEncoding] autorelease]; 206 | [view performSelector:@selector(textMateDidModifyString:) withObject:newString]; 207 | [FailedFiles removeObject:fileName]; 208 | fileName = nil; 209 | } 210 | } 211 | if (fileName) 212 | { 213 | [FailedFiles addObject:fileName]; 214 | NSLog(@"%s view %p, %@, window %@", SELNAME(_cmd), view, view, [view window]); 215 | NSLog(@"%s file name %@, options %@", SELNAME(_cmd), fileName, [options description]); 216 | NSLog(@"%s all %@", SELNAME(_cmd), [OpenFiles description]); 217 | NSBeep(); 218 | } 219 | } 220 | 221 | + (void)handleClosedFileEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEventDescriptor*)replyEvent 222 | { 223 | NSAppleEventDescriptor* fileURL = [[event paramDescriptorForKeyword:keyDirectObject] coerceToDescriptorType:typeFileURL]; 224 | NSString* urlString = [[[NSString alloc] initWithData:[fileURL data] encoding:NSUTF8StringEncoding] autorelease]; 225 | NSString* fileName = [[[NSURL URLWithString:urlString] path] stringByStandardizingPath]; 226 | 227 | if([FailedFiles containsObject:fileName]) 228 | { 229 | if([[NSFileManager defaultManager] fileExistsAtPath:fileName]) 230 | [[NSWorkspace sharedWorkspace] selectFile:fileName inFileViewerRootedAtPath:[fileName stringByDeletingLastPathComponent]]; 231 | [FailedFiles removeObject:fileName]; 232 | } 233 | else 234 | { 235 | [[NSFileManager defaultManager] removeFileAtPath:fileName handler:nil]; 236 | [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; 237 | } 238 | 239 | [OpenFiles removeObjectForKey:[fileName precomposedStringWithCanonicalMapping]]; 240 | if([OpenFiles count] == 0) 241 | [self removeODBEventHandlers]; 242 | } 243 | 244 | + (NSMenu*)findEditMenu 245 | { 246 | NSMenu* mainMenu = [NSApp mainMenu]; 247 | std::map ranked; 248 | for(int i = 0; i != [mainMenu numberOfItems]; i++) 249 | { 250 | NSMenu* candidate = [[mainMenu itemAtIndex:i] submenu]; 251 | static SEL const actions[] = { @selector(undo:), @selector(redo:), @selector(cut:), @selector(copy:), @selector(paste:), @selector(delete:), @selector(selectAll:) }; 252 | size_t score = 0; 253 | for(int j = 0; j != sizeof(actions)/sizeof(actions[0]); j++) 254 | { 255 | if(-1 != [candidate indexOfItemWithTarget:nil andAction:actions[j]]) 256 | score++; 257 | } 258 | 259 | if(score > 0 && ranked.find(score) == ranked.end()) 260 | ranked[score] = candidate; 261 | } 262 | return ranked.empty() ? nil : (--ranked.end())->second; 263 | } 264 | 265 | + (void)installMenuItem:(id)sender 266 | { 267 | if(NSMenu* editMenu = [self findEditMenu]) 268 | { 269 | [editMenu addItem:[NSMenuItem separatorItem]]; 270 | id menuItem = [editMenu addItemWithTitle:[NSString stringWithUTF8String:"Edit in TextMate…"] action:@selector(editInTextMate:) keyEquivalent:@"e"]; 271 | [menuItem setKeyEquivalentModifierMask:NSControlKeyMask | NSCommandKeyMask]; 272 | } 273 | } 274 | 275 | + (void)load 276 | { 277 | OpenFiles = [NSMutableDictionary new]; 278 | FailedFiles = [NSMutableSet new]; 279 | // NSString* bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; 280 | DebugEnabled = [[NSUserDefaults standardUserDefaults] boolForKey:@"EditInTextMateDebugEnabled"]; 281 | if([[NSUserDefaults standardUserDefaults] boolForKey:@"DisableEditInTextMateMenuItem"] == NO) 282 | [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(installMenuItem:) name:NSApplicationDidFinishLaunchingNotification object:[NSApplication sharedApplication]]; 283 | } 284 | @end 285 | -------------------------------------------------------------------------------- /src/WebView: Edit in TextMate.mm: -------------------------------------------------------------------------------- 1 | // 2 | // WebView: Edit in TextMate.mm 3 | // 4 | // Created by Allan Odgaard on 2005-11-27. 5 | // See /trunk/LICENSE for license details 6 | // 7 | 8 | #import 9 | #import 10 | #import "Edit in TextMate.h" 11 | 12 | #ifndef sizeofA 13 | #define sizeofA(x) (sizeof(x)/sizeof(x[0])) 14 | #endif 15 | 16 | // only latest WebKit has this stuff, and it is private 17 | @interface DOMHTMLTextAreaElement (DOMHTMLTextAreaElementPrivate) 18 | - (int)selectionStart; 19 | - (void)setSelectionStart:(int)newSelectionStart; 20 | - (int)selectionEnd; 21 | - (void)setSelectionEnd:(int)newSelectionEnd; 22 | - (void)setSelectionRange:(int)start end:(int)end; 23 | @end 24 | 25 | @interface WebView (EditInTextMate) 26 | - (void)editInTextMate:(id)sender; 27 | @end 28 | 29 | @interface NSString (EditInTextMate) 30 | - (NSString*)TM_stringByTrimmingWhitespace; 31 | - (NSString*)TM_stringByReplacingString:(NSString*)aSearchString withString:(NSString*)aReplaceString; 32 | - (NSString*)TM_stringByNbspEscapingSpaces; 33 | @end 34 | 35 | @implementation NSString (EditInTextMate) 36 | - (NSString*)TM_stringByTrimmingWhitespace 37 | { 38 | NSString* str = self; 39 | while([str hasPrefix:@" "]) 40 | str = [str substringFromIndex:1]; 41 | 42 | while([str hasSuffix:@" "]) 43 | str = [str substringToIndex:[str length]-1]; 44 | return str; 45 | } 46 | 47 | - (NSString*)TM_stringByReplacingString:(NSString*)aSearchString withString:(NSString*)aReplaceString 48 | { 49 | return [[self componentsSeparatedByString:aSearchString] componentsJoinedByString:aReplaceString]; 50 | } 51 | 52 | - (NSString*)TM_stringByNbspEscapingSpaces 53 | { 54 | unsigned len = [self length]; 55 | unichar* buf = new unichar[len]; 56 | [self getCharacters:buf]; 57 | for(unsigned i = 0; i != len; i++) 58 | { 59 | if(buf[i] == ' ' && (i+1 == len || buf[i+1] == ' ')) 60 | buf[i] = 0xA0; 61 | } 62 | return [NSString stringWithCharacters:buf length:len]; 63 | } 64 | @end 65 | 66 | struct convert_dom_to_text 67 | { 68 | convert_dom_to_text (DOMTreeWalker* treeWalker) : string([NSMutableString new]), quoteLevel(0), pendingFlush(NO), pendingWhitespace(NO), didOutputText(NO), atBeginOfLine(YES) { visit_nodes(treeWalker); } 69 | ~convert_dom_to_text () { [string autorelease]; } 70 | operator NSString* () const { return string; } 71 | 72 | private: 73 | void enter_block_tag () 74 | { 75 | pendingFlush |= didOutputText; 76 | didOutputText = NO; 77 | pendingWhitespace = NO; 78 | } 79 | 80 | void leave_block_tag () 81 | { 82 | pendingFlush |= didOutputText; 83 | didOutputText = NO; 84 | pendingWhitespace = NO; 85 | } 86 | 87 | void output_text (NSString* str) 88 | { 89 | if([str isEqualToString:@""]) 90 | return; 91 | 92 | str = [str TM_stringByTrimmingWhitespace]; 93 | if([str isEqualToString:@""]) 94 | { 95 | pendingWhitespace = YES; 96 | return; 97 | } 98 | 99 | str = [str TM_stringByReplacingString:[NSString stringWithUTF8String:" "] withString:@" "]; 100 | 101 | if(pendingFlush) 102 | { 103 | [string appendString:@"\n"]; 104 | pendingFlush = NO; 105 | atBeginOfLine = YES; 106 | } 107 | 108 | if(atBeginOfLine && quoteLevel) 109 | { 110 | for(unsigned i = 0; i < quoteLevel; i++) 111 | [string appendString:@"> "]; 112 | } 113 | else if(!atBeginOfLine && pendingWhitespace) 114 | { 115 | [string appendString:@" "]; 116 | } 117 | 118 | [string appendString:str]; 119 | atBeginOfLine = NO; 120 | didOutputText = YES; 121 | pendingWhitespace = NO; 122 | } 123 | 124 | void visit_nodes (DOMTreeWalker* treeWalker); 125 | 126 | NSMutableString* string; 127 | unsigned quoteLevel; 128 | BOOL pendingFlush; 129 | BOOL pendingWhitespace; 130 | BOOL didOutputText; 131 | BOOL atBeginOfLine; 132 | }; 133 | 134 | struct helper 135 | { 136 | helper (DOMHTMLTextAreaElement* textArea) : textArea(textArea) 137 | { 138 | value = [textArea value]; 139 | selectionStart = [textArea selectionStart]; 140 | selectionEnd = [textArea selectionEnd]; 141 | } 142 | 143 | helper () : textArea(nil), value(nil) { } 144 | bool should_change () const { return selectionStart != 0 || selectionEnd != [value length]; } 145 | bool did_change () const { return selectionStart != [textArea selectionStart] || selectionEnd != [textArea selectionEnd]; } 146 | void reset () const 147 | { 148 | if([textArea value] != value) [textArea setValue:value]; 149 | if(did_change()) [textArea setSelectionRange:selectionStart end:selectionEnd]; 150 | } 151 | 152 | static bool usable (DOMNode* node) 153 | { 154 | static SEL const selectors[] = { @selector(selectionStart), @selector(selectionEnd), @selector(setSelectionStart:), @selector(setSelectionEnd:), @selector(value), @selector(setValue:), @selector(setSelectionRange:end:) }; 155 | BOOL res = [node isKindOfClass:[DOMHTMLTextAreaElement class]] && ![(DOMHTMLTextAreaElement*)node disabled] && ![(DOMHTMLTextAreaElement*)node readOnly]; 156 | for(size_t i = 0; i < sizeofA(selectors); ++i) 157 | res = res && [node respondsToSelector:selectors[i]]; 158 | return res; 159 | } 160 | 161 | DOMHTMLTextAreaElement* textArea; 162 | NSString* value; 163 | unsigned long selectionStart; 164 | unsigned long selectionEnd; 165 | }; 166 | 167 | void convert_dom_to_text::visit_nodes (DOMTreeWalker* treeWalker) 168 | { 169 | for(DOMNode* node = [treeWalker currentNode]; node; node = [treeWalker nextSibling]) 170 | { 171 | if([node nodeType] == DOM_TEXT_NODE) 172 | output_text([node nodeValue]); 173 | else if([[[node nodeName] uppercaseString] isEqualToString:@"BR"]) 174 | output_text(@"\n"), (atBeginOfLine = YES), (didOutputText = NO); 175 | else if([[[node nodeName] uppercaseString] isEqualToString:@"DIV"]) 176 | enter_block_tag(); 177 | else if([[[node nodeName] uppercaseString] isEqualToString:@"BLOCKQUOTE"]) 178 | enter_block_tag(), ++quoteLevel; 179 | else if([[[node nodeName] uppercaseString] isEqualToString:@"P"]) 180 | enter_block_tag(); 181 | 182 | if([treeWalker firstChild]) 183 | { 184 | visit_nodes(treeWalker); 185 | [treeWalker parentNode]; 186 | } 187 | 188 | if([[[node nodeName] uppercaseString] isEqualToString:@"DIV"]) 189 | leave_block_tag(); 190 | else if([[[node nodeName] uppercaseString] isEqualToString:@"BLOCKQUOTE"]) 191 | leave_block_tag(), --quoteLevel; 192 | else if([[[node nodeName] uppercaseString] isEqualToString:@"P"]) 193 | leave_block_tag(); 194 | } 195 | } 196 | 197 | static DOMHTMLTextAreaElement* find_active_text_area_for_frame (WebFrame* frame) 198 | { 199 | DOMHTMLTextAreaElement* res = nil; 200 | DOMDocument* doc = [frame DOMDocument]; 201 | if([doc respondsToSelector:@selector(focusNode)]) 202 | { 203 | // OmniWeb 5.6 has a method to get the focused node 204 | res = [doc performSelector:@selector(focusNode)]; 205 | if(!helper::usable(res)) 206 | res = nil; 207 | } 208 | else 209 | { 210 | // The following is a heuristic for finding the active text area: 211 | // 212 | // 1. If there is just one text area, we use that. 213 | // 214 | // 2. If there are multiple, we ask the web view to “select all” 215 | // which goes to the active text area (hopefully) and then we 216 | // check which of the text areas in the DOM actually changed. 217 | // 218 | // There is a problem if either a text area has no content (in 219 | // which case select all makes no changes) or if everything is 220 | // already selected. If only one text area is in the state of 221 | // “select all would not affect it” and no text areas were 222 | // changed, we assume the one with that state is the active. 223 | 224 | std::vector v; 225 | DOMNodeList* textAreas = [doc getElementsByTagName:@"TEXTAREA"]; 226 | for(unsigned long i = 0; i < [textAreas length]; ++i) 227 | { 228 | if(helper::usable([textAreas item:i])) 229 | v.push_back((DOMHTMLTextAreaElement*)[textAreas item:i]); 230 | } 231 | 232 | if(v.size() == 1) 233 | { 234 | res = v[0].textArea; 235 | } 236 | else if(v.size() > 1) 237 | { 238 | for(std::vector::iterator it = v.begin(); it != v.end(); ++it) 239 | if (!it->should_change()) 240 | [it->textArea setValue:@" "]; 241 | [[frame webView] selectLine:nil]; 242 | 243 | size_t should_change = 0, did_change = 0; 244 | for(std::vector::iterator it = v.begin(); it != v.end(); ++it) 245 | { 246 | did_change += it->did_change() ? 1 : 0; 247 | should_change += it->should_change() ? 1 : 0; 248 | } 249 | 250 | if(did_change == 1) 251 | { 252 | for(std::vector::iterator it = v.begin(); it != v.end(); ++it) 253 | res = it->did_change() ? it->textArea : res; 254 | } 255 | else if(did_change == 0 && should_change == v.size()-1) 256 | { 257 | for(std::vector::iterator it = v.begin(); it != v.end(); ++it) 258 | res = !it->should_change() ? it->textArea : res; 259 | } 260 | 261 | for(std::vector::iterator it = v.begin(); it != v.end(); ++it) 262 | it->reset(); 263 | } 264 | } 265 | return res; 266 | } 267 | 268 | static DOMHTMLTextAreaElement* find_active_text_area (WebView* view) 269 | { 270 | DOMHTMLTextAreaElement* res = nil; 271 | if([view respondsToSelector:@selector(selectedFrame)]) 272 | res = find_active_text_area_for_frame([view performSelector:@selector(selectedFrame)]); 273 | else 274 | { 275 | WebFrame* frame = [view mainFrame]; 276 | NSArray* frames = [[NSArray arrayWithObject: frame] arrayByAddingObjectsFromArray: [frame childFrames]]; 277 | for(unsigned i = 0; i != [frames count] && !res; i++) 278 | res = find_active_text_area_for_frame([frames objectAtIndex:i]); 279 | } 280 | return res; 281 | } 282 | 283 | @implementation WebView (EditInTextMate) 284 | - (void)editInTextMate:(id)sender 285 | { 286 | D(@"editInTextMate: view: %@", self); 287 | if([self isEditable]) 288 | { 289 | // Mail uses an editable WebView, in which case we want to send the entire page to TextMate 290 | D(@"WebView is editable"); 291 | NSString* const CARET = @"\uFFFD"; 292 | NSString* str = @""; 293 | int lineNumber = 0; 294 | 295 | DOMDocumentFragment* selection = [[self selectedDOMRange] cloneContents]; 296 | if(!selection) 297 | { 298 | [self insertText:CARET]; // ugly hack, but we want to preserve the position of the caret 299 | [self selectAll:nil]; 300 | selection = [[self selectedDOMRange] cloneContents]; 301 | 302 | // remove the caret marker. TODO we should start an undo group, so the (chunked) undo doesn’t remove more than just the caret 303 | if(NSUndoManager* undoManager = [self undoManager]) 304 | { 305 | if([undoManager canUndo]) 306 | { 307 | [undoManager undo]; 308 | [self selectAll:nil]; 309 | } 310 | } 311 | } 312 | 313 | if(selection) 314 | { 315 | str = convert_dom_to_text([[[self mainFrame] DOMDocument] createTreeWalker:selection :DOM_SHOW_ALL :nil :YES]); 316 | while([str hasSuffix:@"\n\n"]) 317 | str = [str substringToIndex:[str length]-1]; 318 | 319 | NSArray* split = [str componentsSeparatedByString:CARET]; 320 | if([split count] == 2) 321 | { 322 | lineNumber = [[[split objectAtIndex:0] componentsSeparatedByString:@"\n"] count] - 1; 323 | str = [split componentsJoinedByString:@""]; 324 | } 325 | } 326 | [EditInTextMate externalEditString:str startingAtLine:lineNumber forView:self]; 327 | } 328 | else 329 | { 330 | // Likely the user wants to edit just a text area, so let’s try to find which 331 | if(DOMHTMLTextAreaElement* textArea = find_active_text_area(self)) 332 | { 333 | NSString* str = [textArea value]; 334 | unsigned long selectionStart = [textArea selectionStart]; 335 | int lineNumber = 0; 336 | NSRange range = NSMakeRange(0, 0); 337 | do { 338 | NSRange oldRange = range; 339 | range = [str lineRangeForRange:NSMakeRange(NSMaxRange(range), 0)]; 340 | if(NSMaxRange(oldRange) == NSMaxRange(range) || selectionStart < NSMaxRange(range)) 341 | break; 342 | lineNumber++; 343 | } while(true); 344 | [EditInTextMate externalEditString:str startingAtLine:lineNumber forView:self withObject:textArea]; 345 | } 346 | else 347 | { 348 | D(@"Couldn’t find edit target in WebView"); 349 | NSBeep(); 350 | } 351 | } 352 | } 353 | 354 | - (void)textMateDidModifyString:(NSString*)newString withObject:(NSObject*)textArea 355 | { 356 | if([self isEditable]) 357 | { 358 | NSArray* lines = [newString componentsSeparatedByString:@"\n"]; 359 | NSMutableString* res = [NSMutableString string]; 360 | unsigned quoteLevel = 0; 361 | for(unsigned i = 0; i != [lines count]; i++) 362 | { 363 | NSString* line = [lines objectAtIndex:i]; 364 | 365 | unsigned newQuoteLevel = 0; 366 | while([line hasPrefix:@"> "]) 367 | { 368 | line = [line substringFromIndex:2]; 369 | newQuoteLevel++; 370 | } 371 | 372 | if([line isEqualToString:@">"]) 373 | { 374 | line = @""; 375 | newQuoteLevel++; 376 | } 377 | 378 | if(newQuoteLevel > quoteLevel) 379 | { 380 | for(unsigned j = 0; j != newQuoteLevel - quoteLevel; j++) 381 | [res appendString:@"
"]; 382 | } 383 | else if(newQuoteLevel < quoteLevel) 384 | { 385 | for(unsigned j = 0; j != quoteLevel - newQuoteLevel; j++) 386 | [res appendString:@"
"]; 387 | } 388 | quoteLevel = newQuoteLevel; 389 | 390 | if([line isEqualToString:@""]) 391 | { 392 | [res appendString:@"

"]; 393 | } 394 | else 395 | { 396 | line = [line TM_stringByNbspEscapingSpaces]; 397 | line = [line TM_stringByReplacingString:@"&" withString:@"&"]; 398 | line = [line TM_stringByReplacingString:@"<" withString:@"<"]; 399 | line = [line TM_stringByReplacingString:@">" withString:@">"]; 400 | [res appendFormat:@"
%@
", line]; 401 | } 402 | } 403 | 404 | [self replaceSelectionWithMarkupString:res]; 405 | if(![[self selectedDOMRange] cloneContents]) 406 | [self selectAll:nil]; 407 | } 408 | else 409 | { 410 | [(DOMHTMLTextAreaElement*)textArea setValue:newString]; 411 | } 412 | } 413 | @end 414 | --------------------------------------------------------------------------------