# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2020, Kovid Goyal from __python__ import bound_methods, hash_literals from elementmaker import E from gettext import gettext as _ from uuid import short_uuid from book_list.globals import get_session_data from book_list.theme import cached_color_to_rgba, get_color from dom import clear, ensure_id, svgicon, unique_id from modals import error_dialog from read_book.globals import ui_operations from read_book.shortcuts import shortcut_for_key_event class AnnotationsManager: def __init__(self, view): self.view = view self.set_highlights() def set_highlights(self, highlights): highlights = highlights or v'[]' self.highlights = {h.uuid: h for h in highlights} def remove_highlight(self, uuid): h = self.highlights[uuid] if h: h.timestamp = Date().toISOString() h.removed = True v'delete h.style' v'delete h.highlighted_text' v'delete h.start_cfi' v'delete h.end_cfi' v'delete h.notes' v'delete h.spine_name' v'delete h.spine_index' def add_highlight(self, msg, style, notes): now = Date().toISOString() for uuid in msg.removed_highlights: self.remove_highlight(uuid) annot = self.highlights[msg.uuid] = { 'type': 'highlight', 'timestamp': now, 'uuid': msg.uuid, 'highlighted_text': msg.highlighted_text, 'start_cfi': msg.bounds.start, 'end_cfi': msg.bounds.end, 'style': style, # dict with color and background-color 'spine_name': self.view.currently_showing.name, 'spine_index': self.view.currently_showing.spine_index, } if notes: annot.notes = notes if ui_operations.highlights_changed: ui_operations.highlights_changed(Object.values(self.highlights)) def highlights_for_currently_showing(self): name = self.view.currently_showing.name ans = v'[]' for uuid in Object.keys(self.highlights): h = self.highlights[uuid] if h.spine_name is name and not h.removed and h.start_cfi: ans.push(h) return ans WAITING_FOR_CLICK = 1 WAITING_FOR_DRAG = 2 DRAGGING_LEFT = 3 DRAGGING_RIGHT = 4 dark_fg = '#111' light_fg = '#eee' highlight_colors = { '#fce2ae': dark_fg, '#b6ffea': dark_fg, '#ffb3b3': dark_fg, '#ffdcf7': dark_fg, '#cae8d5': dark_fg, '#204051': light_fg, '#3b6978': light_fg, '#2b580c': light_fg, '#512b58': light_fg, } default_highlight_color = '#fce2ae' def selection_handle(invert, style): ans = svgicon('selection-handle') use = ans.querySelector('use') use.style.stroke = style['color'] use.style.fill = style['background-color'] s = ans.style if invert: s.transform = 'scaleX(-1)' s.position = 'absolute' s.boxSizing = 'border-box' s.touchAction = 'none' return ans def map_from_iframe_coords(point): l = document.getElementById('book-left-margin') point.x += l.offsetWidth t = document.getElementById('book-top-margin') point.y += t.offsetHeight return point def map_to_iframe_coords(point): l = document.getElementById('book-left-margin') point.x -= l.offsetWidth t = document.getElementById('book-top-margin') point.y -= t.offsetHeight return point BAR_SIZE = 32 def create_bar(): ans = E.div( id=unique_id('annot-bar'), style=f'height: {BAR_SIZE}px; width: 100vw; display: flex; justify-content: space-between;', ) return ans class CreateAnnotation: container_id = 'create-annotation-overlay' def __init__(self, view): self.view = view self.annotations_manager = self.view.annotations_manager self.state = WAITING_FOR_CLICK self.left_line_height = self.right_line_height = 8 self.in_flow_mode = False container = self.container container.style.flexDirection = 'column' container.style.justifyContent = 'space-between' self.position_in_handle = {'x': 0, 'y': 0} def button(bar, icon, tt, action): cb = svgicon(icon, bar.style.height, bar.style.height, tt) document.createElement cb.setAttribute('title', tt) cb.classList.add('annot-button') cb.classList.add(f'annot-button-{icon}') cb.style.backgroundColor = get_color('window-background') cb.style.boxSizing = 'border-box' cb.style.padding = '2px' cb.classList.add('simple-link') cb.addEventListener('click', def(ev): ev.preventDefault(), ev.stopPropagation() action() ) bar.appendChild(cb) return cb tb = create_bar() container.appendChild(tb) button(tb, 'close', _('Cancel creation of highlight'), self.hide) button(tb, 'chevron-up', _('Scroll up'), self.scroll_up) button(tb, 'check', _('Finish creation of highlight'), self.accept) middle = E.div(id=unique_id('middle'), style='display: none') self.middle_id = middle.id container.appendChild(middle) bb = create_bar() container.appendChild(bb) button(bb, 'fg', _('Change highlight color'), self.choose_color) button(bb, 'chevron-down', _('Scroll down'), self.scroll_down) button(bb, 'pencil', _('Add a note'), self.add_text) sd = get_session_data() style = sd.get('highlight_style') or { 'background-color': default_highlight_color, 'color': highlight_colors[default_highlight_color] } self.current_highlight_style = style lh = selection_handle(False, style) self.left_handle_id = ensure_id(lh, 'handle') lh.addEventListener('mousedown', self.mousedown_on_handle, {'passive': False}) container.appendChild(lh) rh = selection_handle(True, style) self.right_handle_id = ensure_id(rh, 'handle') rh.addEventListener('mousedown', self.mousedown_on_handle, {'passive': False}) container.appendChild(rh) container.addEventListener('click', self.container_clicked, {'passive': False}) container.addEventListener('mouseup', self.mouseup_on_container, {'passive': False}) container.addEventListener('mousemove', self.mousemove_on_container, {'passive': False}) container.addEventListener('keydown', self.on_keydown, {'passive': False}) def scroll_up(self): self.send_message('scroll', backwards=True) def scroll_down(self): self.send_message('scroll', backwards=False) @property def middle(self): return document.getElementById(self.middle_id) def choose_color(self): container = self.middle container.style.display = 'block' container.style.textAlign = 'center' clear(container) c = E.div( E.h3(_('Choose highlight color')), E.div( style=f'display: flex; flex-wrap: wrap; max-width: calc({BAR_SIZE}px * 6); margin: auto', onclick=def(ev): ev.stopPropagation(), ev.preventDefault() ), onclick=def(ev): ev.stopPropagation(), ev.preventDefault() self.hide_middle() , style=f'background: {get_color("window-background")}; margin: auto; padding: 1rem', ) current_style = self.current_highlight_style container.appendChild(c) found_current = False self.save_handle_state() self.handle_state = self.left_handle.display, self.right_hand def add(bg): ic = svgicon('swatch', BAR_SIZE, BAR_SIZE) ic.classList.add('simple-link') is_current = bg.lower() is current_style['background-color'].lower() sqbg = get_color('window-background2') if is_current else 'unset' ic.querySelector('use').style.fill = bg item = E.div( ic, style=f'padding: 4px; background-color: {sqbg}; margin: 4px', onclick=self.change_color.bind(None, bg) ) c.lastChild.appendChild(item) return is_current for bg in highlight_colors: if add(bg): found_current = True if not found_current: add(current_style['background-color']) def change_color(self, new_color): self.hide_middle() c = self.middle c.style.display = 'none' current_style = self.current_highlight_style if not new_color or current_style['background-color'].lower() is new_color.lower(): return fg = highlight_colors[new_color] if not fg: rgba = cached_color_to_rgba(new_color) is_dark = max(rgba[0], rgba[1], rgba[2]) < 115 fg = light_fg if is_dark else dark_fg self.current_highlight_style = {'background-color': new_color, 'color': fg} self.send_message('set-highlight-style', style=self.current_highlight_style) sd = get_session_data() sd.set('highlight_style', self.current_highlight_style) self.update_handle_colors() def update_handle_colors(self): fill = self.current_highlight_style['background-color'] stroke = self.current_highlight_style['color'] for handle in (self.left_handle, self.right_handle): use = handle.querySelector('use') use.style.stroke = stroke use.style.fill = fill def show_middle(self): self.save_handle_state() self.middle.style.display = 'block' def hide_middle(self): m = self.middle if m.style.display is not 'none': self.restore_handle_state() m.style.display = 'none' def save_handle_state(self): for h in (self.left_handle, self.right_handle): h.dataset.savedState = h.style.display h.style.display = 'none' def restore_handle_state(self): for h in (self.left_handle, self.right_handle): h.style.display = h.dataset.savedState def accept(self): s = self.current_highlight_style style = '' for k in Object.keys(self.current_highlight_style): style += f'{k}: {s[k]}; ' self.send_message( 'apply-highlight', style=style, uuid=short_uuid() ) self.hide() def on_keydown(self, ev): ev.stopPropagation(), ev.preventDefault() if ev.key is 'Enter': return self.accept() sc_name = shortcut_for_key_event(ev, self.view.keyboard_shortcut_map) if sc_name is 'show_chrome': self.hide() elif sc_name in ('up', 'down', 'pageup', 'pagedown'): self.send_message('scroll', backwards=bool('up' in sc_name)) elif sc_name in ('left', 'right'): if self.in_flow_mode: self.send_message('perp-scroll', backwards=bool(sc_name is 'left')) else: self.send_message('scroll', backwards=bool(sc_name is 'left')) def container_clicked(self, ev): ev.stopPropagation(), ev.preventDefault() self.hide_middle() if self.state is WAITING_FOR_CLICK: pt = map_to_iframe_coords({'x': ev.clientX, 'y': ev.clientY}) self.send_message('position-handles-at-point', x=pt.x, y=pt.y) def mousedown_on_handle(self, ev): ev.stopPropagation(), ev.preventDefault() if self.state is WAITING_FOR_CLICK: return q = ev.currentTarget.id if q is self.left_handle_id: self.state = DRAGGING_LEFT handle = self.left_handle elif q is self.right_handle_id: self.state = DRAGGING_RIGHT handle = self.right_handle r = handle.getBoundingClientRect() self.position_in_handle.x = Math.round(ev.clientX - r.left) self.position_in_handle.y = Math.round(ev.clientY - r.top) def mouseup_on_container(self, ev): if self.state in (DRAGGING_RIGHT, DRAGGING_LEFT): self.state = WAITING_FOR_DRAG ev.preventDefault(), ev.stopPropagation() def mousemove_on_container(self, ev): if self.state not in (DRAGGING_RIGHT, DRAGGING_LEFT): return ev.stopPropagation(), ev.preventDefault() handle = self.left_handle if self.state is DRAGGING_LEFT else self.right_handle s = handle.style s.left = (ev.clientX - self.position_in_handle.x) + 'px' s.top = (ev.clientY - self.position_in_handle.y) + 'px' pos = self.current_handle_position pos.start = map_to_iframe_coords(pos.start) pos.end = map_to_iframe_coords(pos.end) self.send_message('set-selection', extents=pos) @property def container(self): return document.getElementById(self.container_id) @property def left_handle(self): return document.getElementById(self.left_handle_id) @property def right_handle(self): return document.getElementById(self.right_handle_id) @property def is_visible(self): return self.container.style.display is not 'none' @property def current_handle_position(self): lh, rh = self.left_handle, self.right_handle lbr, rbr = self.left_handle.getBoundingClientRect(), self.right_handle.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) } } @property def current_highlight_style(self): return JSON.parse(self.container.querySelector('.annot-button-fg').dataset.style) @current_highlight_style.setter def current_highlight_style(self, val): b = self.container.querySelector('.annot-button-fg') b.dataset.style = JSON.stringify(val) def show(self): c = self.container c.style.display = 'flex' c.focus() def hide(self): if self.is_visible: self.container.style.display = 'none' self.view.focus_iframe() self.send_message('set-highlight-style', style=None) def send_message(self, type, **kw): self.view.iframe_wrapper.send_message('annotations', type=type, **kw) def handle_message(self, msg): if msg.type is 'create-annotation': if not self.is_visible: self.view.hide_overlays() self.state = WAITING_FOR_CLICK self.show() self.hide_handles() if msg.extents.start.x is not None: self.place_handles(msg.extents) self.in_flow_mode = msg.in_flow_mode self.send_message('set-highlight-style', style=self.current_highlight_style) elif msg.type is 'position-handles': if self.state is WAITING_FOR_CLICK: self.place_handles(msg.extents) elif msg.type is 'update-handles': self.place_handles(msg.extents) if msg.from_scroll and not msg.selection_extended: middle = map_from_iframe_coords({ 'x': msg.page_rect.left + msg.page_rect.width // 2, 'y': msg.page_rect.top + msg.page_rect.height // 2 }) handle = self.left_handle if msg.backwards else self.right_handle handle.style.display = 'block' handle.style.left = f'{middle.x}px' handle.style.top = f'{middle.y}px' elif msg.type is 'highlight-applied': if not msg.ok: return error_dialog( _('Highlighting failed'), _('Failed to apply highlighting, try adjusting extent of highlight') ) self.annotations_manager.add_highlight(msg, self.current_highlight_style) else: print('Ignoring annotations message with unknown type:', msg.type) def hide_handles(self): self.left_handle.style.display = 'none' self.right_handle.style.display = 'none' def place_handles(self, extents): lh, rh = self.left_handle, self.right_handle def do_it(handle, data): map_from_iframe_coords(data) s = handle.style s.display = 'block' if data.onscreen else 'none' height = data.height * 3 width = data.height * 2 s.width = f'{width}px' s.height = f'{height}px' bottom = data.y + data.height top = bottom - height s.top = f'{top}px' return s, width style, width = do_it(lh, extents.start) style.left = (extents.start.x - width) + 'px' style, width = do_it(rh, extents.end) style.left = extents.end.x + 'px' self.state = WAITING_FOR_DRAG self.left_line_height = extents.start.height self.right_line_height = extents.end.height