Repository: Kuuuube/kanjigrid
Issue category: Feature Request

Issue:
[Feature request]  Context menu for easy access to all "X on click" functions
Hello there! Having used this addon for a few months now, I find that the default 'Copy' context menu action isn't very effective, considering how finicky it is to actually select a kanji in the grid 🤕.

Also, having clicking be assigned only one action (copy, browse, search online) at any given time seems very limiting. For instance, it'd be more effective (IMO) to allow browsing for found kanji while also allowing copying and searching online for missing kanji (since browsing isn't useful here).

With a context menu, all 3 actions can be accessed very conveniently, regardless of what the LMB or MMB do:

![image](https://github.com/user-attachments/assets/746b61ac-65a4-498f-b053-707df83f25ed)

This isn't without precedence. [Kanji Table](https://ankiweb.net/shared/info/1828617820), another addon I used to use, also features a context menu. But their impl is different since they use a QTableWidget and not html (which might be why it seems slower 🤔)

I've included a small (exploratory) diff patch below on how I got it to work. It uses the `mouseenter` and `mouseleave` events to call `bridgeCommand` to inform the python side of the hovered-over character

```diff
diff --git a/__init__.py b/__init__.py
index 82f9f9f..9b4a5c9 100644
--- a/__init__.py
+++ b/__init__.py
@@ -13,11 +13,11 @@ import shlex
 from functools import reduce
 
 from anki.utils import ids2str
-from aqt import mw, dialogs
+from aqt import mw, dialogs, gui_hooks
 from aqt.webview import AnkiWebView
 from aqt.qt import (QAction, QSizePolicy, QDialog, QHBoxLayout,
                     QVBoxLayout, QGroupBox, QLabel, QCheckBox, QSpinBox,
-                    QComboBox, QPushButton, QLineEdit)
+                    QComboBox, QPushButton, QLineEdit, QMenu, QApplication, qconnect)
 
 from . import config_util, data, util, save
 
@@ -32,14 +32,16 @@ class KanjiGrid:
         def kanjitile(char, bgcolor, count = 0, avg_interval = 0):
             tile = ""
             color = "#000"
-
+            # TODO: right now it's gated under config.browseonclick as an exploratory quickfix
+            # perhaps another option like config.contextmenu or config.exporting (internally set) could be added
+            context_menu_events = f" onmouseenter=\"bridgeCommand('h:{char}');\" onmouseleave=\"bridgeCommand('l:{char}');\"" if config.browseonclick else ""
             if config.tooltips:
                 tooltip = "Character: %s" % unicodedata.name(char)
                 if avg_interval:
                     tooltip += " | Avg Interval: " + str("{:.2f}".format(avg_interval)) + " | Score: " + str("{:.2f}".format(util.scoreAdjust(avg_interval / config.interval)))
-                tile += "\t<div class=\"grid-item\" style=\"background:%s;\" title=\"%s\">" % (bgcolor, tooltip)
+                tile += "\t<div class=\"grid-item\" style=\"background:%s;\" title=\"%s\"%s>" % (bgcolor, tooltip, context_menu_events)
             else:
-                tile += "\t<div style=\"background:%s;\">" % (bgcolor)
+                tile += "\t<div style=\"background:%s;\"%s>" % (bgcolor, context_menu_events)
 
             if config.copyonclick:
                 tile += "<a style=\"color:" + color + ";cursor: pointer;\">" + char + "</a>"
@@ -154,13 +156,44 @@ class KanjiGrid:
         browser.form.searchEdit.lineEdit().setText("deck:\"" + deckname + "\" " + fields_string)
         browser.onSearchActivated()
 
+    def open_search_link(self, config, char):
+        link = util.get_search(config, char)
+        self.wv.eval(f"window.open('{link}', '_blank');")
+
+    def link_handler(self, link):
+        if link[:2] == "h:":
+            self.hovered = link[2:]
+        elif link[:2] == "l:":
+            if link[2:] == self.hovered:
+                # clear when outside grid
+                self.hovered = ""
+        else:
+            self.on_browse_cmd(link)
+
+    def add_webview_context_menu_items(self, wv: AnkiWebView, m: QMenu) -> None:
+        # hook is active while kanjigrid is open, and right clicking on the main window (deck list) will also trigger this, so check wv
+        if wv is self.wv and self.hovered != "":
+            char = self.hovered
+            a = m.addAction(f"Copy {char} to clipboard")
+            qconnect(a.triggered, lambda: self.on_copy_cmd(char))
+            a = m.addAction(f"Search deck for {char}")
+            qconnect(a.triggered, lambda: self.on_browse_cmd(char))
+            a = m.addAction(f"Search online for {char}")
+            qconnect(a.triggered, lambda: self.on_search_cmd(char))
+
     def displaygrid(self, config, deckname, units):
         self.generate(config, units)
         self.timepoint("HTML generated")
         self.win = QDialog(mw)
         self.wv = AnkiWebView()
-        fields_list = config.pattern
-        self.wv.set_bridge_command(lambda search_string: self.open_note_browser(mw, deckname, fields_list, search_string), None)
+        self.hovered = ""
+        self.on_browse_cmd = lambda char: self.open_note_browser(mw, deckname, config.pattern, char)
+        self.on_search_cmd = lambda char: self.open_search_link(config, char)
+        self.on_copy_cmd = QApplication.clipboard().setText
+        self.wv.set_bridge_command(self.link_handler, None)
+        # add webview context menu hook and defer cleanup
+        gui_hooks.webview_will_show_context_menu.append(self.add_webview_context_menu_items)
+        qconnect(self.win.finished, lambda: gui_hooks.webview_will_show_context_menu.remove(self.add_webview_context_menu_items))
         vl = QVBoxLayout()
         vl.setContentsMargins(0, 0, 0, 0)
         vl.addWidget(self.wv)

```

Thanks for your time!


Hints:
Any thoughts on this? Sorry for adding a diff patch instead of opening a pr, didn't want to impose since it's a feature request and not a bugfix 😅

Also I realised it might be better to use a `setTimeout` to avoid spamming `bridgeCommand` when moving the pointer quickly across the grid. Please let me know if you'd like me to open a pr for this, thanks!
If you want to make a pr, go for it. I've been busy lately so I haven't been able to get stuff done super fast and #7 has priority at the moment.

Patch:
diff --git a/__init__.py b/__init__.py
index 957f2bd..425dfb2 100644
--- a/__init__.py
+++ b/__init__.py
@@ -12,11 +12,12 @@
 from functools import reduce
 
 from anki.utils import ids2str
-from aqt import mw, dialogs
+from aqt import mw, dialogs, gui_hooks
 from aqt.webview import AnkiWebView
 from aqt.qt import (QAction, QSizePolicy, QDialog, QHBoxLayout,
                     QVBoxLayout, QGroupBox, QLabel, QCheckBox, QSpinBox,
-                    QComboBox, QPushButton, QLineEdit, qconnect)
+                    QComboBox, QPushButton, QLineEdit, QMenu, QApplication,
+                    qconnect)
 
 from . import config_util, data, util, save
 
@@ -27,22 +28,24 @@ def __init__(self, mw):
             mw.form.menuTools.addSeparator()
             mw.form.menuTools.addAction(self.menuAction)
 
-    def generate(self, config, units):
+    def generate(self, config, units, export = False):
         def kanjitile(char, bgcolor, count = 0, avg_interval = 0):
             tile = ""
             color = "#000"
 
+            context_menu_events = f" onmouseenter=\"bridgeCommand('h:{char}');\" onmouseleave=\"bridgeCommand('l:{char}');\"" if not export else ""
+
             if config.tooltips:
                 tooltip = "Character: %s" % util.safe_unicodedata_name(char)
                 if avg_interval:
                     tooltip += " | Avg Interval: " + str("{:.2f}".format(avg_interval)) + " | Score: " + str("{:.2f}".format(util.scoreAdjust(avg_interval / config.interval)))
-                tile += "\t<div class=\"grid-item\" style=\"background:%s;\" title=\"%s\">" % (bgcolor, tooltip)
+                tile += "\t<div class=\"grid-item\" style=\"background:%s;\" title=\"%s\"%s>" % (bgcolor, tooltip, context_menu_events)
             else:
-                tile += "\t<div style=\"background:%s;\">" % (bgcolor)
+                tile += "\t<div style=\"background:%s;\"%s>" % (bgcolor, context_menu_events)
 
             if config.copyonclick:
                 tile += "<a style=\"color:" + color + ";cursor: pointer;\">" + char + "</a>"
-            elif config.browseonclick:
+            elif config.browseonclick and not export:
                 tile += "<a href=\"" + util.get_browse_command(char) + "\" style=\"color:" + color + ";\">" + char + "</a>"
             else:
                 tile += "<a href=\"" + util.get_search(config, char) + "\" style=\"color:" + color + ";\">" + char + "</a>"
@@ -162,16 +165,55 @@ def open_note_browser(self, mw, deckname, fields_list, additional_search_filters
         browser.form.searchEdit.lineEdit().setText("deck:\"" + deckname + "\" " + fields_string + " " + additional_search_filters)
         browser.onSearchActivated()
 
+    def open_search_link(self, config, char):
+        link = util.get_search(config, char)
+        # aqt.utils.openLink is an alternative
+        self.wv.eval(f"window.open('{link}', '_blank');")
+
+    def link_handler(self, link):
+        link_prefix = link[:2]
+        link_suffix = link[2:]
+        if link_prefix == "h:":
+            self.hovered = link_suffix
+        elif link_prefix == "l:":
+            if link_suffix == self.hovered:
+                # clear when outside grid
+                self.hovered = ""
+        else:
+            self.on_browse_cmd(link)
+
+    def add_webview_context_menu_items(self, wv: AnkiWebView, m: QMenu) -> None:
+        # hook is active while kanjigrid is open, and right clicking on the main window (deck list) will also trigger this, so check wv
+        if wv is self.wv and self.hovered != "":
+            char = self.hovered
+            m.clear()
+            copy_action = m.addAction(f"Copy {char} to clipboard")
+            qconnect(copy_action.triggered, lambda: self.on_copy_cmd(char))
+            browse_action = m.addAction(f"Browse deck for {char}")
+            qconnect(browse_action.triggered, lambda: self.on_browse_cmd(char))
+            search_action = m.addAction(f"Search online for {char}")
+            qconnect(search_action.triggered, lambda: self.on_search_cmd(char))
+
     def displaygrid(self, config, deckname, units):
         self.generate(config, units)
         self.timepoint("HTML generated")
         self.win = QDialog(mw)
         self.wv = AnkiWebView()
+
+        def on_window_close(_):
+            gui_hooks.webview_will_show_context_menu.remove(self.add_webview_context_menu_items)
+        self.win.closeEvent = on_window_close
         qconnect(self.win.finished, lambda _: self.wv.cleanup())
         mw.garbage_collect_on_dialog_finish(self.win)
-        fields_list = config.pattern
-        additional_search_filters = config.searchfilter
-        self.wv.set_bridge_command(lambda search_string: self.open_note_browser(mw, deckname, fields_list, additional_search_filters, search_string), None)
+
+        self.hovered = ""
+        self.on_browse_cmd = lambda char: self.open_note_browser(mw, deckname, config.pattern, config.searchfilter, char)
+        self.on_search_cmd = lambda char: self.open_search_link(config, char)
+        self.on_copy_cmd = QApplication.clipboard().setText
+        self.wv.set_bridge_command(self.link_handler, None)
+        # add webview context menu hook and defer cleanup (in on_window_close)
+        gui_hooks.webview_will_show_context_menu.append(self.add_webview_context_menu_items)
+
         vl = QVBoxLayout()
         vl.setContentsMargins(0, 0, 0, 0)
         vl.addWidget(self.wv)
diff --git a/save.py b/save.py
index 4c42126..8720df6 100644
--- a/save.py
+++ b/save.py
@@ -17,9 +17,8 @@ def savehtml(self, mw, config, deckname):
         if ".htm" not in fileName:
             fileName += ".html"
         with open(fileName, 'w', encoding='utf-8') as fileOut:
-            config.browseonclick = False #disallow bridge command on exported html
             units = self.kanjigrid(config)
-            self.generate(config, units)
+            self.generate(config, units, export = True)
             fileOut.write(self.html)
         mw.progress.finish()
         showInfo("Page saved to %s!" % os.path.abspath(fileOut.name))


Test patch: