[TikiWiki-commits] [Git][tikiwiki/tiki][master] [FIX] Plugin MouseOver: hover tooltip was too excited/bouncy on the mouseover tag

Benoit Grégoire (@benoitg) via TikiWiki-cvs <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <69b3192341e43_3b3176d1462799@gitlab-sidekiq-low-urgency-cpu-bound-v2-6c96689c6b-jzwtv.mail>

Benoit Grégoire pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
072474d6 by Grace Nshokano at 2026-03-12T19:43:31+00:00
[FIX] Plugin MouseOver: hover tooltip was too excited/bouncy on the mouseover tag
---
* [FIX] Vite externals: add missing underscore entry

* [FIX] Mouseover: clarify cursor-offset semantics and anti-overlap guard

* [FIX] Mouseover plugin: document Problem 2 positioning assumptions

* [FIX] Mouseover plugin: document offset semantics and anti-bounce guard

* docs(mouseover): document options object and delay semantics

* refactor(author): reuse shared mouseover module for popup behavior

* refactor(mouseover): replace magic number with viewportPaddingPx

* [FIX] keep legacy externals copy targets alphabetical (move underscore after tablesorter)

* [FIX] Resolve conflicts

* [FIX] keep copy targets alphabetical by moving underscore after swiper

* [FIX] wikiplugin_mouseover: move hover logic to module to reduce PHP fragility

* [FIX] wikiplugin_mouseover: keep only reviewer-requested underscore ESM hover fixes

* [FIX] wikiplugin_mouseover: keep reviewer-requested underscore ESM changes only

* [FIX] wikiplugin_mouseover: extract hover logic to ESM module with underscore debounce/throttle

* [FIX] wikiplugin_mouseover: use underscore debounce/throttle with ESM importmap

* fix: address comment left

See merge request tikiwiki/tiki!8508

- - - - -


7 changed files:

- lib/wiki-plugins/wikiplugin_author.php
- lib/wiki-plugins/wikiplugin_mouseover.php
- package-lock.json
- path_js_importmap_generator.php
- src/js/common-externals/package.json
- + src/js/jquery-tiki/wikiplugin-mouseover.js
- src/js/vite.config.mjs


Changes:

=====================================
lib/wiki-plugins/wikiplugin_author.php
=====================================
@@ -117,13 +117,22 @@ function wikiplugin_author($data, $params)
 
                 if ($params['popup'] == 1) {
                     //Mouseover for detailed info
-                    $js = "\$('#author$id-link').on('mouseover', function(event) {
-                        \$('#author$id').css('left', event.pageX).css('top', event.pageY);
-                        showJQ('#author$id', '', '');
-                        1000
-                    });";
-                    $js .= "\$('#author$id-link').on('mouseout', function(event) { setTimeout(function() {hideJQ('#author$id', '', '')}, 1000); });";
-                    $headerlib->add_jq_onready($js);
+                    $jsConfig = json_encode([
+                        'anchorId' => "author$id-link",
+                        'popupId' => "author$id",
+                        'isSticky' => false,
+                        'closeDelayMs' => 0,
+                        'hideDelayMs' => 1000,
+                        // Cursor delta (not absolute page coordinate).
+                        'offsetX' => 0,
+                        'offsetY' => 0,
+                        'effect' => '',
+                        'speed' => 'normal',
+                    ]);
+                    $headerlib->add_js_module(
+                        'import { initMouseoverPlugin } from "@jquery-tiki/wikiplugin-mouseover";'
+                        . "initMouseoverPlugin($jsConfig);"
+                    );
                     $html .= "<span id=\"author$id\" class=\"plugin-mouseover\" style=\"width: 200px; height: 80px; padding: 2px \">" .
                         tra('Author') . ": $author" . (! is_null($params['deleted_by']) ? "<br />" . tra('deleted by') . ': ' . $params['deleted_by'] : '') . "</span>";
                 }


=====================================
lib/wiki-plugins/wikiplugin_mouseover.php
=====================================
@@ -13,7 +13,6 @@ function wikiplugin_mouseover_info()
         $jqfx[] = ['text' => $v, 'value' => $k];
     }
 
-
     return [
         'name' => tra('Mouseover'),
         'documentation' => 'PluginMouseover',
@@ -265,32 +264,40 @@ function wikiplugin_mouseover($data, $params)
     $headerlib = TikiLib::lib('header');
     $headerlib->add_css('.plugin-mouseover-anchor:not([href]) { border-bottom: 1px dotted; color: inherit; cursor: help; text-decoration: none; }');
 
-    if ($closeDelay && $sticky) {
-        $closeDelayStr = "setTimeout(function() {hideJQ('#$id', '$effect', '$speed')}, " . ($closeDelay * 1000) . ");";
-    } else {
-        $closeDelayStr = '';
-    }
-
-    $js = "\$('#$id-link').on('mouseover', function(event) {
-    var pos  = $(this).position();
-    $(this).closest('td').css('position', 'relative');
-    \$('#$id').css('position', 'absolute').css('left', pos.left + $offsetx + 'px').css('top', pos.top + $offsety + 'px');
-    showJQ('#$id', '$effect', '$speed'); $closeDelayStr });
-";
-    if ($sticky) {
-        $js .= "\$('#$id').on('click', function(event) { hideJQ('#$id', '$effect', '$speed'); }).css('cursor','pointer');\n";
-    } else {
-        $js .= "\$('#$id-link').on('mouseout', function(event) { setTimeout(function() {hideJQ('#$id', '$effect', '$speed')}, " . ($closeDelay * 1000) . "); });";
-    }
-    $headerlib->add_jq_onready($js);
+    // Plugin parameter exposes only "closeDelay" (seconds). Internally we split timing:
+    // - closeDelayMs: sticky auto-close timer
+    // - hideDelayMs: non-sticky mouseleave debounce to avoid flicker on inline anchors
+    // offsetX/offsetY are cursor deltas, not absolute page coordinates.
+    // Base cursor position is captured at mouseenter (popup init), then updated on mousemove.
+    $closeDelayMs = max(0, $closeDelay * 1000);
+    $hideDelayMs = $closeDelayMs > 0 ? $closeDelayMs : 80;
+    $jsConfig = json_encode([
+        'anchorId' => "$id-link",
+        'popupId' => $id,
+        'isSticky' => $sticky,
+        'closeDelayMs' => $closeDelayMs,
+        'hideDelayMs' => $hideDelayMs,
+        'offsetX' => $offsetx,
+        'offsetY' => $offsety,
+        'effect' => $effect,
+        'speed' => $speed,
+    ]);
+    $headerlib->add_js_module(
+        'import { initMouseoverPlugin } from "@jquery-tiki/wikiplugin-mouseover";'
+        . "initMouseoverPlugin($jsConfig);"
+    );
 
-    $bgcolor   = ! is_null($params['bgcolor']) ? ("background-color: " . $params['bgcolor'] . ';') : '';
+    $bgcolor = ! is_null($params['bgcolor']) ? ("background-color: " . $params['bgcolor'] . ';') : '';
     $textcolor = ! is_null($params['textcolor']) ? ("color:" . $params['textcolor'] . ';') : '';
-    $class     = is_null($params['class']) ? 'class="plugin-mouseover"' : 'class="plugin-mouseover ' . $params['class'] . '"';
-    $href      = $url ? 'href="' . $url . '"' : '';
+    $class = is_null($params['class']) ? 'class="plugin-mouseover"' : 'class="plugin-mouseover ' . $params['class'] . '"';
+    $href = $url ? 'href="' . $url . '"' : '';
+    // In non-sticky mode the popup must not capture pointer events, or hover can flicker.
+    $pointerEvents = $sticky ? 'auto' : 'none';
 
     $html = "~np~<$tag id=\"$id-link\" $href class=\"plugin-mouseover-anchor\">$label</$tag>" .
-        "<span id=\"$id\" $class style=\"width: {$width}px; " . (! empty($params['height']) ? "height: {$height}px; " : "") . "{$bgcolor} {$textcolor} {$padding} \">$text</span>~/np~";
+        "<span id=\"$id\" $class style=\"display: none; position: absolute; width: {$width}px; " .
+        (! empty($params['height']) ? "height: {$height}px; " : "") .
+        "{$bgcolor} {$textcolor} {$padding} pointer-events: {$pointerEvents};\">$text</span>~/np~";
 
     return $html;
 }


=====================================
package-lock.json
=====================================
@@ -15401,6 +15401,13 @@
             "dev": true,
             "license": "MIT"
         },
+        "node_modules/underscore": {
+            "version": "1.13.8",
+            "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+            "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/undici-types": {
             "version": "7.18.2",
             "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -16836,6 +16843,7 @@
                 "swagger-ui-dist": "^5.18.2",
                 "three": "^0.179.1",
                 "timeago": "^1.6.7",
+                "underscore": "^1.13.8",
                 "vis-timeline": "^7.7.4",
                 "vue": "^3.5.12",
                 "vue3-sfc-loader": "^0.9.5"


=====================================
path_js_importmap_generator.php
=====================================
@@ -93,6 +93,7 @@ function generateJsImportmapScripts(bool $useBaseUrl = false)
                 "timeline" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/vis-timeline/dist/vis-timeline-graph2d.esm.js",
                 "three" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/three/build/three.module.min.js",
 
+                "underscore" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/underscore/underscore-esm-min.js",
                 // currently we don't use the prod build to improve the experience for SFC
                 "vue" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/vue/dist/vue.esm-browser.js",
                 "vue3-sfc-loader" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/vue3-sfc-loader/dist/vue3-sfc-loader.esm.js",
@@ -111,6 +112,7 @@ function generateJsImportmapScripts(bool $useBaseUrl = false)
                 "@jquery-tiki/tiki-admin_menu_options" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-admin_menu_options.js",
                 "@jquery-tiki/tiki-admin_2fa" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-admin_2fa.js",
                 "@jquery-tiki/tiki-edit_structure" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-edit_structure.js",
+                "@jquery-tiki/wikiplugin-mouseover" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/wikiplugin-mouseover.js",
                 "@jquery-tiki/wikiplugin-trackercalendar" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/wikiplugin-trackercalendar.js",
                 "@jquery-tiki/eventcalendar_to_pdf" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/eventcalendar_to_pdf.js",
                 "@jquery-tiki/tiki-maps-ol3" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-maps-ol3.js",


=====================================
src/js/common-externals/package.json
=====================================
@@ -55,6 +55,7 @@
     "swagger-ui-dist": "^5.18.2",
     "three": "^0.179.1",
     "timeago": "^1.6.7",
+    "underscore": "^1.13.8",
     "vis-timeline": "^7.7.4",
     "vue": "^3.5.12",
     "vue3-sfc-loader": "^0.9.5"
@@ -79,6 +80,7 @@
     "moment": "A lot of libraries depend on this",
     "timeago": "",
     "three": "Used by src/js/tiki-model3dviewer",
+      "underscore": "",
     "sortablejs": "Used by legacy js code menubuilder and structures, not just newer libraries like kanban, so needs to be able to be independently included.  Dependency of https://github.com/SortableJS/Vue.Draggable which sadly hasn't moved in 2 years and is stuck at 1.14.0.  Pinning so we don't have a different version in the workspace from the global.  Otherwise dynamic module loading would make it hard to tell.",
     "svgedit": "version 7.4.1 fixes jspdf security vulnerabilities (GHSA-8mvj-3j78-4qmw, GHSA-f8cm-6447-x5h2) Used by tiki-svgedit_draw.js ",
     "vue": ""


=====================================
src/js/jquery-tiki/wikiplugin-mouseover.js
=====================================
@@ -0,0 +1,133 @@
+import { debounce, throttle } from "underscore";
+
+/**
+ * @typedef {Object} MouseoverPluginOptions
+ * @property {string} anchorId DOM id of the trigger element (without '#').
+ * @property {string} popupId DOM id of the popup element (without '#').
+ * @property {boolean} [isSticky=false] If true, popup stays open until click (or optional auto-close).
+ * @property {number} [closeDelayMs=0] Sticky mode auto-close delay in milliseconds. `0` disables auto-close.
+ * @property {number} [hideDelayMs=80] Non-sticky hide debounce in milliseconds to avoid flicker on brief hover gaps.
+ * @property {number} [offsetX=0] Horizontal delta from cursor-based placement (pixels).
+ * First placement uses mouseenter coordinates, then mousemove updates.
+ * @property {number} [offsetY=0] Vertical delta from cursor-based placement (pixels).
+ * First placement uses mouseenter coordinates, then mousemove updates.
+ * @property {string} [effect=""] jQuery UI effect name used by `showJQ`/`hideJQ`; empty string means default show/hide.
+ * @property {"normal"|"fast"|"slow"|string} [speed="normal"] Animation speed forwarded to `showJQ`/`hideJQ`.
+ */
+
+/**
+ * Initializes hover/click behavior and viewport-safe positioning for plugin popups.
+ *
+ * `closeDelayMs` and `hideDelayMs` serve different purposes:
+ * - `closeDelayMs`: sticky popups auto-close timer.
+ * - `hideDelayMs`: debounce for non-sticky mouseleave.
+ *
+ * @param {MouseoverPluginOptions} [options={}]
+ */
+export function initMouseoverPlugin(options = {}) {
+    const viewportPaddingPx = 4;
+    const anchor = document.getElementById(options.anchorId);
+    const popup = document.getElementById(options.popupId);
+    if (!anchor || !popup) {
+        return;
+    }
+
+    const popupSelector = `#${options.popupId}`;
+    const isSticky = Boolean(options.isSticky);
+    const closeDelayMs = Number(options.closeDelayMs) || 0;
+    const hideDelayMs = Number(options.hideDelayMs) || 80;
+    const offsetX = Number(options.offsetX) || 0;
+    const offsetY = Number(options.offsetY) || 0;
+    const effect = options.effect || "";
+    const speed = options.speed || "normal";
+
+    const showPopup = () => {
+        if (typeof window.showJQ === "function") {
+            window.showJQ(popupSelector, effect, speed);
+        } else {
+            popup.style.display = "block";
+        }
+    };
+
+    const hidePopupNow = () => {
+        if (typeof window.hideJQ === "function") {
+            window.hideJQ(popupSelector, effect, speed);
+        } else {
+            popup.style.display = "none";
+        }
+    };
+
+    // Position from cursor + offsets, flip near viewport edges, then avoid cursor overlap.
+    // This keeps hover stable and prevents leave/enter flicker ("bounce").
+    const positionPopup = throttle((event) => {
+        const popupWidth = popup.offsetWidth || popup.clientWidth || 0;
+        const popupHeight = popup.offsetHeight || popup.clientHeight || 0;
+        const viewportLeft = window.scrollX;
+        const viewportTop = window.scrollY;
+        const viewportRight = viewportLeft + window.innerWidth;
+        const viewportBottom = viewportTop + window.innerHeight;
+
+        let left = event.pageX + offsetX;
+        let top = event.pageY + offsetY;
+
+        if (left + popupWidth > viewportRight) {
+            left = event.pageX - popupWidth - offsetX;
+        }
+
+        if (top + popupHeight > viewportBottom) {
+            top = event.pageY - popupHeight - offsetY;
+        }
+
+        if (left < viewportLeft) {
+            left = viewportLeft + viewportPaddingPx;
+        }
+
+        if (top < viewportTop) {
+            top = viewportTop + viewportPaddingPx;
+        }
+
+        // If popup would cover the cursor, move it to the other side.
+        // Otherwise the cursor can enter the popup and trigger hover oscillation.
+        if (left <= event.pageX && event.pageX <= left + popupWidth && top <= event.pageY && event.pageY <= top + popupHeight) {
+            left = Math.max(viewportLeft + viewportPaddingPx, event.pageX - popupWidth - offsetX);
+        }
+
+        popup.style.left = left + "px";
+        popup.style.top = top + "px";
+    }, 16);
+
+    // Workaround for inline anchors split across lines: don't hide immediately on brief hover gaps.
+    const hidePopup = debounce(hidePopupNow, hideDelayMs);
+    const stickyAutoHide = isSticky && closeDelayMs > 0 ? debounce(hidePopupNow, closeDelayMs) : null;
+
+    anchor.addEventListener("mouseenter", (event) => {
+        hidePopup.cancel();
+        if (stickyAutoHide) {
+            stickyAutoHide.cancel();
+        }
+        showPopup();
+        positionPopup(event);
+        if (stickyAutoHide) {
+            stickyAutoHide();
+        }
+    });
+
+    anchor.addEventListener("mousemove", positionPopup);
+
+    anchor.addEventListener("mouseleave", () => {
+        if (!isSticky) {
+            hidePopup();
+        }
+    });
+
+    if (isSticky) {
+        popup.style.cursor = "pointer";
+        popup.addEventListener("click", () => {
+            hidePopup.cancel();
+            if (stickyAutoHide) {
+                stickyAutoHide.cancel();
+            }
+            hidePopupNow();
+        });
+    }
+}


=====================================
src/js/vite.config.mjs
=====================================
@@ -208,6 +208,7 @@ export default defineConfig(({ command, mode }) => {
                     "summernote",
                     "svgedit",
                     "three",
+                    "underscore",
                     "vue",
                     "vue3-sfc-loader",
 
@@ -597,6 +598,10 @@ export default defineConfig(({ command, mode }) => {
                         ],
                         dest: "vendor_dist/tablesorter/dist/js/widgets",
                     },
+                    {
+                        src: "node_modules/underscore/underscore-esm-min.js",
+                        dest: "vendor_dist/underscore",
+                    },
                     /* END src/js/common-externals-legacy-cjs section */
 
                     /* src/js/jquery_tiki - These should be in common-externals* if they are not compiled in!  - benoitg - 2026-03-11 */



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/072474d6b46852f48159d0d141f826d625674350

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/072474d6b46852f48159d0d141f826d625674350
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.