diff --git a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj index 70e7b7c2c..951d27327 100644 --- a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj +++ b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj @@ -147,9 +147,9 @@ A6472CAA2886CF830021A0E8 /* WysiwygApp.swift */, A6472CAE2886CF840021A0E8 /* Assets.xcassets */, A6F3FC0228D4659E00C170E8 /* Extensions */, + A6E13E4629A7AB3600A85A55 /* Mocks */, A6F4D0CD29AE0BE100087A3E /* Pills */, A6472CB02886CF840021A0E8 /* Preview Content */, - A6E13E4629A7AB3600A85A55 /* Mocks */, A6C2157A28C0F63400C8E727 /* Views */, ); path = Wysiwyg; diff --git a/platforms/ios/example/Wysiwyg/Views/Composer.swift b/platforms/ios/example/Wysiwyg/Views/Composer.swift index 1075d1e9d..fbf537fc2 100644 --- a/platforms/ios/example/Wysiwyg/Views/Composer.swift +++ b/platforms/ios/example/Wysiwyg/Views/Composer.swift @@ -21,6 +21,9 @@ import WysiwygComposer /// that grows to a max height. struct Composer: View { @ObservedObject var viewModel: WysiwygComposerViewModel + let itemProviderHelper: WysiwygItemProviderHelper? + let keyCommandHandler: KeyCommandHandler? + let pasteHandler: PasteHandler? let minTextViewHeight: CGFloat = 20 let borderHeight: CGFloat = 40 @State var focused = false @@ -34,7 +37,10 @@ struct Composer: View { HStack { WysiwygComposerView( focused: $focused, - viewModel: viewModel + viewModel: viewModel, + itemProviderHelper: itemProviderHelper, + keyCommandHandler: keyCommandHandler, + pasteHandler: pasteHandler ) .tintColor(.green) .placeholder("Placeholder", color: .gray) @@ -75,6 +81,9 @@ struct Composer: View { struct Composer_Previews: PreviewProvider { static let viewModel = WysiwygComposerViewModel() static var previews: some View { - Composer(viewModel: viewModel) + Composer(viewModel: viewModel, + itemProviderHelper: nil, + keyCommandHandler: nil, + pasteHandler: nil) } } diff --git a/platforms/ios/example/Wysiwyg/Views/ContentView.swift b/platforms/ios/example/Wysiwyg/Views/ContentView.swift index d604881ce..b677378d0 100644 --- a/platforms/ios/example/Wysiwyg/Views/ContentView.swift +++ b/platforms/ios/example/Wysiwyg/Views/ContentView.swift @@ -34,7 +34,19 @@ struct ContentView: View { var body: some View { Spacer() .frame(width: nil, height: 50, alignment: .center) - Composer(viewModel: viewModel) + Composer(viewModel: viewModel, + itemProviderHelper: nil, + keyCommandHandler: { keyCommand in + switch keyCommand { + case .enter: + sentMessage = viewModel.content + viewModel.clearContent() + return true + case .shiftEnter: + return false + } + }, + pasteHandler: { _ in }) Button("Force crash") { viewModel.setHtmlContent("") } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift index 2b47abfe1..a9349beb0 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerView.swift @@ -17,11 +17,34 @@ import OSLog import SwiftUI +/// A protocol to implement in order to inform the composer if a specific item +/// can be pasted into the application. +public protocol WysiwygItemProviderHelper { + /// Determine if the item attached to given item provider can be pasted into the application. + /// + /// - Parameter itemProvider: The item provider. + /// - Returns: True if it can be pasted, false otherwise. + func isPasteSupported(for itemProvider: NSItemProvider) -> Bool +} + +/// Handler for key commands. +public typealias KeyCommandHandler = (WysiwygKeyCommand) -> Bool +/// Handler for paste events. +public typealias PasteHandler = (NSItemProvider) -> Void + /// Provides a SwiftUI displayable view for the composer UITextView component. public struct WysiwygComposerView: UIViewRepresentable { // MARK: - Public + /// Binding var handling whether the composer is currently focused or not. @Binding public var focused: Bool + /// A helper to determine if an item can be pasted into the hosting application. + /// If omitted, most non-text paste events will be ignored. + let itemProviderHelper: WysiwygItemProviderHelper? + /// A handler for key commands. If omitted, default behaviour will be applied. See `WysiwygKeyCommand.swift`. + let keyCommandHandler: KeyCommandHandler? + /// A handler for paste events. If omitted, the composer will try to paste content as raw text. + let pasteHandler: PasteHandler? // MARK: - Private @@ -33,8 +56,14 @@ public struct WysiwygComposerView: UIViewRepresentable { // MARK: - Public public init(focused: Binding, - viewModel: WysiwygComposerViewModelProtocol) { + viewModel: WysiwygComposerViewModelProtocol, + itemProviderHelper: WysiwygItemProviderHelper?, + keyCommandHandler: KeyCommandHandler?, + pasteHandler: PasteHandler?) { _focused = focused + self.itemProviderHelper = itemProviderHelper + self.keyCommandHandler = keyCommandHandler + self.pasteHandler = pasteHandler self.viewModel = viewModel } @@ -54,6 +83,7 @@ public struct WysiwygComposerView: UIViewRepresentable { textView.backgroundColor = .clear textView.tintColor = UIColor(tintColor) textView.clipsToBounds = false + textView.wysiwygDelegate = context.coordinator textView.placeholderFont = UIFont.preferredFont(forTextStyle: .body) textView.placeholderColor = UIColor(placeholderColor) textView.placeholder = placeholder @@ -74,23 +104,44 @@ public struct WysiwygComposerView: UIViewRepresentable { } public func makeCoordinator() -> Coordinator { - Coordinator($focused, viewModel.replaceText, viewModel.select, viewModel.didUpdateText) + Coordinator($focused, + viewModel.replaceText, + viewModel.select, + viewModel.didUpdateText, + viewModel.enter, + itemProviderHelper: itemProviderHelper, + keyCommandHandler: keyCommandHandler, + pasteHandler: pasteHandler) } /// Coordinates UIKit communication. - public class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate { + public class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate, WysiwygTextViewDelegate { var focused: Binding var replaceText: (NSRange, String) -> Bool var select: (NSRange) -> Void var didUpdateText: () -> Void + var enter: () -> Void + + private let itemProviderHelper: WysiwygItemProviderHelper? + private let keyCommandHandler: KeyCommandHandler? + private let pasteHandler: PasteHandler? + init(_ focused: Binding, _ replaceText: @escaping (NSRange, String) -> Bool, _ select: @escaping (NSRange) -> Void, - _ didUpdateText: @escaping () -> Void) { + _ didUpdateText: @escaping () -> Void, + _ enter: @escaping () -> Void, + itemProviderHelper: WysiwygItemProviderHelper?, + keyCommandHandler: KeyCommandHandler?, + pasteHandler: PasteHandler?) { self.focused = focused self.replaceText = replaceText self.select = select self.didUpdateText = didUpdateText + self.enter = enter + self.itemProviderHelper = itemProviderHelper + self.keyCommandHandler = keyCommandHandler + self.pasteHandler = pasteHandler } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { @@ -137,6 +188,37 @@ public struct WysiwygComposerView: UIViewRepresentable { textView.selectedRange = characterRange return false } + + func isPasteSupported(for itemProvider: NSItemProvider) -> Bool { + guard let itemProviderHelper else { + return false + } + + return itemProviderHelper.isPasteSupported(for: itemProvider) + } + + func textViewDidReceiveKeyCommand(_ textView: UITextView, keyCommand: WysiwygKeyCommand) { + if !handleKeyCommand(keyCommand) { + processDefault(for: keyCommand) + } + } + + func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) { + pasteHandler?(provider) + } + + private func handleKeyCommand(_ keyCommand: WysiwygKeyCommand) -> Bool { + guard let keyCommandHandler else { return false } + + return keyCommandHandler(keyCommand) + } + + private func processDefault(for keyCommand: WysiwygKeyCommand) { + switch keyCommand { + case .enter, .shiftEnter: + enter() + } + } } } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift index 119acc367..88b6d45c4 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift @@ -301,16 +301,7 @@ public extension WysiwygComposerViewModel { update = model.backspace() shouldAcceptChange = false } else if replacementText.count == 1, replacementText[String.Index(utf16Offset: 0, in: replacementText)].isNewline { - update = model.enter() - // Pending formats need to be reapplied to the - // NSAttributedString upon next character input if we - // are in a structure that might add non-formatted - // representation chars to it (e.g. NBSP/ZWSP, list prefixes) - if !model - .reversedActions - .isDisjoint(with: [.codeBlock, .quote, .orderedList, .unorderedList]) { - hasPendingFormats = true - } + update = createEnterUpdate() shouldAcceptChange = false } else { update = model.replaceText(newText: replacementText) @@ -386,6 +377,10 @@ public extension WysiwygComposerViewModel { func getLinkAction() -> LinkAction { model.getLinkAction() } + + func enter() { + applyUpdate(createEnterUpdate(), skipTextViewUpdate: false) + } } // MARK: - Private @@ -585,6 +580,20 @@ private extension WysiwygComposerViewModel { return markdownContent } + + func createEnterUpdate() -> ComposerUpdate { + let update = model.enter() + // Pending formats need to be reapplied to the + // NSAttributedString upon next character input if we + // are in a structure that might add non-formatted + // representation chars to it (e.g. NBSP/ZWSP, list prefixes) + if !model + .reversedActions + .isDisjoint(with: [.codeBlock, .quote, .orderedList, .unorderedList]) { + hasPendingFormats = true + } + return update + } } // MARK: - ComposerModelWrapperDelegate diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift index 3905701cc..bb1babcf3 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModelProtocol.swift @@ -38,4 +38,7 @@ public protocol WysiwygComposerViewModelProtocol: AnyObject { /// Notify that the text view content has changed. func didUpdateText() + + /// Apply an enter/return key event. + func enter() } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift index 9deff8d5c..35c953f69 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygTextView.swift @@ -16,7 +16,34 @@ import UIKit +/// An internal delegate for the `WysiwygTextView`, used to bring paste and key commands events +/// to SwiftUI through the `WysiwygComposerView` coordinator class. +protocol WysiwygTextViewDelegate: AnyObject { + /// Asks the delegate if the item attached to given item provider can be pasted into the application. + /// + /// - Parameter itemProvider: The item provider. + /// - Returns: True if it can be pasted, false otherwise. + func isPasteSupported(for itemProvider: NSItemProvider) -> Bool + + /// Notify the delegate that a key command has been received by the text view. + /// + /// - Parameters: + /// - textView: Composer text view. + /// - keyCommand: Key command received. + func textViewDidReceiveKeyCommand(_ textView: UITextView, keyCommand: WysiwygKeyCommand) + + /// Notify the delegate that a paste event has beeb received by the text view. + /// + /// - Parameters: + /// - textView: Composer text view. + /// - provider: Item provider for the paste event. + func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) +} + public class WysiwygTextView: UITextView { + /// Internal delegate for the text view. + weak var wysiwygDelegate: WysiwygTextViewDelegate? + var shouldShowPlaceholder = true { didSet { setNeedsDisplay() @@ -127,6 +154,49 @@ public class WysiwygTextView: UITextView { width: rect.width, height: glyphRect.height + 2 * Constants.caretVerticalOffset) } + + // Enter Key commands support + + override public var keyCommands: [UIKeyCommand]? { + WysiwygKeyCommand.allCases.map { UIKeyCommand(input: $0.input, + modifierFlags: $0.modifierFlags, + action: #selector(keyCommandAction)) } + } + + @objc func keyCommandAction(sender: UIKeyCommand) { + guard let command = WysiwygKeyCommand.from(sender) else { return } + + wysiwygDelegate?.textViewDidReceiveKeyCommand(self, keyCommand: command) + } + + // Paste support + + override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + guard !super.canPerformAction(action, withSender: sender) else { + return true + } + + guard action == #selector(paste(_:)), + let itemProvider = UIPasteboard.general.itemProviders.first, + let wysiwygDelegate else { + return false + } + + return wysiwygDelegate.isPasteSupported(for: itemProvider) + } + + override public func paste(_ sender: Any?) { + guard let provider = UIPasteboard.general.itemProviders.first, + let wysiwygDelegate, + wysiwygDelegate.isPasteSupported(for: provider) else { + // If the item is not supported by the hosting application + // just try pasting its contents into the textfield + super.paste(sender) + return + } + + wysiwygDelegate.textView(self, didReceivePasteWith: provider) + } } private extension WysiwygTextView { diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygKeyCommand.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygKeyCommand.swift new file mode 100644 index 000000000..6912cd7d8 --- /dev/null +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygKeyCommand.swift @@ -0,0 +1,48 @@ +// +// Copyright 2023 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +/// An enum describing key commands that can be handled by the hosting application. +/// This can be done by providing a `KeyCommandHandler` to the `WysiwygComposerView`. +/// If handler is nil, or if the handler returns false, a default behaviour will be applied (see cases description). +public enum WysiwygKeyCommand: CaseIterable { + /// User pressed `enter`. Default behaviour: a line feed is created. + /// Note: in the context of a messaging app, this is usually used to send a message. + case enter + /// User pressed `shift` + `enter`. Default behaviour: a line feed is created. + case shiftEnter + + var input: String { + switch self { + case .enter, .shiftEnter: + return "\r" + } + } + + var modifierFlags: UIKeyModifierFlags { + switch self { + case .enter: + return [] + case .shiftEnter: + return .shift + } + } + + static func from(_ keyCommand: UIKeyCommand) -> WysiwygKeyCommand? { + WysiwygKeyCommand.allCases.first(where: { $0.input == keyCommand.input && $0.modifierFlags == keyCommand.modifierFlags }) + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerSnapshotTests/SnapshotTests.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerSnapshotTests/SnapshotTests.swift index ff186ee67..ac244600e 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerSnapshotTests/SnapshotTests.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerSnapshotTests/SnapshotTests.swift @@ -28,7 +28,11 @@ class SnapshotTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() let binding: Binding = .init(get: { true }, set: { _ in }) - let composerView = WysiwygComposerView(focused: binding, viewModel: viewModel) + let composerView = WysiwygComposerView(focused: binding, + viewModel: viewModel, + itemProviderHelper: nil, + keyCommandHandler: nil, + pasteHandler: nil) .placeholder("Placeholder") hostingController = UIHostingController(rootView: composerView) }