From 36bbf4ee46c6179b50f644f20d69263f15523ab6 Mon Sep 17 00:00:00 2001 From: sk-chan Date: Tue, 5 Mar 2024 19:10:37 +0700 Subject: [PATCH] First commit nothing more. --- .gitignore | 8 + Package.resolved | 14 + Package.swift | 31 + .../Extension/UIApplication+.swift | 30 + Sources/QuillSwiftUI/QuillEditorBase.swift | 32 + Sources/QuillSwiftUI/QuillEditorView.swift | 35 + Sources/QuillSwiftUI/QuillEditorWebView.swift | 652 ++++++++++++++++++ .../QuillSwiftUITests/QuillSwiftUITests.swift | 12 + 8 files changed, 814 insertions(+) create mode 100644 .gitignore create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/QuillSwiftUI/Extension/UIApplication+.swift create mode 100644 Sources/QuillSwiftUI/QuillEditorBase.swift create mode 100644 Sources/QuillSwiftUI/QuillEditorView.swift create mode 100644 Sources/QuillSwiftUI/QuillEditorWebView.swift create mode 100644 Tests/QuillSwiftUITests/QuillSwiftUITests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7daacff --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swifterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwifterSwift/SwifterSwift.git", + "state" : { + "revision" : "1eab3444fa06a344a35e79540719df956ab4d4ad", + "version" : "6.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..1f1ce10 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "QuillSwiftUI", + + platforms: [.iOS(.v15)], + + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "QuillSwiftUI", + targets: ["QuillSwiftUI"]), + ], + + dependencies: [ + .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0") + ], + + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "QuillSwiftUI", dependencies: [.product(name: "SwifterSwift", package: "SwifterSwift")]), + .testTarget( + name: "QuillSwiftUITests", + dependencies: ["QuillSwiftUI"]), + ] +) diff --git a/Sources/QuillSwiftUI/Extension/UIApplication+.swift b/Sources/QuillSwiftUI/Extension/UIApplication+.swift new file mode 100644 index 0000000..9687672 --- /dev/null +++ b/Sources/QuillSwiftUI/Extension/UIApplication+.swift @@ -0,0 +1,30 @@ +// +// UIApplication+.swift +// JERTAM +// +// Created by Chanchana Koedtho on 15/11/2566 BE. +// + +import Foundation +import UIKit + + +extension UIApplication{ + + var currentWindow: UIWindow? { + connectedScenes + .compactMap { + $0 as? UIWindowScene + } + .flatMap { + $0.windows + } + .first { + $0.isKeyWindow + } + } + + func endEdit(){ + self.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/Sources/QuillSwiftUI/QuillEditorBase.swift b/Sources/QuillSwiftUI/QuillEditorBase.swift new file mode 100644 index 0000000..76414d7 --- /dev/null +++ b/Sources/QuillSwiftUI/QuillEditorBase.swift @@ -0,0 +1,32 @@ +// +// QuillEditorBase.swift +// +// +// Created by Chanchana Koedtho on 4/3/2567 BE. +// + +import Foundation +import Combine +import SwiftUI + +public protocol QuillEditorBase: View { + var customFont: UIFont? { get set } + var onTextChange: ((String)->())? { get set } + + func customFont(font: UIFont?) -> Self + func onTextChange(_ perform: ((String)->())?) -> Self +} + +public extension QuillEditorBase { + public func customFont(font: UIFont?) -> Self { + var copy = self + copy.customFont = font + return copy + } + + public func onTextChange(_ perform: ((String)->())?) -> Self { + var copy = self + copy.onTextChange = perform + return copy + } +} diff --git a/Sources/QuillSwiftUI/QuillEditorView.swift b/Sources/QuillSwiftUI/QuillEditorView.swift new file mode 100644 index 0000000..5c2ccc8 --- /dev/null +++ b/Sources/QuillSwiftUI/QuillEditorView.swift @@ -0,0 +1,35 @@ +// +// File.swift +// +// +// Created by Chanchana Koedtho on 4/3/2567 BE. +// + +import Foundation +import SwiftUI + +public struct QuillEditorView: QuillEditorBase { + let placeholder: String + + public var customFont: UIFont? + public var onTextChange: ((String) -> ())? + + @State private var dynamicHeight: CGFloat = 0 + + @Binding var text: String + + public init(_ placeholder: String = "", text: Binding) { + self._text = text + self.placeholder = placeholder + } + + public var body: some View { + QuillEditorWebView(placeholder: placeholder, + dynamicHeight: $dynamicHeight, + text: $text) + .customFont(font: customFont) + .onTextChange(onTextChange) + .padding(.bottom, 10) + .frame(minHeight: dynamicHeight) + } +} diff --git a/Sources/QuillSwiftUI/QuillEditorWebView.swift b/Sources/QuillSwiftUI/QuillEditorWebView.swift new file mode 100644 index 0000000..504b74f --- /dev/null +++ b/Sources/QuillSwiftUI/QuillEditorWebView.swift @@ -0,0 +1,652 @@ +// +// QuillEditorWebView.swift +// QuillSwiftUI +// +// Created by Chanchana Koedtho on 2/3/2567 BE. +// + +import Foundation +import SwiftUI +import WebKit +import SwifterSwift +import SafariServices + +public struct QuillEditorWebView: UIViewRepresentable { + let webView = RichEditorWebView() + + let placeholder: String + + @Binding var dynamicHeight: CGFloat + @Binding var text: String + + public var customFont: UIFont? + public var onTextChange: ((String)->())? + + public init(placeholder: String, + dynamicHeight: Binding, + text: Binding) { + self.placeholder = placeholder + self._dynamicHeight = dynamicHeight + self._text = text + } + + public func makeUIView(context: Context) -> some WKWebView { + settingWebView(context: context) + + webView.didReceive = { message in + + guard message.name != "log" + else { + print(message.body) + return + } + + guard message.name != "heightDidChange" + else { + DispatchQueue.main.async { + dynamicHeight = (message.body as? CGFloat) ?? 0 + } + return + } + + print(message.body) + + if message.name == "editLink", + var url = (message.body as? String)?.url { + if url.scheme == nil { + guard let httpsURL = URL(string: "https://\(url.absoluteString)") else { + return + } + url = httpsURL + } + + alertEditLink(completionHandler: { + if $0 == 0 { + let root = UIApplication.shared.currentWindow?.rootViewController + root?.present(SFSafariViewController(url: url), animated: true, completion: nil) + } else { + alertInsertLink(setupText: url.absoluteString, completionHandler: { + replaceLink(url: $0) + }) + } + }) + } else { + let changeText = (message.body as? String) ?? "" + text = changeText + onTextChange?(changeText) + } + } + + loadEditor() + + return webView + } + + private func loadEditor() { + webView.loadHTMLString(generateHTML(), baseURL: Bundle.main.bundleURL) + } + + private func settingWebView(context: Context) { + webView.scrollView.bounces = false + webView.navigationDelegate = context.coordinator + webView.scrollView.isScrollEnabled = false + + webView.isOpaque = false + webView.backgroundColor = UIColor.clear + webView.scrollView.backgroundColor = UIColor.clear + + + webView.accessoryView = AccessoryInputView { + HStack(spacing: 10){ + Button(action: { + formatText(style: "bold") + }, label: { + Image(systemName: "bold") + }) + + Button(action: { + formatText(style: "italic") + }, label: { + Image(systemName: "italic") + }) + + Button(action: { + formatText(style: "strike") + }, label: { + Image(systemName: "strikethrough") + }) + + Button(action: { + formatText(style: "underline") + }, label: { + Image(systemName: "underline") + }) + + Button(action: { + checkLink(completionHandler: { link in + alertInsertLink(setupText: link == "false" ? "" : link ?? "", completionHandler: { + if link == "false" { + insertLink(url: $0) + } else { + replaceLink(url: $0) + } + }) + }) + }, label: { + Image(systemName: "link") + }) + + Button(action: { + toggleList() + }, label: { + Image(systemName: "list.bullet") + }) + + Button(action: { + toggleListOrder() + }, label: { + Image(systemName: "list.number") + }) + + Spacer() + + Button(action: { + undo() + }, label: { + Image(systemName: "arrow.uturn.backward") + }) + + Button(action: { + redo() + }, label: { + Image(systemName: "arrow.uturn.forward") + }) + } + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background(.white) + } + } + + public func updateUIView(_ uiView: UIViewType, context: Context) { + // Check if it's the first update + print("update richtext") + } + + public func makeCoordinator() -> Coordinator { + return .init(parent: self) + } +} + +extension QuillEditorWebView { + func formatText(style: String) { + let script = """ + var range = quill.getSelection(); + if (range) { + var format = quill.getFormat(range); + if (format['\(style)'] === true) { + // If the formatting is already applied, remove it + quill.formatText(range.index, range.length, '\(style)', false); + } else { + // If the formatting is not applied, apply it + quill.formatText(range.index, range.length, '\(style)', true); + } + } +""" + webView.evaluateJavaScript(script, completionHandler: nil) + } + + func insertLink(url: String) { + guard url.count > 0 + else { return } + + let script = """ + var range = quill.getSelection(); + + // Format the text range as a link + if (range.length > 0) { + quill.formatText(range.index, range.length, 'link', '\(url)'); + } else { + var length = quill.getLength(); + quill.insertText(length - 1, '\(url)', 'link', '\(url)'); + } + + window.webkit.messageHandlers.textDidChange.postMessage(quill.root.innerHTML); + """ + + webView.evaluateJavaScript(script, completionHandler: nil) + } + + private func replaceLink(url: String) { + let script = "replaceURL('\(url)');" + + webView.evaluateJavaScript(script, completionHandler: { result, e in + if let e = e { + print(e.localizedDescription) + } + }) + } + + func checkLink(completionHandler: ((String?)->())?) { + let script = """ + var range = quill.getSelection(); + if (range) { + var formats = quill.getFormat(range.index, range.length); + if (formats && formats.link) { + formats.link + } else { + 'false'; + } + } + """ + + webView.evaluateJavaScript(script) { (result, error) in + if let error = error { + print("Error evaluating JavaScript: \(error)") + } else { + completionHandler?(result as? String) + } + } + } + + + func toggleList() { + let jsCode = """ + var index = quill.getSelection().index || 0; + quill.insertText(index, '\\n'); // Insert a newline before creating the list + quill.formatLine(index + 1, 1, 'list', 'bullet'); // Format the line as a bullet list item +""" + webView.evaluateJavaScript(jsCode, completionHandler: nil) + } + + func toggleListOrder() { + let jsCode = """ + var index = quill.getSelection().index || 0; + quill.insertText(index, '\\n'); // Insert a newline before creating the list + quill.formatLine(index + 1, 1, 'list', 'ordered'); // Format the line as a bullet list item +""" + webView.evaluateJavaScript(jsCode, completionHandler: nil) + } + + func undo() { + let jsCode = "quill.history.undo();" + webView.evaluateJavaScript(jsCode, completionHandler: nil) + } + + func redo() { + let jsCode = "quill.history.redo();" + webView.evaluateJavaScript(jsCode, completionHandler: nil) + } + + func setHTML() { + let js = "quill.clipboard.dangerouslyPasteHTML('\(text)');" + webView.evaluateJavaScript(js, completionHandler: { _,_ in + UIApplication.shared.endEdit() + }) + } + + func generateHTML() -> String { + return """ + + + + + + + + \(generateCSS()) + + + +
+ + + + + + + + + """ + } + + func generateCSS() -> String { + var fontFaceString = "" + var fontBodyString = "" + + if let customFont = self.customFont { + fontFaceString = """ + @font-face { + font-family: '\(customFont.fontName)'; + src: url("\(customFont.fontName).ttf") format('truetype'); // name of your font in Info.plist + } +""" + fontBodyString = """ + font-family: '\(customFont.fontName)'; + font-size: \(customFont.pointSize)px; +""" + } + + return """ + + """ + } + + private func alertInsertLink(setupText: String, completionHandler: ((String)->())?) { + // Create an alert controller + let alertController = UIAlertController(title: "เพิ่มลิงค์", message: nil, preferredStyle: .alert) + + // Add a text field to the alert controller + alertController.addTextField { (textField) in + textField.placeholder = "https://www.abc.com" + textField.text = setupText + } + + // Add actions to the alert controller + let cancelAction = UIAlertAction(title: "ยกเลิก", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + let okayAction = UIAlertAction(title: "ตกลง", style: .default) { (_) in + // Handle okay action, for example, get the text from the text field + if let textField = alertController.textFields?.first, + let text = textField.text { + completionHandler?(text) + } + } + alertController.addAction(okayAction) + + guard let root = UIApplication.shared.currentWindow?.rootViewController + else { return } + + // Present the alert controller + root.present(alertController, animated: true, completion: nil) + } + + private func alertEditLink(completionHandler: ((Int)->())?) { + // Create an alert controller + let alertController = UIAlertController(title: "ตั้งค่า", message: nil, preferredStyle: .actionSheet) + + // Add an action (button) + alertController.addAction(UIAlertAction(title: "เปิดลิงค์", style: .default, handler: {_ in + completionHandler?(0) + })) + + // Add an action (button) + alertController.addAction(UIAlertAction(title: "แก้ไข", style: .default, handler: {_ in + completionHandler?(1) + })) + + // Add an action (button) + alertController.addAction(UIAlertAction(title: "ยกเลิก", style: .cancel, handler: nil)) + + guard let root = UIApplication.shared.currentWindow?.rootViewController + else { return } + + // Present the alert controller + root.present(alertController, animated: true, completion: nil) + } +} + +extension QuillEditorWebView: QuillEditorBase { + +} + +extension QuillEditorWebView { + public class Coordinator: NSObject { + + let parent: QuillEditorWebView + + var isFirstUpdate = true + + init(parent: QuillEditorWebView) { + self.parent = parent + } + + } +} + +extension QuillEditorWebView.Coordinator: WKNavigationDelegate { + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if isFirstUpdate, !parent.text.isEmpty { + parent.setHTML() + isFirstUpdate = false + } + } + + public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + + } + + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard navigationAction.navigationType == WKNavigationType.linkActivated, + var url = navigationAction.request.url else { + decisionHandler(WKNavigationActionPolicy.allow) + return + } + + print("Clicked URL: \(url)") + + if url.scheme == nil { + guard let httpsURL = URL(string: "https://\(url.absoluteString)") else { + decisionHandler(WKNavigationActionPolicy.cancel) + return + } + url = httpsURL + } + + let root = UIApplication.shared.currentWindow?.rootViewController + root?.present(SFSafariViewController(url: url), animated: true, completion: nil) + + decisionHandler(WKNavigationActionPolicy.cancel) + } +} + +//https://stackoverflow.com/a/58001395 +class RichEditorWebView: WKWebView { + + var accessoryView: UIView? + + var didReceive: ((_ message: WKScriptMessage)->())? + + override var inputAccessoryView: UIView? { + // remove/replace the default accessory view + return accessoryView + } + + init() { + let contentController = WKUserContentController() + let config = WKWebViewConfiguration() + config.userContentController = contentController + super.init(frame: .zero, configuration: config) + + contentController.add(self, name: "textDidChange") + contentController.add(self, name: "editLink") + contentController.add(self, name: "log") + contentController.add(self, name: "heightDidChange") + + //https://stackoverflow.com/a/63136483 + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RichEditorWebView: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + didReceive?(message) + } +} + +//https://stackoverflow.com/a/77775004 +class AccessoryInputView: UIInputView { + private let controller: UIHostingController + + init(_ accessoryViewBuilder: () -> AccessoryContent ) { + controller = UIHostingController(rootView: accessoryViewBuilder()) + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .default) + + controller.view.translatesAutoresizingMaskIntoConstraints = false + controller.view.backgroundColor = UIColor.clear + addSubview(controller.view) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var safeAreaInsets: UIEdgeInsets { + .zero + } + + override func layoutSubviews() { + super.layoutSubviews() + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalTo: controller.view.widthAnchor), + heightAnchor.constraint(equalTo: controller.view.heightAnchor), + centerXAnchor.constraint(equalTo: controller.view.centerXAnchor), + centerYAnchor.constraint(equalTo: controller.view.centerYAnchor) + ]) + } +} diff --git a/Tests/QuillSwiftUITests/QuillSwiftUITests.swift b/Tests/QuillSwiftUITests/QuillSwiftUITests.swift new file mode 100644 index 0000000..c58a91b --- /dev/null +++ b/Tests/QuillSwiftUITests/QuillSwiftUITests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import QuillSwiftUI + +final class QuillSwiftUITests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}