SelectionActionDelegateChild.sys.mjs (13352B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", 11 }); 12 13 const MAGNIFIER_PREF = "layout.accessiblecaret.magnifier.enabled"; 14 const ACCESSIBLECARET_HEIGHT_PREF = "layout.accessiblecaret.height"; 15 const PREFS = [MAGNIFIER_PREF, ACCESSIBLECARET_HEIGHT_PREF]; 16 17 // Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to 18 // the GeckoSession on accessible caret changes. 19 export class SelectionActionDelegateChild extends GeckoViewActorChild { 20 constructor(aModuleName, aMessageManager) { 21 super(aModuleName, aMessageManager); 22 23 this._actionCallback = () => {}; 24 this._isActive = false; 25 this._previousMessage = ""; 26 27 // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's 28 // directly, so we create a new function here instead to act as our 29 // nsIObserver, which forwards the notification to the observe method. 30 this._observerFunction = (subject, topic, data) => { 31 this.observe(subject, topic, data); 32 }; 33 for (const pref of PREFS) { 34 Services.prefs.addObserver(pref, this._observerFunction); 35 } 36 37 this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF); 38 this._accessiblecaretHeight = parseFloat( 39 Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0") 40 ); 41 } 42 43 didDestroy() { 44 for (const pref of PREFS) { 45 Services.prefs.removeObserver(pref, this._observerFunction); 46 } 47 } 48 49 _actions = [ 50 { 51 id: "org.mozilla.geckoview.HIDE", 52 predicate: _ => true, 53 perform: _ => this.handleEvent({ type: "pagehide" }), 54 }, 55 { 56 id: "org.mozilla.geckoview.CUT", 57 predicate: e => 58 !e.collapsed && e.selectionEditable && !this._isPasswordField(e), 59 perform: _ => this.docShell.doCommand("cmd_cut"), 60 }, 61 { 62 id: "org.mozilla.geckoview.COPY", 63 predicate: e => !e.collapsed && !this._isPasswordField(e), 64 perform: _ => this.docShell.doCommand("cmd_copy"), 65 }, 66 { 67 id: "org.mozilla.geckoview.PASTE", 68 predicate: e => 69 (this._isContentHtmlEditable(e) && 70 Services.clipboard.hasDataMatchingFlavors( 71 /* The following image types are considered by editor */ 72 ["image/gif", "image/jpeg", "image/png"], 73 Ci.nsIClipboard.kGlobalClipboard 74 )) || 75 (e.selectionEditable && 76 Services.clipboard.hasDataMatchingFlavors( 77 ["text/plain"], 78 Ci.nsIClipboard.kGlobalClipboard 79 )), 80 perform: _ => this._performPaste(), 81 }, 82 { 83 id: "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT", 84 predicate: e => 85 this._isContentHtmlEditable(e) && 86 Services.clipboard.hasDataMatchingFlavors( 87 ["text/html"], 88 Ci.nsIClipboard.kGlobalClipboard 89 ), 90 perform: _ => this._performPasteAsPlainText(), 91 }, 92 { 93 id: "org.mozilla.geckoview.DELETE", 94 predicate: e => !e.collapsed && e.selectionEditable, 95 perform: _ => this.docShell.doCommand("cmd_delete"), 96 }, 97 { 98 id: "org.mozilla.geckoview.COLLAPSE_TO_START", 99 predicate: e => !e.collapsed && e.selectionEditable, 100 perform: () => this.docShell.doCommand("cmd_moveLeft"), 101 }, 102 { 103 id: "org.mozilla.geckoview.COLLAPSE_TO_END", 104 predicate: e => !e.collapsed && e.selectionEditable, 105 perform: () => this.docShell.doCommand("cmd_moveRight"), 106 }, 107 { 108 id: "org.mozilla.geckoview.UNSELECT", 109 predicate: e => !e.collapsed && !e.selectionEditable, 110 perform: () => this.docShell.doCommand("cmd_selectNone"), 111 }, 112 { 113 id: "org.mozilla.geckoview.SELECT_ALL", 114 predicate: e => { 115 if (e.reason === "longpressonemptycontent") { 116 return false; 117 } 118 // When on design mode, focusedElement will be null. 119 const element = 120 Services.focus.focusedElement || e.target?.activeElement; 121 if (e.selectionEditable && e.target && element) { 122 let value = ""; 123 if (element.value) { 124 value = element.value; 125 } else if ( 126 element.isContentEditable || 127 e.target.designMode === "on" 128 ) { 129 value = element.innerText; 130 } 131 // Do not show SELECT_ALL if the editable is empty 132 // or all the editable text is already selected. 133 return value !== "" && value !== e.selectedTextContent; 134 } 135 return true; 136 }, 137 perform: () => this.docShell.doCommand("cmd_selectAll"), 138 }, 139 ]; 140 141 receiveMessage({ name, data }) { 142 debug`receiveMessage ${name}`; 143 144 switch (name) { 145 case "ExecuteSelectionAction": { 146 this._actionCallback(data); 147 } 148 } 149 } 150 151 _performPaste() { 152 this.handleEvent({ type: "pagehide" }); 153 this.docShell.doCommand("cmd_paste"); 154 } 155 156 _performPasteAsPlainText() { 157 this.handleEvent({ type: "pagehide" }); 158 this.docShell.doCommand("cmd_pasteNoFormatting"); 159 } 160 161 _isPasswordField(aEvent) { 162 if (!aEvent.selectionEditable) { 163 return false; 164 } 165 166 const win = aEvent.target.defaultView; 167 const focus = aEvent.target.activeElement; 168 return ( 169 win && 170 win.HTMLInputElement && 171 win.HTMLInputElement.isInstance(focus) && 172 !focus.mozIsTextField(/* excludePassword */ true) 173 ); 174 } 175 176 _isContentHtmlEditable(aEvent) { 177 if (!aEvent.selectionEditable) { 178 return false; 179 } 180 181 if (aEvent.target.designMode == "on") { 182 return true; 183 } 184 185 // focused element isn't <input> nor <textarea> 186 const win = aEvent.target.defaultView; 187 const focus = Services.focus.focusedElement; 188 return ( 189 win && 190 win.HTMLInputElement && 191 win.HTMLTextAreaElement && 192 !win.HTMLInputElement.isInstance(focus) && 193 !win.HTMLTextAreaElement.isInstance(focus) 194 ); 195 } 196 197 _getDefaultMagnifierPoint(aEvent) { 198 const rect = lazy.LayoutUtils.rectToScreenRect(aEvent.target.ownerGlobal, { 199 left: aEvent.clientX, 200 top: aEvent.clientY - this._accessiblecaretHeight, 201 width: 0, 202 height: 0, 203 }); 204 return { x: rect.left, y: rect.top }; 205 } 206 207 _getBetterMagnifierPoint(aEvent) { 208 const win = aEvent.target.defaultView; 209 if (!win) { 210 return this._getDefaultMagnifierPoint(aEvent); 211 } 212 213 const focus = aEvent.target.activeElement; 214 if ( 215 win.HTMLInputElement?.isInstance(focus) && 216 focus.mozIsTextField(false) 217 ) { 218 // <input> element. Use vertical center position of input element. 219 const bounds = focus.getBoundingClientRect(); 220 const rect = lazy.LayoutUtils.rectToScreenRect( 221 aEvent.target.ownerGlobal, 222 { 223 left: aEvent.clientX, 224 top: bounds.top, 225 width: 0, 226 height: bounds.height, 227 } 228 ); 229 return { x: rect.left, y: rect.top + rect.height / 2 }; 230 } 231 232 if (win.HTMLTextAreaElement?.isInstance(focus)) { 233 // TODO: 234 // <textarea> element. How to get better selection bounds? 235 return this._getDefaultMagnifierPoint(aEvent); 236 } 237 238 const selection = win.getSelection(); 239 if (selection.rangeCount != 1) { 240 // When selecting text using accessible caret, selection count will be 1. 241 // This situation means that current selection isn't into text. 242 return this._getDefaultMagnifierPoint(aEvent); 243 } 244 245 // We are looking for better selection bounds, then use it. 246 const bounds = (() => { 247 const range = selection.getRangeAt(0); 248 let distance = Number.MAX_SAFE_INTEGER; 249 let y = aEvent.clientY; 250 const rectList = range.getClientRects(); 251 for (const rect of rectList) { 252 const newDistance = Math.abs(aEvent.clientY - rect.bottom); 253 if (distance > newDistance) { 254 y = rect.top + rect.height / 2; 255 distance = newDistance; 256 } 257 } 258 return { left: aEvent.clientX, top: y, width: 0, height: 0 }; 259 })(); 260 261 const rect = lazy.LayoutUtils.rectToScreenRect( 262 aEvent.target.ownerGlobal, 263 bounds 264 ); 265 return { x: rect.left, y: rect.top }; 266 } 267 268 _handleMagnifier(aEvent) { 269 if (["presscaret", "dragcaret"].includes(aEvent.reason)) { 270 debug`_handleMagnifier: ${aEvent.reason}`; 271 const screenPoint = this._getBetterMagnifierPoint(aEvent); 272 this.sendAsyncMessage("GeckoView:ShowMagnifier", { screenPoint }); 273 } else if (aEvent.reason == "releasecaret") { 274 debug`_handleMagnifier: ${aEvent.reason}`; 275 this.sendAsyncMessage("GeckoView:HideMagnifier"); 276 } 277 } 278 279 /** 280 * Receive and act on AccessibleCarets caret state-change 281 * (mozcaretstatechanged and pagehide) events. 282 */ 283 handleEvent(aEvent) { 284 if (aEvent.type === "pagehide" || aEvent.type === "deactivate") { 285 // Hide any selection actions on page hide or deactivate. 286 aEvent = { 287 reason: "visibilitychange", 288 caretVisibile: false, 289 selectionVisible: false, 290 collapsed: true, 291 selectionEditable: false, 292 }; 293 } 294 295 let reason = aEvent.reason; 296 297 if (this._isActive && !aEvent.caretVisible) { 298 // For mozcaretstatechanged, "visibilitychange" means the caret is hidden. 299 reason = "visibilitychange"; 300 } else if (!aEvent.collapsed && !aEvent.selectionVisible) { 301 reason = "invisibleselection"; 302 } else if ( 303 !this._isActive && 304 aEvent.selectionEditable && 305 aEvent.collapsed && 306 reason !== "longpressonemptycontent" && 307 reason !== "taponcaret" && 308 !Services.prefs.getBoolPref( 309 "geckoview.selection_action.show_on_focus", 310 false 311 ) 312 ) { 313 // Don't show selection actions when merely focusing on an editor or 314 // repositioning the cursor. Wait until long press or the caret is tapped 315 // in order to match Android behavior. 316 reason = "visibilitychange"; 317 } 318 319 debug`handleEvent: ${reason}`; 320 321 if (this._magnifierEnabled) { 322 this._handleMagnifier(aEvent); 323 } 324 325 if ( 326 [ 327 "longpressonemptycontent", 328 "releasecaret", 329 "taponcaret", 330 "updateposition", 331 ].includes(reason) 332 ) { 333 const actions = this._actions.filter(action => 334 action.predicate.call(this, aEvent) 335 ); 336 337 const screenRect = (() => { 338 const boundingRect = aEvent.boundingClientRect; 339 if (!boundingRect) { 340 return null; 341 } 342 const rect = lazy.LayoutUtils.rectToScreenRect( 343 aEvent.target.ownerGlobal, 344 boundingRect 345 ); 346 return { 347 left: rect.left, 348 top: rect.top, 349 right: rect.right, 350 bottom: rect.bottom + this._accessiblecaretHeight, 351 }; 352 })(); 353 354 const password = this._isPasswordField(aEvent); 355 356 const msg = { 357 collapsed: aEvent.collapsed, 358 editable: aEvent.selectionEditable, 359 password, 360 selection: password ? "" : aEvent.selectedTextContent, 361 screenRect, 362 actions: actions.map(action => action.id), 363 }; 364 365 if (this._isActive && JSON.stringify(msg) === this._previousMessage) { 366 // Don't call again if we're already active and things haven't changed. 367 return; 368 } 369 370 this._isActive = true; 371 this._previousMessage = JSON.stringify(msg); 372 373 // We can't just listen to the response of the message because we accept 374 // multiple callbacks. 375 this._actionCallback = data => { 376 const action = actions.find(action => action.id === data.id); 377 if (action) { 378 debug`Performing ${data.id}`; 379 action.perform.call(this, aEvent); 380 } else { 381 warn`Invalid action ${data.id}`; 382 } 383 }; 384 this.sendAsyncMessage("ShowSelectionAction", msg); 385 } else if ( 386 [ 387 "invisibleselection", 388 "presscaret", 389 "scroll", 390 "visibilitychange", 391 ].includes(reason) 392 ) { 393 if (!this._isActive) { 394 return; 395 } 396 this._isActive = false; 397 398 // Mark previous actions as stale. Don't do this for "invisibleselection" 399 // or "scroll" because previous actions should still be valid even after 400 // these events occur. 401 if (reason !== "invisibleselection" && reason !== "scroll") { 402 this._seqNo++; 403 } 404 405 this.sendAsyncMessage("HideSelectionAction", { reason }); 406 } else if (reason == "dragcaret") { 407 // nothing for selection action 408 } else { 409 warn`Unknown reason: ${reason}`; 410 } 411 } 412 413 observe(aSubject, aTopic, aData) { 414 if (aTopic != "nsPref:changed") { 415 return; 416 } 417 418 switch (aData) { 419 case ACCESSIBLECARET_HEIGHT_PREF: 420 this._accessiblecaretHeight = parseFloat( 421 Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0") 422 ); 423 break; 424 case MAGNIFIER_PREF: 425 this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF); 426 break; 427 } 428 // Reset magnifier 429 this.sendAsyncMessage("GeckoView:HideMagnifier"); 430 } 431 } 432 433 const { debug, warn } = SelectionActionDelegateChild.initLogging( 434 "SelectionActionDelegate" 435 );