Track annot over entire selection

Also work on notes/color editor panel
This commit is contained in:
Kovid Goyal 2020-08-03 15:30:07 +05:30
parent 1ce8b68de6
commit 8fa67e7bfd
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 302 additions and 61 deletions

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 33.866666 33.866668"
height="128"
width="128"
>
<rect
width="31.75"
height="31.75"
x="1.0583335"
y="1.0583304"
ry="3.6852679" />
</svg>

Before

Width:  |  Height:  |  Size: 262 B

View File

@ -106,7 +106,7 @@ def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrap
wrapper_counter = 0 wrapper_counter = 0
def wrap_text_in_range(style, r, process_wrapper): def wrap_text_in_range(styler, r, process_wrapper):
if not r: if not r:
sel = window.getSelection() sel = window.getSelection()
if not sel or not sel.rangeCount: 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 = document.createElement('span')
wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + '' wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + ''
if style: if styler:
style = style.rstrip() styler(wrapper_elem)
if !style.endsWith(';'):
style += ';'
style = str.replace(style, ';', ' !important;')
wrapper_elem.setAttribute('style', style)
intersecting_wrappers = {} intersecting_wrappers = {}
wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper) 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 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): def highlight_associated_with_selection(sel, annot_id_uuid_map):
# Return the annotation id for a highlight that contains the focus or # Return the annotation id for a highlight intersecting the selection
# anchor of 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): all_wrappers = document.querySelectorAll('span[data-calibre-range-wrapper]')
if not node: for v'var i = 0; i < sel.rangeCount; i++':
return r = sel.getRangeAt(i)
if node.nodeType is Node.ELEMENT_NODE: for v'var x = 0; x < all_wrappers.length; x++':
if node.dataset.calibreRangeWrapper: wrapper = all_wrappers[x]
return annot_id_uuid_map[node.dataset.calibreRangeWrapper] if r.intersectsNode(wrapper):
if offset is 0: annot_id = annot_id_uuid_map[wrapper.dataset.calibreRangeWrapper]
if node.firstChild?.nodeType is Node.ELEMENT_NODE and node.firstChild.dataset.calibreRangeWrapper: if annot_id:
return annot_id_uuid_map[node.firstChild.dataset.calibreRangeWrapper] return annot_id
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

View File

@ -2,6 +2,15 @@
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals 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 = { builtin_colors_light = {
'yellow': '#ffeb6b', 'yellow': '#ffeb6b',
'green': '#c0ed72', 'green': '#c0ed72',
@ -27,29 +36,237 @@ def default_color(is_dark):
return builtin_color('yellow', 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: class HighlightStyle:
def __init__(self, style): def __init__(self, style):
if jstype(style) is 'string':
style = JSON.parse(style)
self.style = style or {'type': 'builtin', 'kind': 'color', 'which': 'yellow'} 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): def highlight_shade(self, is_dark):
s = self.style s = self.style
if s.type is 'builtin': if s.type is 'builtin':
if s.kind is 'color': if s.kind is 'color':
return builtin_color(s.which, is_dark) return builtin_color(s.which, is_dark)
return default_color(is_dark)
return s['background-color'] or default_color(is_dark) return s['background-color'] or default_color(is_dark)
def as_css(self, is_dark, foreground): def serialized(self):
s = self.style 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.type is 'builtin':
if s.kind is 'color': if s.kind is 'color':
ans = 'background-color: ' + builtin_color(s.which, is_dark) + ';' node.backgroundColor = builtin_color(s.which, is_dark)
if foreground: if foreground:
ans += 'color: ' + foreground + ';' node.color = foreground
return ans return
ans = 'background-color: ' + (s['background-color'] or default_color(is_dark)) + ';' node.backgroundColor = s['background-color'] or default_color(is_dark)
fg = s.color or foreground fg = s.color or foreground
if fg: if fg:
ans += 'color: ' + fg + ';' node.color = fg
return ans
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)
# }}}

View File

@ -35,6 +35,7 @@ from read_book.globals import (
current_spine_item, runtime, set_boss, set_current_spine_item, set_layout_mode, current_spine_item, runtime, set_boss, set_current_spine_item, set_layout_mode,
set_toc_anchor_map set_toc_anchor_map
) )
from read_book.highlights import highlight_style_as_css
from read_book.mathjax import apply_mathjax from read_book.mathjax import apply_mathjax
from read_book.paged_mode import ( from read_book.paged_mode import (
anchor_funcs as paged_anchor_funcs, anchor_funcs as paged_anchor_funcs,
@ -782,7 +783,8 @@ class IframeBoss:
if not sel.rangeCount: if not sel.rangeCount:
return return
bounds = cfi_for_selection() 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'[]' removed_highlights = v'[]'
if annot_id is not None: if annot_id is not None:
intersecting_uuids = {annot_id_uuid_map[x]:True for x in intersecting_wrappers} 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) r = range_from_cfi(h.start_cfi, h.end_cfi)
if not r: if not r:
continue 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) annot_id, intersecting_wrappers = wrap_text_in_range(style, r, self.add_highlight_listeners)
if annot_id is not None: if annot_id is not None:
annot_id_uuid_map[annot_id] = h.uuid annot_id_uuid_map[annot_id] = h.uuid

View File

@ -9,9 +9,8 @@ from book_list.globals import get_session_data
from book_list.theme import get_color from book_list.theme import get_color
from dom import clear, svgicon, unique_id from dom import clear, svgicon, unique_id
from read_book.globals import runtime, ui_operations 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 DRAG_SCROLL_ZONE_MIN_HEIGHT = 10
# Utils {{{ # Utils {{{
@ -220,6 +219,7 @@ class SelectionBar:
self.drag_scroll_timer = None self.drag_scroll_timer = None
self.last_drag_scroll_at = -100000 self.last_drag_scroll_at = -100000
self.left_line_height = self.right_line_height = 0 self.left_line_height = self.right_line_height = 0
self.current_editor = None
left_handle = selection_handle(True) left_handle = selection_handle(True)
left_handle.id = self.left_handle_id left_handle.id = self.left_handle_id
@ -235,6 +235,7 @@ class SelectionBar:
'left: 0; top: 0; display: flex; flex-direction: column;' 'left: 0; top: 0; display: flex; flex-direction: column;'
)) ))
container.appendChild(E.div(id=self.editor_id)) container.appendChild(E.div(id=self.editor_id))
container.lastChild.addEventListener('click', self.editor_container_clicked, {'passive': False})
# bar and handles markup {{{ # bar and handles markup {{{
@ -262,7 +263,7 @@ class SelectionBar:
]: ]:
bar_container.appendChild(x) bar_container.appendChild(x)
bar = bar_container.firstChild 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): def cb(ac, callback):
ans = ac.icon_function(hs) ans = ac.icon_function(hs)
@ -381,7 +382,7 @@ class SelectionBar:
def container_clicked(self, ev): def container_clicked(self, ev):
ev.stopPropagation(), ev.preventDefault() ev.stopPropagation(), ev.preventDefault()
if self.state is EDITING: if self.state is EDITING:
return # TODO: accept edit and apply highlight self.hide_editor(True)
if self.state is WAITING: if self.state is WAITING:
for x in (self.bar, self.left_handle, self.right_handle): for x in (self.bar, self.left_handle, self.right_handle):
if near_element(x, ev.clientX, ev.clientY): if near_element(x, ev.clientX, ev.clientY):
@ -498,6 +499,7 @@ class SelectionBar:
self.editor.style.display = 'none' self.editor.style.display = 'none'
self.set_handle_colors() self.set_handle_colors()
if self.state is DRAGGING: if self.state is DRAGGING:
self.show()
return return
self.left_handle.style.display = 'none' self.left_handle.style.display = 'none'
self.right_handle.style.display = 'none' self.right_handle.style.display = 'none'
@ -516,7 +518,7 @@ class SelectionBar:
self.show() self.show()
if self.state is EDITING: 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' self.bar.style.display = self.left_handle.style.display = self.right_handle.style.display = 'block'
start = map_boundary(cs.start) start = map_boundary(cs.start)
end = map_boundary(cs.end) end = map_boundary(cs.end)
@ -591,7 +593,24 @@ class SelectionBar:
start, end = end, start start, end = end, start
place_single_handle(left_handle, start, True) place_single_handle(left_handle, start, True)
place_single_handle(right_handle, end, False) 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 {{{ # Actions {{{
@ -617,7 +636,17 @@ class SelectionBar:
self.hide() self.hide()
def create_highlight(self): 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): def quick_highlight(self):
cs = self.view.currently_showing.selection cs = self.view.currently_showing.selection

View File

@ -63,7 +63,7 @@ defaults = {
'user_stylesheet': '', 'user_stylesheet': '',
'word_actions': v'[]', 'word_actions': v'[]',
'highlight_style': None, 'highlight_style': None,
'custom_highlight_colors': v'[]', 'custom_highlight_styles': v'[]',
'show_selection_bar': True, 'show_selection_bar': True,
'net_search_url': 'https://google.com/search?q={q}', 'net_search_url': 'https://google.com/search?q={q}',
'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']", 'selection_bar_actions': v"['copy', 'lookup', 'highlight', 'remove_highlight', 'search_net', 'clear']",