Files
omnivore/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift
Jackson Harper 03766734e5 Pull out popup view and replace with Transmission snackbars
PopupView and Transimission can interfere with each other and
cause an issue with the root screen becoming black and the app
getting stuck.
2024-02-06 11:43:43 +08:00

548 lines
17 KiB
Swift

import Models
import Utils
import WebKit
// swiftlint:disable file_length
/// Describes actions that can be sent from the WebView back to native views.
/// The names on the javascript side must match for an action to be handled.
public enum WebViewAction: String, CaseIterable {
case highlightAction
case readingProgressUpdate
}
enum ContextMenu {
case defaultMenu
case highlightMenu
}
public final class OmnivoreWebView: WKWebView {
#if os(iOS)
private var menuDisplayed = false
private var panGestureRecognizer: UIPanGestureRecognizer?
#endif
public var tapHandler: (() -> Void)?
private var currentMenu: ContextMenu = .defaultMenu
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
super.init(frame: frame, configuration: configuration)
#if os(iOS)
initNativeIOSMenus()
if #available(iOS 16.0, *) {
self.isFindInteractionEnabled = true
}
#endif
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func updateTheme() {
do {
try dispatchEvent(.updateTheme(themeName: ThemeManager.currentTheme.themeKey))
} catch {
showInReaderSnackbar("Error updating theme")
}
}
public func updateFontFamily() {
do {
if let fontFamily = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebFont.rawValue) as? String {
try dispatchEvent(.updateFontFamily(family: fontFamily))
}
} catch {
showInReaderSnackbar("Error updating font")
}
}
public func updateFontSize() {
do {
if let fontSize = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebFontSize.rawValue) as? Int {
try dispatchEvent(.updateFontSize(size: fontSize))
}
} catch {
showInReaderSnackbar("Error updating font")
}
}
public func updateMaxWidthPercentage() {
if let maxWidthPercentage = UserDefaults.standard.value(
forKey: UserDefaultKey.preferredWebMaxWidthPercentage.rawValue
) as? Int {
do {
try dispatchEvent(.updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage))
} catch {
showInReaderSnackbar("Error updating max width")
}
}
}
public func updateLineHeight() {
if let height = UserDefaults.standard.value(forKey: UserDefaultKey.preferredWebLineSpacing.rawValue) as? Int {
do {
try dispatchEvent(.updateLineHeight(height: height))
} catch {
showInReaderSnackbar("Error updating line height")
}
}
}
public func updateTextContrast() {
let isHighContrast = UserDefaults.standard.value(
forKey: UserDefaultKey.prefersHighContrastWebFont.rawValue
) as? Bool
if let isHighContrast = isHighContrast {
do {
try dispatchEvent(.handleFontContrastChange(isHighContrast: isHighContrast))
} catch {
showInReaderSnackbar("Error updating text contrast")
}
}
}
public func updateAutoHighlightMode() {
let isEnabled = UserDefaults.standard.value(
forKey: UserDefaultKey.enableHighlightOnRelease.rawValue
) as? Bool
if let isEnabled = isEnabled {
do {
try dispatchEvent(.handleAutoHighlightModeChange(isEnabled: isEnabled))
} catch {
showInReaderSnackbar("Error updating text contrast")
}
}
}
public func updateJustifyText() {
do {
if let justify = UserDefaults.standard.value(forKey: UserDefaultKey.justifyText.rawValue) as? Bool {
try dispatchEvent(.updateJustifyText(justify: justify))
}
} catch {
showInReaderSnackbar("Error updating justify-text")
}
}
public func updateTitle(title: String) {
do {
try dispatchEvent(.updateTitle(title: title))
} catch {
showInReaderSnackbar("Error updating title")
}
}
public func updateLabels(labelsJSON: String) {
do {
try dispatchEvent(.updateLabels(labels: labelsJSON))
} catch {
showInReaderSnackbar("Error updating labels")
}
}
public func shareOriginalItem() {
do {
try dispatchEvent(.share)
} catch {
showInReaderSnackbar("Error updating line height")
}
}
public func dispatchEvent(_ event: WebViewDispatchEvent) throws {
let script = try event.script
var errResult: Error?
evaluateJavaScript(script) { _, err in
if let err = err {
print("evaluateJavaScript error", err)
errResult = err
}
}
if let errResult = errResult {
throw errResult
}
}
#if os(iOS)
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
do {
if ThemeManager.currentTheme == .system {
try dispatchEvent(.updateTheme(themeName: ThemeManager.currentTheme.themeKey))
}
} catch {
showInReaderSnackbar("Error updating theme due to colormode change")
}
}
#elseif os(macOS)
override public func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
if ThemeManager.currentTheme == .system {
try? dispatchEvent(.updateTheme(themeName: ThemeManager.currentTheme.themeKey))
}
}
#endif
// Because all the snackbar stuff lives in app we just use notifications here
func showInReaderSnackbar(_ message: String) {
NotificationCenter.default.post(name: Notification.Name("SnackBar"),
object: nil,
userInfo: ["message": message,
"dismissAfter": 2000 as Any])
}
}
#if os(iOS)
extension OmnivoreWebView: UIGestureRecognizerDelegate, WKScriptMessageHandler {
func initNativeIOSMenus() {
isUserInteractionEnabled = true
NotificationCenter.default
.addObserver(self,
selector: #selector(menuDidHide),
name: UIMenuController.didHideMenuNotification,
object: nil)
setDefaultMenu()
}
public func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard let messageBody = message.body as? [String: Any] else { return }
guard let actionID = messageBody["actionID"] as? String else { return }
switch actionID {
case "showMenu":
if let rectX = messageBody["rectX"] as? Double,
let rectY = messageBody["rectY"] as? Double,
let rectWidth = messageBody["rectWidth"] as? Double,
let rectHeight = messageBody["rectHeight"] as? Double
{ // swiftlint:disable:this opening_brace
showHighlightMenu(CGRect(x: rectX, y: rectY, width: rectWidth, height: rectHeight))
}
case "pageTapped":
if menuDisplayed {
hideMenuAndDismissHighlight()
break
}
if let tapHandler = self.tapHandler {
tapHandler()
}
default:
break
}
}
private func setDefaultMenu() {
currentMenu = .defaultMenu
if #available(iOS 16.0, *) {
// on iOS16 we use menuBuilder to create these items
} else {
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
let highlight = UIMenuItem(title: LocalText.genericHighlight, action: #selector(highlightSelection))
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
UIMenuController.shared.menuItems = [highlight, /* share, */ annotate]
}
}
private func setHighlightMenu() {
currentMenu = .highlightMenu
if #available(iOS 16.0, *) {
// on iOS16 we use menuBuilder to create these items
} else {
// on iOS16 we use menuBuilder to create these items
currentMenu = .defaultMenu
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
let labels = UIMenuItem(title: LocalText.labelsGeneric, action: #selector(setLabels))
let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection))
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
UIMenuController.shared.menuItems = [remove, labels, annotate]
}
}
override public var canBecomeFirstResponder: Bool {
true
}
@objc func menuDidHide() {
setDefaultMenu()
}
// swiftlint:disable:next line_length
public func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
true
}
// swiftlint:disable:next cyclomatic_complexity
override public func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
switch action {
case #selector(annotateSelection): return true
case #selector(highlightSelection): return true
case #selector(shareSelection): return true
case #selector(removeSelection): return true
case #selector(copy(_:)): return true
case #selector(setLabels(_:)): return true
case Selector(("_lookup:")): return (currentMenu == .defaultMenu)
case Selector(("_define:")): return (currentMenu == .defaultMenu)
case Selector(("_translate:")): return (currentMenu == .defaultMenu)
case Selector(("_findSelected:")): return (currentMenu == .defaultMenu)
default: return false
}
}
@objc private func gestureHandled() {
hideMenuAndDismissHighlight()
}
@objc private func annotateSelection() {
do {
try dispatchEvent(.annotate)
} catch {
showInReaderSnackbar("Error creating highlight")
}
hideMenu()
}
@objc private func highlightSelection() {
do {
try dispatchEvent(.highlight)
} catch {
showInReaderSnackbar("Error creating highlight")
}
hideMenu()
}
@objc private func shareSelection() {
do {
try dispatchEvent(.share)
} catch {
showInReaderSnackbar("Error sharing highlight")
}
hideMenu()
}
@objc private func removeSelection() {
do {
try dispatchEvent(.remove)
} catch {
showInReaderSnackbar("Error deleting highlight")
}
hideMenu()
}
@objc override public func copy(_ sender: Any?) {
super.copy(sender)
do {
try dispatchEvent(.copyHighlight)
} catch {
showInReaderSnackbar("Error copying highlight")
}
hideMenu()
}
@objc public func setLabels(_: Any?) {
do {
try dispatchEvent(.setHighlightLabels)
} catch {
showInReaderSnackbar("Error setting labels for highlight")
}
hideMenu()
}
override public func buildMenu(with builder: UIMenuBuilder) {
if #available(iOS 16.0, *) {
let annotate = UICommand(title: "Note", action: #selector(annotateSelection))
let items: [UIMenuElement]
if currentMenu == .defaultMenu {
let highlight = UICommand(title: LocalText.genericHighlight, action: #selector(highlightSelection))
items = [highlight, annotate]
} else {
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
let setLabels = UICommand(title: LocalText.labelsGeneric, action: #selector(setLabels))
items = [annotate, setLabels, remove]
}
let omnivore = UIMenu(title: "", options: .displayInline, children: items)
builder.insertSibling(omnivore, beforeMenu: .lookup)
}
super.buildMenu(with: builder)
}
private func hideMenu() {
UIMenuController.shared.hideMenu()
if let panGestureRecognizer = panGestureRecognizer {
removeGestureRecognizer(panGestureRecognizer)
self.panGestureRecognizer = nil
}
menuDisplayed = false
setDefaultMenu()
}
private func hideMenuAndDismissHighlight() {
hideMenu()
try? dispatchEvent(.dismissHighlight)
}
private func showHighlightMenu(_ rect: CGRect) {
setHighlightMenu()
if panGestureRecognizer == nil {
let pan = UIPanGestureRecognizer(target: self, action: #selector(gestureHandled))
pan.delegate = self
addGestureRecognizer(pan)
panGestureRecognizer = pan
}
menuDisplayed = true
UIMenuController.shared.showMenu(from: self, rect: rect)
}
}
#endif
public enum WebViewDispatchEvent {
case handleFontContrastChange(isHighContrast: Bool)
case handleAutoHighlightModeChange(isEnabled: Bool)
case updateLineHeight(height: Int)
case updateMaxWidthPercentage(maxWidthPercentage: Int)
case updateFontSize(size: Int)
case updateFontFamily(family: String)
case updateTheme(themeName: String)
case updateJustifyText(justify: Bool)
case saveAnnotation(annotation: String)
case annotate
case highlight
case share
case remove
case setHighlightLabels
case copyHighlight
case dismissHighlight
case speakingSection(anchorIdx: String)
case updateLabels(labels: String)
case updateTitle(title: String)
case saveReadPosition
var script: String {
// swiftlint:disable:next implicit_getter
get throws {
let propertyLine = try scriptPropertyLine
return "var event = new Event('\(eventName)');\(propertyLine)document.dispatchEvent(event);"
}
}
private var eventName: String {
switch self {
case .handleFontContrastChange:
return "handleFontContrastChange"
case .updateLineHeight:
return "updateLineHeight"
case .updateMaxWidthPercentage:
return "updateMaxWidthPercentage"
case .updateFontSize:
return "updateFontSize"
case .updateFontFamily:
return "updateFontFamily"
case .updateTheme:
return "updateTheme"
case .updateJustifyText:
return "updateJustifyText"
case .saveAnnotation:
return "saveAnnotation"
case .annotate:
return "annotate"
case .highlight:
return "highlight"
case .share:
return "share"
case .remove:
return "remove"
case .setHighlightLabels:
return "setHighlightLabels"
case .copyHighlight:
return "copyHighlight"
case .dismissHighlight:
return "dismissHighlight"
case .speakingSection:
return "speakingSection"
case .updateLabels:
return "updateLabels"
case .updateTitle:
return "updateTitle"
case .handleAutoHighlightModeChange:
return "handleAutoHighlightModeChange"
case .saveReadPosition:
return "saveReadPosition"
}
}
private var scriptPropertyLine: String {
// swiftlint:disable:next implicit_getter
get throws {
switch self {
case let .handleFontContrastChange(isHighContrast: isHighContrast):
return "event.fontContrast = '\(isHighContrast ? "high" : "normal")';"
case let .updateLineHeight(height: height):
return "event.lineHeight = '\(height)';"
case let .updateMaxWidthPercentage(maxWidthPercentage: maxWidthPercentage):
return "event.maxWidthPercentage = '\(maxWidthPercentage)';"
case let .updateTheme(themeName: themeName):
return "event.themeName = '\(themeName)';"
case let .updateFontSize(size: size):
return "event.fontSize = '\(size)';"
case let .updateJustifyText(justify: justify):
return "event.justifyText = \(justify);"
case let .updateFontFamily(family: family):
return "event.fontFamily = '\(family)';"
case let .updateLabels(labels):
return "event.labels = \(labels);"
case let .updateTitle(title):
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(title) {
let str = String(decoding: encoded, as: UTF8.self)
return "event.title = \(str);"
} else {
throw BasicError.message(messageText: "Unable to serialize title.")
}
case let .saveAnnotation(annotation: annotation):
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(annotation) {
let str = String(decoding: encoded, as: UTF8.self)
return "event.annotation = \(str);"
} else {
throw BasicError.message(messageText: "Unable to serialize highlight note.")
}
case let .speakingSection(anchorIdx: anchorIdx):
return "event.anchorIdx = '\(anchorIdx)';"
case let .handleAutoHighlightModeChange(isEnabled: isEnabled):
return "event.enableHighlightOnRelease = '\(isEnabled ? "on" : "off")';"
case .annotate,
.highlight,
.setHighlightLabels,
.share,
.remove,
.copyHighlight,
.dismissHighlight,
.saveReadPosition:
return ""
}
}
}
}