javascriptsafarievent-handlingtextselection

Trigger text selection UI in iOS Safari when selecting text programmatically


In iOS Safari, I'm able to select text programmatically using the following code. However, the text selection UI (selection handles and blue overlay) don't show, unless the user has already manually selected something on the page.

Everything works as expected in other browsers I've tested (Android Chrome, macOS Chrome, macOS Safari, and macOS Firefox) – it's only failing in iOS/iPadOS Safari.

I've tried various event listeners to trigger the text selection: onclick, pointerdown, pointerup, touchstart, touchend, etc. – but no luck.

Does anyone know of a workaround or have any ideas?

const paragraphs = document.querySelectorAll('p');
for (paragraph of paragraphs) {
  paragraph.addEventListener('click', selectParagraphContents);
}

function selectParagraphContents(event) {
  const selection = window.getSelection();
  const range = document.createRange();
  range.selectNodeContents(event.currentTarget);
  selection.removeAllRanges();
  selection.addRange(range);
}

// Log selection changes to the console for debugging
document.addEventListener('selectionchange', (event) => {
  const selection = window.getSelection();
  console.log(selection.type + ': "' + selection.toString() + '"');
});
<p>Tap to select this paragraph.</p>
<p>Or this paragraph.</p>


Solution

  • enter image description here

    I encountered exactly the same problem:

    After selecting text via js, iOS browsers cannot correctly display the text highlight UI text menu (but it works fine on Chrome), which means I can not dispaly Text Highlight Menu by click.

    The only way make div with text to display the highlight UI on iOS is to long press it. After trying many solutions via js, I realized that this might be an iOS native problem.

    So I dived into WKWebView then found that: iOS's highlight menu is an independent native module called UITextInteractionAssistant, which only listens to long press gestures and double-click gestures from users. Text selection from js can be listened by -(void)selectionChanged, but the highlight menu cannot be triggered by -(void)activate

    I don't know yet whether there is some validation logic from -(void)selectionChanged to -(void)activate to prevent js selection from displaying the highlight menu, or whether iOS has not implemented it at all. But we can manually call -(void)activate to display the highlight menu.

    WKWebView Solution

    If you are using WKWebView to develop apps, you can take a look at the following solutions hack into iOS ( Mobile Safari browser can only find js-based solutions )

    Environment:

    iOS 16.7 / WKWebView tesed.

    Solution A:

    Here is the core code of the iOS part. You need to manually call the iOS code from js via the bridge after selecting the text in js to display the highlight menu:

    class SomeViewController: UIViewController{
        
      var webview:WKWebView?
      var textSelectionAssistant: NSObject?
    
      
      //call this method to activate TextSelection Highlight
      func activeTextSelectionMenu(){
        DispatchQueue.main.async {//must call on main thread
          
          //.get UITextInteractionAssistant from WKWebView
          if (self.textSelectionAssistant == nil && self.webview != nil){
              if
                  //+ WKContentView
                  self.webview!.responds(to: NSSelectorFromString("_currentContentView")),
                  let wkcontentview = self.webview!.perform(NSSelectorFromString("_currentContentView")).takeRetainedValue() as? UIView,
    
                  //+ UITextInteractionAssistant
                  wkcontentview.responds(to: NSSelectorFromString("interactionAssistant")),
                  let textSelectionAssistant = wkcontentview.perform(NSSelectorFromString("interactionAssistant")).takeRetainedValue() as? NSObject
              {
                  self.textSelectionAssistant = textSelectionAssistant;
              }
          }
    
          //.activate Text Highlight Menu
          if (self.textSelectionAssistant != nil && self.textSelectionAssistant?.responds(to: NSSelectorFromString("activate")) == true ){
              self.textSelectionAssistant?.perform( NSSelectorFromString("activate") );
          }
          
        }
      }
    }
    

    Solution B:

    Another solution is hook UITextSelectionView to active UI highlight menu automatically after selectionChange.

        //hook body:
        //  class   UITextSelectionView 
        //  method  -(void)selectionChanged;
        //
        @objc public func selectionChanged_Replaced(){
          self.selectionChanged_Replaced();
          self.perform(NSSelectorFromString("activate"))
        }
    

    Reference:

    hook / methodSwizzling:

    a way to hook WKWebContent

    runtime Headers:

    WKWebView.h

    WKContentView.h

    UITextInteractionAssistant.h

    UITextSelectionView.h

    MobileSafari Solution

    After deep digging, just found a method called - (void)_updateSelectionAssistantSuppressionState will prevent js selection to show text highlight menu.

    So I wrote a demo, to force inject a fake input to bypass iOS limitation, like crazy dog mode

    It works anyway...

    Environment:

    iOS 16.7 - WKWebView / MobileSafari tested

    Reference:

    Live DEMO

    <html>
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no">
        </head>
        <body style="padding: 10px; padding-top:50px; font-family: sans-serif; font-weight: 200;">
            <div class="ios-selection-by-pass">
                <div style="margin-bottom: 20px">
                    <b>DEMO</b>
                    <div style="color:gray; margin: 10px;">this demo resolves <b>"selection triggered by js will not present iOS UI text highlight menu"</b>, unless user long pressed once.</div>
                </div>
                <div style="margin-bottom: 20px">
                    <b>Environment: </b>
                    <div style="color:gray; margin: 10px;">iOS 16.7 - WKWebView / MobileSafari tested.</div>
                </div>
                <div style="margin-bottom: 20px">
                    <b>Known issues</b>
                    <div style="color:gray; margin: 10px;">⚠️ The handler of UI text highlight menu will not shown at first time</div>
                    <div style="color:gray; margin: 10px;">⚠️ This demo only represent the core conccept of bypassing from iOS restriction. User interaction outside of bypass container like "long press" "focus input" may interrupt prev bypass. Be aware of the lifecycle with other elements.</div>
                </div>
            </div>
    
            <div style="margin-bottom: 20px">
                <b>Try it: click text below to highlight it!</b>
                <div class="ios-selection-by-pass" style="padding: 8px 12px; margin: 10px 0px; border: 1px solid rgba(0,0,0,0.1); border-radius: 10px; line-height: 1.8rem;">
                    He bypassed his colleagues on the board and went ahead with the deal.
                </div>
                <div class="ios-selection-by-pass" style="padding: 8px 12px; margin: 10px 0px; border: 1px solid rgba(0,0,0,0.1); border-radius: 10px; line-height: 1.8rem;">
                    Arrangements were well advanced for linking up this newly operated length with the Lancaster bypass.
                </div>
            </div>
        </body>
    
        <script>
            document.addEventListener('DOMContentLoaded', ()=>{
                setupTouchSelectionByPass();
            });
    
            function setupTouchSelectionByPass(){
                let bypassTargetContainer = null;
                let willLongPressToBypass = null;
    
                //.Listeners
                let touchStartListener = async (e) => {
                    let container = e.target.closest(".ios-selection-by-pass");
                    if (
                        container &&
                        container != bypassTargetContainer &&
                        container.getAttribute("tabindex") != "-1"
                    ){
                        container.setAttribute("tabindex", "-1");
                    }
                    willLongPressToBypass = setTimeout(()=>{
                        bypassTargetContainer = container;
                    },500);
                }
                let touchEndListener = async (e) => {
                    await bypassSelectionSuppression(e);
                    selectTextFromTouch(e);
                }
    
                //.Utils
                function bypassSelectionSuppression(e){
                    return new Promise(async (resolve)=>{
                        let container = e.target.closest(".ios-selection-by-pass");
                        if (!container){
                            resolve()
                            return;
                        }
    
                        if (bypassTargetContainer != container){
                            console.log('🔥start bypass:', container);
    
                            clearTimeout(willLongPressToBypass);
    
                            bypassTargetContainer = container;
    
                            let selectionItem = selectTextFromTouch(e);
                            let range = selectionItem.selection.getRangeAt(0);
    
                            //.A
                            let input = document.createElement('input'); input.className = 'input-fake'; input.style = 'position: absolute; left: 0px; top: 0px; opacity: 0;';//
                            container.appendChild(input);
                            input.focus();
                            //.B
                            // e.target.contentEditable = true;
                            // e.target.focus();
    
                            let selection = document.getSelection();
                            selection.removeAllRanges();
                            selection.addRange(range);
    
                            let delay = 20; //10 ~ 30ms is accepted
                            setTimeout(async ()=>{
                                //.A
                                input.blur();
                                input.remove();
                                //.B
                                // document.activeElement.blur();
                                // e.target.contentEditable = false;
    
                                resolve();
                            }, delay);
                        }else{
                            console.log('🔥skip bypass:', container);
                            resolve();
                        }
                    })
                }
                function selectTextFromTouch(e) {
                    let word = "";
                    let selection = document.getSelection()
    
                    let event = e.originalEvent ?? e;
                    let range = document.caretRangeFromPoint(event.clientX || event.pageX, event.clientY || event.pageY);
                    selection.removeAllRanges();
                    selection.addRange(range);
    
                    selection.modify('move', 'backward', 'word');
                    selection.modify('extend', 'forward', 'word');
                    word = selection.toString();
    
                    return {word, selection};
                }
    
                //.Register Event Listeners
                let containers = document.querySelectorAll(".ios-selection-by-pass");
                // remove prev listeners
                for (let container of containers){
                    for (let fn of window.__TouchStartActionEventHandlers ?? []){
                        container.removeEventListener('touchstart', fn, true);
                    }
                    for (let fn of window.__TouchEndActionEventHandlers ?? []){
                        container.removeEventListener('click', fn, true);
                    }
                }
                window.__TouchStartActionEventHandlers = [];
                window.__TouchEndActionEventHandlers = [];
                // add new listeners
                for (let container of containers){
                    container.addEventListener('touchstart', touchStartListener, true);
                    container.addEventListener('click', touchEndListener, true);
                }
                window.__TouchStartActionEventHandlers.push(touchStartListener);
                window.__TouchEndActionEventHandlers.push(touchEndListener);
            }
        </script>
    </html>