From eb30815aa49a4e747070dd2b83b6e78a2937bd93 Mon Sep 17 00:00:00 2001
From: zzalap <zen.zalapksi@math.uzh.ch>
Date: Mon, 13 Jan 2025 12:41:21 +0100
Subject: [PATCH 1/2] refs #18679, New Sticky Tooltip _link param O. Sticky
 Tooltip can be enabled with or without text O == O:TEXT == O:1. Moved
 Previous monitor to 'L'.

---
 Documentation/Report.rst               |   6 +-
 extension/Classes/Core/Constants.php   |   7 +-
 extension/Classes/Core/Report/Link.php |  68 +++++++++-
 javascript/src/Helper/StickyToolTip.js | 178 +++++++++++++++++++++++++
 javascript/src/Main.js                 |   1 +
 less/qfq-bs.css.less                   |  57 +++++++-
 6 files changed, 309 insertions(+), 8 deletions(-)
 create mode 100644 javascript/src/Helper/StickyToolTip.js

diff --git a/Documentation/Report.rst b/Documentation/Report.rst
index b45e59c26..972d9e6b1 100644
--- a/Documentation/Report.rst
+++ b/Documentation/Report.rst
@@ -941,17 +941,19 @@ Column: _link
 +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 |   |   |HTTP redirect |h:[get|temp|perm|<http-code>]      |h:get, h:303, h:307        |Only used with rendering mode 'r:9'. See :ref:`httpRedirection`                                                                         |
 +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
+|   |   |Sticky ToolTip|O:<text>                           |O:Some Text, 0             |Sticky Tool-Tip with copy to clipboard button.                                                                                          |
++---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 
 .. _back-button:
 
 Back Button
 """""""""""
 
-The QFQ reserved page-slug string `_back` creates a `Back` button - the same as the browser back button, but direcly on 
+The QFQ reserved page-slug string `_back` creates a `Back` button - the same as the browser back button, but direcly on
 a web page (not as an icon in the browser title).
 
 Nowadays, some online tools do not behave well by using the browser `back` function. As a consequence, the user might be afraid of
-using the browser back button. In contrast, if the webpage explicitly a `Back` button, the user implies that it is save 
+using the browser back button. In contrast, if the webpage explicitly a `Back` button, the user implies that it is save
 to click on it.  In general, QFQ behaves well by using the browser back button.
 
 Together with the ` ... AS _link` the reservered name `_back` generates a customizable `Back`-Button. Example:
diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php
index cf004314b..536d4ee57 100644
--- a/extension/Classes/Core/Constants.php
+++ b/extension/Classes/Core/Constants.php
@@ -1166,7 +1166,7 @@ const SUBRECORD_EMPTY_HIDE = 'hide';
 const SUBRECORD_EMPTY_MUTE = 'mute';
 const SUBRECORD_EMPTY_SHOW = 'show';
 
-const GLYPH_ICON = 'glyphicon';
+const GLYPH_ICON = 'glyphicon ';
 const GLYPH_ICON_EDIT = 'glyphicon-pencil';
 const GLYPH_ICON_NEW = 'glyphicon-plus';
 const GLYPH_ICON_DELETE = 'glyphicon-trash';
@@ -2352,6 +2352,7 @@ const NAME_AFTER_LINK = 'afterLink';
 const NAME_CACHE = 'cache';
 const NAME_REDIRECT_HTTP_CODE = 'httpCode';
 const NAME_BACK_BUTTON = '_back';
+const NAME_STICKY_TOOL_TIP = 'stickyToolTip';
 const FINAL_WRAP_TAG = 'wrapTag';
 
 const FINAL_HREF = 'finalHref';
@@ -2401,7 +2402,7 @@ const PARAM_TOKEN_DELIMITER = ':';
 
 const PARAM_LIST_DELIMITER = ',';
 const PARAM_KEY_VALUE_DELIMITER = ':';
-
+const TOKEN_STICKY_TOOL_TIP = 'O';
 const TOKEN_URL = 'u';
 const TOKEN_MAIL = 'm';
 const TOKEN_PAGE = 'p';
@@ -2457,7 +2458,7 @@ const TOKEN_CACHE = 'cache';
 const TOKEN_THUMBNAIL = 'T';
 const TOKEN_THUMBNAIL_DIMENSION = 'W';
 
-const TOKEN_MONITOR = 'O';
+const TOKEN_MONITOR = 'L';
 
 const TOKEN_ACTION_DELETE = 'x';
 const TOKEN_ACTION_DELETE_AJAX = 'a';
diff --git a/extension/Classes/Core/Report/Link.php b/extension/Classes/Core/Report/Link.php
index 3435d13a7..97bdc6ce1 100644
--- a/extension/Classes/Core/Report/Link.php
+++ b/extension/Classes/Core/Report/Link.php
@@ -63,13 +63,13 @@ use IMATHUZH\Qfq\Core\Typo3\T3Handler;
  * k:
  * K:
  * l:
- * L:
+ * L:Monitor
  * m:mailto
  * M:Mode
  * n:GET/POST Rest Call
  * N:new
  * o:ToolTip
- * O:Monitor
+ * O:Sticky ToolTip
  * p:page
  * P:picture       [file]
  * q:question  <text>
@@ -181,6 +181,7 @@ class Link {
         TOKEN_ATTRIBUTE => NAME_ATTRIBUTE,
         TOKEN_ORDER_TEXT => NAME_ORDER_TEXT,
         TOKEN_REDIRECT_HTTP_CODE => NAME_REDIRECT_HTTP_CODE,
+        TOKEN_STICKY_TOOL_TIP => NAME_STICKY_TOOL_TIP,
 
         TOKEN_MONITOR => NAME_MONITOR,
         TOKEN_BEFORE_LINK => NAME_BEFORE_LINK,
@@ -732,6 +733,11 @@ class Link {
         }
 
         $rcExplicit[TOOLTIP_DEBUG] = $this->tooltipDebug;
+        // If it is a sticky ToolTip it needs some extra work.
+        if (isset($vars[NAME_STICKY_TOOL_TIP])) {
+            $link = $this->buildStickyToolTip($link, $vars[NAME_STICKY_TOOL_TIP], $vars[FINAL_TOOL_TIP]);
+        }
+
         // Merge span to link if something exists from qualifier 'Y'
         return $vars[NAME_BEFORE_LINK] . ($vars[NAME_ORDER_TEXT_WRAP] ?? '') . $link . $vars[NAME_AFTER_LINK];
     }
@@ -2593,4 +2599,62 @@ EOF;
         $vars[FINAL_HTTP_CODE] = $intValue;
         return $vars;
     }
+
+    /**
+     * Rebuilds Link with added Sticky Tool-Tip div and adds html class 'tooltip-trigger' to element
+     * and removes Title in $link element.
+     *
+     *  Input enabled: $link -> <a href="url" class="btn btn-default" title="TEXT" ...></a>
+     *  Output:<a href="url" class="btn btn-default tooltip-trigger" ...><div class="tooltip">TEXT</div></a>
+     *
+     *  Input disabled (r=3/4): $link -> <span class="btn btn-default disabled" title="TEXT"></span>
+     *  Output disabled: <span class="btn btn-default disabled tooltip-trigger"><div class="tooltip">TEXT</div></span>
+     * @param $link
+     * @param $stickyToolTipText
+     * @param $finalToolTip
+     * @return string
+     */
+    private function buildStickyToolTip($link, $stickyToolTipText, $finalToolTip): string {
+        $toolTipText = $stickyToolTipText . $finalToolTip;
+
+        // Extract Existing Classes
+        preg_match('/class=["\']([^"\']*)["\']/i', $link, $classMatch);
+        $existingClasses = $classMatch[1] ?? ''; // Keep existing classes, or empty if none
+        $updatedClasses = trim($existingClasses . ' tooltip-trigger'); // Append 'tooltip-trigger'
+
+        // Add Classes and Modify Attributes
+        $link = preg_replace_callback(
+            '/(<[^>]+)(\s+title=["\']([^"\']*)["\'])([^>]*>)/i',
+            function ($matches) use ($toolTipText, $updatedClasses) {
+                // Remove title attribute
+                $updatedTag = preg_replace('/\s+title=["\']([^"\']*)["\']/', '', $matches[1]);
+
+                // Add class attribute
+                if (preg_match('/class=["\']([^"\']*)["\']/i', $matches[1])) {
+                    // Replace existing class attribute
+                    $updatedTag = preg_replace(
+                        '/class=["\']([^"\']*)["\']/i',
+                        'class="' . $updatedClasses . '"',
+                        $updatedTag
+                    );
+                } else {
+                    // Add class attribute if none exists
+                    $updatedTag = preg_replace(
+                        '/<([a-zA-Z0-9]+)/',
+                        '<$1 class="' . $updatedClasses . '"',
+                        $updatedTag
+                    );
+                }
+
+                // Return the updated tag and append the tooltip div
+                return $updatedTag . $matches[4] .
+                    '<div class="tooltip" style="display:none; pointer-events:none;">' .
+                    htmlspecialchars($toolTipText) .
+                    '</div>';
+            },
+            $link
+        );
+        return $link;
+    }
+
 }
\ No newline at end of file
diff --git a/javascript/src/Helper/StickyToolTip.js b/javascript/src/Helper/StickyToolTip.js
new file mode 100644
index 000000000..31180015f
--- /dev/null
+++ b/javascript/src/Helper/StickyToolTip.js
@@ -0,0 +1,178 @@
+/**
+ * Qfq Namespace
+ *
+ * @namespace QfqNS
+ */
+
+var QfqNS = QfqNS || {};
+
+/**
+ * Qfq Helper Namespace
+ *
+ * @namespace QfqNS.Helper
+ */
+QfqNS.Helper = QfqNS.Helper || {};
+
+(function (n) {
+    'use strict';
+
+    let activeTooltips = new Map(); // Tracks tooltips by trigger element
+    let pinnedTooltips = new Set(); // Stores pinned tooltips
+    let hideTimeout = null; // Delay for hiding tooltips
+
+    // Create tooltip dynamically
+    function createTooltipElement(tooltipText) {
+        const tooltip = document.createElement('div');
+        tooltip.classList.add('tooltip-container');
+
+        // Add buttons and tooltip text
+        tooltip.innerHTML = `
+            <div class="tooltip-header">
+                <button class="button pin-btn"><i class="fas fa-thumbtack"></i></button>
+                <button class="button copy-btn"><i class="fas fa-clipboard"></i></button>
+            </div>
+            <div class="tooltip-content">${tooltipText.replace(/\n/g, '<br>')}</div>
+        `;
+        return tooltip;
+    }
+
+    // Create and show tooltip
+    function showTooltip(trigger) {
+        if (activeTooltips.has(trigger)) {
+            const { tooltip, popperInstance } = activeTooltips.get(trigger);
+            tooltip.style.display = 'block';
+            popperInstance.update();
+            return tooltip;
+        }
+
+        const tooltipText = trigger.querySelector('.tooltip').innerText.trim();
+
+        const tooltip = createTooltipElement(tooltipText);
+        document.body.appendChild(tooltip);
+
+        const popperInstance = Popper.createPopper(trigger, tooltip, {
+            placement: 'bottom', // place element at the bottom of trigger element
+            modifiers: [
+                {
+                    name: 'preventOverflow',
+                    options: {
+                        boundary: 'viewport',
+                    },
+                },
+                {
+                    name: 'offset',
+                    options: {
+                        offset: [10, 10],
+                    },
+                }
+            ],
+        });
+
+        activeTooltips.set(trigger, { tooltip, popperInstance });
+        addTooltipEventListeners(tooltip, trigger, popperInstance);
+
+        // Immediately show tooltip without requiring a second hover
+        tooltip.style.display = 'block';
+        popperInstance.update();
+        return tooltip;
+    }
+
+    function addTooltipEventListeners(tooltip, trigger, popperInstance) {
+        const copyBtn = tooltip.querySelector('.copy-btn');
+        const pinBtn = tooltip.querySelector('.pin-btn');
+        // copy button event for HTTP and HTTPS pages
+        copyBtn.addEventListener('click', () => {
+            const textContent = tooltip.querySelector('.tooltip-content').innerText;
+            if (navigator.clipboard && navigator.clipboard.writeText) {
+                // Modern API
+                navigator.clipboard.writeText(textContent).then(() => {
+                    copyBtn.innerHTML = '<i class="fas fa-check copied"></i>';
+                    setTimeout(() => {
+                        copyBtn.innerHTML = '<i class="fas fa-clipboard"></i>';
+                    }, 1000);
+                }).catch(err => {
+                    console.error('Failed to copy text: ', err);
+                });
+            } else {
+                // Fallback for HTTP or unsupported browsers
+                const textArea = document.createElement('textarea');
+                textArea.value = textContent;
+                document.body.appendChild(textArea);
+                textArea.select();
+                try {
+                    document.execCommand('copy');
+                    copyBtn.innerHTML = '<i class="fas fa-check copied"></i>';
+                    setTimeout(() => {
+                        copyBtn.innerHTML = '<i class="fas fa-clipboard"></i>';
+                    }, 1000);
+                } catch (err) {
+                    console.error('Fallback: Could not copy text: ', err);
+                }
+                document.body.removeChild(textArea);
+            }
+        });
+
+        pinBtn.addEventListener('click', () => {
+            if (pinnedTooltips.has(tooltip)) {
+                tooltip.classList.remove('pinned');
+                pinnedTooltips.delete(tooltip);
+                pinBtn.innerHTML = '<i class="fas fa-thumbtack"></i>';
+            } else {
+                tooltip.classList.add('pinned');
+                pinnedTooltips.add(tooltip);
+                pinBtn.innerHTML = '<i class="fas fa-thumbtack copied"></i>';
+            }
+        });
+
+        tooltip.addEventListener('mouseenter', () => {
+            clearTimeout(hideTimeout);
+        });
+
+        tooltip.addEventListener('mouseleave', () => {
+            if (!pinnedTooltips.has(tooltip)) {
+                hideTooltipWithDelay(tooltip, popperInstance, trigger);
+            }
+        });
+    }
+
+    function hideTooltipWithDelay(tooltip, popperInstance, trigger) {
+        hideTimeout = setTimeout(() => {
+            if (!pinnedTooltips.has(tooltip)) {
+                tooltip.style.display = 'none';
+            }
+        }, 100);
+    }
+
+    // init StickyToolTip on page
+    function initializeStickyToolTip() {
+        document.querySelectorAll('.tooltip-trigger').forEach(trigger => {
+            const tooltipDiv = trigger.querySelector('.tooltip');
+            // remove interaction with div containing tooltip text
+            if (tooltipDiv) {
+                tooltipDiv.style.pointerEvents = 'none';
+                tooltipDiv.style.display = 'none';
+            }
+
+            trigger.addEventListener('mouseenter', () => {
+                showTooltip(trigger);
+            });
+
+            trigger.addEventListener('mouseleave', () => {
+                const tooltipData = activeTooltips.get(trigger);
+                if (tooltipData && !pinnedTooltips.has(tooltipData.tooltip)) {
+                    hideTooltipWithDelay(tooltipData.tooltip, tooltipData.popperInstance, trigger);
+                }
+            });
+        });
+
+        document.addEventListener('click', (event) => {
+            pinnedTooltips.forEach((tooltip) => {
+                if (!tooltip.contains(event.target)) {
+                }
+            });
+        });
+    }
+
+    n.initializeStickyToolTip = initializeStickyToolTip;
+
+})(QfqNS.Helper);
\ No newline at end of file
diff --git a/javascript/src/Main.js b/javascript/src/Main.js
index ad44ee9aa..2d37a027f 100644
--- a/javascript/src/Main.js
+++ b/javascript/src/Main.js
@@ -260,6 +260,7 @@ $(document).ready( function () {
         n.Helper.codemirror();
         n.Helper.calendar();
         n.Helper.selectBS();
+        n.Helper.initializeStickyToolTip();
 
         // Initialize chat elements
         let chatWindowsElements = document.getElementsByClassName("qfq-chat-window");
diff --git a/less/qfq-bs.css.less b/less/qfq-bs.css.less
index 394c15472..1add68dba 100644
--- a/less/qfq-bs.css.less
+++ b/less/qfq-bs.css.less
@@ -2510,4 +2510,59 @@ input.qfq-password {
 .search-submit-btn:hover {
   background-color: #0056b3 !important;
 }
-// End of Search Refactor
\ No newline at end of file
+// End of Search Refactor
+
+// Start of sticky ToolTip
+.tooltip-container {
+  position: absolute;
+  background-color: rgba(0, 0, 0, 0.8); /* Dark background with slight transparency */
+  color: #fff; /* White text for readability */
+  padding: 8px 12px; /* Padding for better spacing */
+  border-radius: 4px; /* Rounded corners */
+  font-size: 12px; /* Smaller font size for a tooltip feel */
+  max-width: 300px; /* Limit width to keep it compact */
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); /* Subtle shadow for elevation */
+  z-index: 1000; /* Ensure tooltip appears above other elements */
+  pointer-events: auto; /* Allow interactions */
+}
+
+.tooltip-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 5px; /* Spacing between header and content */
+}
+
+.tooltip-header button {
+  background: transparent;
+  border: none;
+  color: #fff;
+  font-size: 12px; /* Smaller button icons */
+  cursor: pointer;
+  padding: 4px;
+}
+
+.tooltip-header button:hover {
+  color: #00bfff; /* Highlight color on hover */
+}
+
+.tooltip-content {
+  font-family: Arial, sans-serif; /* Clean font style */
+  line-height: 1.4; /* Improve text readability */
+  white-space: pre-wrap; /* Preserve line breaks in the text */
+}
+
+.pinned {
+  background-color: rgba(0, 0, 0, 0.9); /* Slightly darker background when pinned */
+  border: 1px solid #00bfff; /* Highlight border for pinned tooltips */
+}
+
+.tooltip-container.pinned .pin-btn i {
+  color: #00bfff; /* Highlight pinned icon */
+}
+
+.tooltip {
+  height: 1px;
+  width: 1px;
+}
+// End of sticky ToolTip
\ No newline at end of file
-- 
GitLab


From 7a9b8ec8bc3da013c3fc412c3ee3f70ea5129d41 Mon Sep 17 00:00:00 2001
From: zzalap <zen.zalapksi@math.uzh.ch>
Date: Mon, 13 Jan 2025 16:15:20 +0100
Subject: [PATCH 2/2] refs #18679, Added Unit Test for buildStickyToolTip in
 LinkTest.php.

---
 extension/Classes/Core/Report/Link.php        | 10 ++--
 extension/Tests/Unit/Core/Report/LinkTest.php | 49 +++++++++++++++++++
 2 files changed, 56 insertions(+), 3 deletions(-)

diff --git a/extension/Classes/Core/Report/Link.php b/extension/Classes/Core/Report/Link.php
index 97bdc6ce1..8c3e58337 100644
--- a/extension/Classes/Core/Report/Link.php
+++ b/extension/Classes/Core/Report/Link.php
@@ -2605,16 +2605,20 @@ EOF;
      * and removes Title in $link element.
      *
      *  Input enabled: $link -> <a href="url" class="btn btn-default" title="TEXT" ...></a>
-     *  Output:<a href="url" class="btn btn-default tooltip-trigger" ...><div class="tooltip">TEXT</div></a>
+     *  Output:<a href="url" class="btn btn-default tooltip-trigger" REMOVED TITLE  ...>
+     *          <div class="tooltip">TITLE TEXT</div> // Added Tool-Tip Div
+     *         </a>
      *
      *  Input disabled (r=3/4): $link -> <span class="btn btn-default disabled" title="TEXT"></span>
-     *  Output disabled: <span class="btn btn-default disabled tooltip-trigger"><div class="tooltip">TEXT</div></span>
+     *  Output disabled: <span class="btn btn-default disabled tooltip-trigger" REMOVED TITLE ...>
+     *                      <div class="tooltip">TITLE TEXT</div> // Added Tool-Tip Div
+     *                   </span>
      * @param $link
      * @param $stickyToolTipText
      * @param $finalToolTip
      * @return string
      */
-    private function buildStickyToolTip($link, $stickyToolTipText, $finalToolTip): string {
+    public function buildStickyToolTip($link, $stickyToolTipText, $finalToolTip): string {
         $toolTipText = $stickyToolTipText . $finalToolTip;
 
         // Extract Existing Classes
diff --git a/extension/Tests/Unit/Core/Report/LinkTest.php b/extension/Tests/Unit/Core/Report/LinkTest.php
index a42c78eee..05fee73ba 100644
--- a/extension/Tests/Unit/Core/Report/LinkTest.php
+++ b/extension/Tests/Unit/Core/Report/LinkTest.php
@@ -1969,6 +1969,55 @@ EOF;
         $result = $link->renderLink('p:_back|b|v:pre|V:post');
         $this -> assertEquals($expected, $result);
     }
+    /**
+     * @throws \UserFormException
+     * @throws \CodeException
+     * @throws RedirectResponse
+     * @throws \UserReportException
+     * @throws \DbException
+     */
+    public function testBuildStickyToolTip() {
+        $linkClass = new Link($this->sip, DB_INDEX_DEFAULT, true);
+        $stickyToolTipText = "Sticky Tooltip: ";
+        $finalToolTip = "Extra Info";
+
+
+        $link = '<a href="url" title="Sample Tooltip"></a>';
+        $expected = '<a class="tooltip-trigger" href="url"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div></a>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+
+
+        $link = '<a href="url" class="btn btn-primary" title="Sample Tooltip"></a>';
+        $expected = '<a href="url" class="btn btn-primary tooltip-trigger"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div></a>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+
+
+        $link = '<span class="btn btn-default disabled" title="Disabled Tooltip"></span>';
+        $expected = '<span class="btn btn-default disabled tooltip-trigger"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div></span>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+
+
+        $link = '<a href="url" title="No Class Tooltip"></a>';
+        $expected = '<a class="tooltip-trigger" href="url"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div></a>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+
+
+        $link = '<a href="url" class="btn tooltip-trigger" title="Existing Tooltip"></a>';
+        $expected = '<a href="url" class="btn tooltip-trigger tooltip-trigger"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div></a>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+
+
+        $link = '<a href="url" title="Nested Tooltip"><span class="glyphicon glyphicon-pencil"></span> Text</a>';
+        $expected = '<a class="glyphicon glyphicon-pencil tooltip-trigger" href="url"><div class="tooltip" style="display:none; pointer-events:none;">Sticky Tooltip: Extra Info</div><span class="glyphicon glyphicon-pencil"></span> Text</a>';
+        $result = $linkClass->buildStickyToolTip($link, $stickyToolTipText, $finalToolTip);
+        $this->assertEquals($expected, $result);
+    }
+
 
     /**
      * @throws \CodeException
-- 
GitLab