From f3c701908cd3081ac7c244056bfbba53d0da8c12 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 9 Dec 2025 17:39:33 +0100 Subject: [PATCH 1/3] Close hotspot tooltip when link button is clicked When clicking the link button in a hotspot tooltip, the tooltip now closes. This applies both to tooltips opened via hover and via click. Clicking elsewhere in the tooltip (e.g., selecting text) still keeps the tooltip open. In the editor, clicking the link preview tooltip (that shows the URL on hover) triggers the onClick callback, since clicking the link text itself is used for editing. --- .../hotspots/Hotspots/panZoomScroller-spec.js | 40 +++++++++++++++++++ .../hotspots/Hotspots/tooltipDisplay-spec.js | 37 +++++++++++++++++ .../spec/frontend/EditableLink-spec.js | 15 +++++++ .../inlineEditing/EditableLink-spec.js | 19 +++++++++ .../src/contentElements/hotspots/Hotspots.js | 5 +++ .../src/contentElements/hotspots/Tooltip.js | 5 ++- .../package/src/frontend/EditableLink.js | 4 +- .../frontend/inlineEditing/EditableLink.js | 2 + .../src/frontend/inlineEditing/LinkTooltip.js | 15 +++++-- 9 files changed, 135 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/panZoomScroller-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/panZoomScroller-spec.js index ecc60c21fa..9f232e9fd5 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/panZoomScroller-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/panZoomScroller-spec.js @@ -501,6 +501,46 @@ describe('Hotspots', () => { expect(scroller.scrollTo).toHaveBeenCalled(); }); + + it('scrolls pan zoom scroller when link button is clicked', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + link: [{type: 'heading', children: [{text: 'Some link'}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const user = userEvent.setup(); + const {container, getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + simulateIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + await user.click(getByRole('link')); + + expect(scroller.scrollTo).toHaveBeenCalled(); + }); }); function clickableArea(container) { diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/tooltipDisplay-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/tooltipDisplay-spec.js index 2d1e726d7a..f2a84adc97 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/tooltipDisplay-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots/tooltipDisplay-spec.js @@ -195,6 +195,43 @@ describe('Hotspots', () => { expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); }); + it('hides tooltip when link button is clicked', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + link: [{type: 'heading', children: [{text: 'Some link'}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const user = userEvent.setup(); + const {container, getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + await user.hover(clickableArea(container)); + await user.click(getByRole('link')); + + expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull(); + }); + it('hides when backdrop element is intersecting content', async () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, diff --git a/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js b/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js index a16a9e611e..562e559af7 100644 --- a/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js +++ b/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js @@ -3,6 +3,7 @@ import React from 'react'; import {EditableLink} from 'frontend'; import {renderInEntry} from 'support'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect' // Link behavior is tested in Link-spec.js. @@ -25,4 +26,18 @@ describe('EditableLink', () => { expect(getByRole('link')).toHaveClass('custom') }); + + it('supports onClick', async () => { + const onClick = jest.fn(event => + event.preventDefault() // Prevent jsdom warning + ); + const user = userEvent.setup(); + const {getByRole} = renderInEntry( + Some link + ); + + await user.click(getByRole('link')); + + expect(onClick).toHaveBeenCalled(); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js index 604d8212e1..b5d382e16b 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js @@ -13,6 +13,12 @@ import '@testing-library/jest-dom/extend-expect' jest.mock('frontend/inlineEditing/useSelectLinkDestination'); describe('EditableLink', () => { + beforeEach(() => { + const rect = {width: 100, height: 20, top: 100, left: 100, bottom: 120, right: 200, x: 100, y: 100}; + Element.prototype.getClientRects = jest.fn(() => [rect]); + Element.prototype.getBoundingClientRect = jest.fn(() => rect); + }); + useFakeTranslations({ 'pageflow_scrolled.inline_editing.change_link_destination': 'Change link destination', 'pageflow_scrolled.inline_editing.select_link_destination': 'Select link destination', @@ -145,4 +151,17 @@ describe('EditableLink', () => { expect(onChange).toHaveBeenCalledWith(null); }); + + it('triggers onClick when tooltip is clicked', async () => { + const onClick = jest.fn(); + const user = userEvent.setup(); + render( + Some link + ); + + await user.hover(screen.getByText('Some link')); + await user.click(screen.getByRole('link')); + + expect(onClick).toHaveBeenCalled(); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 6aa077ee02..32e046ce59 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -193,6 +193,11 @@ export function HotspotsImage({ onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} onClick={() => setActiveIndex(index)} + onLinkClick={event => { + activateArea(-1); + setHoveredIndex(-1); + event.stopPropagation(); + }} onDismiss={() => activateArea(-1)} /> ); } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 5a90c0d0df..ec39d7ea65 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -48,7 +48,7 @@ export function Tooltip({ imageFile, containerRect, keepInViewport, floatingStrategy, aboveNavigationWidgets, wrapperRef, - onMouseEnter, onMouseLeave, onClick, onDismiss, + onMouseEnter, onMouseLeave, onClick, onDismiss, onLinkClick }) { const {t: translateWithEntryLocale} = useI18n(); const {t} = useI18n({locale: 'ui'}); @@ -239,7 +239,8 @@ export function Tooltip({ value={tooltipTexts[area.id]?.link} allowRemove={true} onTextChange={value => handleTextChange('link', value)} - onLinkChange={value => handleLinkChange(value)} />} + onLinkChange={value => handleLinkChange(value)} + onClick={onLinkClick} />} diff --git a/entry_types/scrolled/package/src/frontend/EditableLink.js b/entry_types/scrolled/package/src/frontend/EditableLink.js index 6218f3d866..d6a56ba298 100644 --- a/entry_types/scrolled/package/src/frontend/EditableLink.js +++ b/entry_types/scrolled/package/src/frontend/EditableLink.js @@ -5,11 +5,11 @@ import {Link} from './Link'; export const EditableLink = withInlineEditingAlternative( 'EditableLink', - function EditableLink({className, href, openInNewTab, children}) { + function EditableLink({className, href, openInNewTab, onClick, children}) { return ( ); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js index 170e7c372b..f498100458 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js @@ -12,6 +12,7 @@ import styles from './EditableLink.module.css'; export function EditableLink({ className, href, openInNewTab, children, onChange, + onClick, linkPreviewDisabled, linkPreviewPosition = 'below', linkPreviewAlign = 'center', @@ -43,6 +44,7 @@ export function EditableLink({ {children} @@ -130,17 +131,25 @@ export function LinkPreview({disabled, href, openInNewTab, children, className}) ); } -export function LinkTooltip({disabled, setFloating, floatingStyles, floatingContext, arrowRef, state}) { +export function LinkTooltip({disabled, setFloating, floatingStyles, floatingContext, arrowRef, onClick, state}) { const {keep, deactivate} = useContext(UpdateContext); if (disabled || !state || !state.href) { return null; } + function handleClick(event) { + event.stopPropagation(); + + if (onClick) { + onClick(event); + } + } + return (
e.stopPropagation()} + onClick={handleClick} onMouseEnter={keep} onMouseLeave={deactivate} style={floatingStyles}> From 93c89ccf642743da7f21bce4b31ea3eb751dbdb0 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 9 Dec 2025 17:39:50 +0100 Subject: [PATCH 2/3] Ignore extra enter event on tooltip when entering excursion Prevent the tooltip from being activated again. --- .../scrolled/package/src/contentElements/hotspots/Tooltip.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index ec39d7ea65..f553527992 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -198,7 +198,7 @@ export function Tooltip({ light ? styles.light : styles.dark, {[styles.paddingForScrollButtons]: keepInViewport, [styles.minWidth]: presentOrEditing('link')})} - onMouseEnter={onMouseEnter} + onMouseEnter={() => storylineMode === 'active' && onMouseEnter()} onMouseLeave={onMouseLeave} onClick={onClick} {...getFloatingProps()}> From ec083e9c54c134f0ea8e40d69baacbef4e5f4612 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 9 Dec 2025 17:40:40 +0100 Subject: [PATCH 3/3] Fix outside press dismissal of hotspot tooltips floating-ui appears to use native event handlers for detecting mousedown on the document, but React event callbacks for intercepting mousedown inside the floating element in the capture phase. Looks like capture phase for React events happens after native events in React 16. React 17 changes the event system here. This causes the first click on a link tooltip of the hotspot tooltip link button to close the tooltip before the link can be clicked in the editor. In the published editor it disables outside press dismissal once after a click inside the floating element. Disable capture for outside press to work around these issue. --- .../scrolled/package/src/contentElements/hotspots/Tooltip.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index f553527992..4e33217619 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -110,7 +110,10 @@ export function Tooltip({ const dismiss = useDismiss(context, { outsidePressEvent: 'mousedown', - outsidePress: event => !insidePagerButton(event.target) + outsidePress: event => !insidePagerButton(event.target), + capture: { + outsidePress: false + } }); const {getReferenceProps, getFloatingProps} = useInteractions([