-
-
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: "'", with: "'") | |
.replacingOccurrences(of: "'", with: "'") | |
.replacingOccurrences(of: "&", with: "&") | |
.replacingOccurrences(of: " ", 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 [ | |
("\"", "\\\""), | |
("“", """), | |
("\r", "\\r"), | |
("\n", "\\n") | |
].reduce(self) { | |
return $0.replacingOccurrences(of: $1.0, with: $1.1) | |
} | |
} | |
} |
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:
- observe keyboard show/hide
- place the input accessory view in the
WKWebView.superview
- on keyboard show: position the input accessory view above the keyboard
- 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
Im curious, in this implementation, why did you subclass UIView
and have an editorView
property of type WKWebView
? Why not just subclass WKWebView
?
Im curious, in this implementation, why did you subclass
UIView
and have aneditorView
property of typeWKWebView
? Why not just subclassWKWebView
?
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.
Thanks u
Is there any way to reproduce it on SwiftUI?
Is there any way to reproduce it on SwiftUI?
I didn't test it but UIViewRepresentable
could be your friend here.
@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) {}
}
Great gist! Any suggestions for a JavaScript library that can apply formatting to the selected text (like toggling bold, etc)?
Is there any way we can add an
inputAccessoryView
??