diff --git a/Documentation/Report.rst b/Documentation/Report.rst index b45e59c260105889dc6f504857bc65ac260ededf..972d9e6b18cbe1d1efdd0b6167ef06d73e208c8c 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 cf004314b3bb902249352fd3b987c17a19f8dc48..536d4ee57d27ad14dc2e7fae7e2b7f279f2315ef 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 3435d13a751cc0ba1e470edb0535aba40bf040b0..8c3e583370714c05da7fc12b8d9bc569f73d8234 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,66 @@ 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" 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" REMOVED TITLE ...> + * <div class="tooltip">TITLE TEXT</div> // Added Tool-Tip Div + * </span> + * @param $link + * @param $stickyToolTipText + * @param $finalToolTip + * @return string + */ + public 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/extension/Tests/Unit/Core/Report/LinkTest.php b/extension/Tests/Unit/Core/Report/LinkTest.php index a42c78eee25bd4fad4c01e23141117de6668cd05..05fee73ba064234de6c54b51ac916f45812ca065 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 diff --git a/javascript/src/Helper/StickyToolTip.js b/javascript/src/Helper/StickyToolTip.js new file mode 100644 index 0000000000000000000000000000000000000000..31180015f002e053764e07c5e0ddbb1f42279fc6 --- /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 ad44ee9aa90aad8c005a16371574e23122741cb7..2d37a027f6e0c901c00e4a6e6eed1e1820a1b47c 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 394c15472ff427bdc145d1a7fa87f27f02094743..1add68dbad1ff211b1da521850037031ce9b88f9 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