Skip to content
This repository has been archived by the owner on Sep 27, 2024. It is now read-only.

[iOS] Handle paste and key commands #759

Merged
merged 1 commit into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions platforms/ios/example/Wysiwyg/Views/Composer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
14 changes: 13 additions & 1 deletion platforms/ios/example/Wysiwyg/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<//strong>")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,8 +56,14 @@ public struct WysiwygComposerView: UIViewRepresentable {
// MARK: - Public

public init(focused: Binding<Bool>,
viewModel: WysiwygComposerViewModelProtocol) {
viewModel: WysiwygComposerViewModelProtocol,
itemProviderHelper: WysiwygItemProviderHelper?,
keyCommandHandler: KeyCommandHandler?,
pasteHandler: PasteHandler?) {
_focused = focused
self.itemProviderHelper = itemProviderHelper
self.keyCommandHandler = keyCommandHandler
self.pasteHandler = pasteHandler
self.viewModel = viewModel
}

Expand All @@ -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
Expand All @@ -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<Bool>
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<Bool>,
_ 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 {
Expand Down Expand Up @@ -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()
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -386,6 +377,10 @@ public extension WysiwygComposerViewModel {
func getLinkAction() -> LinkAction {
model.getLinkAction()
}

func enter() {
applyUpdate(createEnterUpdate(), skipTextViewUpdate: false)
}
}

// MARK: - Private
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
Loading