calibre/src/pyj/read_book/create_annotation.pyj

764 lines
28 KiB
Plaintext

# vim:fileencoding=utf-8
# 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 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, question_dialog
from read_book.annotations import merge_annotation_maps
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 merge_highlights(self, highlights):
highlights = highlights or v'[]'
updated = False
if highlights.length:
base = {'highlight': Object.values(self.highlights)}
newvals = {'highlight': highlights}
updated, ans = merge_annotation_maps(base, newvals)
if updated:
self.set_highlights(ans)
return updated
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'
return True
def delete_highlight(self, uuid):
if self.remove_highlight(uuid):
if ui_operations.highlights_changed:
ui_operations.highlights_changed(Object.values(self.highlights))
def notes_for_highlight(self, uuid):
h = self.highlights[uuid]
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 spine_index_for_highlight(self, uuid, spine):
h = self.highlights[uuid]
if not h:
return -1
ans = h.spine_index
name = h.spine_name
if name:
idx = spine.indexOf(name)
if idx > -1:
ans = idx
return ans
def cfi_for_highlight(self, uuid, spine_index):
h = self.highlights[uuid]
if h:
x = 2 * (spine_index + 1)
return f'epubcfi(/{x}{h.start_cfi})'
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 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')
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; max-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.active_touch = None
self.editing_annot_uuid = None
self.current_notes = ''
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.style.border = 'solid 2px currentColor'
cb.style.borderRadius = '4px'
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)
tb.appendChild(E.span(style=f'height: {tb.style.height}'))
button(tb.lastChild, 'trash', _('Remove this highlight'), self.delete_highlight)
tb.lastChild.appendChild(E.span('\xa0\xa0\xa0'))
if ui_operations.copy_selection:
button(tb.lastChild, 'copy', _('Copy to clipboard'), self.copy_to_clipboard)
tb.lastChild.appendChild(E.span('\xa0\xa0\xa0'))
button(tb.lastChild, 'check', _('Finish creation of highlight'), self.accept)
middle = E.div(id=unique_id('middle'), style='display: none; text-align: center; z-index: 90000')
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_notes)
sd = get_session_data()
style = sd.get('highlight_style') or default_highlight_style()
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})
lh.addEventListener('touchstart', self.touchstart_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})
rh.addEventListener('touchstart', self.touchstart_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('touchmove', self.touchmove_on_container, {'passive': False})
container.addEventListener('touchend', self.touchend_on_container, {'passive': False})
container.addEventListener('touchcancel', self.touchend_on_container, {'passive': False})
container.addEventListener('keydown', self.on_keydown, {'passive': False})
def copy_to_clipboard(self):
self.view.iframe_wrapper.send_message('copy_selection')
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 add_notes(self):
current_notes = self.current_notes
if not current_notes and self.editing_annot_uuid:
current_notes = self.annotations_manager.notes_for_highlight(self.editing_annot_uuid)
self.show_middle(self.apply_notes)
container = self.middle
clear(container)
c = E.div(
style=f'background: {get_color("window-background")}; margin: auto; padding: 1rem',
onclick=def(ev):
ev.stopPropagation(), ev.preventDefault()
self.hide_middle()
,
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=def(ev):
ev.stopPropagation()
if ev.key is 'Escape':
self.hide_middle()
,
onclick=def(ev):
ev.stopPropagation()
),
E.div(
style='display: flex; justify-content: space-between; margin-top: 1ex; align-items: center',
E.a(
svgicon('eraser', f'{BAR_SIZE}px', f'{BAR_SIZE}px'),
href='javascript:void',
class_='simple-link',
title=_('Clear'),
onclick=def(ev):
ev.preventDefault(), ev.stopPropagation()
ta = self.middle.querySelector('textarea')
ta.value = ''
ta.focus()
),
E.div(
_('To view the notes for a highlight, long-tap or double click on it.'),
style='font-size-smaller; margin-left: 1rem; margin-right: 1rem'
),
E.a(
svgicon('check', f'{BAR_SIZE}px', f'{BAR_SIZE}px'),
href='javascript:void',
class_='simple-link',
title=_('Done adding notes'),
onclick=def(ev):
ev.preventDefault(), ev.stopPropagation()
self.hide_middle()
),
),
)
container.appendChild(c)
c.querySelector('textarea').focus()
def apply_notes(self):
self.current_notes = self.middle.querySelector('textarea').value or ''
return True
def choose_color(self):
self.show_middle()
container = self.middle
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, pre_close_callback):
self.pre_middle_close_callback = pre_close_callback
self.save_handle_state()
self.middle.style.display = 'block'
def hide_middle(self):
m = self.middle
if m.style.display is not 'none':
if self.pre_middle_close_callback:
if not self.pre_middle_close_callback():
return
self.restore_handle_state()
m.style.display = 'none'
self.container.focus()
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(), existing=self.editing_annot_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 start_handle_drag(self, ev, q):
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 mousedown_on_handle(self, ev):
ev.stopPropagation(), ev.preventDefault()
if self.state is WAITING_FOR_CLICK:
return
self.start_handle_drag(ev, ev.currentTarget.id)
def touchstart_on_handle(self, ev):
ev.stopPropagation(), ev.preventDefault()
if self.state is WAITING_FOR_CLICK:
return
for touch in ev.changedTouches:
self.active_touch = touch.identifier
self.start_handle_drag(touch, ev.currentTarget.id)
break
def mouseup_on_container(self, ev):
if self.state in (DRAGGING_RIGHT, DRAGGING_LEFT):
self.state = WAITING_FOR_DRAG
ev.preventDefault(), ev.stopPropagation()
def touchend_on_container(self, ev):
if self.state in (DRAGGING_RIGHT, DRAGGING_LEFT):
ev.preventDefault(), ev.stopPropagation()
for touch in ev.changedTouches:
if touch.identifier is self.active_touch:
self.active_touch = None
self.state = WAITING_FOR_DRAG
return
def handle_moved(self, ev):
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)
def mousemove_on_container(self, ev):
if self.state not in (DRAGGING_RIGHT, DRAGGING_LEFT):
return
ev.stopPropagation(), ev.preventDefault()
self.handle_moved(ev)
def touchmove_on_container(self, ev):
if self.state not in (DRAGGING_RIGHT, DRAGGING_LEFT):
return
ev.stopPropagation(), ev.preventDefault()
for touch in ev.changedTouches:
if touch.identifier is self.active_touch:
self.handle_moved(touch)
return
@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):
self.middle.style.display = 'none'
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 edit_highlight(self, uuid):
self.send_message('edit-highlight', uuid=uuid)
self.show()
def remove_highlight(self, uuid):
self.send_message('remove-highlight', uuid=uuid)
self.annotations_manager.delete_highlight(uuid)
self.hide()
def delete_highlight(self):
uuid = self.editing_annot_uuid
question_dialog(_('Are you sure?'), _('Are you sure you want to delete this highlight permanently?'),
def (yes):
if yes:
self.remove_highlight(uuid) if uuid else self.hide()
)
def handle_message(self, msg):
if msg.type is 'create-annotation':
self.editing_annot_uuid = None
self.current_notes = ''
if not self.is_visible:
self.view.hide_overlays()
self.state = WAITING_FOR_CLICK
self.show()
self.hide_handles()
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:
self.place_handles(msg.extents)
self.editing_annot_uuid = msg.existing or None
if self.editing_annot_uuid:
self.current_notes = self.annotations_manager.notes_for_highlight(self.editing_annot_uuid) or ''
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, 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)
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
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()