diff --git a/imgsrc/srv/selection-handle-vertical.svg b/imgsrc/srv/selection-handle-vertical.svg new file mode 100644 index 0000000000..f6f9237941 --- /dev/null +++ b/imgsrc/srv/selection-handle-vertical.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 7ae3531185..966255b418 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -543,7 +543,8 @@ class IframeBoss: self.send_message( 'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id, drag_mouse_position=drag_mouse_position, selection_change_caused_by_search=by_search, - selection_extents=selection_extents(current_layout_mode() is 'flow')) + selection_extents=selection_extents(current_layout_mode() is 'flow'), + rtl = scroll_viewport.rtl, vertical = scroll_viewport.vertical_writing_mode) def onresize_stage2(self): if scroll_viewport.width() is self.last_window_width and scroll_viewport.height() is self.last_window_height: diff --git a/src/pyj/read_book/selection_bar.pyj b/src/pyj/read_book/selection_bar.pyj index de80c621df..dfce9c6221 100644 --- a/src/pyj/read_book/selection_bar.pyj +++ b/src/pyj/read_book/selection_bar.pyj @@ -8,7 +8,7 @@ from uuid import short_uuid from book_list.globals import get_session_data from book_list.theme import get_color -from dom import clear, svgicon, unique_id +from dom import change_icon_image, clear, svgicon, unique_id from modals import error_dialog, question_dialog from read_book.globals import runtime, ui_operations from read_book.highlights import ( @@ -28,11 +28,27 @@ def get_margins(): } -def map_boundaries(cs): +def map_boundaries(cs, vertical, rtl): margins = get_margins() - def map_boundary(x): - return {'x': (x.x or 0) + margins.left, 'y': (x.y or 0) + margins.top, 'height': x.height or 0, 'onscreen': x.onscreen} + def map_boundary(b): + x_offset = 0 + y_offset = 0 + if not vertical: + # Horizontal LTR + if not rtl: + if b.selected_prev: + x_offset = b.width + # Horizontal RTL + else: + if not b.selected_prev: + x_offset = b.width + else: + # Vertical: + if b.selected_prev: + y_offset = b.height + + return {'x': (b.x or 0) + x_offset + margins.left, 'y': (b.y or 0) + y_offset + margins.top, 'height': b.height or 0, 'width': b.width or 0, 'onscreen': b.onscreen} return map_boundary(cs.start), map_boundary(cs.end) @@ -182,11 +198,9 @@ def all_actions(): return all_actions.ans -def selection_handle(is_left): +def selection_handle(): ans = svgicon('selection-handle') s = ans.style - if not is_left: - s.transform = 'scaleX(-1)' s.position = 'absolute' s.boxSizing = 'border-box' s.touchAction = 'none' @@ -217,10 +231,14 @@ class SelectionBar: self.current_highlight_style = HighlightStyle(get_session_data().get('highlight_style')) self.current_notes = '' self.state = HIDDEN - self.left_handle_id = unique_id('handle') - self.right_handle_id = unique_id('handle') + self.start_handle_id = unique_id('handle') + self.end_handle_id = unique_id('handle') self.bar_id = unique_id('bar') self.editor_id = unique_id('editor') + # Sensible defaults until we get information from a selection message. + self.ltr = True + self.rtl = False + self.vertical = False container = self.container container.style.overflow = 'hidden' container.addEventListener('click', self.container_clicked, {'passive': False}) @@ -237,14 +255,14 @@ class SelectionBar: self.active_touch = None self.drag_scroll_timer = None self.last_drag_scroll_at = None - self.left_line_height = self.right_line_height = 0 + self.start_line_length = self.end_line_length = 0 self.current_editor = None - left_handle = selection_handle(True) - left_handle.id = self.left_handle_id - right_handle = selection_handle(False) - right_handle.id = self.right_handle_id - for h in (left_handle, right_handle): + start_handle = selection_handle() + start_handle.id = self.start_handle_id + end_handle = selection_handle() + end_handle.id = self.end_handle_id + for h in (start_handle, end_handle): h.addEventListener('mousedown', self.mousedown_on_handle, {'passive': False}) h.addEventListener('touchstart', self.touchstart_on_handle, {'passive': False}) container.appendChild(h) @@ -263,7 +281,7 @@ class SelectionBar: def set_handle_colors(self): handle_fill = get_color('window-background') fg = self.view.current_color_scheme.foreground - for h in (self.left_handle, self.right_handle): + for h in (self.start_handle, self.end_handle): set_handle_color(h, handle_fill, fg) def build_bar(self, annot_id): @@ -357,12 +375,12 @@ class SelectionBar: return document.getElementById(self.bar_id) @property - def left_handle(self): - return document.getElementById(self.left_handle_id) + def start_handle(self): + return document.getElementById(self.start_handle_id) @property - def right_handle(self): - return document.getElementById(self.right_handle_id) + def end_handle(self): + return document.getElementById(self.end_handle_id) @property def editor(self): @@ -374,18 +392,58 @@ class SelectionBar: @property def current_handle_position(self): - lh, rh = self.left_handle, self.right_handle - lbr, rbr = lh.getBoundingClientRect(), rh.getBoundingClientRect() - return { - 'start': { - 'onscreen': lh.style.display is not 'none', - 'x': Math.round(lbr.right), 'y': Math.round(lbr.bottom - self.left_line_height // 2) - }, - 'end': { - 'onscreen': rh.style.display is not 'none', - 'x': Math.round(rbr.left), 'y': Math.round(rbr.bottom - self.right_line_height // 2) + sh, eh = self.start_handle, self.end_handle + sbr, ebr = sh.getBoundingClientRect(), eh.getBoundingClientRect() + + if not self.vertical: + # Horizontal LTR (i.e. English) + if not self.rtl: + return { + 'start': { + 'onscreen': sh.style.display is not 'none', + 'x': Math.round(sbr.right), 'y': Math.round(sbr.bottom - self.start_line_length // 2) + }, + 'end': { + 'onscreen': eh.style.display is not 'none', + 'x': Math.round(ebr.left), 'y': Math.round(ebr.bottom - self.end_line_length // 2) + } + } + # Horizontal RTL (i.e. Hebrew, Arabic) + else: + return { + 'start': { + 'onscreen': sh.style.display is not 'none', + 'x': Math.round(sbr.left), 'y': Math.round(sbr.bottom - self.start_line_length // 2) + }, + 'end': { + 'onscreen': eh.style.display is not 'none', + 'x': Math.round(ebr.right), 'y': Math.round(ebr.bottom - self.end_line_length // 2) + } + } + # Vertical RTL (i.e. Traditional Chinese, Japanese) + else if self.rtl: + return { + 'start': { + 'onscreen': sh.style.display is not 'none', + 'x': Math.round(sbr.left + self.start_line_length // 2), 'y': Math.round(sbr.bottom) + }, + 'end': { + 'onscreen': eh.style.display is not 'none', + 'x': Math.round(ebr.right - self.end_line_length // 2), 'y': Math.round(ebr.top) + } + } + # Vertical LTR (i.e. Mongolian) + else: + return { + 'start': { + 'onscreen': sh.style.display is not 'none', + 'x': Math.round(sbr.right - self.end_line_length // 2), 'y': Math.round(sbr.bottom) + }, + 'end': { + 'onscreen': eh.style.display is not 'none', + 'x': Math.round(ebr.left + self.start_line_length // 2), 'y': Math.round(ebr.top) + } } - } # }}} @@ -419,7 +477,7 @@ class SelectionBar: if self.last_double_click_at and now - self.last_double_click_at < 500: self.send_message('extend-to-paragraph') return - for x in (self.bar, self.left_handle, self.right_handle): + for x in (self.bar, self.start_handle, self.end_handle): if near_element(x, ev.clientX, ev.clientY): return self.clear_selection() @@ -446,7 +504,7 @@ class SelectionBar: s.top = (ev.clientY - self.position_in_handle.y) + 'px' margins = get_margins() pos = self.current_handle_position - if self.dragging_handle is self.left_handle_id: + if self.dragging_handle is self.start_handle_id: start = True position = map_to_iframe_coords(pos.start, margins) else: @@ -524,10 +582,10 @@ class SelectionBar: if self.last_drag_scroll_at is None: # dont jump a page immediately in paged mode if in_flow_mode: - self.send_drag_scroll_message(backwards, 'left' if self.dragging_handle is self.left_handle_id else 'right', True) + self.send_drag_scroll_message(backwards, 'left' if self.dragging_handle is self.start_handle_id else 'right', True) self.last_drag_scroll_at = now elif now - self.last_drag_scroll_at > interval: - self.send_drag_scroll_message(backwards, 'left' if self.dragging_handle is self.left_handle_id else 'right', True) + self.send_drag_scroll_message(backwards, 'left' if self.dragging_handle is self.start_handle_id else 'right', True) self.last_drag_scroll_at = now def send_drag_scroll_message(self, backwards, handle, extend_selection): @@ -575,13 +633,13 @@ class SelectionBar: self.position_undragged_handle() return if self.state is EDITING: - self.left_handle.style.display = 'none' - self.right_handle.style.display = 'none' + self.start_handle.style.display = 'none' + self.end_handle.style.display = 'none' self.show() self.place_editor() return - self.left_handle.style.display = 'none' - self.right_handle.style.display = 'none' + self.start_handle.style.display = 'none' + self.end_handle.style.display = 'none' self.editor.style.display = 'none' if not cs or cs.empty or jstype(cs.drag_mouse_position.x) is 'number' or cs.selection_change_caused_by_search: @@ -590,9 +648,20 @@ class SelectionBar: if not cs.start.onscreen and not cs.end.onscreen: return self.hide() + self.rtl = cs.rtl + self.ltr = not self.rtl + self.vertical = cs.vertical + for h in (self.start_handle, self.end_handle): + if h.vertical is not self.vertical: + h.vertical = self.vertical + if self.vertical: + change_icon_image(h, 'selection-handle-vertical') + else: + change_icon_image(h, 'selection-handle') + self.show() - self.bar.style.display = self.left_handle.style.display = self.right_handle.style.display = 'block' - start, end = map_boundaries(cs) + self.bar.style.display = self.start_handle.style.display = self.end_handle.style.display = 'block' + start, end = map_boundaries(cs, self.vertical, self.rtl) bar = self.build_bar(cs.annot_id) bar_height = bar.offsetHeight bar_width = bar.offsetWidth @@ -602,8 +671,8 @@ class SelectionBar: # - 10 ensures we dont cover scroll bar 'left': buffer, 'right': container.offsetWidth - bar_width - buffer - 10 } - left_handle, right_handle = self.left_handle, self.right_handle - self.position_handles(left_handle, right_handle, start, end) + start_handle, end_handle = self.start_handle, self.end_handle + self.position_handles(start_handle, end_handle, start, end) def place_vertically(pos, put_below): if put_below: @@ -617,7 +686,7 @@ class SelectionBar: # We try to place the bar near the last dragged handle so it shows up # close to current mouse position. We assume it is the "end" handle. - if dragged_handle and dragged_handle is not self.right_handle_id: + if dragged_handle and dragged_handle is not self.end_handle_id: start, end = end, start if not end.onscreen and start.onscreen: start, end = end, start @@ -636,54 +705,107 @@ class SelectionBar: left = end.x - bar_width // 2 left = max(limits.left, min(left, limits.right)) bar.style.left = left + 'px' - lh, rh = left_handle.getBoundingClientRect(), right_handle.getBoundingClientRect() - changed = position_bar_avoiding_handles(lh, rh, left, top, bar_width, bar_height, container.offsetWidth - 10, container.offsetHeight, buffer) + sh, eh = start_handle.getBoundingClientRect(), end_handle.getBoundingClientRect() + changed = position_bar_avoiding_handles(sh, eh, left, top, bar_width, bar_height, container.offsetWidth - 10, container.offsetHeight, buffer) if changed: if changed.top?: place_vertically(changed.top, changed.put_below) if changed.left?: bar.style.left = changed.left + 'px' - def place_single_handle(self, handle_height, handle, boundary, is_left): + def place_single_handle(self, selection_size, handle, boundary, is_start): s = handle.style s.display = 'block' if boundary.onscreen else 'none' - height = handle_height * 2 - width = int(height * 2 / 3) + + # Cap this to prevent very large handles when selecting images. + selection_size = min(60, selection_size) + + if not self.vertical: + height = selection_size * 2 + width = int(height * 2 / 3) + else: + width = selection_size * 2 + height = int(width * 2 / 3) + s.width = f'{width}px' s.height = f'{height}px' - bottom = boundary.y + boundary.height - top = bottom - height - s.top = f'{top}px' - if is_left: - s.left = (boundary.x - width) + 'px' - self.left_line_height = boundary.height + s.transform = 'none' + if not self.vertical: + bottom = boundary.y + boundary.height + top = bottom - height + s.top = f'{top}px' + # Horizontal, start, LTR + if is_start and self.ltr: + s.left = (boundary.x - width) + 'px' + self.start_line_length = selection_size + # Horizontal, start, RTL + else if is_start: + s.left = (boundary.x) + 'px' + self.start_line_length = selection_size + s.transform = 'scaleX(-1)' + # Horizontal, end, LTR + else if self.ltr: + s.left = boundary.x + 'px' + self.end_line_length = selection_size + s.transform = 'scaleX(-1)' + # Horizontal, end, RTL + else: + s.left = (boundary.x - width) + 'px' + self.end_line_length = selection_size else: - s.left = boundary.x + 'px' - self.right_line_height = boundary.height + # Vertical, start, RTL + if is_start and self.rtl: + s.top = boundary.y - height + 'px' + s.left = boundary.x + 'px' + self.start_line_length = selection_size + s.transform = f'scaleX(-1) scaleY(-1)' + # Vertical, start, LTR + else if is_start: + s.top = boundary.y - height + 'px' + s.left = boundary.x - width + boundary.width + 'px' + self.start_line_length = selection_size + s.transform = f'scaleY(-1)' + # Vertical, end, RTL + else if self.rtl: + s.top = boundary.y + 'px' + s.left = boundary.x - width + boundary.width + 'px' + self.end_line_length = selection_size + # Vertical, end, LTR + else: + s.top = boundary.y + 'px' + s.left = boundary.x + 'px' + self.end_line_length = selection_size + s.transform = f'scaleX(-1)' - def position_handles(self, left_handle, right_handle, start, end): - handle_height = max(start.height, end.height) - self.place_single_handle(handle_height, left_handle, start, True) - self.place_single_handle(handle_height, right_handle, end, False) + def position_handles(self, start_handle, end_handle, start, end): + if not self.vertical: + selection_size = max(start.height, end.height) + else: + selection_size = max(start.width, end.width) + self.place_single_handle(selection_size, start_handle, start, True) + self.place_single_handle(selection_size, end_handle, end, False) def position_undragged_handle(self): cs = self.view.currently_showing.selection - start, end = map_boundaries(cs) - handle_height = max(start.height, end.height) - if self.dragging_handle is self.left_handle_id: - handle = self.right_handle - boundary = end - is_left = False + start, end = map_boundaries(cs, self.vertical, self.rtl) + if not self.vertical: + selection_size = max(start.height, end.height) else: - handle = self.left_handle + selection_size = max(start.width, end.width) + if self.dragging_handle is self.start_handle_id: + handle = self.end_handle + boundary = end + is_start = False + else: + handle = self.start_handle boundary = start - is_left = True - self.place_single_handle(handle_height, handle, boundary, is_left) + is_start = True + self.place_single_handle(selection_size, handle, boundary, is_start) # }}} # Editor {{{ def show_editor(self, highlight_style, notes): - for x in (self.bar, self.left_handle, self.right_handle): + for x in (self.bar, self.start_handle, self.end_handle): x.style.display = 'none' container = self.editor clear(container) @@ -699,7 +821,7 @@ class SelectionBar: return ed = self.editor cs = self.view.currently_showing.selection - start, end = map_boundaries(cs) + start, end = map_boundaries(cs, self.vertical, self.rtl) if not start.onscreen and not end.onscreen: return width, height = ed.offsetWidth, ed.offsetHeight diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index fcb2c66f24..d0bb7bec08 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -543,7 +543,8 @@ class View: 'text': data.text, 'empty': data.empty, 'start': data.selection_extents.start, 'end': data.selection_extents.end, 'annot_id': data.annot_id, 'drag_mouse_position': data.drag_mouse_position, - 'selection_change_caused_by_search': data.selection_change_caused_by_search + 'selection_change_caused_by_search': data.selection_change_caused_by_search, + 'rtl': data.rtl, 'vertical': data.vertical } if ui_operations.selection_changed: ui_operations.selection_changed(self.currently_showing.selection.text, self.currently_showing.selection.annot_id) diff --git a/src/pyj/select.pyj b/src/pyj/select.pyj index 0f3de7a5e6..b8de2fce12 100644 --- a/src/pyj/select.pyj +++ b/src/pyj/select.pyj @@ -44,10 +44,32 @@ def word_at_point(x, y): def empty_range_extents(): return { - 'start': {'x': 0, 'y': 0, 'height': 0, 'onscreen': False, 'is_empty': True}, - 'end': {'x': 0, 'y': 0, 'height': 0, 'onscreen': False, 'is_empty': True} + 'start': {'x': 0, 'y': 0, 'height': 0, 'width': 0, 'onscreen': False, 'selected_prev': False}, + 'end': {'x': 0, 'y': 0, 'height': 0, 'width': 0, 'onscreen': False, 'selected_prev': False} } +# Returns a node that we know will produce a reasonable bounding box closest to the start or end +# of the DOM tree from the specified node. +# Currently: BR, IMG, and text nodes. +# This a depth first traversal, with the modification that if a node is reached that meets the criteria, +# the traversal stops, because higher nodes in the DOM tree always have larger bounds than their children. +def get_selection_node_at_boundary(node, start): + stack = [] + stack.push({'node': node, 'visited': False}) + while stack.length > 0: + top = stack[-1] + # If the top node is a target type, we know that no nodes below it can be more to the start or end + # than it, so return it immediately. + if top.node.nodeType is Node.TEXT_NODE or top.node.nodeName.upper() == 'IMG' or top.node.nodeName.upper() == 'BR': + return top.node + # Otherwise, depth-first traversal. + else if top.visited: + stack.pop() + else: + top.visited = True + for c in top.node.childNodes if start else reversed(top.node.childNodes): + stack.push({'node': c, 'visited': False}) + return None def range_extents(q, in_flow_mode): ans = empty_range_extents() @@ -55,21 +77,40 @@ def range_extents(q, in_flow_mode): return ans start = q.cloneRange() end = q.cloneRange() - start.collapse(True) - end.collapse(False) - def for_boundary(r, ans): + def rect_onscreen(r): + if r.right <= window.innerWidth and r.bottom <= window.innerHeight and r.left >= 0 and r.top >= 0: + return True + return False + + def for_boundary(r, ans, is_start): rect = r.getBoundingClientRect() - if rect.height is 0: + if rect.height is 0 and rect.width is 0: # this tends to happen when moving the mouse downwards # at the boundary between paragraphs if r.startContainer?.nodeType is Node.ELEMENT_NODE: node = r.startContainer if r.startOffset and node.childNodes.length > r.startOffset: node = node.childNodes[r.startOffset] + + boundary_node = get_selection_node_at_boundary(node, is_start) + # If we found a node that will produce a reasonable bounding box at a boundary, use it: + if boundary_node: + if boundary_node.nodeType is Node.TEXT_NODE: + if is_start: + r.setStart(boundary_node, boundary_node.length - 1) + r.setEnd(boundary_node, boundary_node.length) + else: + r.setStart(boundary_node, 0) + r.setEnd(boundary_node, 1) + rect = r.getBoundingClientRect() + else: + rect = boundary_node.getBoundingClientRect() + if not is_start: + ans.selected_prev = True # we cant use getBoundingClientRect as the node might be split # among multiple columns - if node.getClientRects: + else if node.getClientRects: rects = node.getClientRects() if rects.length: erect = rects[0] @@ -77,13 +118,61 @@ def range_extents(q, in_flow_mode): ans.x = Math.round(rect.left) ans.y = Math.round(rect.top) ans.height = rect.height - if rect.right <= window.innerWidth and rect.bottom <= window.innerHeight and rect.left >= 0 and rect.top >= 0: - ans.onscreen = True + ans.width = rect.width + ans.onscreen = rect_onscreen(rect) - for_boundary(start, ans.start) - for_boundary(end, ans.end) - ans.start.is_empty = ans.start.height <= 0 - ans.end.is_empty = ans.end.height <= 0 + if q.startContainer.nodeType is Node.ELEMENT_NODE: + start.collapse(True) + for_boundary(start, ans.start, True) + else if q.startOffset is 0 and q.startContainer.length is 0: + start.collapse(True) + for_boundary(start, ans.start, True) + else if q.startOffset == q.startContainer.length: + start.setStart(q.startContainer, q.startOffset - 1) + start.setEnd(q.startContainer, q.startOffset) + rect = start.getBoundingClientRect() + ans.start.x = rect.left + ans.start.y = rect.top + ans.start.height = rect.height + ans.start.width = rect.width + ans.start.selected_prev = True + ans.start.onscreen = rect_onscreen(rect) + else: + start.setStart(q.startContainer, q.startOffset) + start.setEnd(q.startContainer, q.startOffset + 1) + rect = start.getBoundingClientRect() + ans.start.x = rect.left + ans.start.y = rect.top + ans.start.height = rect.height + ans.start.width = rect.width + ans.start.onscreen = rect_onscreen(rect) + + if q.endContainer.nodeType is Node.ELEMENT_NODE: + end.collapse(False) + for_boundary(end, ans.end, False) + else if q.endOffset is 0 and q.endContainer.length is 0: + end.collapse(False) + for_boundary(end, ans.end, False) + else if q.endOffset is q.endContainer.length: + end.setStart(q.endContainer, q.endOffset - 1) + end.setEnd(q.endContainer, q.endOffset) + rect = end.getBoundingClientRect() + ans.end.x = rect.left + ans.end.y = rect.top + ans.end.height = rect.height + ans.end.width = rect.width + ans.end.selected_prev = True + ans.end.onscreen = rect_onscreen(rect) + else: + end.setStart(q.endContainer, q.endOffset) + end.setEnd(q.endContainer, q.endOffset + 1) + rect = end.getBoundingClientRect() + ans.end.x = rect.left + ans.end.y = rect.top + ans.end.height = rect.height + ans.end.width = rect.width + ans.end.onscreen = rect_onscreen(rect) + if ans.end.height is 2 and ans.start.height > 2: ans.end.height = ans.start.height if ans.start.height is 2 and ans.end.height > 2: