diff --git a/imgsrc/srv/swatch.svg b/imgsrc/srv/swatch.svg deleted file mode 100644 index 84b0207e21..0000000000 --- a/imgsrc/srv/swatch.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/src/pyj/range_utils.pyj b/src/pyj/range_utils.pyj index f521409550..4676327f42 100644 --- a/src/pyj/range_utils.pyj +++ b/src/pyj/range_utils.pyj @@ -106,7 +106,7 @@ def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrap wrapper_counter = 0 -def wrap_text_in_range(style, r, process_wrapper): +def wrap_text_in_range(styler, r, process_wrapper): if not r: sel = window.getSelection() if not sel or not sel.rangeCount: @@ -117,12 +117,8 @@ def wrap_text_in_range(style, r, process_wrapper): wrapper_elem = document.createElement('span') wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + '' - if style: - style = style.rstrip() - if !style.endsWith(';'): - style += ';' - style = str.replace(style, ';', ' !important;') - wrapper_elem.setAttribute('style', style) + if styler: + styler(wrapper_elem) intersecting_wrappers = {} wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper) @@ -152,27 +148,38 @@ def set_selection_to_highlight(): return crw or None +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) + 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 that contains the focus or - # anchor of the selection + # 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 - def get_annot_id_for(node, offset): - 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) - 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] - - annot_id = get_annot_id_for(sel.focusNode, sel.focusOffset) - if not annot_id: - annot_id = get_annot_id_for(sel.anchorNode, sel.anchorOffset) - return annot_id + all_wrappers = document.querySelectorAll('span[data-calibre-range-wrapper]') + for v'var i = 0; i < sel.rangeCount; i++': + r = sel.getRangeAt(i) + for v'var x = 0; x < all_wrappers.length; x++': + wrapper = all_wrappers[x] + if r.intersectsNode(wrapper): + annot_id = annot_id_uuid_map[wrapper.dataset.calibreRangeWrapper] + if annot_id: + return annot_id diff --git a/src/pyj/read_book/highlights.pyj b/src/pyj/read_book/highlights.pyj index 8c769bf7a8..b7514c3931 100644 --- a/src/pyj/read_book/highlights.pyj +++ b/src/pyj/read_book/highlights.pyj @@ -2,6 +2,15 @@ # License: GPL v3 Copyright: 2020, Kovid Goyal from __python__ import bound_methods, hash_literals +from elementmaker import E +from gettext import gettext as _ + +from book_list.globals import get_session_data +from book_list.theme import get_color +from dom import unique_id +from widgets import create_button + +ICON_SIZE = '3ex' builtin_colors_light = { 'yellow': '#ffeb6b', 'green': '#c0ed72', @@ -27,29 +36,237 @@ def default_color(is_dark): return builtin_color('yellow', is_dark) +def all_builtin_styles(): + ans = v'[]' + for col in builtin_colors_light: + ans.push({'type': 'builtin', 'kind': 'color', 'which': col}) + return ans + + +def custom_color_theme(bg): + return {'type': 'custom', 'kind': 'color', 'which': bg} + + class HighlightStyle: def __init__(self, style): + if jstype(style) is 'string': + style = JSON.parse(style) self.style = style or {'type': 'builtin', 'kind': 'color', 'which': 'yellow'} + self.key = f'type:{style.type} kind:{style.kind} which: {style.which} bg: {style["background-color"]}' + + def make_swatch(self, container, is_dark): + s = container.style + s.width = s.height = s.minimumWidth = s.minimumHeight = ICON_SIZE + bg = None + if s.type is 'builtin': + if s.kind is 'color': + bg = builtin_color(s.which, is_dark) + if bg is None and s['background-color']: + bg = s['background-color'] + if bg: + s.backgroundColor = bg + s.borderRadius = '4px' def highlight_shade(self, is_dark): s = self.style if s.type is 'builtin': if s.kind is 'color': return builtin_color(s.which, is_dark) - return default_color(is_dark) return s['background-color'] or default_color(is_dark) - def as_css(self, is_dark, foreground): - s = self.style + def serialized(self): + return JSON.stringify(self.style) + + +def highlight_style_as_css(s, is_dark, foreground): + + def styler(node): + node = node.style if s.type is 'builtin': if s.kind is 'color': - ans = 'background-color: ' + builtin_color(s.which, is_dark) + ';' + node.backgroundColor = builtin_color(s.which, is_dark) if foreground: - ans += 'color: ' + foreground + ';' - return ans - ans = 'background-color: ' + (s['background-color'] or default_color(is_dark)) + ';' + node.color = foreground + return + node.backgroundColor = s['background-color'] or default_color(is_dark) fg = s.color or foreground if fg: - ans += 'color: ' + fg + ';' - return ans + node.color = fg + + return styler + + +def custom_styles_equal(a, b): + seen = {} + for k in a: + seen[k] = True + if a[k] is not b[k]: + return False + for k in b: + if not seen[k]: + if a[k] is not b[k]: + return False + return True + + +class EditNotesAndColors: # {{{ + + def __init__(self, container, is_dark_theme, current_notes, current_style, close_editor): + self.initial_style = current_style + self.is_dark_theme = is_dark_theme + + def separator(): + return E.hr(style='max-width: 80em; width: 80vw; border-top: solid 1px; margin: auto; margin-top: 2ex; margin-bottom: 2ex') + + def finish(): + close_editor(True) + + def abort(): + close_editor(False) + + def handle_keypress(ev): + ev.stopPropagation() + if ev.key is 'Escape': + abort() + elif ev.key is 'Enter' and ev.ctrlKey: + finish() + + c = E.div( + style=f'background: {get_color("window-background")}; margin: auto; padding: 1rem', + onclick=def(ev): ev.stopPropagation();, + id=unique_id(), + E.h3(_('Add notes for this highlight')), + E.textarea( + current_notes or '', + rows='10', spellcheck='true', style='resize: none; width: 80vw; max-width: 80em; margin: 1ex', + onkeydown=handle_keypress, + ), + E.div( + style='margin: 1ex; font-size: smaller', + _('Double click or long tap on a highlight to see its notes') + ), + + separator(), + + E.h3(_('Choose the color for this highlight'), style='margin-bottom: 2ex'), + E.div( + class_='color-block', + style=f'display: flex; flex-wrap: wrap; max-width: calc({ICON_SIZE} * 8); margin: auto', + ), + E.div( + style='max-width: 80em; width: 80vw; margin: auto; margin-top: 2ex; display: flex; justify-content: space-between; align-items: center', + E.div( + E.label(_('New color:'), ' ', E.input(type='color', onchange=self.add_custom_color)) + ), + E.div( + E.a(_('Remove color'), class_='simple-link remove-custom-color', onclick=self.remove_custom_color), + ), + ), + + separator(), + + E.div( + style='max-width: 80em; width: 80vw; margin: auto; display: flex; justify-content: space-between', + create_button(_('Cancel'), 'close', abort, _('Abort') + ' [Esc]'), + create_button(_('Finish'), 'check', finish, _('Finish editing highlight') + ' [Ctrl+Enter]', True), + ) + + ) + self.container_id = c.id + container.appendChild(c) + self.seen_colors = {} + custom_highlight_styles = get_session_data().get('custom_highlight_styles') + for raw in custom_highlight_styles: + self.add_color(HighlightStyle(raw)).classList.add('custom-style') + for raw in all_builtin_styles(): + self.add_color(HighlightStyle(raw)) + if not c.querySelector('.current-swatch'): + self.add_color(self.initial_style) + + self.set_visibility_of_remove_button() + self.notes_edit.focus() + + def set_visibility_of_remove_button(self): + c = self.container + item = c.querySelector('.current-swatch.custom-style') + visibility = 'unset' if item else 'hidden' + c.querySelector('.remove-custom-color').style.visibility = visibility + + def add_color(self, hs, at_start): + if self.seen_colors[hs.key]: + return + self.seen_colors[hs.key] = True + ic = E.div() + hs.make_swatch(ic, self.is_dark_theme) + ic.classList.add('simple-link') + is_current = hs.key is self.initial_style.key + sqbg = get_color('window-background2') if is_current else 'unset' + item = E.div( + ic, style=f'padding: 4px; background-color: {sqbg}; margin: 4px', + onclick=self.change_color + ) + if is_current: + item.classList.add('current-swatch') + item.dataset.style = hs.serialized() + parent = self.container.getElementsByClassName('color-block')[0] + if at_start: + parent.insertBefore(item, parent.firstChild) + else: + parent.appendChild(item) + return item + + def add_custom_color(self): + bg = self.container.querySelector('input[type=color]').value + cct = custom_color_theme(bg) + item = self.add_color(HighlightStyle(cct), True) + item.classList.add('custom-style') + self.make_swatch_current(item) + sd = get_session_data() + custom_highlight_styles = sd.get('custom_highlight_styles') + custom_highlight_styles.unshift(cct) + sd.set('custom_highlight_styles', custom_highlight_styles) + + def remove_custom_color(self): + item = self.container.getElementsByClassName('current-swatch')[0] + cct = JSON.parse(item.dataset.style) + p = item.parentNode + p.removeChild(item) + self.make_swatch_current(p.firstChild) + sd = get_session_data() + custom_highlight_styles = sd.get('custom_highlight_styles') + ans = v'[]' + for x in custom_highlight_styles: + if not custom_styles_equal(x, cct): + ans.push(x) + sd.set('custom_highlight_styles', ans) + + @property + def container(self): + return document.getElementById(self.container_id) + + @property + def notes_edit(self): + return self.container.getElementsByTagName('textarea')[0] + + def change_color(self, evt): + evt.stopPropagation() + self.make_swatch_current(evt.currentTarget) + + def make_swatch_current(self, item): + for child in item.parentNode.childNodes: + child.style.backgroundColor = 'unset' + child.classList.remove('current-swatch') + item.style.backgroundColor = get_color('window-background2') + item.classList.add('current-swatch') + self.notes_edit.focus() + self.set_visibility_of_remove_button() + + @property + def current_notes(self): + return self.notes_edit.value or '' + + @property + def current_style(self): + return HighlightStyle(self.container.getElementsByClassName('current-swatch')[0].dataset.style) +# }}} diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 48a827b295..748c4f48c0 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -35,6 +35,7 @@ from read_book.globals import ( current_spine_item, runtime, set_boss, set_current_spine_item, set_layout_mode, set_toc_anchor_map ) +from read_book.highlights import highlight_style_as_css from read_book.mathjax import apply_mathjax from read_book.paged_mode import ( anchor_funcs as paged_anchor_funcs, @@ -782,7 +783,8 @@ class IframeBoss: if not sel.rangeCount: return bounds = cfi_for_selection() - annot_id, intersecting_wrappers = wrap_text_in_range(data.style, None, self.add_highlight_listeners) + style = highlight_style_as_css(data.style, opts.is_dark_theme, opts.color_scheme.foreground) + annot_id, intersecting_wrappers = wrap_text_in_range(style, None, self.add_highlight_listeners) removed_highlights = v'[]' if annot_id is not None: intersecting_uuids = {annot_id_uuid_map[x]:True for x in intersecting_wrappers} @@ -821,7 +823,7 @@ class IframeBoss: r = range_from_cfi(h.start_cfi, h.end_cfi) if not r: continue - style = f'color: {h.style.color}; background-color: {h.style["background-color"]}' + style = highlight_style_as_css(h.style, opts.is_dark_theme, opts.color_scheme.foreground) annot_id, intersecting_wrappers = wrap_text_in_range(style, r, self.add_highlight_listeners) if annot_id is not None: annot_id_uuid_map[annot_id] = h.uuid diff --git a/src/pyj/read_book/selection_bar.pyj b/src/pyj/read_book/selection_bar.pyj index 800e6949ff..846b86e509 100644 --- a/src/pyj/read_book/selection_bar.pyj +++ b/src/pyj/read_book/selection_bar.pyj @@ -9,9 +9,8 @@ from book_list.globals import get_session_data from book_list.theme import get_color from dom import clear, svgicon, unique_id from read_book.globals import runtime, ui_operations -from read_book.highlights import HighlightStyle +from read_book.highlights import ICON_SIZE, EditNotesAndColors, HighlightStyle -ICON_SIZE = '3ex' DRAG_SCROLL_ZONE_MIN_HEIGHT = 10 # Utils {{{ @@ -220,6 +219,7 @@ class SelectionBar: self.drag_scroll_timer = None self.last_drag_scroll_at = -100000 self.left_line_height = self.right_line_height = 0 + self.current_editor = None left_handle = selection_handle(True) left_handle.id = self.left_handle_id @@ -235,6 +235,7 @@ class SelectionBar: 'left: 0; top: 0; display: flex; flex-direction: column;' )) container.appendChild(E.div(id=self.editor_id)) + container.lastChild.addEventListener('click', self.editor_container_clicked, {'passive': False}) # bar and handles markup {{{ @@ -262,7 +263,7 @@ class SelectionBar: ]: bar_container.appendChild(x) bar = bar_container.firstChild - hs = self.current_highlight_style.highlight_shade + hs = self.current_highlight_style.highlight_shade(self.view.current_color_scheme.is_dark_theme) def cb(ac, callback): ans = ac.icon_function(hs) @@ -381,7 +382,7 @@ class SelectionBar: def container_clicked(self, ev): ev.stopPropagation(), ev.preventDefault() if self.state is EDITING: - return # TODO: accept edit and apply highlight + self.hide_editor(True) if self.state is WAITING: for x in (self.bar, self.left_handle, self.right_handle): if near_element(x, ev.clientX, ev.clientY): @@ -498,6 +499,7 @@ class SelectionBar: self.editor.style.display = 'none' self.set_handle_colors() if self.state is DRAGGING: + self.show() return self.left_handle.style.display = 'none' self.right_handle.style.display = 'none' @@ -516,7 +518,7 @@ class SelectionBar: self.show() if self.state is EDITING: - return self.show_editor() # TODO: Implement this + return self.bar.style.display = self.left_handle.style.display = self.right_handle.style.display = 'block' start = map_boundary(cs.start) end = map_boundary(cs.end) @@ -591,7 +593,24 @@ class SelectionBar: start, end = end, start place_single_handle(left_handle, start, True) place_single_handle(right_handle, end, False) + # }}} + # Editor {{{ + def show_editor(self, highlight_style, notes): + for x in (self.bar, self.left_handle, self.right_handle): + x.style.display = 'none' + container = self.editor + clear(container) + container.style.display = 'block' + self.state = EDITING + self.current_editor = EditNotesAndColors( + container, self.view.current_color_scheme.is_dark_theme, notes, highlight_style, self.hide_editor) + + def hide_editor(self, apply): + pass # TODO: Implement this + + def editor_container_clicked(self, ev): + ev.stopPropagation(), ev.preventDefault() # }}} # Actions {{{ @@ -617,7 +636,17 @@ class SelectionBar: self.hide() def create_highlight(self): - pass # TODO: Implement this + cs = self.view.currently_showing.selection + hs = self.current_highlight_style + notes = '' + if cs.annot_id: + am = self.view.annotations_manager + q = am.style_for_highlight(cs.annot_id) + if q: + hs = HighlightStyle(q) + notes = am.notes_for_highlight(cs.annot_id) or notes + self.show() + self.show_editor(hs, notes) def quick_highlight(self): cs = self.view.currently_showing.selection diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index cf7a4013c7..b1036bdeea 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -63,7 +63,7 @@ defaults = { 'user_stylesheet': '', 'word_actions': v'[]', 'highlight_style': None, - 'custom_highlight_colors': v'[]', + 'custom_highlight_styles': v'[]', 'show_selection_bar': True, 'net_search_url': 'https://google.com/search?q={q}', 'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']",