[TikiWiki-commits] [Git][tikiwiki/tiki][29.x] [BP][FIX] Plugin MouseOver: hover tooltip was too excited/bouncy on the mouseover tag
"Olivier Kango \(@olivierkango\) via TikiWiki-cvs" <[email protected]>
| Newsgroups | gmane.comp.cms.tiki.cvs |
|---|---|
| Message-ID | <69b8f5ab592_3b18d210710a6@gitlab-sidekiq-low-urgency-cpu-bound-v2-7fb98cc9d4-n5hwn.mail> |
Olivier Kango pushed to branch 29.x at Tiki Wiki CMS Groupware / Tiki
Commits:
8ead8793 by Olivier Kango at 2026-03-17T08:25:35+02:00
[BP][FIX] Plugin MouseOver: hover tooltip was too excited/bouncy on the mouseover tag
---
* [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
(cherry picked from commit 072474d6b46852f48159d0d141f826d625674350)
See merge request tikiwiki/tiki!9760
- - - - -
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
=====================================
@@ -127,13 +127,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" . (isset($params['deleted_by']) ? "<br />" . tra('deleted by') . ': ' . $params['deleted_by'] : '') . "</span>";
}
=====================================
lib/wiki-plugins/wikiplugin_mouseover.php
=====================================
@@ -14,7 +14,6 @@ function wikiplugin_mouseover_info()
$jqfx[] = ['text' => $v, 'value' => $k];
}
-
return [
'name' => tra('Mouseover'),
'documentation' => 'PluginMouseover',
@@ -273,32 +272,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 = isset($params['bgcolor']) ? ("background-color: " . $params['bgcolor'] . ';') : '';
+ $bgcolor = isset($params['bgcolor']) ? ("background-color: " . $params['bgcolor'] . ';') : '';
$textcolor = isset($params['textcolor']) ? ("color:" . $params['textcolor'] . ';') : '';
- $class = ! isset($params['class']) ? 'class="plugin-mouseover"' : 'class="plugin-mouseover ' . $params['class'] . '"';
- $href = $url ? 'href="' . $url . '"' : '';
+ $class = ! isset($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; " . (isset($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
=====================================
@@ -14664,6 +14664,13 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"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": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -16010,6 +16017,7 @@
"swiper": "^11.2.6",
"tablesorter": "^2.32.0",
"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
=====================================
@@ -42,6 +42,7 @@ function generateJsImportmapScripts(bool $useBaseUrl = false)
"sortablejs" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/sortablejs/modular/sortable.esm.js",
"summernote" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/summernote/dist/summernote-bs5.min.js",
"timeline" => $tikiUrl . NODE_PUBLIC_DIST_PATH . "/vis-timeline/dist/vis-timeline-graph2d.esm.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",
@@ -67,6 +68,7 @@ function generateJsImportmapScripts(bool $useBaseUrl = false)
"@jquery-tiki/tiki-handle_svgedit" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-handle_svgedit.js",
"@jquery-tiki/tiki-admin_menu_options" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-admin_menu_options.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/fullcalendar_to_pdf" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/fullcalendar_to_pdf.js",
"@jquery-tiki/tiki-maps-ol3" => $tikiUrl . JS_ASSETS_PATH . "/jquery-tiki/tiki-maps-ol3.js",
=====================================
src/js/common-externals/package.json
=====================================
@@ -52,6 +52,7 @@
"swagger-ui-dist": "^5.18.2",
"tablesorter": "^2.32.0",
"swiper": "^11.2.6",
+ "underscore": "^1.13.8",
"vis-timeline": "^7.7.4",
"vue": "^3.5.12",
"vue3-sfc-loader": "^0.9.5"
@@ -69,7 +70,8 @@
"jquery-validation": "",
"moment": "A lot of libraries depend on this",
"timeago": "",
- "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.",
+ "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.",
"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
=====================================
@@ -185,6 +185,7 @@ export default defineConfig(({ command, mode }) => {
"sortablejs",
"subtotal",
"summernote",
+ "underscore",
"vue",
],
input: rollupInput,
@@ -570,6 +571,13 @@ 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 */
{
src: "node_modules/vue/dist/vue.esm-browser.js",
dest: "vendor_dist/vue/dist",
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/8ead879363f21a791b05a5ba613e30633923e37d
--
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/8ead879363f21a791b05a5ba613e30633923e37d
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