Browse Source

search in WebViews (#1589)

* add search controller and search accessory view to WebViewViewController

* add js and WKWebView extensions for highlighting and scrolling in webviews

* allow preshipped js, but not external js (iOS 14+)

* allow search only for iOS 14+

* call search and highlight js script in WebViewViewController

* move search handling to WebViewViewController

* allow search in FullMessageView and HelpViewController

* automatically select first search result

* new InputBarAccessoryViewDelegate delegate method to react on changed keyboard size

* adapt webview's bottom content inset, so that all content can be scrolled above the keyboard

* fix typo

* only aggregate elements that are visible during search

* use orange for highlighting current selection
cyBerta 3 năm trước cách đây
mục cha
commit
cbd10f4a8d

+ 125 - 0
deltachat-ios/Assets/search.js

@@ -0,0 +1,125 @@
+
+// We're using a global variable to store the number of occurrences
+var WKWebView_CurrentlySelected = -1;
+var WKWebView_SearchResultCount = 0;
+
+// helper function, recursively searches in elements and their child nodes
+function WKWebView_HighlightAllOccurencesOfStringForElement(element,keyword) {
+    
+    if (element) {
+        if (element.nodeType == 3) {        // Text node
+            while (true) {
+                var value = element.nodeValue;  // Search for keyword in text node
+                var idx = value.toLowerCase().indexOf(keyword);
+                
+                if (idx < 0) break;             // not found, abort
+                
+                var span = document.createElement("span");
+                var text = document.createTextNode(value.substr(idx,keyword.length));
+                span.appendChild(text);
+                span.setAttribute("class","WKWebView_Highlight");
+                span.style.backgroundColor="yellow";
+                span.style.color="black";
+                text = document.createTextNode(value.substr(idx+keyword.length));
+                element.deleteData(idx, value.length - idx);
+                var next = element.nextSibling;
+                element.parentNode.insertBefore(span, next);
+                element.parentNode.insertBefore(text, next);
+                element = text;
+                WKWebView_SearchResultCount++;  // update the counter
+                
+            }
+        } else if (element.nodeType == 1) { // Element node
+            if (WKWebView_isElementVisible(element) && element.nodeName.toLowerCase() != 'select') {
+                for (var i=element.childNodes.length-1; i>=0; i--) {
+                    WKWebView_HighlightAllOccurencesOfStringForElement(element.childNodes[i],keyword);
+                }
+            }
+        }
+    }
+}
+
+function WKWebView_SearchNext(){
+    WKWebView_jump(1);
+}
+function WKWebView_SearchPrev(){
+    WKWebView_jump(-1);
+}
+
+function WKWebView_jump(increment){
+    prevSelected = WKWebView_CurrentlySelected;
+    WKWebView_CurrentlySelected = WKWebView_CurrentlySelected + increment;
+    
+    if (WKWebView_CurrentlySelected < 0){
+        WKWebView_CurrentlySelected = WKWebView_SearchResultCount + WKWebView_CurrentlySelected;
+    }
+    
+    if (WKWebView_CurrentlySelected >= WKWebView_SearchResultCount){
+        WKWebView_CurrentlySelected = WKWebView_CurrentlySelected - WKWebView_SearchResultCount;
+    }
+    
+    prevEl = document.getElementsByClassName("WKWebView_Highlight")[prevSelected];
+    
+    if (prevEl){
+        prevEl.style.backgroundColor="yellow";
+    }
+    el = document.getElementsByClassName("WKWebView_Highlight")[WKWebView_CurrentlySelected];
+    el.style.backgroundColor="orange";
+    
+    el.scrollIntoView(true);
+}
+
+
+// the main entry point to start the search
+function WKWebView_HighlightAllOccurencesOfString(keyword) {
+    WKWebView_RemoveAllHighlights();
+    WKWebView_HighlightAllOccurencesOfStringForElement(document.body, keyword.toLowerCase());
+    if (WKWebView_SearchResultCount > 0) {
+        WKWebView_SearchNext()
+    }
+}
+
+// helper function, recursively removes the highlights in elements and their childs
+function WKWebView_RemoveAllHighlightsForElement(element) {
+    if (element) {
+        if (element.nodeType == 1) {
+            if (element.getAttribute("class") == "WKWebView_Highlight") {
+                var text = element.removeChild(element.firstChild);
+                element.parentNode.insertBefore(text,element);
+                element.parentNode.removeChild(element);
+                return true;
+            } else {
+                var normalize = false;
+                for (var i=element.childNodes.length-1; i>=0; i--) {
+                    if (WKWebView_RemoveAllHighlightsForElement(element.childNodes[i])) {
+                        normalize = true;
+                    }
+                }
+                if (normalize) {
+                    element.normalize();
+                }
+            }
+        }
+    }
+    return false;
+}
+
+// the main entry point to remove the highlights
+function WKWebView_RemoveAllHighlights() {
+    
+    WKWebView_SearchResultCount = 0;
+    WKWebView_CurrentlySelected = -1;
+    
+    WKWebView_RemoveAllHighlightsForElement(document.body);
+}
+
+function WKWebView_isElementVisible(element) {
+    var style = window.getComputedStyle(element);
+    var isvisible = style.width > "0" &&
+    style.height > "0" &&
+    style.opacity > "0" &&
+    style.display !=='none' &&
+    style.visibility !== 'hidden';
+    console.log("isElementVisible: ", element, isvisible);
+    return isvisible;
+}

+ 2 - 0
deltachat-ios/Chat/InputBarAccessoryView/InputBarAccessoryView.swift

@@ -418,9 +418,11 @@ open class InputBarAccessoryView: UIView {
         keyboardManager.on(event: .willChangeFrame, do: {  [weak self] (notification) in
             guard let self = self else { return }
             self.keyboardHeight = notification.endFrame.height - self.intrinsicContentSize.height
+            self.delegate?.inputBar(self, didAdaptToKeyboard: self.keyboardHeight)
         }).on(event: .didChangeFrame, do: {  [weak self] (notification) in
             guard let self = self else { return }
             self.keyboardHeight = notification.endFrame.height - self.intrinsicContentSize.height
+            self.delegate?.inputBar(self, didAdaptToKeyboard: self.keyboardHeight)
         }).on(event: .didShow, do: { [weak self] _ in
             guard let self = self else { return }
             if UIApplication.shared.statusBarOrientation.isLandscape && UIDevice.current.userInterfaceIdiom == .phone {

+ 10 - 0
deltachat-ios/Chat/InputBarAccessoryView/Protocols/InputBarAccessoryViewDelegate.swift

@@ -60,6 +60,13 @@ public protocol InputBarAccessoryViewDelegate: AnyObject {
     ///   - inputBar: The InputBarAccessoryView
     ///   - gesture: The gesture that was recognized
     func inputBar(_ inputBar: InputBarAccessoryView, didSwipeTextViewWith gesture: UISwipeGestureRecognizer)
+
+    /// Called when keyboard notifications have been processed by InputBarAccessoryView
+    ///
+    /// - Parameters:
+    ///   - inputBar: The InputBarAccessoryView
+    ///   - height: adapted keyboardHeight (without inputBar's intrinsic content size)
+    func inputBar(_ inputBar: InputBarAccessoryView, didAdaptToKeyboard height: CGFloat)
 }
 
 public extension InputBarAccessoryViewDelegate {
@@ -71,4 +78,7 @@ public extension InputBarAccessoryViewDelegate {
     func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String) {}
     
     func inputBar(_ inputBar: InputBarAccessoryView, didSwipeTextViewWith gesture: UISwipeGestureRecognizer) {}
+
+    func inputBar(_ inputBar: InputBarAccessoryView, didAdaptToKeyboard height: CGFloat) {}
+
 }

+ 1 - 0
deltachat-ios/Controller/FullMessageViewController.swift

@@ -34,6 +34,7 @@ class FullMessageViewController: WebViewViewController {
         self.dcContext = dcContext
         self.messageId = messageId
         super.init()
+        self.allowSearch = true
     }
 
     required init?(coder: NSCoder) {

+ 9 - 0
deltachat-ios/Controller/HelpViewController.swift

@@ -4,6 +4,15 @@ import DcCore
 
 class HelpViewController: WebViewViewController {
 
+    override init() {
+        super.init()
+        self.allowSearch = true
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
     override func viewDidLoad() {
         super.viewDidLoad()
         self.title = String.localized("menu_help")

+ 191 - 5
deltachat-ios/Controller/WebViewViewController.swift

@@ -9,13 +9,54 @@ class WebViewViewController: UIViewController, WKNavigationDelegate {
         return view
     }()
 
+    lazy var searchController: UISearchController = {
+        let searchController = UISearchController(searchResultsController: nil)
+        searchController.obscuresBackgroundDuringPresentation = false
+        searchController.searchBar.placeholder = String.localized("search")
+        searchController.searchBar.delegate = self
+        searchController.delegate = self
+        searchController.searchBar.inputAccessoryView = accessoryViewContainer
+        searchController.searchBar.autocorrectionType = .yes
+        searchController.searchBar.keyboardType = .default
+        return searchController
+    }()
+
+    lazy var accessoryViewContainer: InputBarAccessoryView = {
+        let inputBar = InputBarAccessoryView()
+        inputBar.setMiddleContentView(searchAccessoryBar, animated: false)
+        inputBar.sendButton.isHidden = true
+        inputBar.delegate = self
+        return inputBar
+    }()
+
+    public lazy var searchAccessoryBar: ChatSearchAccessoryBar = {
+        let view = ChatSearchAccessoryBar()
+        view.delegate = self
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.isEnabled = false
+        return view
+    }()
+
+    private lazy var keyboardManager: KeyboardManager? = {
+        let manager = KeyboardManager()
+        return manager
+    }()
+
+
+    private var debounceTimer: Timer?
+    private var initializedSearch = false
+    open var allowSearch = false
+
     open var configuration: WKWebViewConfiguration {
         let preferences = WKPreferences()
-        preferences.javaScriptEnabled = false
-
-        let configuration = WKWebViewConfiguration()
-        configuration.preferences = preferences
-        return configuration
+        let config = WKWebViewConfiguration()
+        if #available(iOS 14.0, *) {
+            config.defaultWebpagePreferences.allowsContentJavaScript = false
+        } else {
+            preferences.javaScriptEnabled = false
+        }
+        config.preferences = preferences
+        return config
     }
 
     init() {
@@ -43,8 +84,15 @@ class WebViewViewController: UIViewController, WKNavigationDelegate {
     override func viewDidLoad() {
         super.viewDidLoad()
         setupSubviews()
+        keyboardManager?.bind(to: webView.scrollView)
+        keyboardManager?.on(event: .didHide) { [weak self] _ in
+            self?.webView.scrollView.contentInset.bottom = 0
+        }
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        keyboardManager = nil
+    }
 
     // MARK: - setup + configuration
     private func setupSubviews() {
@@ -54,5 +102,143 @@ class WebViewViewController: UIViewController, WKNavigationDelegate {
         webView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0).isActive = true
         webView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0).isActive = true
         webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: 0).isActive = true
+        webView.scrollView.keyboardDismissMode = .interactive
+        webView.scrollView.contentInset.bottom = 0
+
+        if allowSearch, #available(iOS 14.0, *) {
+            navigationItem.searchController = searchController
+        }
+        accessoryViewContainer.setLeftStackViewWidthConstant(to: 0, animated: false)
+        accessoryViewContainer.setRightStackViewWidthConstant(to: 0, animated: false)
+        accessoryViewContainer.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
+    }
+
+    private func initSearch() {
+        guard let path = Bundle.main.url(forResource: "search", withExtension: "js", subdirectory: "Assets") else {
+            logger.error("internal search js not found")
+            return
+        }
+        do {
+            let data: Data = try Data(contentsOf: path)
+            let jsCode: String = String(decoding: data, as: UTF8.self)
+            // inject the search code
+            webView.evaluateJavaScript(jsCode, completionHandler: { _, error in
+                if let error = error {
+                    logger.error(error)
+                }
+            })
+        } catch {
+            logger.error("could not load javascript: \(error)")
+        }
+    }
+
+    private func find(text: String) {
+        highlightAllOccurencesOf(string: text)
+        updateAccessoryBar()
+    }
+
+    private func highlightAllOccurencesOf(string: String) {
+        // search function
+        let searchString = "WKWebView_HighlightAllOccurencesOfString('\(string)')"
+        // perform search
+        webView.evaluateJavaScript(searchString, completionHandler: { _, error in
+            if let error = error {
+                logger.error(error)
+            }
+        })
+    }
+
+    private func updateAccessoryBar() {
+        handleSearchResultCount { [weak self] result in
+            guard let self = self else { return }
+            logger.debug("found \(result) elements")
+            self.searchAccessoryBar.isEnabled = result > 0
+            self.handleCurrentlySelected { [weak self] position in
+                self?.searchAccessoryBar.updateSearchResult(sum: result, position: position == -1 ? 0 : position + 1)
+            }
+        }
+    }
+
+    private func handleSearchResultCount( completionHandler: @escaping (_ result: Int) -> Void) {
+        getInt(key: "WKWebView_SearchResultCount", completionHandler: completionHandler)
+    }
+
+    private func handleCurrentlySelected( completionHandler: @escaping (_ result: Int) -> Void) {
+        getInt(key: "WKWebView_CurrentlySelected", completionHandler: completionHandler)
+    }
+
+    private func getInt(key: String, completionHandler: @escaping (_ result: Int) -> Void) {
+        webView.evaluateJavaScript(key) { (result, error) in
+            if let error = error {
+                logger.error(error)
+            } else if result != nil,
+               let integerResult = result as? Int {
+                    completionHandler(integerResult)
+            }
+        }
+    }
+
+    private func removeAllHighlights() {
+        webView.evaluateJavaScript("WKWebView_RemoveAllHighlights()", completionHandler: nil)
+        updateAccessoryBar()
+    }
+
+    private func searchNext() {
+        webView.evaluateJavaScript("WKWebView_SearchNext()", completionHandler: nil)
+        updateAccessoryBar()
+    }
+
+    private func searchPrevious() {
+        webView.evaluateJavaScript("WKWebView_SearchPrev()", completionHandler: nil)
+        updateAccessoryBar()
+    }
+}
+
+extension WebViewViewController: UISearchBarDelegate, UISearchControllerDelegate {
+    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+        debounceTimer?.invalidate()
+        debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
+            logger.debug("search for \(searchText)")
+            if searchText.isEmpty {
+                self?.removeAllHighlights()
+            } else {
+                self?.find(text: searchText)
+            }
+        }
+    }
+
+    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
+        let text = searchController.searchBar.text ?? ""
+        self.find(text: text)
+    }
+
+    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
+        self.removeAllHighlights()
+    }
+
+    func willPresentSearchController(_ searchController: UISearchController) {
+        if !initializedSearch {
+            initializedSearch = true
+            initSearch()
+        }
+    }
+}
+
+extension WebViewViewController: ChatSearchDelegate {
+    func onSearchPreviousPressed() {
+        logger.debug("onSearchPrevious pressed")
+        self.searchPrevious()
+    }
+
+    func onSearchNextPressed() {
+        logger.debug("onSearchNextPressed pressed")
+        self.searchNext()
+    }
+}
+
+extension WebViewViewController: InputBarAccessoryViewDelegate {
+    func inputBar(_ inputBar: InputBarAccessoryView, didAdaptToKeyboard height: CGFloat) {
+        logger.debug("didAdaptToKeyboard: \(height)")
+        self.webView.scrollView.contentInset.bottom = height
     }
 }