# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2020, Kovid Goyal # globals: NodeFilter, Range from __python__ import bound_methods, hash_literals def is_non_empty_text_node(node): return (node.nodeType is Node.TEXT_NODE or node.nodeType is Node.CDATA_SECTION_NODE) and node.nodeValue.length > 0 def is_element_visible(elem): s = window.getComputedStyle(elem) return s.display is not 'none' and s.visibility is not 'hidden' def is_node_visible(node): if node.nodeType is not Node.ELEMENT_NODE: node = node.parentElement if not node: return False current = node while current: if not is_element_visible(current): return False current = current.parentElement return True def select_nodes_from_range(r, predicate): parent = r.commonAncestorContainer doc = parent.ownerDocument or document iterator = doc.createNodeIterator(parent) in_range = False ans = v'[]' check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent)) while True: node = iterator.nextNode() if not node: break if not in_range and node.isSameNode(r.startContainer): in_range = True if in_range: if predicate(node): ans.push(node) if check_for_end and node.isSameNode(r.endContainer): break return ans def select_first_node_from_range(r, predicate): parent = r.commonAncestorContainer doc = parent.ownerDocument or document iterator = doc.createNodeIterator(parent) in_range = False check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent)) while True: node = iterator.nextNode() if not node: break if not in_range and node.isSameNode(r.startContainer): in_range = True if in_range: if predicate(node): return node if check_for_end and node.isSameNode(r.endContainer): break def text_nodes_in_range(r): return select_nodes_from_range(r, is_non_empty_text_node) def all_annots_in_range(r, annot_id_uuid_map, ans): parent = r.commonAncestorContainer doc = parent.ownerDocument or document iterator = doc.createNodeIterator(parent) is_full_tree = parent is doc.documentElement in_range = is_full_tree check_for_end = not is_full_tree and not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent)) while True: node = iterator.nextNode() if not node: break if not in_range and node.isSameNode(r.startContainer): in_range = True if in_range: if node.dataset and node.dataset.calibreRangeWrapper: annot_id = annot_id_uuid_map[node.dataset.calibreRangeWrapper] if annot_id: if not ans: return annot_id ans[annot_id] = True if check_for_end and node.isSameNode(r.endContainer): break return ans def first_annot_in_range(r, annot_id_uuid_map): return all_annots_in_range(r, annot_id_uuid_map) def all_annots_in_selection(sel, annot_id_uuid_map): ans = v'{}' for i in range(sel.rangeCount): all_annots_in_range(sel.getRangeAt(i), annot_id_uuid_map, ans) return Object.keys(ans) def remove(node): if node.parentNode: node.parentNode.removeChild(node) def replace_node(replacement, node): p = node.parentNode p.insertBefore(replacement, node) remove(node) return p def unwrap(node): r = (node.ownerDocument or document).createRange() r.selectNodeContents(node) p = replace_node(r.extractContents(), node) if p: p.normalize() def unwrap_crw(crw): for node in document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]'): unwrap(node) def unwrap_all_crw(): for node in document.querySelectorAll('span[data-calibre-range-wrapper]'): unwrap(node) def select_crw(crw): nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]') if nodes and nodes.length: r = document.createRange() r.setStart(nodes[0].firstChild, 0) r.setEnd(nodes[-1].lastChild, nodes[-1].lastChild.nodeValue.length) sel = window.getSelection() sel.removeAllRanges() sel.addRange(r) return True print(f'range-wrapper: {crw} does not exist') return False def wrap_range(r, wrapper): try: r.surroundContents(wrapper) except: wrapper.appendChild(r.extractContents()) r.insertNode(wrapper) def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers): start_node = r.startContainer end_node = r.endContainer start_offset = r.startOffset end_offset = r.endOffset def wrap_node(node): nonlocal start_node, end_node, start_offset, end_offset current_range = (node.ownerDocument or document).createRange() current_wrapper = wrapper_elem.cloneNode() current_range.selectNodeContents(node) # adjust start and end in case the current node is one of the # boundaries of the original range if node.isSameNode(start_node): current_range.setStart(node, start_offset) start_node = current_wrapper start_offset = 0 if node.isSameNode(end_node): current_range.setEnd(node, end_offset) end_node = current_wrapper end_offset = 1 if current_range.collapsed: # Don't wrap empty ranges. This is needed otherwise two adjacent # selections of text will incorrectly be detected as overlapping. # For example: highlight abc then def in the word abcdef here the # second highlight's first range is the collapsed range at the end # of abc return crw = node.parentNode?.dataset?.calibreRangeWrapper if crw: intersecting_wrappers[crw] = True wrap_range(current_range, current_wrapper) if process_wrapper: process_wrapper(current_wrapper) all_wrappers.push(current_wrapper) return wrap_node wrapper_counter = 0 def wrap_text_in_range(styler, r, class_to_add_to_last, process_wrapper): if not r: sel = window.getSelection() if not sel or not sel.rangeCount: return None, v'[]' r = sel.getRangeAt(0) if r.isCollapsed: return None, v'[]' wrapper_elem = document.createElement('span') wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + '' if styler: styler(wrapper_elem) intersecting_wrappers = {} all_wrappers = v'[]' wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers) text_nodes_in_range(r).map(wrap_node) ancestor = r.commonAncestorContainer if ancestor.nodeType is Node.TEXT_NODE: ancestor = ancestor.parentNode # remove any empty text nodes created by surroundContents() on either # side of the wrapper. This happens for instance on Chrome when # wrapping all text inside some text ancestor.normalize() crw = wrapper_elem.dataset.calibreRangeWrapper v'delete intersecting_wrappers[crw]' if class_to_add_to_last and all_wrappers.length: all_wrappers[-1].classList.add(class_to_add_to_last) return crw, Object.keys(intersecting_wrappers) def last_span_for_crw(crw): nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]') if nodes and nodes.length: return nodes[-1] def reset_highlight_counter(): nonlocal wrapper_counter wrapper_counter = 0 def get_annot_id_for(node, offset, annot_id_uuid_map): if not node: return if node.nodeType is Node.ELEMENT_NODE: if node.dataset?.calibreRangeWrapper: return annot_id_uuid_map[node.dataset.calibreRangeWrapper] if offset is 0: if node.firstChild?.nodeType is Node.ELEMENT_NODE and node.firstChild.dataset?.calibreRangeWrapper: return annot_id_uuid_map[node.firstChild.dataset.calibreRangeWrapper] elif offset < node.childNodes.length: node = node.childNodes[offset] return get_annot_id_for(node, 0, annot_id_uuid_map) elif node.nodeType is Node.TEXT_NODE: if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset?.calibreRangeWrapper: return annot_id_uuid_map[node.parentNode.dataset.calibreRangeWrapper] def highlight_associated_with_selection(sel, annot_id_uuid_map): # Return the annotation id for a highlight intersecting the selection if sel.rangeCount: annot_id = get_annot_id_for(sel.focusNode, sel.focusOffset, annot_id_uuid_map) or get_annot_id_for(sel.anchorNode, sel.anchorOffset, annot_id_uuid_map) if annot_id: return annot_id for v'var i = 0; i < sel.rangeCount; i++': r = sel.getRangeAt(i) annot_id = first_annot_in_range(r, annot_id_uuid_map) if annot_id: return annot_id