Implement action menus for text
[WebKit-https.git] / Source / WebKit2 / UIProcess / mac / WKActionMenuController.mm
1 /*
2  * Copyright (C) 2014 Apple Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 #import "config.h"
27 #import "WKActionMenuController.h"
28
29 #if PLATFORM(MAC)
30
31 #import "WKNSURLExtras.h"
32 #import "WKViewInternal.h"
33 #import "WebContext.h"
34 #import "WebKitSystemInterface.h"
35 #import "WebPageMessages.h"
36 #import "WebPageProxy.h"
37 #import "WebPageProxyMessages.h"
38 #import "WebProcessProxy.h"
39 #import <Foundation/Foundation.h>
40 #import <ImageIO/ImageIO.h>
41 #import <ImageKit/ImageKit.h>
42 #import <WebCore/DataDetectorsSPI.h>
43 #import <WebCore/NSSharingServicePickerSPI.h>
44 #import <WebCore/NSViewSPI.h>
45 #import <WebCore/SoftLinking.h>
46 #import <WebCore/URL.h>
47
48 // FIXME: This should move into an SPI header if it stays.
49 @class QLPreviewBubble;
50 @interface NSObject (WKQLPreviewBubbleDetails)
51 @property (copy) NSArray * controls;
52 @property NSSize maximumSize;
53 @property NSRectEdge preferredEdge;
54 @property (retain) IBOutlet NSWindow* parentWindow;
55 - (void)showPreviewItem:(id)previewItem itemFrame:(NSRect)frame;
56 - (void)setAutomaticallyCloseWithMask:(NSEventMask)autocloseMask filterMask:(NSEventMask)filterMask block:(void (^)(void))block;
57 @end
58
59 SOFT_LINK_FRAMEWORK_IN_UMBRELLA(Quartz, ImageKit)
60 SOFT_LINK_CLASS(ImageKit, IKSlideshow)
61
62 using namespace WebCore;
63 using namespace WebKit;
64
65 @interface WKActionMenuController () <NSSharingServiceDelegate, NSSharingServicePickerDelegate>
66 - (void)_updateActionMenuItems;
67 - (BOOL)_canAddImageToPhotos;
68 @end
69
70 @interface WKView (WKDeprecatedSPI)
71 - (NSArray *)_actionMenuItemsForHitTestResult:(WKHitTestResultRef)hitTestResult defaultActionMenuItems:(NSArray *)defaultMenuItems;
72 @end
73
74 @implementation WKActionMenuController
75
76 - (instancetype)initWithPage:(WebPageProxy&)page view:(WKView *)wkView
77 {
78     self = [super init];
79
80     if (!self)
81         return nil;
82
83     _page = &page;
84     _wkView = wkView;
85     _type = kWKActionMenuNone;
86
87     return self;
88 }
89
90 - (void)willDestroyView:(WKView *)view
91 {
92     _page = nullptr;
93     _wkView = nullptr;
94 }
95
96 - (void)prepareForMenu:(NSMenu *)menu withEvent:(NSEvent *)event
97 {
98     if (menu != _wkView.actionMenu)
99         return;
100
101     _page->performActionMenuHitTestAtLocation([_wkView convertPoint:event.locationInWindow fromView:nil]);
102
103     _state = ActionMenuState::Pending;
104     [self _updateActionMenuItems];
105 }
106
107 - (void)willOpenMenu:(NSMenu *)menu withEvent:(NSEvent *)event
108 {
109     if (menu != _wkView.actionMenu)
110         return;
111
112     if (_type != kWKActionMenuReadOnlyText)
113         return;
114
115     // Action menus for text should highlight the text so that it is clear what the action menu actions
116     // will apply to. If the text is already selected, the menu will use the existing selection.
117     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
118     if (!hitTestResult->isSelected())
119         _page->selectLookupTextAtLocation([_wkView convertPoint:event.locationInWindow fromView:nil]);
120 }
121
122 - (void)didCloseMenu:(NSMenu *)menu withEvent:(NSEvent *)event
123 {
124     if (menu != _wkView.actionMenu)
125         return;
126     
127     _state = ActionMenuState::None;
128     _hitTestResult = ActionMenuHitTestResult();
129     _type = kWKActionMenuNone;
130     _sharingServicePicker = nil;
131 }
132
133 - (void)didPerformActionMenuHitTest:(const ActionMenuHitTestResult&)hitTestResult
134 {
135     // FIXME: This needs to use the WebKit2 callback mechanism to avoid out-of-order replies.
136     _state = ActionMenuState::Ready;
137     _hitTestResult = hitTestResult;
138 }
139
140 #pragma mark Link actions
141
142 - (NSArray *)_defaultMenuItemsForLink
143 {
144     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
145     if (!WebCore::protocolIsInHTTPFamily(hitTestResult->absoluteLinkURL()))
146         return @[ ];
147
148     RetainPtr<NSMenuItem> openLinkItem = [self _createActionMenuItemForTag:kWKContextActionItemTagOpenLinkInDefaultBrowser];
149     RetainPtr<NSMenuItem> previewLinkItem = [self _createActionMenuItemForTag:kWKContextActionItemTagPreviewLink];
150     RetainPtr<NSMenuItem> readingListItem = [self _createActionMenuItemForTag:kWKContextActionItemTagAddLinkToSafariReadingList];
151
152     return @[ openLinkItem.get(), previewLinkItem.get(), [NSMenuItem separatorItem], readingListItem.get() ];
153 }
154
155 - (void)_openURLFromActionMenu:(id)sender
156 {
157     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
158     [[NSWorkspace sharedWorkspace] openURL:[NSURL _web_URLWithWTFString:hitTestResult->absoluteLinkURL()]];
159 }
160
161 - (void)_addToReadingListFromActionMenu:(id)sender
162 {
163     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
164     NSSharingService *service = [NSSharingService sharingServiceNamed:NSSharingServiceNameAddToSafariReadingList];
165     [service performWithItems:@[ [NSURL _web_URLWithWTFString:hitTestResult->absoluteLinkURL()] ]];
166 }
167
168 - (void)_quickLookURLFromActionMenu:(id)sender
169 {
170     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
171     NSRect itemFrame = [_wkView convertRect:hitTestResult->elementBoundingBox() toView:nil];
172     NSSize maximumPreviewSize = NSMakeSize(_wkView.bounds.size.width * 0.75, _wkView.bounds.size.height * 0.75);
173
174     RetainPtr<QLPreviewBubble> bubble = adoptNS([[NSClassFromString(@"QLPreviewBubble") alloc] init]);
175     [bubble setParentWindow:_wkView.window];
176     [bubble setMaximumSize:maximumPreviewSize];
177     [bubble setPreferredEdge:NSMaxYEdge];
178     [bubble setControls:@[ ]];
179     NSEventMask filterMask = NSAnyEventMask & ~(NSAppKitDefinedMask | NSSystemDefinedMask | NSApplicationDefinedMask | NSMouseEnteredMask | NSMouseExitedMask);
180     NSEventMask autocloseMask = NSLeftMouseDownMask | NSRightMouseDownMask | NSKeyDownMask;
181     [bubble setAutomaticallyCloseWithMask:autocloseMask filterMask:filterMask block:[bubble] {
182         [bubble close];
183     }];
184     [bubble showPreviewItem:[NSURL _web_URLWithWTFString:hitTestResult->absoluteLinkURL()] itemFrame:itemFrame];
185 }
186
187 #pragma mark Image actions
188
189 - (NSArray *)_defaultMenuItemsForImage
190 {
191     RetainPtr<NSMenuItem> copyImageItem = [self _createActionMenuItemForTag:kWKContextActionItemTagCopyImage];
192     RetainPtr<NSMenuItem> addToPhotosItem;
193     if ([self _canAddImageToPhotos])
194         addToPhotosItem = [self _createActionMenuItemForTag:kWKContextActionItemTagAddImageToPhotos];
195     else
196         addToPhotosItem = [NSMenuItem separatorItem];
197     RetainPtr<NSMenuItem> saveToDownloadsItem = [self _createActionMenuItemForTag:kWKContextActionItemTagSaveImageToDownloads];
198     RetainPtr<NSMenuItem> shareItem = [self _createActionMenuItemForTag:kWKContextActionItemTagShareImage];
199
200     if (RefPtr<ShareableBitmap> bitmap = _hitTestResult.image) {
201         RetainPtr<CGImageRef> image = bitmap->makeCGImage();
202         RetainPtr<NSImage> nsImage = adoptNS([[NSImage alloc] initWithCGImage:image.get() size:NSZeroSize]);
203         _sharingServicePicker = adoptNS([[NSSharingServicePicker alloc] initWithItems:@[ nsImage.get() ]]);
204         [_sharingServicePicker setDelegate:self];
205         [shareItem setSubmenu:[_sharingServicePicker menu]];
206     }
207
208     return @[ copyImageItem.get(), addToPhotosItem.get(), saveToDownloadsItem.get(), shareItem.get() ];
209 }
210
211 - (void)_copyImage:(id)sender
212 {
213     RefPtr<ShareableBitmap> bitmap = _hitTestResult.image;
214     if (!bitmap)
215         return;
216
217     RetainPtr<CGImageRef> image = bitmap->makeCGImage();
218     RetainPtr<NSImage> nsImage = adoptNS([[NSImage alloc] initWithCGImage:image.get() size:NSZeroSize]);
219     [[NSPasteboard generalPasteboard] clearContents];
220     [[NSPasteboard generalPasteboard] writeObjects:@[ nsImage.get() ]];
221 }
222
223 - (void)_saveImageToDownloads:(id)sender
224 {
225     WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult();
226     _page->process().context().download(_page, hitTestResult->absoluteImageURL());
227 }
228
229 // FIXME: We should try to share this with WebPageProxyMac's similar PDF functions.
230 static NSString *temporaryPhotosDirectoryPath()
231 {
232     static NSString *temporaryPhotosDirectoryPath;
233
234     if (!temporaryPhotosDirectoryPath) {
235         NSString *temporaryDirectoryTemplate = [NSTemporaryDirectory() stringByAppendingPathComponent:@"WebKitPhotos-XXXXXX"];
236         CString templateRepresentation = [temporaryDirectoryTemplate fileSystemRepresentation];
237
238         if (mkdtemp(templateRepresentation.mutableData()))
239             temporaryPhotosDirectoryPath = [[[NSFileManager defaultManager] stringWithFileSystemRepresentation:templateRepresentation.data() length:templateRepresentation.length()] copy];
240     }
241
242     return temporaryPhotosDirectoryPath;
243 }
244
245 static NSString *pathToPhotoOnDisk(NSString *suggestedFilename)
246 {
247     NSString *photoDirectoryPath = temporaryPhotosDirectoryPath();
248     if (!photoDirectoryPath) {
249         WTFLogAlways("Cannot create temporary photo download directory.");
250         return nil;
251     }
252
253     NSString *path = [photoDirectoryPath stringByAppendingPathComponent:suggestedFilename];
254
255     NSFileManager *fileManager = [NSFileManager defaultManager];
256     if ([fileManager fileExistsAtPath:path]) {
257         NSString *pathTemplatePrefix = [photoDirectoryPath stringByAppendingPathComponent:@"XXXXXX-"];
258         NSString *pathTemplate = [pathTemplatePrefix stringByAppendingString:suggestedFilename];
259         CString pathTemplateRepresentation = [pathTemplate fileSystemRepresentation];
260
261         int fd = mkstemps(pathTemplateRepresentation.mutableData(), pathTemplateRepresentation.length() - strlen([pathTemplatePrefix fileSystemRepresentation]) + 1);
262         if (fd < 0) {
263             WTFLogAlways("Cannot create photo file in the temporary directory (%@).", suggestedFilename);
264             return nil;
265         }
266
267         close(fd);
268         path = [fileManager stringWithFileSystemRepresentation:pathTemplateRepresentation.data() length:pathTemplateRepresentation.length()];
269     }
270
271     return path;
272 }
273
274 - (BOOL)_canAddImageToPhotos
275 {
276     return [getIKSlideshowClass() canExportToApplication:@"com.apple.Photos"];
277 }
278
279 - (void)_addImageToPhotos:(id)sender
280 {
281     if (![self _canAddImageToPhotos])
282         return;
283
284     RefPtr<ShareableBitmap> bitmap = _hitTestResult.image;
285     if (!bitmap)
286         return;
287     RetainPtr<CGImageRef> image = bitmap->makeCGImage();
288
289     dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
290         NSString * const suggestedFilename = @"image.jpg";
291
292         NSString *filePath = pathToPhotoOnDisk(suggestedFilename);
293         if (!filePath)
294             return;
295
296         NSURL *fileURL = [NSURL fileURLWithPath:filePath];
297         auto dest = adoptCF(CGImageDestinationCreateWithURL((CFURLRef)fileURL, kUTTypeJPEG, 1, nullptr));
298         CGImageDestinationAddImage(dest.get(), image.get(), nullptr);
299         CGImageDestinationFinalize(dest.get());
300
301         dispatch_async(dispatch_get_main_queue(), ^{
302             // This API provides no way to report failure, but if 18420778 is fixed so that it does, we should handle this.
303             [getIKSlideshowClass() exportSlideshowItem:filePath toApplication:@"com.apple.Photos"];
304         });
305     });
306 }
307
308 #pragma mark Text actions
309
310 - (NSArray *)_defaultMenuItemsForText
311 {
312     RetainPtr<NSMenuItem> copyTextItem = [self _createActionMenuItemForTag:kWKContextActionItemTagCopyText];
313     RetainPtr<NSMenuItem> lookupTextItem = [self _createActionMenuItemForTag:kWKContextActionItemTagLookupText];
314
315     return @[ copyTextItem.get(), lookupTextItem.get() ];
316 }
317
318 -(void)_copyText:(id)sender
319 {
320     _page->executeEditCommand("copy");
321 }
322
323 -(void)_lookupText:(id)sender
324 {
325     _page->performDictionaryLookupOfCurrentSelection();
326 }
327
328 #pragma mark NSMenuDelegate implementation
329
330 - (void)menuNeedsUpdate:(NSMenu *)menu
331 {
332     if (menu != _wkView.actionMenu)
333         return;
334
335     ASSERT(_state != ActionMenuState::None);
336
337     // FIXME: We need to be able to cancel this if the menu goes away.
338     // FIXME: Connection can be null if the process is closed; we should clean up better in that case.
339     if (_state == ActionMenuState::Pending) {
340         if (auto* connection = _page->process().connection())
341             connection->waitForAndDispatchImmediately<Messages::WebPageProxy::DidPerformActionMenuHitTest>(_page->pageID(), std::chrono::milliseconds(500));
342     }
343
344     [self _updateActionMenuItems];
345 }
346
347 #pragma mark NSSharingServicePickerDelegate implementation
348
349 - (id <NSSharingServiceDelegate>)sharingServicePicker:(NSSharingServicePicker *)sharingServicePicker delegateForSharingService:(NSSharingService *)sharingService
350 {
351     return self;
352 }
353
354 #pragma mark NSSharingServiceDelegate implementation
355
356 - (NSWindow *)sharingService:(NSSharingService *)sharingService sourceWindowForShareItems:(NSArray *)items sharingContentScope:(NSSharingContentScope *)sharingContentScope
357 {
358     return _wkView.window;
359 }
360
361 #pragma mark Menu Items
362
363 - (RetainPtr<NSMenuItem>)_createActionMenuItemForTag:(uint32_t)tag
364 {
365     SEL selector = nullptr;
366     NSString *title = nil;
367     NSImage *image = nil;
368
369     // FIXME: These titles need to be localized.
370     switch (tag) {
371     case kWKContextActionItemTagOpenLinkInDefaultBrowser:
372         selector = @selector(_openURLFromActionMenu:);
373         title = @"Open";
374         image = webKitBundleImageNamed(@"OpenInNewWindowTemplate");
375         break;
376
377     case kWKContextActionItemTagPreviewLink:
378         selector = @selector(_quickLookURLFromActionMenu:);
379         title = @"Preview";
380         image = [NSImage imageNamed:NSImageNameQuickLookTemplate];
381         break;
382
383     case kWKContextActionItemTagAddLinkToSafariReadingList:
384         selector = @selector(_addToReadingListFromActionMenu:);
385         title = @"Add to Safari Reading List";
386         image = [NSImage imageNamed:NSImageNameBookmarksTemplate];
387         break;
388
389     case kWKContextActionItemTagCopyImage:
390         selector = @selector(_copyImage:);
391         title = @"Copy";
392         image = webKitBundleImageNamed(@"CopyImageTemplate");
393         break;
394
395     case kWKContextActionItemTagAddImageToPhotos:
396         selector = @selector(_addImageToPhotos:);
397         title = @"Add to Photos";
398         image = webKitBundleImageNamed(@"AddImageToPhotosTemplate");
399         break;
400
401     case kWKContextActionItemTagSaveImageToDownloads:
402         selector = @selector(_saveImageToDownloads:);
403         title = @"Save to Downloads";
404         image = webKitBundleImageNamed(@"SaveImageToDownloadsTemplate");
405         break;
406
407     case kWKContextActionItemTagShareImage:
408         title = @"Share";
409         image = webKitBundleImageNamed(@"ShareImageTemplate");
410         break;
411
412     case kWKContextActionItemTagCopyText:
413         selector = @selector(_copyText:);
414         title = @"Copy";
415         image = [NSImage imageNamed:@"NSActionMenuCopy"];
416         break;
417
418     case kWKContextActionItemTagLookupText:
419         selector = @selector(_lookupText:);
420         title = @"Lookup";
421         image = [NSImage imageNamed:@"NSActionMenuLookup"];
422         break;
423
424     default:
425         ASSERT_NOT_REACHED();
426         return nil;
427     }
428
429     RetainPtr<NSMenuItem> item = adoptNS([[NSMenuItem alloc] initWithTitle:title action:selector keyEquivalent:@""]);
430     [item setImage:image];
431     [item setTarget:self];
432     [item setTag:tag];
433     return item;
434 }
435
436 static NSImage *webKitBundleImageNamed(NSString *name)
437 {
438     return [[NSBundle bundleForClass:[WKView class]] imageForResource:name];
439 }
440
441 - (NSArray *)_defaultMenuItems
442 {
443     if (WebHitTestResult* hitTestResult = _page->lastMouseMoveHitTestResult()) {
444         if (!hitTestResult->absoluteImageURL().isEmpty() && _hitTestResult.image) {
445             _type = kWKActionMenuImage;
446             return [self _defaultMenuItemsForImage];
447         }
448
449         if (!hitTestResult->absoluteLinkURL().isEmpty()) {
450             _type = kWKActionMenuLink;
451             return [self _defaultMenuItemsForLink];
452         }
453
454         if (hitTestResult->isTextNode()) {
455             if (DDActionContext *actionContext = _hitTestResult.actionContext.get()) {
456                 WKSetDDActionContextIsForActionMenu(actionContext);
457                 actionContext.highlightFrame = [_wkView.window convertRectToScreen:[_wkView convertRect:_hitTestResult.actionBoundingBox toView:nil]];
458                 NSArray *dataDetectorMenuItems = [[getDDActionsManagerClass() sharedManager] menuItemsForResult:[_hitTestResult.actionContext mainResult] actionContext:actionContext];
459                 if (dataDetectorMenuItems.count) {
460                     _type = kWKActionMenuDataDetectedItem;
461                     return dataDetectorMenuItems;
462                 }
463             }
464             _type = kWKActionMenuReadOnlyText;
465             return [self _defaultMenuItemsForText];
466         }
467     }
468
469     _type = kWKActionMenuNone;
470     return _state != ActionMenuState::Ready ? @[ [NSMenuItem separatorItem] ] : @[ ];
471 }
472
473 - (void)_updateActionMenuItems
474 {
475     [_wkView.actionMenu removeAllItems];
476
477     NSArray *menuItems = [self _defaultMenuItems];
478     if ([_wkView respondsToSelector:@selector(_actionMenuItemsForHitTestResult:defaultActionMenuItems:)])
479         menuItems = [_wkView _actionMenuItemsForHitTestResult:toAPI(_page->lastMouseMoveHitTestResult()) defaultActionMenuItems:menuItems];
480     else
481         menuItems = [_wkView _actionMenuItemsForHitTestResult:toAPI(_page->lastMouseMoveHitTestResult()) withType:_type defaultActionMenuItems:menuItems];
482
483     for (NSMenuItem *item in menuItems)
484         [_wkView.actionMenu addItem:item];
485 }
486
487 @end
488
489 #endif // PLATFORM(MAC)