mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Edit book: Allow Ctrl-clicking on class names to jump to the first style rule that matches the tag and class
This commit is contained in:
parent
12393e3650
commit
7b52611fdd
@ -821,7 +821,8 @@ Context sensitive help
|
||||
You can right click on an HTML tag name or a CSS property name to get help for that tag or property.
|
||||
|
||||
You can also hold down the :kbd:`Ctrl` key and click on any filename inside a link tag
|
||||
to open that file in the editor automatically.
|
||||
to open that file in the editor automatically. Similarly, :kbd:`Ctrl` clicking
|
||||
a class name will take you to the first style rule that matches the tag and class.
|
||||
|
||||
.. _editor_auto_complete:
|
||||
|
||||
|
@ -1102,6 +1102,22 @@ class Boss(QObject):
|
||||
error_dialog(self.gui, _('Not found'), _(
|
||||
'No file with the name %s was found in the book') % target, show=True)
|
||||
|
||||
def editor_class_clicked(self, class_data):
|
||||
from calibre.gui2.tweak_book.jump_to_class import find_first_matching_rule, NoMatchingTagFound, NoMatchingRuleFound
|
||||
ed = self.gui.central.current_editor
|
||||
name = editor_name(ed)
|
||||
try:
|
||||
res = find_first_matching_rule(current_container(), name, ed.get_raw_data(), class_data)
|
||||
except (NoMatchingTagFound, NoMatchingRuleFound):
|
||||
res = None
|
||||
if res is not None and res.file_name and res.rule_address:
|
||||
editor = self.open_editor_for_name(res.file_name)
|
||||
if editor:
|
||||
editor.goto_css_rule(res.rule_address, sourceline_address=res.style_tag_address)
|
||||
else:
|
||||
error_dialog(self.gui, _('No matches found'), _('No style rules that match the class {} were found').format(
|
||||
class_data['class']), show=True)
|
||||
|
||||
def save_search(self):
|
||||
state = self.gui.central.search_panel.state
|
||||
self.show_saved_searches()
|
||||
@ -1264,9 +1280,7 @@ class Boss(QObject):
|
||||
raise
|
||||
self.apply_container_update_to_gui()
|
||||
|
||||
def link_clicked(self, name, anchor, show_anchor_not_found=False):
|
||||
if not name:
|
||||
return
|
||||
def open_editor_for_name(self, name):
|
||||
if name in editors:
|
||||
editor = editors[name]
|
||||
self.gui.central.show_editor(editor)
|
||||
@ -1284,6 +1298,12 @@ class Boss(QObject):
|
||||
self.gui, _('Unsupported file format'),
|
||||
_('Editing files of type %s is not supported') % mt, show=True)
|
||||
editor = self.edit_file(name, syntax)
|
||||
return editor
|
||||
|
||||
def link_clicked(self, name, anchor, show_anchor_not_found=False):
|
||||
if not name:
|
||||
return
|
||||
editor = self.open_editor_for_name(name)
|
||||
if anchor and editor is not None:
|
||||
if editor.go_to_anchor(anchor):
|
||||
self.gui.preview.pending_go_to_anchor = anchor
|
||||
@ -1566,6 +1586,8 @@ class Boss(QObject):
|
||||
editor.word_ignored.connect(self.word_ignored)
|
||||
if hasattr(editor, 'link_clicked'):
|
||||
editor.link_clicked.connect(self.editor_link_clicked)
|
||||
if hasattr(editor, 'class_clicked'):
|
||||
editor.class_clicked.connect(self.editor_class_clicked)
|
||||
if getattr(editor, 'syntax', None) == 'html':
|
||||
editor.smart_highlighting_updated.connect(self.gui.live_css.sync_to_editor)
|
||||
if hasattr(editor, 'set_request_completion'):
|
||||
|
@ -22,7 +22,7 @@ class NullSmarts(object):
|
||||
def verify_for_spellcheck(self, cursor, highlighter):
|
||||
return False
|
||||
|
||||
def cursor_position_with_sourceline(self, cursor, for_position_sync=True):
|
||||
def cursor_position_with_sourceline(self, cursor, for_position_sync=True, use_matched_tag=True):
|
||||
return None, None
|
||||
|
||||
def goto_sourceline(self, editor, sourceline, tags, attribute=None):
|
||||
|
@ -484,6 +484,7 @@ def create_formats(highlighter, add_css=True):
|
||||
formats['spell'].setProperty(SPELL_PROPERTY, True)
|
||||
formats['class_attr'] = syntax_text_char_format(t['Special'])
|
||||
formats['class_attr'].setProperty(CLASS_ATTRIBUTE_PROPERTY, True)
|
||||
formats['class_attr'].setToolTip(_('Hold down the Ctrl key and click to open the first matching CSS style rule'))
|
||||
formats['link'] = syntax_text_char_format(t['Link'])
|
||||
formats['link'].setProperty(LINK_PROPERTY, True)
|
||||
formats['link'].setToolTip(_('Hold down the Ctrl key and click to open this link'))
|
||||
|
@ -24,8 +24,8 @@ from calibre.gui2.tweak_book import (
|
||||
)
|
||||
from calibre.gui2.tweak_book.completion.popup import CompletionPopup
|
||||
from calibre.gui2.tweak_book.editor import (
|
||||
LINK_PROPERTY, SPELL_LOCALE_PROPERTY, SPELL_PROPERTY, SYNTAX_PROPERTY,
|
||||
store_locale
|
||||
CLASS_ATTRIBUTE_PROPERTY, LINK_PROPERTY, SPELL_LOCALE_PROPERTY, SPELL_PROPERTY,
|
||||
SYNTAX_PROPERTY, store_locale
|
||||
)
|
||||
from calibre.gui2.tweak_book.editor.smarts import NullSmarts
|
||||
from calibre.gui2.tweak_book.editor.snippets import SnippetManager
|
||||
@ -93,6 +93,7 @@ class LineNumbers(QWidget): # {{{
|
||||
class TextEdit(PlainTextEdit):
|
||||
|
||||
link_clicked = pyqtSignal(object)
|
||||
class_clicked = pyqtSignal(object)
|
||||
smart_highlighting_updated = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None, expected_geometry=(100, 50)):
|
||||
@ -764,6 +765,16 @@ class TextEdit(PlainTextEdit):
|
||||
if r is not None and r.format.property(LINK_PROPERTY):
|
||||
return self.text_for_range(c.block(), r)
|
||||
|
||||
def class_for_position(self, pos):
|
||||
c = self.cursorForPosition(pos)
|
||||
r = self.syntax_range_for_cursor(c)
|
||||
if r is not None and r.format.property(CLASS_ATTRIBUTE_PROPERTY):
|
||||
c.select(QTextCursor.SelectionType.WordUnderCursor)
|
||||
class_name = c.selectedText()
|
||||
if class_name:
|
||||
tags = self.current_tag(for_position_sync=False, cursor=c)
|
||||
return {'class': class_name, 'sourceline_address': tags}
|
||||
|
||||
def mousePressEvent(self, ev):
|
||||
if self.completion_popup.isVisible() and not self.completion_popup.rect().contains(ev.pos()):
|
||||
# For some reason using eventFilter for this does not work, so we
|
||||
@ -775,6 +786,11 @@ class TextEdit(PlainTextEdit):
|
||||
ev.accept()
|
||||
self.link_clicked.emit(url)
|
||||
return
|
||||
class_data = self.class_for_position(ev.pos())
|
||||
if class_data is not None:
|
||||
ev.accept()
|
||||
self.class_clicked.emit(class_data)
|
||||
return
|
||||
return PlainTextEdit.mousePressEvent(self, ev)
|
||||
|
||||
def get_range_inside_tag(self):
|
||||
@ -962,8 +978,12 @@ version="1.1" width="100%%" height="100%%" viewBox="0 0 {w} {h}" preserveAspectR
|
||||
if hasattr(self.smarts, 'rename_block_tag'):
|
||||
self.smarts.rename_block_tag(self, new_name)
|
||||
|
||||
def current_tag(self, for_position_sync=True):
|
||||
return self.smarts.cursor_position_with_sourceline(self.textCursor(), for_position_sync=for_position_sync)
|
||||
def current_tag(self, for_position_sync=True, cursor=None):
|
||||
use_matched_tag = False
|
||||
if cursor is None:
|
||||
use_matched_tag = True
|
||||
cursor = self.textCursor()
|
||||
return self.smarts.cursor_position_with_sourceline(cursor, for_position_sync=for_position_sync, use_matched_tag=use_matched_tag)
|
||||
|
||||
def goto_sourceline(self, sourceline, tags, attribute=None):
|
||||
return self.smarts.goto_sourceline(self, sourceline, tags, attribute=attribute)
|
||||
|
@ -138,6 +138,7 @@ class Editor(QMainWindow):
|
||||
cursor_position_changed = pyqtSignal()
|
||||
word_ignored = pyqtSignal(object, object)
|
||||
link_clicked = pyqtSignal(object)
|
||||
class_clicked = pyqtSignal(object)
|
||||
smart_highlighting_updated = pyqtSignal()
|
||||
|
||||
def __init__(self, syntax, parent=None):
|
||||
@ -161,6 +162,7 @@ class Editor(QMainWindow):
|
||||
self.editor.copyAvailable.connect(self._copy_available)
|
||||
self.editor.cursorPositionChanged.connect(self._cursor_position_changed)
|
||||
self.editor.link_clicked.connect(self.link_clicked)
|
||||
self.editor.class_clicked.connect(self.class_clicked)
|
||||
self.editor.smart_highlighting_updated.connect(self.smart_highlighting_updated)
|
||||
|
||||
@property
|
||||
@ -425,7 +427,7 @@ class Editor(QMainWindow):
|
||||
self.restore_state()
|
||||
|
||||
def break_cycles(self):
|
||||
for x in ('modification_state_changed', 'word_ignored', 'link_clicked', 'smart_highlighting_updated'):
|
||||
for x in ('modification_state_changed', 'word_ignored', 'link_clicked', 'class_clicked', 'smart_highlighting_updated'):
|
||||
try:
|
||||
getattr(self, x).disconnect()
|
||||
except TypeError:
|
||||
@ -441,6 +443,7 @@ class Editor(QMainWindow):
|
||||
self.editor.copyAvailable.disconnect()
|
||||
self.editor.cursorPositionChanged.disconnect()
|
||||
self.editor.link_clicked.disconnect()
|
||||
self.editor.class_clicked.disconnect()
|
||||
self.editor.smart_highlighting_updated.disconnect()
|
||||
self.editor.setPlainText('')
|
||||
self.editor.smarts = None
|
||||
|
143
src/calibre/gui2/tweak_book/jump_to_class.py
Normal file
143
src/calibre/gui2/tweak_book/jump_to_class.py
Normal file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
# License: GPL v3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
from contextlib import suppress
|
||||
from css_parser.css import CSSRule
|
||||
from typing import List, NamedTuple, Optional, Tuple
|
||||
|
||||
from calibre.ebooks.oeb.parse_utils import barename
|
||||
from calibre.ebooks.oeb.polish.container import get_container
|
||||
from calibre.ebooks.oeb.polish.parsing import parse
|
||||
from css_selectors import Select, SelectorError
|
||||
|
||||
|
||||
class NoMatchingTagFound(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class NoMatchingRuleFound(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class RuleLocation(NamedTuple):
|
||||
rule_address: List[int]
|
||||
file_name: str
|
||||
style_tag_address: Optional[Tuple[int, List[int]]] = None
|
||||
|
||||
|
||||
def rule_matches_elem(rule, elem, select, class_name):
|
||||
for selector in rule.selectorList:
|
||||
if class_name in selector.selectorText:
|
||||
with suppress(SelectorError):
|
||||
if elem in select(selector.selectorText):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_first_rule_that_matches_elem(
|
||||
container,
|
||||
elem,
|
||||
select,
|
||||
class_name,
|
||||
rules,
|
||||
current_file_name,
|
||||
recursion_level=0,
|
||||
rule_address=None
|
||||
):
|
||||
# iterate over rules handling @import and @media rules returning a rule
|
||||
# address for matching rule
|
||||
if recursion_level > 16:
|
||||
return None
|
||||
rule_address = rule_address or []
|
||||
for i, rule in enumerate(rules):
|
||||
if rule.type == CSSRule.STYLE_RULE:
|
||||
if rule_matches_elem(rule, elem, select, class_name):
|
||||
return RuleLocation(rule_address + [i], current_file_name)
|
||||
elif rule.type == CSSRule.MEDIA_RULE:
|
||||
res = find_first_rule_that_matches_elem(
|
||||
container, elem, select, class_name, rule.cssRules,
|
||||
current_file_name, recursion_level + 1, rule_address + [i]
|
||||
)
|
||||
if res is not None:
|
||||
return res
|
||||
elif rule.type == CSSRule.IMPORT_RULE:
|
||||
if not rule.href:
|
||||
continue
|
||||
sname = container.href_to_name(rule.href, current_file_name)
|
||||
if sname:
|
||||
try:
|
||||
sheet = container.parsed(sname)
|
||||
except Exception:
|
||||
continue
|
||||
if not hasattr(sheet, 'cssRules'):
|
||||
continue
|
||||
res = find_first_rule_that_matches_elem(
|
||||
container, elem, select, class_name, sheet.cssRules, sname,
|
||||
recursion_level + 1
|
||||
)
|
||||
if res is not None:
|
||||
return res
|
||||
return None
|
||||
|
||||
|
||||
def find_first_matching_rule(
|
||||
container, html_file_name, raw_html, class_data, lnum_attr='data-lnum'
|
||||
):
|
||||
lnum, tags = class_data['sourceline_address']
|
||||
class_name = class_data['class']
|
||||
root = parse(
|
||||
raw_html,
|
||||
decoder=lambda x: x.decode('utf-8'),
|
||||
line_numbers=True,
|
||||
linenumber_attribute=lnum_attr
|
||||
)
|
||||
tags_on_line = root.xpath(f'//*[@{lnum_attr}={lnum}]')
|
||||
barenames = [barename(tag.tag) for tag in tags_on_line]
|
||||
if barenames[:len(tags)] != tags:
|
||||
raise NoMatchingTagFound(
|
||||
f'No tag matching the specification was found in {html_file_name}'
|
||||
)
|
||||
target_elem = tags_on_line[len(tags) - 1]
|
||||
select = Select(root, ignore_inappropriate_pseudo_classes=True)
|
||||
for tag in root.iter('*'):
|
||||
tn = barename(tag.tag)
|
||||
if tn == 'style' and tag.text and tag.get('type', 'text/css') == 'text/css':
|
||||
try:
|
||||
sheet = container.parse_css(tag.text)
|
||||
except Exception:
|
||||
continue
|
||||
res = find_first_rule_that_matches_elem(
|
||||
container, target_elem, select, class_name, sheet.cssRules,
|
||||
html_file_name
|
||||
)
|
||||
if res is not None:
|
||||
return res._replace(style_tag_address=(int(tag.get(lnum_attr)), ['style']))
|
||||
elif tn == 'link' and tag.get('href') and tag.get('rel') == 'stylesheet':
|
||||
sname = container.href_to_name(tag.get('href'), html_file_name)
|
||||
try:
|
||||
sheet = container.parsed(sname)
|
||||
except Exception:
|
||||
continue
|
||||
if not hasattr(sheet, 'cssRules'):
|
||||
continue
|
||||
res = find_first_rule_that_matches_elem(
|
||||
container, target_elem, select, class_name, sheet.cssRules, sname
|
||||
)
|
||||
if res is not None:
|
||||
return res
|
||||
raise NoMatchingRuleFound(
|
||||
f'No CSS rules that apply to the specified tag in {html_file_name} with the class {class_name} found'
|
||||
)
|
||||
|
||||
|
||||
def develop():
|
||||
container = get_container('/t/demo.epub', tweak_mode=True)
|
||||
fname = 'index_split_002.html'
|
||||
data = {'class': 'xxx', 'sourceline_address': (13, ['body'])}
|
||||
print(
|
||||
find_first_matching_rule(
|
||||
container, fname,
|
||||
container.open(fname).read(), data
|
||||
)
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user