selection.py (13230B)
1 # -*- coding: utf-8 -*- 2 # This Source Code Form is subject to the terms of the Mozilla Public 3 # License, v. 2.0. If a copy of the MPL was not distributed with this 4 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 6 from marionette_driver.marionette import Actions, errors 7 8 9 class CaretActions(Actions): 10 def __init__(self, marionette): 11 super(CaretActions, self).__init__(marionette) 12 self._reset_action_chain() 13 14 def _reset_action_chain(self): 15 # Release the action so that two consecutive clicks won't become a 16 # double-click. 17 self.release() 18 19 self.mouse_chain = self.sequence( 20 "pointer", "pointer_id", {"pointerType": "mouse"} 21 ) 22 self.key_chain = self.sequence("key", "keyboard_id") 23 24 def move(self, element, x, y, duration=None): 25 """Queue a pointer_move action. 26 27 :param element: an element where its top-left corner is the origin of 28 the coordinates. 29 :param x: Destination x-axis coordinate in CSS pixels. 30 :param y: Destination y-axis coordinate in CSS pixels. 31 :param duration: Number of milliseconds over which to distribute the 32 move. If None, remote end defaults to 0. 33 34 """ 35 rect = element.rect 36 el_x, el_y = rect["x"], rect["y"] 37 38 # Add the element's top-left corner (el_x, el_y) to make the coordinate 39 # relative to the viewport. 40 dest_x, dest_y = int(el_x + x), int(el_y + y) 41 42 self.mouse_chain.pointer_move(dest_x, dest_y, duration=duration) 43 return self 44 45 def click(self, element=None): 46 """Queue a click action. 47 48 If an element is given, move the pointer to that element first, 49 otherwise click current pointer coordinates. 50 51 :param element: Optional element to click. 52 53 """ 54 self.mouse_chain.click(element=element) 55 return self 56 57 def flick(self, element, x1, y1, x2, y2, duration=200): 58 """Queue a flick gesture on the target element. 59 60 :param element: The element to perform the flick gesture on. Its 61 top-left corner is the origin of the coordinates. 62 :param x1: Starting x-axis coordinate of the flick in CSS Pixels. 63 :param y1: Starting y-axis coordinate of the flick in CSS Pixels. 64 :param x2: Ending x-axis coordinate of the flick in CSS Pixels. 65 :param y2: Ending y-axis coordinate of the flick in CSS Pixels. 66 67 """ 68 self.move(element, x1, y1, duration=0) 69 self.mouse_chain.pointer_down() 70 self.move(element, x2, y2, duration=duration) 71 self.mouse_chain.pointer_up() 72 return self 73 74 def send_keys(self, keys): 75 """Perform a keyDown and keyUp action for each character in `keys`. 76 77 :param keys: String of keys to perform key actions with. 78 79 """ 80 self.key_chain.send_keys(keys) 81 return self 82 83 def perform(self): 84 """Perform the action chain built so far to the server side for execution 85 and clears the current chain of actions. 86 87 Warning: This method performs all the mouse actions before all the key 88 actions! 89 90 """ 91 self.mouse_chain.perform() 92 self.key_chain.perform() 93 self._reset_action_chain() 94 95 96 class SelectionManager(object): 97 """Interface for manipulating the selection and carets of the element. 98 99 We call the blinking cursor (nsCaret) as cursor, and call AccessibleCaret as 100 caret for short. 101 102 Simple usage example: 103 104 :: 105 106 element = marionette.find_element(By.ID, 'input') 107 sel = SelectionManager(element) 108 sel.move_caret_to_front() 109 110 """ 111 112 def __init__(self, element): 113 self.element = element 114 115 def _input_or_textarea(self): 116 """Return True if element is either <input> or <textarea>.""" 117 return self.element.tag_name in ("input", "textarea") 118 119 def js_selection_cmd(self): 120 """Return a command snippet to get selection object. 121 122 If the element is <input> or <textarea>, return the selection object 123 associated with it. Otherwise, return the current selection object. 124 125 Note: "element" must be provided as the first argument to 126 execute_script(). 127 128 """ 129 if self._input_or_textarea(): 130 # We must unwrap sel so that DOMRect could be returned to Python 131 # side. 132 return """var sel = arguments[0].editor.selection;""" 133 else: 134 return """var sel = window.getSelection();""" 135 136 def move_cursor_by_offset(self, offset, backward=False): 137 """Move cursor in the element by character offset. 138 139 :param offset: Move the cursor to the direction by offset characters. 140 :param backward: Optional, True to move backward; Default to False to 141 move forward. 142 143 """ 144 cmd = ( 145 self.js_selection_cmd() 146 + """ 147 for (let i = 0; i < {0}; ++i) {{ 148 sel.modify("move", "{1}", "character"); 149 }} 150 """.format( 151 offset, "backward" if backward else "forward" 152 ) 153 ) 154 155 self.element.marionette.execute_script( 156 cmd, script_args=(self.element,), sandbox="system" 157 ) 158 159 def move_cursor_to_front(self): 160 """Move cursor in the element to the front of the content.""" 161 if self._input_or_textarea(): 162 cmd = """arguments[0].setSelectionRange(0, 0);""" 163 else: 164 cmd = """var sel = window.getSelection(); 165 sel.collapse(arguments[0].firstChild, 0);""" 166 167 self.element.marionette.execute_script( 168 cmd, script_args=(self.element,), sandbox=None 169 ) 170 171 def move_cursor_to_end(self): 172 """Move cursor in the element to the end of the content.""" 173 if self._input_or_textarea(): 174 cmd = """var len = arguments[0].value.length; 175 arguments[0].setSelectionRange(len, len);""" 176 else: 177 cmd = """var sel = window.getSelection(); 178 sel.collapse(arguments[0].lastChild, arguments[0].lastChild.length);""" 179 180 self.element.marionette.execute_script( 181 cmd, script_args=(self.element,), sandbox=None 182 ) 183 184 def selection_rect_list(self, idx): 185 """Return the selection's DOMRectList object for the range at given idx. 186 187 If the element is either <input> or <textarea>, return the DOMRectList of 188 the range at given idx of the selection within the element. Otherwise, 189 return the DOMRectList of the of the range at given idx of current selection. 190 191 """ 192 cmd = ( 193 self.js_selection_cmd() 194 + """return sel.getRangeAt({}).getClientRects();""".format(idx) 195 ) 196 return self.element.marionette.execute_script( 197 cmd, script_args=(self.element,), sandbox="system" 198 ) 199 200 def range_count(self): 201 """Get selection's range count""" 202 cmd = self.js_selection_cmd() + """return sel.rangeCount;""" 203 return self.element.marionette.execute_script( 204 cmd, script_args=(self.element,), sandbox="system" 205 ) 206 207 def _selection_location_helper(self, location_type): 208 """Return the start and end location of the selection in the element. 209 210 Return a tuple containing two pairs of (x, y) coordinates of the start 211 and end locations in the element. The coordinates are relative to the 212 top left-hand corner of the element. Both ltr and rtl directions are 213 considered. 214 215 """ 216 range_count = self.range_count() 217 if range_count <= 0: 218 raise errors.MarionetteException( 219 "Expect at least one range object in Selection, but found nothing!" 220 ) 221 222 # FIXME (Bug 1682382): We shouldn't need the retry for-loops if 223 # selection_rect_list() can reliably return a valid list. 224 retry_times = 3 225 for _ in range(retry_times): 226 try: 227 first_rect_list = self.selection_rect_list(0) 228 first_rect = first_rect_list["0"] 229 break 230 except KeyError: 231 continue 232 else: 233 raise errors.MarionetteException( 234 "Expect at least one rect in the first range, but found nothing!" 235 ) 236 237 for _ in range(retry_times): 238 try: 239 # Making a selection over some non-selectable elements can 240 # create multiple ranges. 241 last_rect_list = ( 242 first_rect_list 243 if range_count == 1 244 else self.selection_rect_list(range_count - 1) 245 ) 246 last_list_length = last_rect_list["length"] 247 last_rect = last_rect_list[str(last_list_length - 1)] 248 break 249 except KeyError: 250 continue 251 else: 252 raise errors.MarionetteException( 253 "Expect at least one rect in the last range, but found nothing!" 254 ) 255 256 origin_x, origin_y = self.element.rect["x"], self.element.rect["y"] 257 258 if self.element.get_property("dir") == "rtl": # such as Arabic 259 start_pos, end_pos = "right", "left" 260 else: 261 start_pos, end_pos = "left", "right" 262 263 # Calculate y offset according to different needs. 264 if location_type == "center": 265 start_y_offset = first_rect["height"] / 2.0 266 end_y_offset = last_rect["height"] / 2.0 267 elif location_type == "caret": 268 # Selection carets' tip are below the bottom of the two ends of the 269 # selection. Add 5px to y should be sufficient to locate them. 270 caret_tip_y_offset = 5 271 start_y_offset = first_rect["height"] + caret_tip_y_offset 272 end_y_offset = last_rect["height"] + caret_tip_y_offset 273 else: 274 start_y_offset = end_y_offset = 0 275 276 caret1_x = first_rect[start_pos] - origin_x 277 caret1_y = first_rect["top"] + start_y_offset - origin_y 278 caret2_x = last_rect[end_pos] - origin_x 279 caret2_y = last_rect["top"] + end_y_offset - origin_y 280 281 return ((caret1_x, caret1_y), (caret2_x, caret2_y)) 282 283 def selection_location(self): 284 """Return the start and end location of the selection in the element. 285 286 Return a tuple containing two pairs of (x, y) coordinates of the start 287 and end of the selection. The coordinates are relative to the top 288 left-hand corner of the element. Both ltr and rtl direction are 289 considered. 290 291 """ 292 return self._selection_location_helper("center") 293 294 def carets_location(self): 295 """Return a pair of the two carets' location. 296 297 Return a tuple containing two pairs of (x, y) coordinates of the two 298 carets' tip. The coordinates are relative to the top left-hand corner of 299 the element. Both ltr and rtl direction are considered. 300 301 """ 302 return self._selection_location_helper("caret") 303 304 def cursor_location(self): 305 """Return the blanking cursor's center location within the element. 306 307 Return (x, y) coordinates of the cursor's center relative to the top 308 left-hand corner of the element. 309 310 """ 311 return self._selection_location_helper("center")[0] 312 313 def first_caret_location(self): 314 """Return the first caret's location. 315 316 Return (x, y) coordinates of the first caret's tip relative to the top 317 left-hand corner of the element. 318 319 """ 320 return self.carets_location()[0] 321 322 def second_caret_location(self): 323 """Return the second caret's location. 324 325 Return (x, y) coordinates of the second caret's tip relative to the top 326 left-hand corner of the element. 327 328 """ 329 return self.carets_location()[1] 330 331 def select_all(self): 332 """Select all the content in the element.""" 333 if self._input_or_textarea(): 334 cmd = """var len = arguments[0].value.length; 335 arguments[0].focus(); 336 arguments[0].setSelectionRange(0, len);""" 337 else: 338 cmd = """var range = document.createRange(); 339 range.setStart(arguments[0].firstChild, 0); 340 range.setEnd(arguments[0].lastChild, arguments[0].lastChild.length); 341 var sel = window.getSelection(); 342 sel.removeAllRanges(); 343 sel.addRange(range);""" 344 345 self.element.marionette.execute_script( 346 cmd, script_args=(self.element,), sandbox=None 347 ) 348 349 @property 350 def content(self): 351 """Return all the content of the element.""" 352 if self._input_or_textarea(): 353 return self.element.get_property("value") 354 else: 355 return self.element.text 356 357 @property 358 def selected_content(self): 359 """Return the selected portion of the content in the element.""" 360 cmd = self.js_selection_cmd() + """return sel.toString();""" 361 return self.element.marionette.execute_script( 362 cmd, script_args=(self.element,), sandbox="system" 363 )