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)
}
}
}
@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