Skip to content

Instantly share code, notes, and snippets.

@fabfelici
Last active March 9, 2024 07:47
Show Gist options
  • Save fabfelici/837037f7737dc24765fe3035b15255ce to your computer and use it in GitHub Desktop.
Save fabfelici/837037f7737dc24765fe3035b15255ce to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1.0" />
</head>
<body>
<div id="editor" contenteditable="true"></div>
</body>
</html>
var richeditor = {};
var editor = document.getElementById("editor");
richeditor.insertText = function(text) {
editor.innerHTML = text;
window.webkit.messageHandlers.heightDidChange.postMessage(document.body.offsetHeight);
}
editor.addEventListener("input", function() {
window.webkit.messageHandlers.textDidChange.postMessage(editor.innerHTML);
}, false)
document.addEventListener("selectionchange", function() {
window.webkit.messageHandlers.heightDidChange.postMessage(document.body.offsetHeight);
}, false);
public protocol RichTextEditorDelegate: class {
func textDidChange(text: String)
func heightDidChange()
}
fileprivate class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
weak var delegate: WKScriptMessageHandler?
init(delegate: WKScriptMessageHandler) {
self.delegate = delegate
}
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
self.delegate?.userContentController(userContentController, didReceive: message)
}
}
public class RichTextEditor: UIView, WKScriptMessageHandler, WKNavigationDelegate, UIScrollViewDelegate {
private static let textDidChange = "textDidChange"
private static let heightDidChange = "heightDidChange"
private static let defaultHeight: CGFloat = 60
public weak var delegate: RichTextEditorDelegate?
public var height: CGFloat = RichTextEditor.defaultHeight
public var placeholder: String? {
didSet {
placeholderLabel.text = placeholder
}
}
private var textToLoad: String?
public var text: String? {
didSet {
guard let text = text else { return }
if editorView.isLoading {
textToLoad = text
} else {
editorView.evaluateJavaScript("richeditor.insertText(\"\(text.htmlEscapeQuotes)\");", completionHandler: nil)
placeholderLabel.isHidden = !text.htmlToPlainText.isEmpty
}
}
}
private var editorView: WKWebView!
private let placeholderLabel = UILabel()
public override init(frame: CGRect = .zero) {
placeholderLabel.textColor = UIColor.lightGray.withAlphaComponent(0.65)
guard let bundlePath = Bundle(for: type(of: self)).path(forResource: "Resources", ofType: "bundle"),
let bundle = Bundle(path: bundlePath),
let scriptPath = bundle.path(forResource: "RichTextEditor", ofType: "js"),
let scriptContent = try? String(contentsOfFile: scriptPath, encoding: String.Encoding.utf8),
let htmlPath = bundle.path(forResource: "RichTextEditor", ofType: "html"),
let html = try? String(contentsOfFile: htmlPath, encoding: String.Encoding.utf8)
else { fatalError("Unable to find javscript/html for text editor") }
let configuration = WKWebViewConfiguration()
configuration.userContentController.addUserScript(
WKUserScript(source: scriptContent,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
)
editorView = WKWebView(frame: .zero, configuration: configuration)
super.init(frame: frame)
[RichTextEditor.textDidChange, RichTextEditor.heightDidChange].forEach {
configuration.userContentController.add(WeakScriptMessageHandler(delegate: self), name: $0)
}
editorView.navigationDelegate = self
editorView.isOpaque = false
editorView.backgroundColor = .clear
editorView.scrollView.isScrollEnabled = false
editorView.scrollView.showsHorizontalScrollIndicator = false
editorView.scrollView.showsVerticalScrollIndicator = false
editorView.scrollView.bounces = false
editorView.scrollView.isScrollEnabled = false
editorView.scrollView.delegate = self
addSubview(placeholderLabel)
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
placeholderLabel.topAnchor.constraint(equalTo: topAnchor),
placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
placeholderLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
])
addSubview(editorView)
editorView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
editorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
editorView.topAnchor.constraint(equalTo: topAnchor, constant: 10),
editorView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
editorView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
editorView.loadHTMLString(html, baseURL: nil)
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case RichTextEditor.textDidChange:
guard let body = message.body as? String else { return }
placeholderLabel.isHidden = !body.htmlToPlainText.isEmpty
delegate?.textDidChange(text: body)
case RichTextEditor.heightDidChange:
guard let height = message.body as? CGFloat else { return }
self.height = height > RichTextEditor.defaultHeight ? height + 30 : RichTextEditor.defaultHeight
delegate?.heightDidChange()
default:
break
}
}
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if let textToLoad = textToLoad {
self.textToLoad = nil
text = textToLoad
}
}
public func viewForZooming(in: UIScrollView) -> UIView? {
return nil
}
}
fileprivate extension String {
var htmlToPlainText: String {
return [
("(<[^>]*>)|(&\\w+;)", " "),
("[ ]+", " ")
].reduce(self) {
try! $0.replacing(pattern: $1.0, with: $1.1)
}.resolvedHTMLEntities
}
var resolvedHTMLEntities: String {
return self
.replacingOccurrences(of: "&#39;", with: "'")
.replacingOccurrences(of: "&#x27;", with: "'")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&nbsp;", with: " ")
}
func replacing(pattern: String, with template: String) throws -> String {
let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
return regex.stringByReplacingMatches(in: self, options: [], range: NSRange(0..<self.utf16.count), withTemplate: template)
}
var htmlEscapeQuotes: String {
return [
("\"", "\\\""),
("“", "&quot;"),
("\r", "\\r"),
("\n", "\\n")
].reduce(self) {
return $0.replacingOccurrences(of: $1.0, with: $1.1)
}
}
}
@MarwanT
Copy link

MarwanT commented Feb 15, 2018

Is there any way we can add an inputAccessoryView ??

@cbess
Copy link

cbess commented Sep 17, 2019

Is there any way we can add an inputAccessoryView ??

Yes and no. Yes, if you do it the old fashioned way (responding to keyboard show/hide notifications), and no, if you want it to be automatic.

For others, the old fashioned way:

  1. observe keyboard show/hide
  2. place the input accessory view in the WKWebView.superview
  3. on keyboard show: position the input accessory view above the keyboard
  4. hide it when the keyboard is hidden

As perhaps obvious, use delegation to pass action events between the WKWebView and your UIViewController/UIView subclass.

In iOS 13+, you can subclass WKWebView and return your own inputAccessoryView value: https://stackoverflow.com/a/58001395/344591

@MadeByDouglas
Copy link

Im curious, in this implementation, why did you subclass UIView and have an editorView property of type WKWebView? Why not just subclass WKWebView?

@fabfelici
Copy link
Author

Im curious, in this implementation, why did you subclass UIView and have an editorView property of type WKWebView? Why not just subclass WKWebView?

Mostly because whoever is using this class does not need to know there's a webview under the hood and I don't want you to change properties/call functions on the webview that could break the functionality.
As you can see the editorView property is private, so is not accessible from outside this class. By subclassing WKWebView you would expose everything WKWebView exposes.

@vinhqn
Copy link

vinhqn commented Sep 19, 2020

Thanks u

@sonnguyen9800
Copy link

Is there any way to reproduce it on SwiftUI?

@fabfelici
Copy link
Author

Is there any way to reproduce it on SwiftUI?

I didn't test it but UIViewRepresentable could be your friend here.

@dominiquemiller
Copy link

dominiquemiller commented Mar 16, 2022

@sonnguyen9800 Yes, I have used it in my SwiftUI project:

struct RichTextEditorView: UIViewRepresentable {
    @Binding var htmlText: String
    @Binding var dynamicHeight: CGFloat

    class Coordinator: NSObject, RichTextEditorDelegate {
        var parent: RichTextEditorView

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

        func textDidChange(text: String) {
            parent.htmlText = text
        }

        func heightDidChange(newHeight: CGFloat) {
            parent.dynamicHeight = newHeight
        }
    }

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

    func makeUIView(context: Context) -> RichEditorWebView {
        let editor = RichEditorWebView()
        editor.delegate = context.coordinator
        editor.text = htmlText

        return editor
    }

    func updateUIView(_ editor: RichEditorWebView, context: Context) {}
}

@darrarski
Copy link

Great gist! Any suggestions for a JavaScript library that can apply formatting to the selected text (like toggling bold, etc)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment