diff --git a/src/pyj/range_utils.pyj b/src/pyj/range_utils.pyj index 3548ffb19c..3e7216146a 100644 --- a/src/pyj/range_utils.pyj +++ b/src/pyj/range_utils.pyj @@ -63,7 +63,7 @@ def select_crw(crw): sel.addRange(r) -def create_wrapper_function(wrapper_elem, r, intersecting_wrappers): +def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper): start_node = r.startContainer end_node = r.endContainer start_offset = r.startOffset @@ -86,6 +86,8 @@ def create_wrapper_function(wrapper_elem, r, intersecting_wrappers): if crw: intersecting_wrappers[crw] = True current_range.surroundContents(current_wrapper) + if process_wrapper: + process_wrapper(current_wrapper) return current_wrapper return wrap_node @@ -94,9 +96,12 @@ def create_wrapper_function(wrapper_elem, r, intersecting_wrappers): wrapper_counter = 0 -def wrap_text_in_range(style, r): +def wrap_text_in_range(style, r, process_wrapper): if not r: - r = window.getSelection().getRangeAt(0) + sel = window.getSelection() + if not sel or not sel.rangeCount: + return None, v'[]' + r = sel.getRangeAt(0) if r.isCollapsed: return None, v'[]' @@ -106,7 +111,7 @@ def wrap_text_in_range(style, r): wrapper_elem.setAttribute('style', style) intersecting_wrappers = {} - wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers) + wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper) text_nodes_in_range(r).map(wrap_node) crw = wrapper_elem.dataset.calibreRangeWrapper v'delete intersecting_wrappers[crw]' diff --git a/src/pyj/read_book/create_annotation.pyj b/src/pyj/read_book/create_annotation.pyj index 19347a358d..db49ed4908 100644 --- a/src/pyj/read_book/create_annotation.pyj +++ b/src/pyj/read_book/create_annotation.pyj @@ -42,6 +42,14 @@ class AnnotationsManager: if h: return h.notes + def style_for_highlight(self, uuid): + h = self.highlights[uuid] + if h: + return h.style + + def data_for_highlight(self, uuid): + return self.highlights[uuid] + def add_highlight(self, msg, style, notes): now = Date().toISOString() for uuid in msg.removed_highlights: @@ -95,6 +103,13 @@ highlight_colors = { default_highlight_color = '#fce2ae' +def default_highlight_style(): + return { + 'background-color': default_highlight_color, + 'color': highlight_colors[default_highlight_color] + } + + def selection_handle(invert, style): ans = svgicon('selection-handle') use = ans.querySelector('use') @@ -191,10 +206,7 @@ class CreateAnnotation: button(bb, 'pencil', _('Add a note'), self.add_notes) sd = get_session_data() - style = sd.get('highlight_style') or { - 'background-color': default_highlight_color, - 'color': highlight_colors[default_highlight_color] - } + style = sd.get('highlight_style') or default_highlight_style() self.current_highlight_style = style lh = selection_handle(False, style) @@ -496,6 +508,10 @@ class CreateAnnotation: def send_message(self, type, **kw): self.view.iframe_wrapper.send_message('annotations', type=type, **kw) + def edit_highlight(self, uuid): + self.send_message('edit-highlight', uuid=uuid) + self.show() + def handle_message(self, msg): if msg.type is 'create-annotation': self.editing_annot_uuid = None @@ -508,6 +524,7 @@ class CreateAnnotation: if msg.extents and msg.extents.start.x is not None: self.place_handles(msg.extents) self.in_flow_mode = msg.in_flow_mode + self.editing_annot_uuid = msg.existing or None self.send_message('set-highlight-style', style=self.current_highlight_style) elif msg.type is 'position-handles': if self.state is WAITING_FOR_CLICK: @@ -533,6 +550,8 @@ class CreateAnnotation: _('Failed to apply highlighting, try adjusting extent of highlight') ) self.annotations_manager.add_highlight(msg, self.current_highlight_style, self.current_notes) + elif msg.type is 'annotation-activated': + self.view.view_annotation.show(msg.uuid) else: print('Ignoring annotations message with unknown type:', msg.type) @@ -563,3 +582,88 @@ class CreateAnnotation: self.state = WAITING_FOR_DRAG self.left_line_height = extents.start.height self.right_line_height = extents.end.height + + +class ViewAnnotation: + + container_id = 'view-annotation-overlay' + + def __init__(self, view): + self.view = view + self.annotations_manager = view.annotations_manager + c = self.container + self.showing_uuid = None + c.style.flexDirection = 'column' + c.style.justifyContent = 'flex-end' + c.appendChild(E.div( + style='pointer-events: auto; padding: 1ex 1rem; box-sizing: border-box; border-top: solid 2px currentColor; overflow: hidden', + onclick=def(ev): + ev.preventDefault(), ev.stopPropagation() + , + E.div( + style='display: flex; justify-content: space-between; align-items: flex-start', + E.a( + svgicon('close', f'{BAR_SIZE}px', f'{BAR_SIZE}px'), + class_='simple-link', href='javascript: void', title=_('Close'), + onclick=def(ev): + self.hide() + ), + E.div( + style='margin-left: 2rem; margin-right: 2rem; max-height: 20vh; overflow-y: auto; overflow-x: hidden; box-sizing: border-box; padding-top: 1ex', + class_='highlight-notes-viewer' + ), + E.a( + svgicon('pencil', f'{BAR_SIZE}px', f'{BAR_SIZE}px'), + class_='simple-link', href='javascript: void', title=_('Edit this highlight'), + onclick=def(ev): + self.edit_current() + ), + ) + )) + + @property + def container(self): + return document.getElementById(self.container_id) + + def show(self, uuid): + c = self.container + c.style.display = 'flex' + self.showing_uuid = uuid + s = self.annotations_manager.style_for_highlight(uuid) or default_highlight_style() + c = c.firstChild + c.style.color = s.color + c.style.backgroundColor = s['background-color'] + text = self.annotations_manager.notes_for_highlight(uuid) or '' + self.display_text(text) + + def display_text(self, text): + text = text or _('This highlight has no added notes') + text = text.strip() + div = self.container.querySelector('.highlight-notes-viewer') + clear(div) + current_block = '' + + def add_para(): + nonlocal current_block + div.appendChild(E.p(current_block)) + if div.childNodes.length > 1: + div.lastChild.style.marginTop = '2ex' + current_block = '' + + for line in text.splitlines(): + if not line or not line.strip(): + if current_block: + add_para() + continue + current_block += line + '\n' + if current_block: + add_para() + + def hide(self): + self.container.style.display = 'none' + self.showing_uuid = None + + def edit_current(self): + if self.showing_uuid: + self.view.create_annotation.edit_highlight(self.showing_uuid) + self.hide() diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 161e42a001..6da7e615eb 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -12,7 +12,7 @@ from select import ( from fs_images import fix_fullscreen_svg_images from iframe_comm import IframeClient from range_utils import ( - reset_highlight_counter, set_selection_to_highlight, unwrap_crw, + reset_highlight_counter, select_crw, set_selection_to_highlight, unwrap_crw, wrap_text_in_range ) from read_book.cfi import ( @@ -591,6 +591,12 @@ class IframeBoss: if refnum?: self.scroll_to_ref(refnum) + def ensure_selection_visible(self): + if current_layout_mode() is 'flow': + ensure_selection_visible() + else: + snap_to_selection() + def find(self, data, from_load): if data.searched_in_spine: window.getSelection().removeAllRanges() @@ -606,10 +612,7 @@ class IframeBoss: def show_search_result(self, data, from_load): if select_search_result(data.search_result): - if current_layout_mode() is 'flow': - ensure_selection_visible() - else: - snap_to_selection() + self.ensure_selection_visible() else: self.send_message('search_result_not_found', search_result=data.search_result) @@ -623,7 +626,7 @@ class IframeBoss: else: end_reference_mode() - def initiate_creation_of_annotation(self): + def initiate_creation_of_annotation(self, existing): self.auto_scroll_action('stop') in_flow_mode = current_layout_mode() is 'flow' self.send_message( @@ -631,6 +634,7 @@ class IframeBoss: type='create-annotation', in_flow_mode=in_flow_mode, extents=selection_extents(in_flow_mode), + existing=existing or None, ) def annotations_msg_received(self, data): @@ -657,13 +661,19 @@ class IframeBoss: self.send_message('annotations', type='update-handles', extents=selection_extents(in_flow_mode)) elif data.type is 'set-highlight-style': set_selection_style(data.style) + elif data.type is 'edit-highlight': + crw_ = {v: k for k, v in Object.entries(self.annot_id_uuid_map)}[data.uuid] + if crw_: + select_crw(crw_) + self.ensure_selection_visible() + self.initiate_creation_of_annotation(data.uuid) elif data.type is 'apply-highlight': sel = window.getSelection() text = sel.toString() if not sel.rangeCount: return bounds = cfi_for_selection() - annot_id, intersecting_wrappers = wrap_text_in_range(data.style) + annot_id, intersecting_wrappers = wrap_text_in_range(data.style, None, self.add_highlight_listeners) removed_highlights = v'[]' if annot_id is not None: intersecting_uuids = [self.annot_id_uuid_map[x] for x in intersecting_wrappers] @@ -704,13 +714,22 @@ class IframeBoss: if not r: continue style = f'color: {h.style.color}; background-color: {h.style["background-color"]}' - annot_id, intersecting_wrappers = wrap_text_in_range(style, r) + annot_id, intersecting_wrappers = wrap_text_in_range(style, r, self.add_highlight_listeners) if annot_id is not None: self.annot_id_uuid_map[annot_id] = h.uuid for crw in intersecting_wrappers: unwrap_crw(crw) v'delete self.annot_id_uuid_map[crw]' + def add_highlight_listeners(self, wrapper): + wrapper.addEventListener('click', self.highlight_wrapper_clicked) + + def highlight_wrapper_clicked(self, ev): + crw = ev.currentTarget.dataset.calibreRangeWrapper + uuid = self.annot_id_uuid_map[crw] + if uuid: + self.send_message('annotations', type='annotation-activated', uuid=uuid) + def copy_selection(self): text = window.getSelection().toString() if text: diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index 32b72a68b2..4409b5b69c 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -13,7 +13,7 @@ from dom import add_extra_css, build_rule, clear, set_css, svgicon, unique_id from iframe_comm import IframeWrapper from modals import error_dialog, warning_dialog from read_book.content_popup import ContentPopupOverlay -from read_book.create_annotation import AnnotationsManager, CreateAnnotation +from read_book.create_annotation import AnnotationsManager, CreateAnnotation, ViewAnnotation from read_book.globals import ( current_book, runtime, set_current_spine_item, ui_operations ) @@ -220,6 +220,7 @@ class View: E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; overflow: auto; display:none', id='book-overlay'), # main overlay E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none', id='controls-help-overlay'), # controls help overlay E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none; overflow: hidden', id=CreateAnnotation.container_id, tabindex='0'), # create annotation overlay + E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; pointer-events:none; display:none; z-index: 4000', id=ViewAnnotation.container_id), # view annotation overlay ) ), E.div( @@ -285,6 +286,7 @@ class View: self.book_scrollbar.apply_visibility() self.annotations_manager = AnnotationsManager(self) self.create_annotation = CreateAnnotation(self) + self.view_annotation = ViewAnnotation(self) @property def iframe(self): @@ -567,6 +569,7 @@ class View: self.content_popup_overlay.hide() self.reference_mode_overlay.style.display = 'none' self.create_annotation.hide() + self.view_annotation.hide() self.focus_iframe() def focus_iframe(self):