mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Track annot over entire selection
Also work on notes/color editor panel
This commit is contained in:
parent
1ce8b68de6
commit
8fa67e7bfd
@ -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 |
@ -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
|
||||
|
@ -2,6 +2,15 @@
|
||||
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
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)
|
||||
# }}}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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']",
|
||||
|
Loading…
x
Reference in New Issue
Block a user