diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 5cfd7f0085..9b470a8b26 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -1050,6 +1050,21 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS arguments:@[ @(client) ]]; } +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + shareSelectedText:(NSString*)selectedText { + [self.platformPlugin showShareViewController:selectedText]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + searchWebWithSelectedText:(NSString*)selectedText { + [self.platformPlugin searchWeb:selectedText]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + lookUpSelectedText:(NSString*)selectedText { + [self.platformPlugin showLookUpViewController:selectedText]; +} + #pragma mark - FlutterViewEngineDelegate - (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h index 45fd69cddf..f8f40edabd 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h @@ -14,6 +14,9 @@ - (instancetype)initWithEngine:(FlutterEngine*)engine NS_DESIGNATED_INITIALIZER; - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; +- (void)showShareViewController:(NSString*)content; +- (void)searchWeb:(NSString*)searchTerm; +- (void)showLookUpViewController:(NSString*)term; @end namespace flutter { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 0bf715a880..d2464dcbd1 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -67,6 +67,12 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { didResignFirstResponderWithTextInputClient:(int)client; - (void)flutterTextInputView:(FlutterTextInputView*)textInputView willDismissEditMenuWithTextInputClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + shareSelectedText:(NSString*)selectedText; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + searchWebWithSelectedText:(NSString*)selectedText; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + lookUpSelectedText:(NSString*)selectedText; @end #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTDELEGATE_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 08ea6910bb..2b53e96b59 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -802,6 +802,7 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, // etc) @property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; @property(nonatomic, assign) CGRect editMenuTargetRect; +@property(nonatomic, strong) NSArray* editMenuItems; - (void)setEditableTransform:(NSArray*)matrix; @end @@ -875,10 +876,123 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, return self; } +- (void)handleSearchWebAction { + [self.textInputDelegate flutterTextInputView:self + searchWebWithSelectedText:[self textInRange:_selectedTextRange]]; +} + +- (void)handleLookUpAction { + [self.textInputDelegate flutterTextInputView:self + lookUpSelectedText:[self textInRange:_selectedTextRange]]; +} + +- (void)handleShareAction { + [self.textInputDelegate flutterTextInputView:self + shareSelectedText:[self textInRange:_selectedTextRange]]; +} + +// DFS algorithm to search a UICommand from the menu tree. +- (UICommand*)searchCommandWithSelector:(SEL)selector + element:(UIMenuElement*)element API_AVAILABLE(ios(16.0)) { + if ([element isKindOfClass:UICommand.class]) { + UICommand* command = (UICommand*)element; + return command.action == selector ? command : nil; + } else if ([element isKindOfClass:UIMenu.class]) { + NSArray* children = ((UIMenu*)element).children; + for (UIMenuElement* child in children) { + UICommand* result = [self searchCommandWithSelector:selector element:child]; + if (result) { + return result; + } + } + return nil; + } else { + return nil; + } +} + +- (void)addBasicEditingCommandToItems:(NSMutableArray*)items + type:(NSString*)type + selector:(SEL)selector + suggestedMenu:(UIMenu*)suggestedMenu { + UICommand* command = [self searchCommandWithSelector:selector element:suggestedMenu]; + if (command) { + [items addObject:command]; + } else { + FML_LOG(ERROR) << "Cannot find context menu item of type \"" << type.UTF8String << "\"."; + } +} + +- (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items + type:(NSString*)type + selector:(SEL)selector + encodedItem:(NSDictionary*)encodedItem { + NSString* title = encodedItem[@"title"]; + if (title) { + UICommand* command = [UICommand commandWithTitle:title + image:nil + action:selector + propertyList:nil]; + [items addObject:command]; + } else { + FML_LOG(ERROR) << "Missing title for context menu item of type \"" << type.UTF8String << "\"."; + } +} + - (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction menuForConfiguration:(UIEditMenuConfiguration*)configuration suggestedActions:(NSArray*)suggestedActions API_AVAILABLE(ios(16.0)) { - return [UIMenu menuWithChildren:suggestedActions]; + UIMenu* suggestedMenu = [UIMenu menuWithChildren:suggestedActions]; + if (!_editMenuItems) { + return suggestedMenu; + } + + NSMutableArray* items = [NSMutableArray array]; + for (NSDictionary* encodedItem in _editMenuItems) { + NSString* type = encodedItem[@"type"]; + if ([type isEqualToString:@"copy"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(copy:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"paste"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(paste:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"cut"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(cut:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"delete"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(delete:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"selectAll"]) { + [self addBasicEditingCommandToItems:items + type:type + selector:@selector(selectAll:) + suggestedMenu:suggestedMenu]; + } else if ([type isEqualToString:@"searchWeb"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleSearchWebAction) + encodedItem:encodedItem]; + } else if ([type isEqualToString:@"share"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleShareAction) + encodedItem:encodedItem]; + } else if ([type isEqualToString:@"lookUp"]) { + [self addAdditionalBasicCommandToItems:items + type:type + selector:@selector(handleLookUpAction) + encodedItem:encodedItem]; + } + } + return [UIMenu menuWithChildren:items]; } - (void)editMenuInteraction:(UIEditMenuInteraction*)interaction @@ -894,8 +1008,10 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, return _editMenuTargetRect; } -- (void)showEditMenuWithTargetRect:(CGRect)targetRect API_AVAILABLE(ios(16.0)) { +- (void)showEditMenuWithTargetRect:(CGRect)targetRect + items:(NSArray*)items API_AVAILABLE(ios(16.0)) { _editMenuTargetRect = targetRect; + _editMenuItems = items; UIEditMenuConfiguration* config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:CGPointZero]; [self.editMenuInteraction presentEditMenuWithConfiguration:config]; @@ -2574,7 +2690,7 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, [encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue], [encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]); CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView]; - [self.activeView showEditMenuWithTargetRect:localTargetRect]; + [self.activeView showEditMenuWithTargetRect:localTargetRect items:args[@"items"]]; return YES; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index c51db3219c..42e71551de 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -30,6 +30,9 @@ FLUTTER_ASSERT_ARC - (BOOL)isVisibleToAutofill; - (id)textInputDelegate; - (void)configureWithDictionary:(NSDictionary*)configuration; +- (void)handleSearchWebAction; +- (void)handleLookUpAction; +- (void)handleShareAction; @end @interface FlutterTextInputViewSpy : FlutterTextInputView @@ -3035,6 +3038,264 @@ FLUTTER_ASSERT_ARC } } +- (void)testEditMenu_shouldPresentEditMenuWithSuggestedItemsByDefaultIfNoFrameworkData { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + // No items provided from framework. Show the suggested items by default. + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + NSArray* suggestedActions = @[ copyItem, pasteItem ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + XCTAssertEqualObjects(menu.children, suggestedActions, + @"Must show suggested items by default."); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBasicEditingActions { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = + @[ @{@"type" : @"paste"}, @{@"type" : @"copy"} ]; + + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + NSArray* suggestedActions = @[ copyItem, pasteItem ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + // The item ordering should follow the encoded data sent from the framework. + NSArray* expectedChildren = @[ pasteItem, copyItem ]; + XCTAssertEqualObjects(menu.children, expectedChildren); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBasicEditingActions { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = + @[ @{@"type" : @"cut"}, @{@"type" : @"paste"}, @{@"type" : @"copy"} ]; + + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + UICommand* copyItem = [UICommand commandWithTitle:@"Copy" + image:nil + action:@selector(copy:) + propertyList:nil]; + UICommand* cutItem = [UICommand commandWithTitle:@"Cut" + image:nil + action:@selector(cut:) + propertyList:nil]; + UICommand* pasteItem = [UICommand commandWithTitle:@"Paste" + image:nil + action:@selector(paste:) + propertyList:nil]; + /* + A more complex menu hierarchy for DFS: + + menu + / | \ + copy menu menu + | \ + paste menu + | + cut + */ + NSArray* suggestedActions = @[ + copyItem, [UIMenu menuWithChildren:@[ pasteItem ]], + [UIMenu menuWithChildren:@[ [UIMenu menuWithChildren:@[ cutItem ]] ]] + ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + // The item ordering should follow the encoded data sent from the framework. + NSArray* expectedActions = @[ cutItem, pasteItem, copyItem ]; + XCTAssertEqualObjects(menu.children, expectedActions); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + NSArray*>* encodedItems = @[ + @{@"type" : @"searchWeb", @"title" : @"Search Web"}, + @{@"type" : @"lookUp", @"title" : @"Look Up"}, @{@"type" : @"share", @"title" : @"Share"} + ]; + + BOOL shownEditMenu = + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + NSArray* suggestedActions = @[ + [UICommand commandWithTitle:@"copy" image:nil action:@selector(copy:) propertyList:nil], + ]; + + UIMenu* menu = [myInputView editMenuInteraction:mockInteraction + menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class]) + suggestedActions:suggestedActions]; + XCTAssert(menu.children.count == 3, @"There must be 3 menu items"); + + XCTAssert(((UICommand*)menu.children[0]).action == @selector(handleSearchWebAction), + @"Must create search web item in the tree."); + XCTAssert(((UICommand*)menu.children[1]).action == @selector(handleLookUpAction), + @"Must create look up item in the tree."); + XCTAssert(((UICommand*)menu.children[2]).action == @selector(handleShareAction), + @"Must create share item in the tree."); + } +} + - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [UIApplication.sharedApplication.keyWindow addSubview:inputView];