rajdeep / proton

Purely native and extensible rich text editor for iOS and macOS Catalyst apps
Other
1.27k stars 81 forks source link
editor extensible ios maccatalyst native plugin-architecture rich-text-editor richtexteditor swift text textkit wysiwyg wysiwyg-editor
Proton logo

Note: While Proton is already a very powerful and flexible framework, it is still in early stages of development. The APIs and public interfaces are still undergoing revisions and may introduce breaking changes with every version bump before reaching stable version 1.0.0.

Build codecov License

Proton is a simple library that allows you to extend the behavior of a textview to add rich content that you always wanted. It provides simple API that allows you to extend the textView to include complex content like nested textViews or for that matter, any other UIView. In the simplest terms - It's what you always wanted UITextView to be.

Proton is designed keeping the following requirements in mind:

Core Concepts

At it's core, Proton constitutes of following key components:

A practical use case

The power of EditorView to host rich content is made possible by the use of Attachment which allows hosting any UIView in the EditorView. This is further enhanced by use of TextProcessor and EditorCommand to add interactive behavior to the editing experience.

Let's take an example of a Panel and see how that can be created in the EditorView. Following are the key requirements for a Panel:

  1. A text block that is indented and has a custom UI besides the Editor.
  2. Change height based on the content being typed.
  3. Have a different font color than the main text.
  4. Able to be inserted using a button.
  5. Able to be inserted by selecting text and clicking a button.
  6. Able to be inserted in a given Editor by use of >> char.
  7. Nice to have: delete using backspace key when empty similar to a Blockquote.

Panel view

  1. The first thing that is required is to create a view that represents the Panel. Once we have created this view, we can add it to an attachment and insert it in the EditorView.

    extension EditorContent.Name {
        static let panel = EditorContent.Name("panel")
    }
    class PanelView: UIView, BlockContent, EditorContentView {
        let container = UIView()
        let editor: EditorView
        let iconView = UIImageView()    
        var name: EditorContent.Name {
            return .panel
        }   
        override init(frame: CGRect) {
            self.editor = EditorView(frame: frame)
            super.init(frame: frame)    
            setup()
        }   
        var textColor: UIColor {
            get { editor.textColor }
            set { editor.textColor = newValue }
        }   
        override var backgroundColor: UIColor? {
            get { container.backgroundColor }
            set {
                container.backgroundColor = newValue
                editor.backgroundColor = newValue
            }
        }   
        private func setup() {
            // setup view by creating required constraints
        }
    }
  2. As the Panel contains an Editor inside itself, the height will automatically change based on the content as it is typed in. To restrict the height to a given maximum value, an absolute size or autolayout constraint may be used.

  3. Using the textColor property, the default font color may be changed.

  4. For the ability to add Panel to the Editor using a button, we can make use of EditorCommand. A Command can be executed on a given EditorView or via CommandExecutor that automatically takes care of executing the command on the focussed EditorView. To insert an EditorView inside another, we need to first create an Attachment and then used a Command to add to the desired position:

    class PanelAttachment: Attachment {
        var view: PanelView 
        init(frame: CGRect) {
            view = PanelView(frame: frame)
            super.init(view, size: .fullWidth)
            view.delegate = self
            view.boundsObserver = self
        }   
        var attributedText: NSAttributedString {
            get { view.attributedText }
            set { view.attributedText = newValue }
        }   
    }   
    class PanelCommand: EditorCommand {
        func execute(on editor: EditorView) {
            let selectedText = editor.selectedText  
            let attachment = PanelAttachment(frame: .zero)
            attachment.selectBeforeDelete = true
            editor.insertAttachment(in: editor.selectedRange, attachment: attachment)   
            let panel = attachment.view
            panel.editor.maxHeight = 300
            panel.editor.replaceCharacters(in: .zero, with: selectedText)
            panel.editor.selectedRange = panel.editor.textEndRange
        }
    }
  5. The code in PanelCommand.execute reads the selectedText from editor and sets it back in panel.editor. This makes it possible to take the selected text from main editor, wrap it in a panel and then insert the panel in the main editor replacing the selected text.

  6. To allow insertion of a Panel using a shortcut text input instead of clicking a button, you can use a TextProcessor:

    class PanelTextProcessor: TextProcessing {  
     private let trigger = ">> "
     var name: String {
         return "PanelTextProcessor"
     }  
     var priority: TextProcessingPriority {
         return .medium
     }  
     func process(editor: EditorView, range editedRange: NSRange, changeInLength delta: Int, processed: inout Bool) {
         let line = editor.currentLine
         guard line.text.string == trigger else {
             return
         }
         let attachment = PanelAttachment(frame: .zero)
         attachment.selectBeforeDelete = true        
         editor.insertAttachment(in: line.range, attachment: attachment)
     }
  7. For a requirement like deleting the Panel when backspace is tapped at index 0 on an empty Panel, EdtiorViewDelegate may be utilized:

    extension PanelAttachment: PanelViewDelegate {
    
    func panel(_ panel: PanelView, didReceiveKey key: EditorKey, at range: NSRange, handled: inout Bool) {
        if key == .backspace, range == .zero, panel.editor.attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            removeFromContainer()
            handled = true
            }
        }
    }    

    In the code above, PanelViewDelegate is acting as a passthrough for EditorViewDelegate for the Editor inside the PanelView.

    Checkout the complete code in the ExamplesApp.

Example usages

  1. Changing text as it is typed using custom TextProcessor:

    Markup text processor
  2. Adding attributes as it is typed using custom TextProcessor:

    Mentions text processor
  3. Nested editors

    Nested editors
  4. Panel from existing text:

    Panel from text
  5. Relaying attributes to editor contained in an attachment:

    Relay attributes
  6. Highlighting using custom command in Editor:

    Highlight in Renderer
  7. Find text and scroll in Editor:

    Find in Renderer

Basic SWIFT UI integration example

Proton's Editor may be used with SwiftUI same was as a standard UIKit component. SwiftUI support is provided as is, and will be refined on in future.

struct ProtonView: View {

    @Binding var attributedText: NSAttributedString
    @State var height: CGFloat = 0
    var body: some View {
        ProtonWrapperView(attributedText: $attributedText) { view in
            let height = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).height

            self.height = height
        }
        .frame(height: height)
    }
}

struct ProtonWrapperView: UIViewRepresentable {

    @Binding var attributedText: NSAttributedString
    let textDidChange: (EditorView) -> Void

    func makeUIView(context: Context) -> EditorView {
        let view = EditorView()
        view.becomeFirstResponder()
        view.attributedText = attributedText
        view.isScrollEnabled = false
        view.setContentCompressionResistancePriority(.required, for: .vertical)

        view.heightAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true

        DispatchQueue.main.async {
            self.textDidChange(view)
        }

        EditorViewContext.shared.delegate = context.coordinator

        return view
    }

    func updateUIView(_ view: EditorView, context: Context) {

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, EditorViewDelegate {
        var parent: ProtonWrapperView

        init(_ parent: ProtonWrapperView) {
            self.parent = parent
        }

        func editor(_ editor: EditorView, didChangeTextAt range: NSRange) {
            editor.isScrollEnabled = false
            parent.attributedText = editor.attributedText
            DispatchQueue.main.async {
                self.parent.textDidChange(editor)
            }
        }
    }
}

Learn more

Questions and feature requests

Feel free to create issues in github should you have any questions or feature requests. While Proton is created as a side project, I'll endeavour to respond to your issues at earliest possible.

License

Proton is released under the Apache 2.0 license. Please see LICENSE for details.