diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 73adc4eaf7..2f37aef7e3 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -5,6 +5,8 @@ de: pageflow_scrolled: public: more: Mehr + next: Nächster + previous: Vorheriger inline_editing: select_link_destination: "Link-Ziel auswählen" change_link_destination: "Link-Ziel ändern" @@ -12,6 +14,9 @@ de: content_elements: hotspots: edit_area: + back: Zurück + destroy: Löschen + confirm_delete_link: Soll der Bereich wirklich gelöscht werden? tabs: area: Hotspot-Breiech portrait: Hochkant @@ -27,6 +32,8 @@ de: label: Aktives Bild area: label: Bereich + zoom: + label: Zoom portraitTooltipPosition: label: Tooltip-Position (Hochkant) values: @@ -38,6 +45,8 @@ de: label: Aktives Bild (Hochkant) portraitArea: label: Bereich (Hochkant) + portraitZoom: + label: Zoom (Hochkant) edit_area_dialog: header: Bereichsumriss und Indikatorposition tabs: diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index adb6cc399c..df33f68e82 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -5,6 +5,8 @@ en: pageflow_scrolled: public: more: More + next: Next + previous: Previous inline_editing: select_link_destination: "Select link destination" change_link_destination: "Change link destination" @@ -12,6 +14,9 @@ en: content_elements: hotspots: edit_area: + back: Back + destroy: Delete + confirm_delete_link: Are you sure you want to delete this area? tabs: area: Hotspot Area portrait: Portrait @@ -27,6 +32,8 @@ en: label: Active image area: label: Area + zoom: + label: Zoom portraitTooltipPosition: label: Tooltip orientation (Portrait) values: @@ -38,6 +45,8 @@ en: label: Active image (Portrait) portraitArea: label: Area (Portrait) + portraitZoom: + label: Zoom (Portrait) edit_area_dialog: header: Area outline and indicator position tabs: diff --git a/entry_types/scrolled/package/package.json b/entry_types/scrolled/package/package.json index a5e3c37138..01ae09fadf 100644 --- a/entry_types/scrolled/package/package.json +++ b/entry_types/scrolled/package/package.json @@ -8,6 +8,7 @@ "license": "MIT", "dependencies": { "@egjs/view360": "^3.4.3", + "@floating-ui/react": "https://github.com/tf/floating-ui-react#react-16-focus-fix", "@headlessui/react": "^1.6.6", "core-js": "^3.6.5", "debounce": "^1.2.0", diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index ddc89d1d1e..9af9c3c615 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -4,13 +4,25 @@ import {Hotspots} from 'contentElements/hotspots/Hotspots'; import areaStyles from 'contentElements/hotspots/Area.module.css'; import indicatorStyles from 'contentElements/hotspots/Indicator.module.css'; import tooltipStyles from 'contentElements/hotspots/Tooltip.module.css'; +import scrollerStyles from 'contentElements/hotspots/Scroller.module.css'; import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; -import {within} from '@testing-library/react'; +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {within, act} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect' import userEvent from '@testing-library/user-event'; +import {getPanZoomStepTransform} from 'contentElements/hotspots/panZoom'; +jest.mock('contentElements/hotspots/panZoom'); + +jest.mock('contentElements/hotspots/TooltipPortal'); +jest.mock('contentElements/hotspots/useTooltipTransitionStyles'); + describe('Hotspots', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.next': 'Next' + }); + it('does not render images by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, @@ -75,11 +87,11 @@ describe('Hotspots', () => { ] }; - const {getByRole} = renderInContentElement( + const {container} = renderInContentElement( , {seed} ); - expect(getByRole('button')).toHaveStyle( + expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle( 'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)' ); }); @@ -101,11 +113,11 @@ describe('Hotspots', () => { }; window.matchMedia.mockPortrait(); - const {getByRole} = renderInContentElement( + const {container} = renderInContentElement( , {seed} ); - expect(getByRole('button')).toHaveStyle( + expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle( 'clip-path: polygon(20% 20%, 20% 30%, 30% 30%, 30% 20%)' ); }); @@ -126,14 +138,14 @@ describe('Hotspots', () => { }; window.matchMedia.mockPortrait(); - const {getByRole} = renderInContentElement( + const {container} = renderInContentElement( , {seed} ); - expect(getByRole('button')).toHaveStyle( + expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle( 'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)' ); - }); + }); it('renders area indicators', () => { const seed = { @@ -262,7 +274,7 @@ describe('Hotspots', () => { }); }); - it('falls back to default indicator color for portrait indicator', () => { + it('falls back to default indicator color for portrait indicator', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] @@ -316,7 +328,7 @@ describe('Hotspots', () => { }); }); - it('renders tooltip', () => { + it('renders tooltip texts and links', async () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}] @@ -341,10 +353,11 @@ describe('Hotspots', () => { } }; - const {queryByText, getByRole} = renderInContentElement( + const user = userEvent.setup(); + const {container, queryByText, getByRole} = renderInContentElement( , {seed} ); - + await user.click(container.querySelector(`.${areaStyles.clip}`)) expect(queryByText('Some title')).not.toBeNull(); expect(queryByText('Some description')).not.toBeNull(); expect(queryByText('Some link')).not.toBeNull(); @@ -352,7 +365,7 @@ describe('Hotspots', () => { expect(getByRole('link')).toHaveAttribute('target', '_blank'); }); - it('does not render tooltip link if link text is blank', () => { + it('does not render tooltip link if link text is blank', async () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}] @@ -377,14 +390,16 @@ describe('Hotspots', () => { } }; - const {queryByRole} = renderInContentElement( + const user = userEvent.setup(); + const {container, queryByRole} = renderInContentElement( , {seed} ); + await user.click(container.querySelector(`.${areaStyles.clip}`)) expect(queryByRole('link')).toBeNull(); }); - it('positions tooltip based on indicator position', () => { + it('positions tooltip reference based on indicator position', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}] @@ -402,7 +417,7 @@ describe('Hotspots', () => { , {seed} ); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({ + expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ left: '10%', top: '20%' }); @@ -429,7 +444,7 @@ describe('Hotspots', () => { , {seed} ); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({ + expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ left: '20%', top: '30%' }); @@ -455,45 +470,13 @@ describe('Hotspots', () => { , {seed} ); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({ + expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ left: '10%', top: '20%' }); }); - it('shows tooltip on area click', async () => { - const seed = { - imageFileUrlTemplates: {large: ':id_partition/image.webp'}, - imageFiles: [{id: 1, permaId: 100}] - }; - const configuration = { - image: 100, - areas: [ - { - outline: [[10, 20], [10, 30], [40, 30], [40, 20]], - indicatorPosition: [20, 25], - } - ], - tooltipTexts: { - 1: { - title: [{type: 'heading', children: [{text: 'Some title'}]}], - } - } - }; - - const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( - , {seed} - ); - - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); - - await user.click(getByRole('button')); - - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); - }); - - it('shows tooltip on area or tooltip hover', async () => { + it('shows tooltip on area hover', async () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}] @@ -502,6 +485,7 @@ describe('Hotspots', () => { image: 100, areas: [ { + id: 1, outline: [[10, 20], [10, 30], [40, 30], [40, 20]], indicatorPosition: [20, 25], } @@ -514,27 +498,15 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( + const {queryByText, container} = renderInContentElement( , {seed} ); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); - - await user.hover(getByRole('button')); - - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); - - await user.unhover(getByRole('button')); - - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); - - await user.hover(container.querySelector(`.${tooltipStyles.tooltip}`)); - - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + expect(queryByText('Some title')).toBeNull(); - await user.unhover(container.querySelector(`.${tooltipStyles.tooltip}`)); + await user.hover(container.querySelector(`.${areaStyles.clip}`)); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + expect(queryByText('Some title')).not.toBeNull(); }); it('does not show other tooltip on hover after area has been clicked', async () => { @@ -567,14 +539,14 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( + const {container, queryByText} = renderInContentElement( , {seed} ); - await user.click(getByRole('button', {name: 'Area 1'})); - await user.hover(getByRole('button', {name: 'Area 2'})); + await user.click(container.querySelectorAll(`.${areaStyles.clip}`)[0]); + await user.hover(container.querySelectorAll(`.${areaStyles.clip}`)[1]); - expect(container.querySelectorAll(`.${tooltipStyles.visible}`).length).toEqual(1); + expect(queryByText('Area 1')).not.toBeNull(); }); it('hides tooltip when clicked outside area', async () => { @@ -586,6 +558,7 @@ describe('Hotspots', () => { image: 100, areas: [ { + id: 1, outline: [[10, 20], [10, 30], [40, 30], [40, 20]], indicatorPosition: [20, 25], } @@ -598,14 +571,14 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( + const {container, queryByText} = renderInContentElement( , {seed} ); - await user.click(getByRole('button')); + await user.click(container.querySelector(`.${areaStyles.clip}`)); await user.click(document.body); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull(); }); it('does not hide tooltip on click inside tooltip', async () => { @@ -630,14 +603,14 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {container, getByRole, getByText} = renderInContentElement( + const {container, getByText} = renderInContentElement( , {seed} ); - await user.click(getByRole('button')); + await user.click(container.querySelector(`.${areaStyles.clip}`)); await user.click(getByText('Some title')); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); }); it('does not hide tooltip on unhover after click in tooltip', async () => { @@ -662,15 +635,15 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {container, getByRole, getByText} = renderInContentElement( + const {container, getByText} = renderInContentElement( , {seed} ); - await user.hover(getByRole('button')); + await user.hover(container.querySelector(`.${areaStyles.clip}`)); await user.click(getByText('Some title')); - await user.unhover(getByRole('button')); + await user.unhover(container.querySelector(`.${areaStyles.clip}`)); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); }); it('supports active image rendered inside area', async () => { @@ -819,18 +792,18 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( + const {container} = renderInContentElement( , {seed} ); expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); - await user.click(getByRole('button')); + await user.click(container.querySelector(`.${areaStyles.clip}`)); expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible); }); - it('shows active image on area or tooltip hover', async () => { + it('shows active image on area hover', async () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] @@ -852,25 +825,17 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {getByRole, container} = renderInContentElement( + const {container} = renderInContentElement( , {seed} ); expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); - await user.hover(getByRole('button')); + await user.hover(container.querySelector(`.${areaStyles.clip}`)); expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible); - await user.unhover(getByRole('button')); - - expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); - - await user.hover(container.querySelector(`.${tooltipStyles.tooltip}`)); - - expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible); - - await user.unhover(container.querySelector(`.${tooltipStyles.tooltip}`)); + await user.unhover(container.querySelector(`.${areaStyles.clip}`)); expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); }); @@ -993,7 +958,7 @@ describe('Hotspots', () => { ); triggerEditorCommand({type: 'SET_ACTIVE_AREA', index: 0}); - expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); }); it('sets active area id in transient state in editor', async () => { @@ -1013,15 +978,755 @@ describe('Hotspots', () => { const setTransientState = jest.fn(); const user = userEvent.setup(); - const {getByRole} = renderInContentElement( + const {container} = renderInContentElement( , { seed, - editorState: {isEditable: true, setTransientState} + editorState: {isEditable: true, isSelected: true, setTransientState} } ); - await user.click(getByRole('button')); + await user.click(container.querySelector(`.${areaStyles.clip}`)); expect(setTransientState).toHaveBeenCalledWith({activeAreaId: 1}) }); + + describe('pan and zoom', () => { + let animateMock; + let scrollTimelines; + let observeResizeMock; + + let intersectionObservers; + + function intersectionObserverByRoot(root) { + return intersectionObservers.find(intersectionObserver => + intersectionObserver.root === root + ); + } + + beforeEach(() => { + animateMock = jest.fn(() => { + return { + cancel() {} + } + }); + HTMLDivElement.prototype.animate = animateMock; + + scrollTimelines = []; + + window.ScrollTimeline = function(options) { + this.options = options; + scrollTimelines.push(this); + }; + + observeResizeMock = jest.fn(function() { + this.callback([ + { + contentRect: observeResizeMock.mockContentRect || {width: 100, height: 100} + } + ]); + }); + + window.ResizeObserver = function(callback) { + this.callback = callback; + this.observe = observeResizeMock; + this.unobserve = function(element) {}; + }; + + intersectionObservers = []; + + window.IntersectionObserver = function(callback, {threshold, root}) { + if (intersectionObserverByRoot(root)) { + console.log(intersectionObservers.map(i => i.root?.outerHTML)) + throw new Error('Did not except more than one intersection observer per root'); + } + + intersectionObservers.push(this); + + this.root = root; + this.targets = new Set(); + + this.observe = function(target) { + this.targets.add(target); + }; + + this.unobserve = function(target) { + this.targets.delete(target); + }; + + this.mockIntersecting = function(target) { + if (!this.targets.has(target)) { + throw new Error(`Intersection observer does not currently ${target}.`); + } + + act(() => + callback([{target, isIntersecting: true, intersectionRatio: threshold}]) + ); + }; + + this.disconnect = function() {} + }; + + getPanZoomStepTransform.mockReset(); + getPanZoomStepTransform.mockReturnValue({ + x: 0, + y: 0, + scale: 1 + }); + }); + + it('does not render invisible scroller when pan zoom is disabled', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'never', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]] + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)).toBeNull(); + }); + + it('renders invisible scroller when pan zoom is enabled', () => { + 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]] + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)).not.toBeNull(); + }); + + it('does not render invisible scroller if pan zoom enabled on phone platform', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'phonePlatform', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]] + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)).toBeNull(); + }); + + it('renders invisible scroller on phone platform if pan zoom enabled on phone platform', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'phonePlatform', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]] + } + ] + }; + + const {container} = renderInContentElement( + , {seed, phonePlatform: true} + ); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)).not.toBeNull(); + }); + + it('scroller has one step per area plus two for overview states', () => { + 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]] + }, + { + id: 1, + outline: [[40, 20], [40, 30], [60, 30], [60, 20]] + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelectorAll(`.${scrollerStyles.step}`).length).toEqual(4); + }); + + it('does not observe resize by default', () => { + 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]] + } + ] + }; + + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(observeResizeMock).not.toHaveBeenCalled(); + }); + + it('observes resize if pan zoom is enabled', () => { + 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]] + } + ] + }; + + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(observeResizeMock).toHaveBeenCalledTimes(1); + expect(observeResizeMock).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + }); + + it('neither calls animate nor sets up scroll timeline by default', () => { + 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]] + } + ] + }; + + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(scrollTimelines.length).toEqual(0); + expect(animateMock).not.toHaveBeenCalled(); + }); + + it('calls animate with scroll timeline when near viewport and pan and zoom is enabled', () => { + 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]] + } + ] + }; + + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(scrollTimelines.length).toEqual(1); + expect(scrollTimelines[0].options).toEqual({ + source: expect.any(HTMLDivElement), + axis: 'inline' + }); + expect(animateMock).toHaveBeenCalledTimes(2); + expect(animateMock).toHaveBeenCalledWith( + Array.from({length: 3}, () => expect.objectContaining({ + transform: expect.any(String), + easing: 'ease' + })), + expect.objectContaining({ + timeline: scrollTimelines[0] + }) + ); + }); + + it('only sets up pan zoom scroll timeline when near viewport', () => { + 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]] + } + ] + }; + + renderInContentElement( + , {seed} + ); + + expect(scrollTimelines.length).toEqual(0); + expect(animateMock).not.toHaveBeenCalled(); + }); + + it('calls getPanZoomStepTransform with relevant dimensions', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + zoom: 50 + } + ] + }; + + observeResizeMock.mockContentRect = {width: 2000, height: 500}; + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(getPanZoomStepTransform).toHaveBeenCalledWith({ + areaOutline: [[10, 20], [10, 30], [40, 30], [40, 20]], + areaZoom: 50, + imageFileWidth: 1920, + imageFileHeight: 1080, + containerWidth: 2000, + containerHeight: 500 + }); + }); + + it('passes portrait outlines to getPanZoomStepTransform in portrait mode', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [ + {id: 1, permaId: 100, width: 1920, height: 1080}, + {id: 2, permaId: 101, width: 1080, height: 1920} + ] + }; + const configuration = { + image: 100, + portraitImage: 101, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + portraitOutline: [[20, 20], [20, 30], [30, 30], [30, 20]], + zoom: 50, + portraitZoom: 40 + } + ] + }; + + window.matchMedia.mockPortrait(); + observeResizeMock.mockContentRect = {width: 2000, height: 500}; + const {simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(getPanZoomStepTransform).toHaveBeenCalledWith({ + areaOutline: [[20, 20], [20, 30], [30, 30], [30, 20]], + areaZoom: 40, + imageFileWidth: 1080, + imageFileHeight: 1920, + containerWidth: 2000, + containerHeight: 500 + }); + }); + + it('only sets up resize observer when near viewport', () => { + 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]] + } + ] + }; + + renderInContentElement( + , {seed} + ); + + expect(observeResizeMock).not.toHaveBeenCalled(); + }); + + it('observes intersection of scroller steps', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + zoom: 50 + } + ] + }; + + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect( + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)).targets + ).toContain(container.querySelector(`.${scrollerStyles.step}`)); + }); + + it('sets active section based on intersecting scroller step when pan zoom is enabled', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + zoom: 50 + } + ] + }; + + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)) + .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); + }); + + it('only sets up intersection observer when near viewport', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + zoom: 50 + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect( + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)) + ).toBeUndefined(); + }); + + it('scrolls pan zoom scroller instead of setting active index directly when pan zoom is enabled', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + zoom: 50 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Area 1'}]}], + } + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)); + + expect(scroller.scrollTo).toHaveBeenCalled(); + expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull(); + }); + + it('scrolls pan zoom scroll when setting active area via command and pan zoom is enabled', () => { + 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]] + } + ] + }; + + const {container, simulateScrollPosition, triggerEditorCommand} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + simulateScrollPosition('near viewport'); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + triggerEditorCommand({type: 'SET_ACTIVE_AREA', index: 0}); + + expect(scroller.scrollTo).toHaveBeenCalled(); + expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull(); + }); + + it('scrolls pan zoom scroller instead of resetting active area directly when pan zoom is enabled', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)) + .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + await user.click(document.body); + + expect(scroller.scrollTo).toHaveBeenCalled(); + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull(); + }); + + it('scroller lets pointer events pass when no area is active', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ] + }; + + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)) + .toHaveClass(scrollerStyles.noPointerEvents); + }); + + it('scroller has pointer events once area is active', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ] + }; + + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)) + .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + + expect(container.querySelector(`.${scrollerStyles.scroller}`)) + .not.toHaveClass(scrollerStyles.noPointerEvents); + }); + + it('accounts for pan zoom in tooltip position', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 2000, height: 1000}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + outline: [[80, 45], [100, 45], [100, 55], [80, 55]], + zoom: 100, + indicatorPosition: [90, 50], + } + ] + }; + + observeResizeMock.mockContentRect = {width: 200, height: 100}; + getPanZoomStepTransform.mockReturnValue({ + x: -800, + y: -200, + scale: 5 + }) + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)) + .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + + expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ + left: '100px', + top: '50px' + }); + }); + + it('allows changing active area via scroll button', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100, width: 2000, height: 1000}] + }; + const configuration = { + image: 100, + enablePanZoom: 'always', + areas: [ + { + outline: [[80, 45], [100, 45], [100, 55], [80, 55]], + indicatorPosition: [90, 50], + }, + { + outline: [[20, 45], [30, 45], [30, 55], [20, 55]], + indicatorPosition: [25, 50], + } + ] + }; + + const user = userEvent.setup(); + const {container, getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + intersectionObserverByRoot(scroller) + .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]); + await user.click(getByRole('button', {name: 'Next'})); + + expect(scroller.scrollTo).toHaveBeenCalled(); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js index b21e6ebef5..e432793820 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js @@ -1,10 +1,13 @@ import {SidebarEditAreaView} from 'contentElements/hotspots/editor/SidebarEditAreaView'; import {AreasCollection} from 'contentElements/hotspots/editor/models/AreasCollection'; -import {Tabs, useFakeTranslations} from 'pageflow/testHelpers'; -import {useEditorGlobals} from 'support'; +import {ConfigurationEditor, Tabs, useFakeTranslations} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; +import {within} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; describe('SidebarEditAreaView', () => { + useFakeXhr(); const {createEntry} = useEditorGlobals(); useFakeTranslations({ @@ -75,4 +78,84 @@ describe('SidebarEditAreaView', () => { expect(tabs.tabLabels()).toEqual(['Area']); }); + + it('renders zoom inputs if pan zoom is enabled', async () => { + const entry = createEntry({ + imageFiles: [ + {perma_id: 10}, + {perma_id: 11} + ], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + portraitImage: 11, + enablePanZoom: 'always', + areas: [{id: 1, zoom: 0}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const areas = AreasCollection.forContentElement(contentElement); + const view = new SidebarEditAreaView({ + model: areas.get(1), + collection: areas, + entry, + contentElement + }); + + const user = userEvent.setup(); + const {getByText} = within(view.render().el); + + let configurationEditor = ConfigurationEditor.find(view); + expect(configurationEditor.inputPropertyNames()).toContain('zoom'); + + await user.click(getByText('Portrait')); + configurationEditor = ConfigurationEditor.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('portraitZoom'); + }); + + it('does not render zoom inputs if pan zoom is disabled', async () => { + const entry = createEntry({ + imageFiles: [ + {perma_id: 10}, + {perma_id: 11}, + ], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + portraitImage: 11, + enablePanZoom: 'never', + areas: [{id: 1}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const areas = AreasCollection.forContentElement(contentElement); + const view = new SidebarEditAreaView({ + model: areas.get(1), + collection: areas, + entry, + contentElement + }); + + const user = userEvent.setup(); + const {getByText} = within(view.render().el); + + let configurationEditor = ConfigurationEditor.find(view); + expect(configurationEditor.inputPropertyNames()).not.toContain('zoom'); + + await user.click(getByText('Portrait')); + configurationEditor = ConfigurationEditor.find(view); + + expect(configurationEditor.inputPropertyNames()).not.toContain('portraitZoom'); + }); }); diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js index d35b273edf..6b66fec718 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js @@ -51,6 +51,38 @@ describe('hotspots AreasCollection', () => { ]) }); + it('prunes tooltip texts and link when removing an element with single change event', () => { + const contentElement = factories.contentElement({ + configuration: { + areas: [ + {id: 1}, + {id: 2}, + ], + tooltipTexts: { + 1: {title: [{children: [{text: 'Title for item 1'}]}]}, + 2: {title: [{children: [{text: 'Title for item 2'}]}]}, + }, + tooltipLinks: { + 1: {href: 'https://example.com'}, + 2: {href: 'https://other.example.com'}, + } + } + }); + const itemsCollection = AreasCollection.forContentElement(contentElement); + const listener = jest.fn(); + + contentElement.on('change:configuration', listener); + itemsCollection.remove(1); + + expect(contentElement.configuration.get('tooltipTexts')).toEqual({ + 2: {title: [{children: [{text: 'Title for item 2'}]}]} + }); + expect(contentElement.configuration.get('tooltipLinks')).toEqual({ + 2: {href: 'https://other.example.com'}, + }); + expect(listener).toHaveBeenCalledTimes(1); + }); + it('posts content element command on highlight', () => { const contentElement = factories.contentElement({ id: 10, diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js new file mode 100644 index 0000000000..b525d407ec --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js @@ -0,0 +1,37 @@ +import {getPanZoomStepTransform} from 'contentElements/hotspots/panZoom'; + +describe('getPanZoomStepTransform', () => { + it('covers container such that area is centered', () => { + const result = getPanZoomStepTransform({ + areaOutline: [[30, 20], [50, 20], [50, 100], [30, 100]], + areaZoom: 0, + containerWidth: 1000, + containerHeight: 1000, + imageFileWidth: 4000, + imageFileHeight: 2000 + }); + + expect(result).toMatchObject({ + scale: 1, + x: -300, + y: 0 + }); + }); + + it('supports zooming to fit area in container', () => { + const result = getPanZoomStepTransform({ + areaOutline: [[10, 10], [30, 10], [30, 30], [10, 30]], + areaZoom: 100, + containerWidth: 1000, + containerHeight: 1000, + imageFileWidth: 4000, + imageFileHeight: 2000 + }); + + expect(result).toMatchObject({ + scale: 5, + x: -1500, + y: -500, + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js b/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js index 360d385a57..edd5ad24f3 100644 --- a/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js +++ b/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js @@ -1,19 +1,16 @@ - -import {PhonePlatformProvider} from 'frontend'; import {usePhonePlatform} from 'frontend/usePhonePlatform'; import {loadInlineEditingComponents} from 'frontend/inlineEditing'; -import {renderHook} from '@testing-library/react-hooks'; -import {asyncHandlingOf} from 'support/asyncHandlingOf/forHooks'; +import {renderHookInEntry} from 'support'; +import {asyncHandlingOf} from 'support/asyncHandlingOf'; import '@testing-library/jest-dom/extend-expect' - describe('usePhonePlatform', () => { beforeAll(loadInlineEditingComponents); it('sets value when emulation mode is mobile', async () => { - const {result} = renderHook(() => usePhonePlatform(), {wrapper: PhonePlatformProvider}); + const {result} = renderHookInEntry(() => usePhonePlatform()); await asyncHandlingOf(() => { window.postMessage({ @@ -26,7 +23,7 @@ describe('usePhonePlatform', () => { }); it('sets value when emulation mode is desktop', async () => { - const {result} = renderHook(() => usePhonePlatform(), {wrapper: PhonePlatformProvider}); + const {result} = renderHookInEntry(() => usePhonePlatform()); await asyncHandlingOf(() => { window.postMessage({ diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index eb4bc57cc4..afae4df13f 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -9,8 +9,6 @@ import { useFile } from 'pageflow-scrolled/frontend'; -import {Indicator} from './Indicator'; - import styles from './Area.module.css'; export function Area({ @@ -34,17 +32,16 @@ export function Area({
- + ); +} + +export function insideScrollButton(element) { + return !!element.closest(`.${styles.button}`); +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css new file mode 100644 index 0000000000..3b473ba595 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css @@ -0,0 +1,60 @@ +.button { + height: 100%; + padding: 0; + background-color: transparent; + border: 0; + color: #fff; + transition: opacity 0.2s linear, visibility 0.2s; +} + +.left { + text-align: left; +} + +.right { + text-align: right; +} + +.disabled { + opacity: 0; + visibility: hidden; +} + +.button svg { + transition: transform 0.2s ease; + display: block; + width: var(--theme-hotspts-scroll-button-size); + height: var(--theme-hotspots-scroll-button-size); +} + +.button:not(.disabled):hover svg, +.button:not(.disabled):focus svg { + transform: scale(1.2); +} + +.icon { + position: relative; +} + +.icon::before { + content: ' '; + position: absolute; + top: 50%; + left: 50%; + width: 50px; + height: 50px; + transform: translate(-50%, -50%); + background: radial-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%); + z-index: -1; +} + +.visuallyHidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js new file mode 100644 index 0000000000..c2b6331786 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js @@ -0,0 +1,35 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {ScrollButton} from './ScrollButton'; + +import styles from './Scroller.module.css'; + +export const Scroller = React.forwardRef(function Scroller( + {areas, activeIndex, onScrollButtonClick, noPointerEvents, setStepRef}, ref +) { + return ( + <> +
+ onScrollButtonClick(activeIndex - 1)} /> +
+
+ onScrollButtonClick( + activeIndex >= areas.length - 1 ? -1 : activeIndex + 1 + )}/> +
+
+ {Array.from({length: areas.length + 2}, (_, index) => +
+ )} +
+ + ); +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css new file mode 100644 index 0000000000..a5bfc51e86 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css @@ -0,0 +1,37 @@ +.scroller { + overflow: hidden; + overflow-x: auto; + display: grid; + grid-auto-columns: 100%; + grid-auto-flow: column; + scroll-snap-type: x mandatory; + scrollbar-width: none; + z-index: 1; +} + +.scroller::-webkit-scrollbar { + display: none; +} + +.noPointerEvents { + pointer-events: none; +} + +.step { + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.leftButton, +.rightButton { + z-index: 2; +} + + +.leftButton { + justify-self: start; +} + +.rightButton { + justify-self: end; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 4a7622aa83..20804c257c 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -1,6 +1,18 @@ -import React, {useLayoutEffect, useRef, useState} from 'react'; +import React, {useRef} from 'react'; import classNames from 'classnames'; +import { + useFloating, useDismiss, useInteractions, useRole, + CompositeItem, + FloatingArrow, FloatingFocusManager, + arrow, shift, offset, flip, + autoUpdate +} from '@floating-ui/react'; + +import {TooltipPortal} from './TooltipPortal'; +import {useTooltipTransitionStyles} from './useTooltipTransitionStyles'; +import {insideScrollButton} from './ScrollButton'; + import { EditableText, EditableInlineText, @@ -12,26 +24,62 @@ import { utils } from 'pageflow-scrolled/frontend'; +import {getPanZoomStepTransform} from './panZoom'; + import styles from './Tooltip.module.css'; export function Tooltip({ area, contentElementId, portraitMode, configuration, visible, active, - onMouseEnter, onMouseLeave, onClick + panZoomEnabled, imageFile, containerRect, flip: shouldFlip, + onMouseEnter, onMouseLeave, onClick, onDismiss, }) { - const {t} = useI18n(); + const {t} = useI18n({locale: 'ui'}); const updateConfiguration = useContentElementConfigurationUpdate(); const {isEditable} = useContentElementEditorState(); - const indicatorPosition = ( - portraitMode ? - area.portraitIndicatorPosition : - area.indicatorPosition - ) || [50, 50]; + const indicatorPosition = getIndicatorPosition({ + area, + contentElementId, portraitMode, configuration, + panZoomEnabled, imageFile, containerRect, + }) + const tooltipTexts = configuration.tooltipTexts || {}; const tooltipLinks = configuration.tooltipLinks || {}; - const [ref, delta] = useKeepInViewport(visible); + const arrowRef = useRef(); + const {refs, floatingStyles, context} = useFloating({ + open: visible, + onOpenChange: open => !open && onDismiss(), + placement: area.tooltipPosition === 'above' ? 'top' : 'bottom', + middleware: [ + offset(20), + shift(), + shouldFlip && flip(), + arrow({ + element: arrowRef + }) + ], + whileElementsMounted(referenceEl, floatingEl, update) { + return autoUpdate(referenceEl, floatingEl, update, { + elementResize: false, + layoutShift: false, + }); + } + }); + + const role = useRole(context, {role: 'label'}); + + const dismiss = useDismiss(context, { + outsidePressEvent: 'mousedown', + outsidePress: event => !insideScrollButton(event.target) + }); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + role, + dismiss, + ]); + const {isMounted, styles: transitionStyles} = useTooltipTransitionStyles(context); function handleTextChange(propertyName, value) { updateConfiguration({ @@ -71,89 +119,90 @@ export function Tooltip({ } return ( -
-
- {presentOrEditing('title') && -

- - handleTextChange('title', value)} - placeholder={t('pageflow_scrolled.inline_editing.type_heading')} /> - -

} - {presentOrEditing('description') && - handleTextChange('description', value)} - scaleCategory="hotspotsTooltipDescription" - placeholder={t('pageflow_scrolled.inline_editing.type_text')} />} - {presentOrEditing('link') && - - handleLinkChange(value)}> - handleTextChange('link', value)} - placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> - › - - } -
-
- ); -} - -export function insideTooltip(element) { - return !!element.closest(`.${styles.tooltip}`); + <> + }> +
+ + {isMounted && + + +
+
+ + {presentOrEditing('title') && +

+ + handleTextChange('title', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_heading')} /> + +

} + {presentOrEditing('description') && + handleTextChange('description', value)} + scaleCategory="hotspotsTooltipDescription" + placeholder={t('pageflow_scrolled.inline_editing.type_text')} />} + {presentOrEditing('link') && + + handleLinkChange(value)}> + handleTextChange('link', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> + › + + } +
+
+
+
} + +); } -function useKeepInViewport(visible) { - const ref = useRef(); - const [delta, setDelta] = useState(0); - - useLayoutEffect(() => { - if (!visible) { - return; - } - - const current = ref.current; - - const intersectionObserver = new IntersectionObserver( - entries => { - if (entries[entries.length - 1].intersectionRatio < 1) { - const rect = current.getBoundingClientRect(); - - if (rect.left < 0) { - setDelta(-rect.left); - } - else if (rect.right > document.body.clientWidth) { - setDelta(document.body.clientWidth - rect.right); - } - } - else { - setDelta(0); - } - }, - { - threshold: 1 - } - ); +function getIndicatorPosition({ + area, + portraitMode, + panZoomEnabled, imageFile, containerRect +}) { + const indicatorPositionInPercent = ( + portraitMode ? + area.portraitIndicatorPosition : + area.indicatorPosition + ) || [50, 50]; - intersectionObserver.observe(current); + if (panZoomEnabled) { + const transform = getPanZoomStepTransform({ + areaOutline: portraitMode ? area.portraitOutline : area.outline, + areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0, + imageFileWidth: imageFile?.width, + imageFileHeight: imageFile?.height, + containerWidth: containerRect.width, + containerHeight: containerRect.height + }); - return () => intersectionObserver.unobserve(current); - }, [visible]); + const indicatorPositionInPixels = [ + containerRect.width * transform.scale * indicatorPositionInPercent[0] / 100 + transform.x, + containerRect.height * transform.scale * indicatorPositionInPercent[1] / 100 + transform.y + ]; - return [ref, delta]; + return indicatorPositionInPixels.map(coord => `${coord}px`); + } + else { + return indicatorPositionInPercent.map(coord => `${coord}%`); + } } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css index 075473e688..1330696ef8 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -1,52 +1,42 @@ -.tooltip { +.compositeItem { position: absolute; - width: calc(100% - 2rem); - max-width: 400px; - transition: opacity 0.2s, visibility 0.2s; - transition-delay: 0s; - opacity: 0; - visibility: hidden; - z-index: 10; - padding: 0 5px; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; } -.tooltip::after { - content: ""; +.reference { position: absolute; - left: calc(50% - 15px); - border: solid 15px transparent; -} - -.position-above { - transform: translate(-50%, calc(-100% - 30px)); -} - -.position-above::after { - top: 99%; - border-top-color: #fff; -} - -.position-below { - transform: translate(-50%, 30px); + border: 0; + width: 0; + height: 0; + background: transparent; } -.position-below::after { - bottom: 99%; - border-bottom-color: #fff; +.reference { + outline: none !important; } .box { - transform: translateX(var(--delta)); + font-family: var(--theme-entry-font-family); background-color: #fff; color: #000; box-sizing: border-box; padding: 1rem; box-shadow: 0px 3px 3px -2px rgba(0,0,0,0.2), 0px 3px 4px 0px rgba(0,0,0,0.14), 0px 1px 8px 0px rgba(0,0,0,0.12); border-radius: 5px; + width: calc(100% - 2rem); + max-width: 400px; +} + +.box svg { + fill: #fff; } -.tooltip h3, -.tooltip p { +.box h3, +.box p { margin: 0; } @@ -77,12 +67,6 @@ margin-top: 0; } -.tooltip.visible { - opacity: 1; - visibility: visible; - transition-delay: 0.2s; -} - .editable .link { opacity: 0.5; } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js b/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js new file mode 100644 index 0000000000..d56ee3afb1 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js @@ -0,0 +1,3 @@ +import {FloatingPortal} from '@floating-ui/react'; + +export const TooltipPortal = FloatingPortal; diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js new file mode 100644 index 0000000000..ae85b6b5fc --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js @@ -0,0 +1,3 @@ +export function TooltipPortal({children}) { + return children; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js new file mode 100644 index 0000000000..dc802d6cab --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js @@ -0,0 +1,5 @@ +import {useTransitionStyles} from '@floating-ui/react'; + +export function useTooltipTransitionStyles(context) { + return useTransitionStyles(context, {duration: 0}); +}; diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js index c97f2dc77b..677e20f3e2 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -1,4 +1,4 @@ -import {ConfigurationEditorView, SelectInputView} from 'pageflow/ui'; +import {ConfigurationEditorView, SelectInputView, SliderInputView} from 'pageflow/ui'; import {editor, FileInputView} from 'pageflow/editor'; import Marionette from 'backbone.marionette'; import I18n from 'i18n-js'; @@ -9,8 +9,8 @@ import styles from './SidebarEditAreaView.module.css'; export const SidebarEditAreaView = Marionette.Layout.extend({ template: (data) => ` - ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.back')} - ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.destroy')} + ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.back')} + ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.destroy')}
`, @@ -38,6 +38,7 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ const file = options.contentElement.configuration.getImageFile('image'); const portraitFile = options.contentElement.configuration.getImageFile('portraitImage'); + const panZoomEnabled = options.contentElement.configuration.get('enablePanZoom') !== 'never'; if (file && portraitFile) { this.previousEmulationMode = options.entry.get('emulation_mode') || 'desktop'; @@ -52,6 +53,11 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ file, portraitFile }); + + if (panZoomEnabled) { + this.input('zoom', SliderInputView); + } + this.group('PaletteColor', { propertyName: 'color', entry: options.entry @@ -81,6 +87,11 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ portraitFile, defaultTab: 'portrait' }); + + if (panZoomEnabled) { + this.input('portraitZoom', SliderInputView); + } + this.group('PaletteColor', { propertyName: 'portraitColor', entry: options.entry @@ -117,7 +128,7 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ }, destroyLink: function () { - if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.confirm_delete_link'))) { + if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.confirm_delete_link'))) { this.options.collection.remove(this.model); this.goBack(); } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index 256e8ad153..e73cd7ec72 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -51,11 +51,6 @@ editor.contentElementTypes.register('hotspots', { this.input('enablePanZoom', SelectInputView, { values: ['phonePlatform', 'always', 'never'] }); - this.input('panZoomInitially', CheckBoxInputView, { - disabledBinding: 'panZoom', - disabled: panZoom => panZoom !== 'always', - displayUncheckedIfDisabled: true - }); this.view(SeparatorView); this.input('enableFullscreen', CheckBoxInputView, { disabledBinding: ['position', 'width'], diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js index 062b5d8b83..7b00d1ae82 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js @@ -1,4 +1,5 @@ import Backbone from 'backbone'; +import _ from 'underscore'; import {Area} from './Area'; @@ -10,11 +11,31 @@ export const AreasCollection = Backbone.Collection.extend({ this.entry = options.entry; this.contentElement = options.contentElement; - this.listenTo(this, 'add remove change sort', this.updateConfiguration); + this.listenTo(this, 'add change sort', this.updateConfiguration); + this.listenTo(this, 'remove', () => this.updateConfiguration({pruneTooltips: true})); }, - updateConfiguration() { - this.contentElement.configuration.set('areas', this.toJSON()); + updateConfiguration({pruneTooltips}) { + let updatedAttributes = {areas: this.toJSON()}; + + if (pruneTooltips) { + updatedAttributes = { + ...updatedAttributes, + ...this.getPrunedProperty('tooltipTexts'), + ...this.getPrunedProperty('tooltipLinks') + }; + } + + this.contentElement.configuration.set(updatedAttributes); + }, + + getPrunedProperty(propertyName) { + return { + [propertyName]: _.pick( + this.contentElement.configuration.get(propertyName) || {}, + ...this.pluck('id') + ) + }; }, addWithId(model) { diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg index 510c81b3c5..27ebd23b22 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg @@ -1 +1,9 @@ - + + + + diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js new file mode 100644 index 0000000000..88bb112f87 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js @@ -0,0 +1,48 @@ +export function getPanZoomStepTransform({ + imageFileWidth, imageFileHeight, areaOutline, areaZoom, containerWidth, containerHeight, indicatorPositions = [] +}) { + const rect = getBoundingRect(areaOutline || []); + + const displayImageWidth = imageFileWidth * containerHeight / imageFileHeight; + const displayImageHeight = containerHeight; + + const displayAreaWidth = rect.width / 100 * displayImageWidth; + const displayAreaLeft = rect.left / 100 * displayImageWidth; + const displayAreaHeight = rect.height / 100 * displayImageHeight; + const displayAreaTop = rect.top / 100 * displayImageHeight; + + const scale = (100 - areaZoom) / 100 + (areaZoom / 100) * containerHeight / (displayAreaHeight + 0); + + let translateX = (containerWidth - displayAreaWidth * scale) / 2 - displayAreaLeft * scale; + let translateY = (containerHeight - displayAreaHeight * scale - 0) / 2 - displayAreaTop * scale; + + translateX = Math.min(0, Math.max(containerWidth - displayImageWidth * scale, translateX)); + translateY = Math.min(0, Math.max(containerHeight - displayImageHeight * scale, translateY)); + + return { + x: translateX, + y: translateY, + indicators: indicatorPositions.map(indicatorPosition => ({ + x: translateX + displayImageWidth * indicatorPosition[0] / 100 * (scale - 1), + y: translateY + displayImageHeight * indicatorPosition[1] / 100 * (scale - 1) + })), + scale + }; +} + +function getBoundingRect(area) { + const xCoords = area.map(point => point[0]); + const yCoords = area.map(point => point[1]); + + const minX = Math.min(...xCoords); + const maxX = Math.max(...xCoords); + const minY = Math.min(...yCoords); + const maxY = Math.max(...yCoords); + + return { + left: minX, + top: minY, + width: maxX - minX, + height: maxY - minY + }; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/stories.js b/entry_types/scrolled/package/src/contentElements/hotspots/stories.js new file mode 100644 index 0000000000..b5f1c58556 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/stories.js @@ -0,0 +1,32 @@ +import './frontend'; +import {storiesOfContentElement, filePermaId} from 'pageflow-scrolled/spec/support/stories'; + +storiesOfContentElement(module, { + typeName: 'hotspots', + baseConfiguration: { + image: filePermaId('imageFiles', 'turtle'), + width: 2, + initialActiveArea: 0, + areas: [ + { + id: 1, + outline: [[20, 30], [50, 30], [50, 50], [20, 50]], + indicatorPosition: [25, 45] + } + ], + tooltipTexts: { + 1: { + title: [{children: [{text: 'Some Title'}]}], + description: [{type: 'paragraph', children: [{text: 'This text describes area'}]}], + link: [{children: [{text: 'Read more'}]}] + } + } + }, + variants: [ + { + name: 'With Caption', + configuration: {caption: 'Some text here'} + } + ], + inlineFileRights: true +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js b/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js new file mode 100644 index 0000000000..74759a7d28 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js @@ -0,0 +1,28 @@ +import {useEffect, useRef, useState} from 'react'; + +export function useContentRect({enabled}) { + const [contentRect, setContentRect] = useState({width: 0, height: 0}); + const ref = useRef(); + + useEffect(() => { + if (!enabled) { + return; + } + + const current = ref.current; + + const resizeObserver = new ResizeObserver( + entries => { + setContentRect(entries[entries.length - 1].contentRect); + } + ); + + resizeObserver.observe(current); + + return () => { + resizeObserver.unobserve(current); + }; + }, [enabled]); + + return [contentRect, ref]; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js b/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js new file mode 100644 index 0000000000..cf5f0dc224 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js @@ -0,0 +1,65 @@ +import {useRef, useEffect} from 'react'; + +export function useIntersectionObserver({threshold, onVisibleIndexChange, enabled}) { + const containerRef = useRef(); + const childRefs = useRef([]); + const observerRef = useRef(); + + useEffect(() => { + if (!enabled) { + return; + } + + const observer = observerRef.current = new IntersectionObserver( + (entries) => { + const containerElement = containerRef.current; + + let found = false; + + entries.forEach((entry) => { + const entryIndex = Array.from(containerElement.children).findIndex( + (child) => child === entry.target + ); + + if (entry.isIntersecting && entry.intersectionRatio >= threshold) { + found = true; + onVisibleIndexChange(entryIndex); + } + }); + + if (!found) { + onVisibleIndexChange(-1); + } + }, + { + root: containerRef.current, + threshold + } + ); + + childRefs.current.forEach((child) => { + if (child) { + observer.observe(child); + } + }); + + return () => { + observer.disconnect(); + }; + }, [enabled, threshold, onVisibleIndexChange]); + + const setChildRef = (index) => (ref) => { + if (observerRef.current) { + if (ref) { + observerRef.current.observe(ref); + } + else { + observerRef.current.unobserve(childRefs.current[index]); + } + } + + childRefs.current[index] = ref; + }; + + return [containerRef, setChildRef]; +}; diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js new file mode 100644 index 0000000000..85c3de89b6 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -0,0 +1,140 @@ +import {useRef, useCallback, useMemo} from 'react'; +import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend'; + +import {useIntersectionObserver} from './useIntersectionObserver'; +import {getPanZoomStepTransform} from './panZoom'; + +export function useScrollPanZoom({ + imageFile, containerRect, areas, + enabled, portraitMode, + onChange +}) { + const wrapperRef = useRef(); + const indicatorRefs = useRef([]); + + const onVisibleIndexChange = useCallback(index => onChange(index - 1), [onChange]); + const [scrollerRef, setStepRef] = useIntersectionObserver({ + enabled, + threshold: 0.7, + onVisibleIndexChange + }); + + const imageFileWidth = imageFile?.width; + const imageFileHeight = imageFile?.height; + + const containerWidth = containerRect.width; + const containerHeight = containerRect.height; + + const steps = useMemo(() => { + if (!enabled || !containerWidth) { + return; + } + + return [ + { + x: 0, + y: 0, + scale: 1, + indicators: [] + }, + ...areas.map(area => getPanZoomStepTransform({ + areaOutline: portraitMode ? area.portraitOutline : area.outline, + areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0, + indicatorPositions: areas.map(area => (portraitMode ? area.portraitIndicatorPosition : area.indicatorPosition) || [50, 50]), + imageFileWidth, + imageFileHeight, + containerWidth, + containerHeight + })), + { + x: 0, + y: 0, + scale: 1, + indicators: [] + } + ]; + }, [ + areas, + enabled, + imageFileWidth, + imageFileHeight, + containerWidth, + containerHeight, + portraitMode + ]); + + const scrollFromTo = useCallback((from, to) => { + const scroller = scrollerRef.current; + const step = scroller.children[to + 1]; + + scroller.scrollTo(Math.abs(scroller.offsetLeft - step.offsetLeft), 0); + + wrapperRef.current.animate( + [ + keyframe(steps[from + 1]), + keyframe(steps[to + 1]) + ], + { + duration: 200 + } + ); + + areas.forEach((area, index) => { + indicatorRefs.current[index].animate( + [ + keyframe(steps[from + 1].indicators?.[index] || {x: 0, y: 0}), + keyframe(steps[to + 1].indicators?.[index] || {x: 0, y: 0}) + ], + { + duration: 200 + } + ); + }); + }, [scrollerRef, steps, areas]); + + useIsomorphicLayoutEffect(() => { + if (!steps) { + return; + } + + const scrollTimeline = new window.ScrollTimeline({ + source: scrollerRef.current, + axis: 'inline' + }); + + const animations = [] + + animations.push(wrapperRef.current.animate( + steps.map(keyframe), + { + fill: 'both', + timeline: scrollTimeline + } + )); + + areas.forEach((area, index) => { + animations.push(indicatorRefs.current[index].animate( + steps.map(step => keyframe(step.indicators?.[index] || {x: 0, y: 0})), + { + fill: 'both', + timeline: scrollTimeline + } + )); + }); + + return () => animations.forEach(animation => animation.cancel()); + }, [areas, steps]); + + const setIndicatorRef = index => ref => { + indicatorRefs.current[index] = ref; + } + + return [wrapperRef, scrollerRef, setStepRef, setIndicatorRef, scrollFromTo]; +} + +function keyframe(step) { + return { + transform: `translate(${step.x}px, ${step.y}px) scale(${step.scale || 1})`, + easing: 'ease', + }; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js b/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js new file mode 100644 index 0000000000..1d10e978b8 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js @@ -0,0 +1,3 @@ +import {useTransitionStyles} from '@floating-ui/react'; + +export const useTooltipTransitionStyles = useTransitionStyles; diff --git a/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js b/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js index 5bc5fb28c8..033aa59103 100644 --- a/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js +++ b/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js @@ -1,5 +1,3 @@ import React from 'react'; -const PhonePlatformContext = React.createContext(false); - -export default PhonePlatformContext; \ No newline at end of file +export const PhonePlatformContext = React.createContext(false); diff --git a/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js b/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js index 862a3e942a..4909bdeaa4 100644 --- a/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js +++ b/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js @@ -1,6 +1,6 @@ import React from 'react'; -import PhonePlatformContext from './PhonePlatformContext'; +import {PhonePlatformContext} from './PhonePlatformContext'; import {useBrowserFeature} from './useBrowserFeature'; import {withInlineEditingAlternative} from './inlineEditing'; diff --git a/entry_types/scrolled/package/src/frontend/global.module.css b/entry_types/scrolled/package/src/frontend/global.module.css index 2c7b14c9c7..f47dba4ddb 100644 --- a/entry_types/scrolled/package/src/frontend/global.module.css +++ b/entry_types/scrolled/package/src/frontend/global.module.css @@ -19,3 +19,8 @@ -moz-osx-font-smoothing: grayscale; -webkit-tap-highlight-color: transparent; } + +:global [data-floating-ui-portal] { + position: relative; + z-index: 30000; +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 6f6f87a388..351a957b28 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -96,11 +96,12 @@ export {useMediaMuted, useOnUnmuteMedia} from './useMediaMuted'; export {usePortraitOrientation} from './usePortraitOrientation'; export {useScrollPosition} from './useScrollPosition'; export {usePhonePlatform} from './usePhonePlatform'; +export {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; export {EditableText} from './EditableText'; export {EditableInlineText} from './EditableInlineText'; export {EditableLink} from './EditableLink'; -export {PhonePlatformProvider} from './PhonePlatformProvider'; +export {PhonePlatformContext} from './PhonePlatformContext'; export { OptIn as ThirdPartyOptIn, OptOutInfo as ThirdPartyOptOutInfo, diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js b/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js index 9d5f81e451..91f6b1255d 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js @@ -1,6 +1,6 @@ import React, {useEffect, useState} from 'react'; -import PhonePlatformContext from '../PhonePlatformContext'; +import {PhonePlatformContext} from '../PhonePlatformContext'; export function PhonePlatformProvider({children}) { @@ -27,4 +27,4 @@ export function PhonePlatformProvider({children}) { {children} ); -} \ No newline at end of file +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js index 6d1af3aa62..3cfdce6d7a 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js @@ -50,7 +50,8 @@ export function SectionDecorator({backdrop, section, contentElements, transition function selectIfOutsideContentItem(event) { if (!event.target.closest(`.${contentElementStyles.wrapper}`) && !event.target.closest(`.${backdropStyles.wrapper}`) && - !event.target.closest('#fullscreenRoot')) { + !event.target.closest('#fullscreenRoot') && + !event.target.closest('[data-floating-ui-portal]')) { isSelected ? resetSelection() : select(); } } diff --git a/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js b/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js index 2cb90d326c..b7d80f81bd 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js @@ -3,7 +3,8 @@ import {useContext, createContext} from 'react'; export const ContentElementEditorStateContext = createContext({ isSelected: false, isEditable: false, - setTransientState() {} + setTransientState() {}, + select() {} }); /** diff --git a/entry_types/scrolled/package/src/frontend/usePhonePlatform.js b/entry_types/scrolled/package/src/frontend/usePhonePlatform.js index 320e09287c..f7f42ae3d8 100644 --- a/entry_types/scrolled/package/src/frontend/usePhonePlatform.js +++ b/entry_types/scrolled/package/src/frontend/usePhonePlatform.js @@ -1,5 +1,5 @@ import React from 'react'; -import PhonePlatformContext from './PhonePlatformContext'; +import {PhonePlatformContext} from './PhonePlatformContext'; export function usePhonePlatform() { return React.useContext(PhonePlatformContext); diff --git a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js index 458f9b13f0..7a93883b4b 100644 --- a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js +++ b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js @@ -6,7 +6,8 @@ import { ContentElementAttributesProvider, ContentElementEditorCommandEmitterContext, ContentElementEditorStateContext, - ContentElementLifecycleContext + ContentElementLifecycleContext, + PhonePlatformContext } from 'pageflow-scrolled/frontend'; import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycle'; @@ -20,6 +21,7 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * @param {Function} callback - React component or function returning a React component. * @param {Object} [options] - Supports all options supported by {@link `renderInEntry`}. * @param {Object} [options.editorState] - Fake result of `useContentElementEditorState`. + * @param {Object} [options.phonePlatform] - Fake result of `usePhonePlatform`. * * @example * @@ -30,22 +32,27 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl * simulateScrollPosition('near viewport'); * triggerEditorCommand({type: 'HIGHLIGHT'}); */ -export function renderInContentElement(ui, {editorState, wrapper, ...options}) { +export function renderInContentElement(ui, {editorState, + phonePlatform = false, + wrapper: OriginalWrapper, + ...options}) { const emitter = Object.assign({}, BackboneEvents); function Wrapper({children}) { const defaultEditorState = useContext(ContentElementEditorStateContext); return ( - - - - {wrapper ? : children} - - - + + + + + {OriginalWrapper ? : children} + + + + ); } diff --git a/yarn.lock b/yarn.lock index 01cc2f03e7..f382674064 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1530,6 +1530,41 @@ resolved "https://registry.yarnpkg.com/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz#c05ed35ad82df8e6ac616c68b92c2282bd083ba4" integrity sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ== +"@floating-ui/core@^1.6.0": + version "1.6.4" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.4.tgz#0140cf5091c8dee602bff9da5ab330840ff91df6" + integrity sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA== + dependencies: + "@floating-ui/utils" "^0.2.4" + +"@floating-ui/dom@^1.0.0": + version "1.6.7" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.7.tgz#85d22f731fcc5b209db504478fb1df5116a83015" + integrity sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.4" + +"@floating-ui/react-dom@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.1.tgz#cca58b6b04fc92b4c39288252e285e0422291fb0" + integrity sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/react@https://github.com/tf/floating-ui-react#react-16-focus-fix": + version "0.26.20" + resolved "https://github.com/tf/floating-ui-react#47ea5ce39b43c58be2e341c27e22680b7d36ec8d" + dependencies: + "@floating-ui/react-dom" "^2.1.1" + "@floating-ui/utils" "^0.2.5" + tabbable "^6.0.0" + +"@floating-ui/utils@^0.2.4", "@floating-ui/utils@^0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.5.tgz#105c37d9d9620ce69b7f692a20c821bf1ad2cbf9" + integrity sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ== + "@headlessui/react@^1.6.6": version "1.6.6" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.6.tgz#3073c066b85535c9d28783da0a4d9288b5354d0c" @@ -11999,7 +12034,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12020,13 +12055,6 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -12189,6 +12217,11 @@ synchronous-promise@^2.0.15: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.17.tgz#38901319632f946c982152586f2caf8ddc25c032" integrity sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g== +tabbable@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" @@ -13015,16 +13048,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==