From 238d3d147f9e06b5d64172e4275d8eeb8b642992 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 16 May 2024 06:53:13 +0200 Subject: [PATCH 01/25] Prune hotspot tooltip text and links properties when areas are removed REDMINE-20673 --- .../editor/models/AreasCollection-spec.js | 32 +++++++++++++++++++ .../hotspots/editor/models/AreasCollection.js | 27 ++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) 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 d35b273ed..6b66fec71 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/src/contentElements/hotspots/editor/models/AreasCollection.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js index 062b5d8b8..7b00d1ae8 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) { From d4c0923e49f6c91bd1034752d6c9c9855bb059e0 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 16 May 2024 09:16:57 +0200 Subject: [PATCH 02/25] Add storybook stories for hotspots element REDMINE-20673 --- .../src/contentElements/hotspots/Hotspots.js | 4 ++- .../src/contentElements/hotspots/stories.js | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/stories.js diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index b30c80769..5ae77fddc 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -59,7 +59,9 @@ export function HotspotsImage({ const {shouldLoad} = useContentElementLifecycle(); const {setTransientState} = useContentElementEditorState(); - const [activeIndex, setActiveIndexState] = useState(-1); + const [activeIndex, setActiveIndexState] = useState( + 'initialActiveArea' in configuration ? configuration.initialActiveArea : -1 + ); const [hoveredIndex, setHoveredIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1); 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 000000000..b5f1c5855 --- /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 +}); From 4bd6327fdf302731c4441659eaade673482a8a30 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 16 May 2024 09:36:21 +0200 Subject: [PATCH 03/25] Replace left over external links translation in hotspots code REDMINE-20673 --- entry_types/scrolled/config/locales/new/hotspots.de.yml | 3 +++ entry_types/scrolled/config/locales/new/hotspots.en.yml | 3 +++ .../contentElements/hotspots/editor/SidebarEditAreaView.js | 6 +++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 73adc4eaf..c7abb01f7 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -12,6 +12,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 diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index adb6cc399..4db2d7e18 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -12,6 +12,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 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 c97f2dc77..069326765 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.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')}
`, @@ -117,7 +117,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(); } From c27d4991e938bb81d2d29065cf6a9e6126a68c8a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 16 May 2024 10:31:10 +0200 Subject: [PATCH 04/25] Add invisible scroller for hotspots pan zoom effect REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 76 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 44 ++++++----- .../hotspots/Hotspots.module.css | 12 +++ .../src/contentElements/hotspots/Scroller.js | 14 ++++ .../hotspots/Scroller.module.css | 23 ++++++ .../contentElements/hotspots/editor/index.js | 4 +- .../scrolled/package/src/frontend/index.js | 1 + 7 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css 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 ddc89d1d1..9952cb0ca 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -4,6 +4,7 @@ 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'; @@ -1024,4 +1025,79 @@ describe('Hotspots', () => { expect(setTransientState).toHaveBeenCalledWith({activeAreaId: 1}) }); + + describe('pan and zoom', () => { + 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('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); + }); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 5ae77fddc..e710ea748 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -16,6 +16,7 @@ import { contentElementWidths } from 'pageflow-scrolled/frontend'; +import {Scroller} from './Scroller'; import {Area} from './Area'; import {Tooltip, insideTooltip} from './Tooltip'; @@ -68,6 +69,8 @@ export function HotspotsImage({ const portraitMode = portraitOrientation && portraitImageFile const imageFile = portraitMode ? portraitImageFile : defaultImageFile; + const panZoomEnabled = configuration.enablePanZoom === 'always'; + const areas = useMemo(() => configuration.areas || [], [configuration.areas]); const hasActiveArea = activeIndex >= 0; @@ -111,25 +114,28 @@ export function HotspotsImage({ -
- - {areas.map((area, index) => - setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => setActiveIndex(index)} /> - )} +
+
+ + {areas.map((area, index) => + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => setActiveIndex(index)} /> + )} +
+ {panZoomEnabled && }
{displayFullscreenToggle && div { + grid-row: 1; + grid-column: 1; +} + .center { display: flex; flex-direction: column; 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 000000000..47d5c97a7 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js @@ -0,0 +1,14 @@ +import React from 'react'; +import classNames from 'classnames'; + +import styles from './Scroller.module.css'; + +export function Scroller({areas}) { + return ( +
+ {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 000000000..c04431f88 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css @@ -0,0 +1,23 @@ +.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; +} + +.overview { + pointer-events: none; +} + +.step { + scroll-snap-align: start; + scroll-snap-stop: always; +} 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 256e8ad15..40b8eb46d 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -52,8 +52,8 @@ editor.contentElementTypes.register('hotspots', { values: ['phonePlatform', 'always', 'never'] }); this.input('panZoomInitially', CheckBoxInputView, { - disabledBinding: 'panZoom', - disabled: panZoom => panZoom !== 'always', + disabledBinding: 'enablePanZoom', + disabled: enablePanZoom => enablePanZoom !== 'always', displayUncheckedIfDisabled: true }); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 6f6f87a38..9e3fbd6a3 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -96,6 +96,7 @@ 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'; From eb24f3df36697e80938b2f4d22ad7c6918c1e771 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 16 May 2024 17:18:31 +0200 Subject: [PATCH 05/25] Add skeleton for scroll animation driven pan zoom REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 235 ++++++++++++++++++ .../contentElements/hotspots/panZoom-spec.js | 37 +++ .../src/contentElements/hotspots/Hotspots.js | 22 +- .../hotspots/Hotspots.module.css | 1 + .../src/contentElements/hotspots/Scroller.js | 7 +- .../src/contentElements/hotspots/panZoom.js | 41 +++ .../hotspots/useContentRect.js | 28 +++ .../hotspots/useScrollPanZoom.js | 73 ++++++ 8 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js 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 9952cb0ca..9753ddb20 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -11,6 +11,9 @@ import {within} 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'); + describe('Hotspots', () => { it('does not render images by default', () => { const seed = { @@ -1027,6 +1030,47 @@ describe('Hotspots', () => { }); describe('pan and zoom', () => { + let animateMock; + let scrollTimelines; + let observeResizeMock; + + 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) {}; + }; + + 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'}, @@ -1099,5 +1143,196 @@ describe('Hotspots', () => { 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(1); + 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).toHaveBeenCalledTimes(1); + expect(getPanZoomStepTransform).toHaveBeenCalledWith({ + areaOutline: [[10, 20], [10, 30], [40, 30], [40, 20]], + areaZoom: 50, + imageFileWidth: 1920, + imageFileHeight: 1080, + 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(); + }); }); }); 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 000000000..e3e54fc32 --- /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: -100 + }); + }); + + 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/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index e710ea748..5f1d517a1 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -20,6 +20,9 @@ import {Scroller} from './Scroller'; import {Area} from './Area'; import {Tooltip, insideTooltip} from './Tooltip'; +import {useContentRect} from './useContentRect'; +import {useScrollPanZoom} from './useScrollPanZoom'; + import styles from './Hotspots.module.css'; export function Hotspots({contentElementId, contentElementWidth, configuration}) { @@ -73,6 +76,16 @@ export function HotspotsImage({ const areas = useMemo(() => configuration.areas || [], [configuration.areas]); + const [containerRect, contentRectRef] = useContentRect({ + enabled: panZoomEnabled && shouldLoad + }); + const [wrapperRef, scrollerRef] = useScrollPanZoom({ + containerRect, + imageFile, + areas, + enabled: panZoomEnabled && shouldLoad + }); + const hasActiveArea = activeIndex >= 0; const setActiveIndex = useCallback(index => { setActiveIndexState(index); @@ -114,8 +127,10 @@ export function HotspotsImage({ -
-
+
+
setActiveIndex(index)} /> )}
- {panZoomEnabled && } + {panZoomEnabled && }
{displayFullscreenToggle && +
{Array.from({length: areas.length + 2}, (_, index) =>
)}
); -} +}); 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 000000000..bbb230239 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js @@ -0,0 +1,41 @@ +export function getPanZoomStepTransform({ + imageFileWidth, imageFileHeight, areaOutline, areaZoom, containerWidth, containerHeight +}) { + 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; + + const translateX = (containerWidth - displayAreaWidth * scale) / 2 - displayAreaLeft * scale; + const translateY = (containerHeight - displayAreaHeight * scale) / 2 - displayAreaTop * scale; + + return { + x: translateX, + y: translateY, + 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/useContentRect.js b/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js new file mode 100644 index 000000000..74759a7d2 --- /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/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js new file mode 100644 index 000000000..615b915e6 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -0,0 +1,73 @@ +import {useRef} from 'react'; +import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend'; + +import {getPanZoomStepTransform} from './panZoom'; + +export function useScrollPanZoom({imageFile, containerRect, areas, enabled}) { + const wrapperRef = useRef(); + const scrollerRef = useRef(); + + const imageFileWidth = imageFile?.width; + const imageFileHeight = imageFile?.height; + + const containerWidth = containerRect.width; + const containerHeight = containerRect.height; + + useIsomorphicLayoutEffect(() => { + if (!enabled || !containerWidth) { + return; + } + + const steps = [ + { + x: 0, + y: 0, + scale: 1 + }, + ...areas.map(area => getPanZoomStepTransform({ + areaOutline: area.outline, + areaZoom: area.zoom || 0, + imageFileWidth, + imageFileHeight, + containerWidth, + containerHeight + })), + { + x: 0, + y: 0, + scale: 1 + } + ]; + + const scrollTimeline = new window.ScrollTimeline({ + source: scrollerRef.current, + axis: 'inline' + }); + + const animation = wrapperRef.current.animate( + steps.map(keyframe), + { + fill: 'both', + timeline: scrollTimeline + } + ); + + return () => animation.cancel(); + }, [ + areas, + enabled, + imageFileWidth, + imageFileHeight, + containerWidth, + containerHeight + ]); + + return [wrapperRef, scrollerRef]; +} + +function keyframe(step) { + return { + transform: `translate(${step.x}px, ${step.y}px) scale(${step.scale})`, + easing: 'ease', + }; +} From db4eb90a8771b879d128bb4762bc33e6e3177799 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 10:11:06 +0200 Subject: [PATCH 06/25] Add zoom input to edit hotspot area view REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 4 + .../config/locales/new/hotspots.en.yml | 4 + .../editor/SidebarEditAreaView-spec.js | 87 ++++++++++++++++++- .../hotspots/editor/SidebarEditAreaView.js | 13 ++- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index c7abb01f7..bd5406683 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -30,6 +30,8 @@ de: label: Aktives Bild area: label: Bereich + zoom: + label: Zoom portraitTooltipPosition: label: Tooltip-Position (Hochkant) values: @@ -41,6 +43,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 4db2d7e18..97c87d7b5 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -30,6 +30,8 @@ en: label: Active image area: label: Area + zoom: + label: Zoom portraitTooltipPosition: label: Tooltip orientation (Portrait) values: @@ -41,6 +43,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/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js index b21e6ebef..e43279382 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/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js index 069326765..677e20f3e 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'; @@ -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 From 76200e165fec9f65dbc7b5f818ea08ebe3fe2f5f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 14:05:40 +0200 Subject: [PATCH 07/25] Control active area via pan zoom scroller REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 124 +++++++++++++++++- .../src/contentElements/hotspots/Hotspots.js | 21 +-- .../src/contentElements/hotspots/Scroller.js | 6 +- .../hotspots/useIntersectionObserver.js | 65 +++++++++ .../hotspots/useScrollPanZoom.js | 15 ++- 5 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js 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 9753ddb20..7328a3705 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -7,7 +7,7 @@ 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 {within, act} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect' import userEvent from '@testing-library/user-event'; @@ -1034,6 +1034,14 @@ describe('Hotspots', () => { let scrollTimelines; let observeResizeMock; + let intersectionObservers; + + function intersectionObserverByRoot(root) { + return intersectionObservers.find(intersectionObserver => + intersectionObserver.root === root + ); + } + beforeEach(() => { animateMock = jest.fn(() => { return { @@ -1063,6 +1071,40 @@ describe('Hotspots', () => { 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, @@ -1334,5 +1376,85 @@ describe('Hotspots', () => { 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.tooltip}`)).toHaveClass(tooltipStyles.visible); + }); + + 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(); + }); }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 5f1d517a1..530a06a1f 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -76,22 +76,24 @@ export function HotspotsImage({ const areas = useMemo(() => configuration.areas || [], [configuration.areas]); + const hasActiveArea = activeIndex >= 0; + const setActiveIndex = useCallback(index => { + setActiveIndexState(index); + setTransientState({activeAreaId: areas[index]?.id}); + }, [setActiveIndexState, setTransientState, areas]); + const [containerRect, contentRectRef] = useContentRect({ enabled: panZoomEnabled && shouldLoad }); - const [wrapperRef, scrollerRef] = useScrollPanZoom({ + + const [wrapperRef, scrollerRef, setScrollerStepRef] = useScrollPanZoom({ containerRect, imageFile, areas, - enabled: panZoomEnabled && shouldLoad + enabled: panZoomEnabled && shouldLoad, + onChange: setActiveIndex }); - const hasActiveArea = activeIndex >= 0; - const setActiveIndex = useCallback(index => { - setActiveIndexState(index); - setTransientState({activeAreaId: areas[index]?.id}); - }, [setActiveIndexState, setTransientState, areas]); - useEffect(() => { if (hasActiveArea) { document.body.addEventListener('click', handleClick); @@ -151,7 +153,8 @@ export function HotspotsImage({ )}
{panZoomEnabled && } + ref={scrollerRef} + setStepRef={setScrollerStepRef} />}
{displayFullscreenToggle && {Array.from({length: areas.length + 2}, (_, index) => -
+
)}
); 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 000000000..cf5f0dc22 --- /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 index 615b915e6..ffa5dae0b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -1,11 +1,18 @@ -import {useRef} from 'react'; +import {useRef, useCallback} from 'react'; import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend'; +import {useIntersectionObserver} from './useIntersectionObserver'; import {getPanZoomStepTransform} from './panZoom'; -export function useScrollPanZoom({imageFile, containerRect, areas, enabled}) { +export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onChange}) { const wrapperRef = useRef(); - const scrollerRef = 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; @@ -62,7 +69,7 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled}) { containerHeight ]); - return [wrapperRef, scrollerRef]; + return [wrapperRef, scrollerRef, setStepRef]; } function keyframe(step) { From d43f6326f559e37a621d6ab338d75aade29ed10e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 15:35:10 +0200 Subject: [PATCH 08/25] Scroll pan zoom scroller to activate areas REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 98 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 15 +-- .../hotspots/useScrollPanZoom.js | 9 +- 3 files changed, 115 insertions(+), 7 deletions(-) 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 7328a3705..182751340 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1456,5 +1456,103 @@ describe('Hotspots', () => { 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 + } + ] + }; + + const user = userEvent.setup(); + const {container, getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + const scroller = container.querySelector(`.${scrollerStyles.scroller}`); + scroller.scrollTo = jest.fn(); + simulateScrollPosition('near viewport'); + await user.click(getByRole('button')); + + expect(scroller.scrollTo).toHaveBeenCalled(); + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + }); + + 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, triggerEditorCommand} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + 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.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + }); + + 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(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + expect(scroller.scrollTo).toHaveBeenCalled(); + }); }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 530a06a1f..0e9f03e1c 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -82,11 +82,12 @@ export function HotspotsImage({ setTransientState({activeAreaId: areas[index]?.id}); }, [setActiveIndexState, setTransientState, areas]); + const [containerRect, contentRectRef] = useContentRect({ enabled: panZoomEnabled && shouldLoad }); - const [wrapperRef, scrollerRef, setScrollerStepRef] = useScrollPanZoom({ + const [wrapperRef, scrollerRef, setScrollerStepRef, scrollToArea] = useScrollPanZoom({ containerRect, imageFile, areas, @@ -94,6 +95,8 @@ export function HotspotsImage({ onChange: setActiveIndex }); + const activateArea = panZoomEnabled ? scrollToArea : setActiveIndex; + useEffect(() => { if (hasActiveArea) { document.body.addEventListener('click', handleClick); @@ -102,10 +105,10 @@ export function HotspotsImage({ function handleClick(event) { if (!insideTooltip(event.target)) { - setActiveIndex(-1); + activateArea(-1); } } - }, [hasActiveArea, setActiveIndex]); + }, [hasActiveArea, activateArea]); useContentElementEditorCommandSubscription(command => { if (command.type === 'HIGHLIGHT_AREA') { @@ -115,7 +118,7 @@ export function HotspotsImage({ setHighlightedIndex(-1); } else if (command.type === 'SET_ACTIVE_AREA') { - setActiveIndex(command.index); + activateArea(command.index); } }); @@ -149,7 +152,7 @@ export function HotspotsImage({ highlighted={hoveredIndex === index || highlightedIndex === index || activeIndex === index} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => setActiveIndex(index)} /> + onClick={() => activateArea(index)} /> )}
{panZoomEnabled && setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js index ffa5dae0b..17c46e327 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -20,6 +20,13 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh const containerWidth = containerRect.width; const containerHeight = containerRect.height; + const scrollToStep = useCallback(index => { + const scroller = scrollerRef.current; + const step = scroller.children[index + 1]; + + scroller.scrollTo(Math.abs(scroller.offsetLeft - step.offsetLeft), 0); + }, [scrollerRef]); + useIsomorphicLayoutEffect(() => { if (!enabled || !containerWidth) { return; @@ -69,7 +76,7 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh containerHeight ]); - return [wrapperRef, scrollerRef, setStepRef]; + return [wrapperRef, scrollerRef, setStepRef, scrollToStep]; } function keyframe(step) { From a07a86cd0348a4dd7cb05212c1c746f1882b5d9d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 15:47:22 +0200 Subject: [PATCH 09/25] Animate pan zoom to specific area REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 3 +- .../src/contentElements/hotspots/Hotspots.js | 6 ++- .../hotspots/useScrollPanZoom.js | 54 ++++++++++++------- 3 files changed, 42 insertions(+), 21 deletions(-) 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 182751340..9e1a3c088 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1503,13 +1503,14 @@ describe('Hotspots', () => { ] }; - const {container, triggerEditorCommand} = renderInContentElement( + 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}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 0e9f03e1c..b0c1f5e18 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -87,7 +87,7 @@ export function HotspotsImage({ enabled: panZoomEnabled && shouldLoad }); - const [wrapperRef, scrollerRef, setScrollerStepRef, scrollToArea] = useScrollPanZoom({ + const [wrapperRef, scrollerRef, setScrollerStepRef, scrollFromToArea] = useScrollPanZoom({ containerRect, imageFile, areas, @@ -95,6 +95,10 @@ export function HotspotsImage({ onChange: setActiveIndex }); + const scrollToArea = useCallback((index) => { + scrollFromToArea(activeIndex, index); + }, [scrollFromToArea, activeIndex]); + const activateArea = panZoomEnabled ? scrollToArea : setActiveIndex; useEffect(() => { diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js index 17c46e327..8fa6b22de 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -1,4 +1,4 @@ -import {useRef, useCallback} from 'react'; +import {useRef, useCallback, useMemo} from 'react'; import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend'; import {useIntersectionObserver} from './useIntersectionObserver'; @@ -20,19 +20,12 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh const containerWidth = containerRect.width; const containerHeight = containerRect.height; - const scrollToStep = useCallback(index => { - const scroller = scrollerRef.current; - const step = scroller.children[index + 1]; - - scroller.scrollTo(Math.abs(scroller.offsetLeft - step.offsetLeft), 0); - }, [scrollerRef]); - - useIsomorphicLayoutEffect(() => { + const steps = useMemo(() => { if (!enabled || !containerWidth) { return; } - const steps = [ + return [ { x: 0, y: 0, @@ -52,6 +45,36 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh scale: 1 } ]; + }, [ + areas, + enabled, + imageFileWidth, + imageFileHeight, + containerWidth, + containerHeight + ]); + + 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 + } + ); + }, [scrollerRef, steps]); + + useIsomorphicLayoutEffect(() => { + if (!steps) { + return; + } const scrollTimeline = new window.ScrollTimeline({ source: scrollerRef.current, @@ -67,16 +90,9 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh ); return () => animation.cancel(); - }, [ - areas, - enabled, - imageFileWidth, - imageFileHeight, - containerWidth, - containerHeight - ]); + }, [steps]); - return [wrapperRef, scrollerRef, setStepRef, scrollToStep]; + return [wrapperRef, scrollerRef, setStepRef, scrollFromTo]; } function keyframe(step) { From 590df2b2517fc4d7d4f8176b90dfbde4eec9682a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 15:57:26 +0200 Subject: [PATCH 10/25] Allow clicking hotspot areas in pan zoom overview mode REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 52 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 3 +- .../src/contentElements/hotspots/Scroller.js | 4 +- .../hotspots/Scroller.module.css | 2 +- 4 files changed, 57 insertions(+), 4 deletions(-) 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 9e1a3c088..febda1bbe 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1555,5 +1555,57 @@ describe('Hotspots', () => { expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); expect(scroller.scrollTo).toHaveBeenCalled(); }); + + 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); + }); }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index b0c1f5e18..f708de7f5 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -161,7 +161,8 @@ export function HotspotsImage({
{panZoomEnabled && } + setStepRef={setScrollerStepRef} + noPointerEvents={activeIndex < 0} />}
{displayFullscreenToggle && + className={classNames(styles.scroller, {[styles.noPointerEvents]: noPointerEvents})}> {Array.from({length: areas.length + 2}, (_, index) =>
Date: Fri, 17 May 2024 16:44:10 +0200 Subject: [PATCH 11/25] Account for pan zoom in tooltip positions REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 39 +++++++++++++- .../src/contentElements/hotspots/Hotspots.js | 3 ++ .../src/contentElements/hotspots/Tooltip.js | 51 ++++++++++++++++--- 3 files changed, 84 insertions(+), 9 deletions(-) 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 febda1bbe..5a7ba46e7 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1110,7 +1110,7 @@ describe('Hotspots', () => { x: 0, y: 0, scale: 1 - }) + }); }); it('does not render invisible scroller when pan zoom is disabled', () => { @@ -1343,7 +1343,6 @@ describe('Hotspots', () => { ); simulateScrollPosition('near viewport'); - expect(getPanZoomStepTransform).toHaveBeenCalledTimes(1); expect(getPanZoomStepTransform).toHaveBeenCalledWith({ areaOutline: [[10, 20], [10, 30], [40, 30], [40, 20]], areaZoom: 50, @@ -1607,5 +1606,41 @@ describe('Hotspots', () => { 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.tooltip}`)).toHaveStyle({ + left: '100px', + top: '50px' + }); + }); }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index f708de7f5..a2a274664 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -175,6 +175,9 @@ export function HotspotsImage({ `${coord}px`); + } + else { + return indicatorPositionInPercent.map(coord => `${coord}%`); + } +} + export function insideTooltip(element) { return !!element.closest(`.${styles.tooltip}`); } From e61686bb0dbd3c8d7e12e38b31aed34fdaae7466 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 21:44:20 +0200 Subject: [PATCH 12/25] Place hotspot area indicators before active image REDMINE-20673 --- .../package/src/contentElements/hotspots/Indicator.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css index c0e71463d..87fed8b8e 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css @@ -3,6 +3,7 @@ margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2); animation: inner 2s infinite; pointer-events: none; + z-index: 1; } .indicator, From c7eb0ac33e931078434c77524b0fd8aa4165cd77 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 21:47:43 +0200 Subject: [PATCH 13/25] Use ui locale for hotspot tooltip placeholder texts REDMINE-20673 --- .../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 f2a58f806..2af08de12 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -22,7 +22,7 @@ export function Tooltip({ panZoomEnabled, imageFile, containerRect, onMouseEnter, onMouseLeave, onClick }) { - const {t} = useI18n(); + const {t} = useI18n({locale: 'ui'}); const updateConfiguration = useContentElementConfigurationUpdate(); const {isEditable} = useContentElementEditorState(); From 9555be1ea83cdb7af3f2371451b3eb0d467ad405 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 May 2024 21:48:28 +0200 Subject: [PATCH 14/25] Select hotspot area in editor on pan zoom REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 2 +- .../src/contentElements/hotspots/Hotspots.js | 17 +++++++++++++---- .../frontend/useContentElementEditorState.js | 3 ++- 3 files changed, 16 insertions(+), 6 deletions(-) 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 5a7ba46e7..c22b6a7f6 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1021,7 +1021,7 @@ describe('Hotspots', () => { , { seed, - editorState: {isEditable: true, setTransientState} + editorState: {isEditable: true, isSelected: true, setTransientState} } ); await user.click(getByRole('button')); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index a2a274664..3a56ddec1 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -61,7 +61,7 @@ export function HotspotsImage({ const portraitOrientation = usePortraitOrientation(); const {shouldLoad} = useContentElementLifecycle(); - const {setTransientState} = useContentElementEditorState(); + const {setTransientState, select, isEditable, isSelected} = useContentElementEditorState(); const [activeIndex, setActiveIndexState] = useState( 'initialActiveArea' in configuration ? configuration.initialActiveArea : -1 @@ -78,10 +78,15 @@ export function HotspotsImage({ const hasActiveArea = activeIndex >= 0; const setActiveIndex = useCallback(index => { - setActiveIndexState(index); setTransientState({activeAreaId: areas[index]?.id}); - }, [setActiveIndexState, setTransientState, areas]); + setActiveIndexState(activeIndex => { + if (activeIndex !== index && index >= 0 && isSelected) { + select(); + } + return index; + }); + }, [setActiveIndexState, setTransientState, areas, select, isSelected]); const [containerRect, contentRectRef] = useContentRect({ enabled: panZoomEnabled && shouldLoad @@ -156,7 +161,11 @@ export function HotspotsImage({ highlighted={hoveredIndex === index || highlightedIndex === index || activeIndex === index} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => activateArea(index)} /> + onClick={() => { + if (!isEditable || isSelected) { + activateArea(index) + } + }} /> )}
{panZoomEnabled && Date: Mon, 17 Jun 2024 06:51:59 +0200 Subject: [PATCH 15/25] Use portrait outline/zoom for pan zoom in portrait mode REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 42 ++++++++++++++++++- .../src/contentElements/hotspots/Hotspots.js | 1 + .../hotspots/useScrollPanZoom.js | 13 ++++-- 3 files changed, 51 insertions(+), 5 deletions(-) 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 c22b6a7f6..95c738f1d 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -266,7 +266,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}] @@ -1353,6 +1353,46 @@ describe('Hotspots', () => { }); }); + 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'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 3a56ddec1..3eb681435 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -97,6 +97,7 @@ export function HotspotsImage({ imageFile, areas, enabled: panZoomEnabled && shouldLoad, + portraitMode, onChange: setActiveIndex }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js index 8fa6b22de..c064364b5 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js @@ -4,7 +4,11 @@ import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend'; import {useIntersectionObserver} from './useIntersectionObserver'; import {getPanZoomStepTransform} from './panZoom'; -export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onChange}) { +export function useScrollPanZoom({ + imageFile, containerRect, areas, + enabled, portraitMode, + onChange +}) { const wrapperRef = useRef(); const onVisibleIndexChange = useCallback(index => onChange(index - 1), [onChange]); @@ -32,8 +36,8 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh scale: 1 }, ...areas.map(area => getPanZoomStepTransform({ - areaOutline: area.outline, - areaZoom: area.zoom || 0, + areaOutline: portraitMode ? area.portraitOutline : area.outline, + areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0, imageFileWidth, imageFileHeight, containerWidth, @@ -51,7 +55,8 @@ export function useScrollPanZoom({imageFile, containerRect, areas, enabled, onCh imageFileWidth, imageFileHeight, containerWidth, - containerHeight + containerHeight, + portraitMode ]); const scrollFromTo = useCallback((from, to) => { From e69b75ce6f0b9930a83026f94c8720702b5ff70c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Jun 2024 07:35:27 +0200 Subject: [PATCH 16/25] Support phone platform mode in renderInContentElement REDMINE-20673 --- .../usePhonePlatform/inEditorPreview-spec.js | 11 +++---- .../src/frontend/PhonePlatformContext.js | 4 +-- .../src/frontend/PhonePlatformProvider.js | 2 +- .../scrolled/package/src/frontend/index.js | 2 +- .../inlineEditing/PhonePlatformProvider.js | 4 +-- .../package/src/frontend/usePhonePlatform.js | 2 +- .../src/testHelpers/renderInContentElement.js | 29 ++++++++++++------- 7 files changed, 28 insertions(+), 26 deletions(-) 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 360d385a5..edd5ad24f 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/frontend/PhonePlatformContext.js b/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js index 5bc5fb28c..033aa5910 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 862a3e942..4909bdeaa 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/index.js b/entry_types/scrolled/package/src/frontend/index.js index 9e3fbd6a3..351a957b2 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -101,7 +101,7 @@ 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 9d5f81e45..91f6b1255 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/usePhonePlatform.js b/entry_types/scrolled/package/src/frontend/usePhonePlatform.js index 320e09287..f7f42ae3d 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 458f9b13f..7a93883b4 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} + + + + ); } From 526d87deaa499c0d3f99a5bb6fbbc8b534210795 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Jun 2024 07:36:22 +0200 Subject: [PATCH 17/25] Support using pan zoom on phone platform only REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 46 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 5 +- 2 files changed, 50 insertions(+), 1 deletion(-) 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 95c738f1d..d71cbd7ca 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1159,6 +1159,52 @@ describe('Hotspots', () => { 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'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 3eb681435..aac5f7afb 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -12,6 +12,7 @@ import { useContentElementLifecycle, useFileWithInlineRights, usePortraitOrientation, + usePhonePlatform, InlineFileRights, contentElementWidths } from 'pageflow-scrolled/frontend'; @@ -59,6 +60,7 @@ export function HotspotsImage({ configuration, collectionName: 'imageFiles', propertyName: 'portraitImage' }); const portraitOrientation = usePortraitOrientation(); + const isPhonePlatform = usePhonePlatform(); const {shouldLoad} = useContentElementLifecycle(); const {setTransientState, select, isEditable, isSelected} = useContentElementEditorState(); @@ -72,7 +74,8 @@ export function HotspotsImage({ const portraitMode = portraitOrientation && portraitImageFile const imageFile = portraitMode ? portraitImageFile : defaultImageFile; - const panZoomEnabled = configuration.enablePanZoom === 'always'; + const panZoomEnabled = configuration.enablePanZoom === 'always' || + (configuration.enablePanZoom === 'phonePlatform' && isPhonePlatform); const areas = useMemo(() => configuration.areas || [], [configuration.areas]); From bd34501d47babdc152a25513d77f6b2309cc52cb Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Jun 2024 09:52:22 +0200 Subject: [PATCH 18/25] Add scroll indicator buttons for pan zoom scroller REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 2 + .../config/locales/new/hotspots.en.yml | 2 + .../contentElements/hotspots/Hotspots-spec.js | 50 +++++++++++++++- .../src/contentElements/hotspots/Hotspots.js | 3 +- .../hotspots/Hotspots.module.css | 1 + .../contentElements/hotspots/ScrollButton.js | 29 +++++++++ .../hotspots/ScrollButton.module.css | 60 +++++++++++++++++++ .../src/contentElements/hotspots/Scroller.js | 36 ++++++++--- .../hotspots/Scroller.module.css | 14 +++++ 9 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index bd5406683..2f37aef7e 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" diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index 97c87d7b5..df33f68e8 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" 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 d71cbd7ca..86340b161 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -7,6 +7,7 @@ import tooltipStyles from 'contentElements/hotspots/Tooltip.module.css'; import scrollerStyles from 'contentElements/hotspots/Scroller.module.css'; import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +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'; @@ -15,6 +16,10 @@ import {getPanZoomStepTransform} from 'contentElements/hotspots/panZoom'; jest.mock('contentElements/hotspots/panZoom'); describe('Hotspots', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.next': 'Next' + }); + it('does not render images by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, @@ -1556,7 +1561,12 @@ describe('Hotspots', () => { outline: [[10, 20], [10, 30], [40, 30], [40, 20]], zoom: 50 } - ] + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Area 1'}]}], + } + } }; const user = userEvent.setup(); @@ -1566,7 +1576,7 @@ describe('Hotspots', () => { const scroller = container.querySelector(`.${scrollerStyles.scroller}`); scroller.scrollTo = jest.fn(); simulateScrollPosition('near viewport'); - await user.click(getByRole('button')); + await user.click(getByRole('button', {name: 'Area 1'})); expect(scroller.scrollTo).toHaveBeenCalled(); expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); @@ -1693,7 +1703,7 @@ describe('Hotspots', () => { .not.toHaveClass(scrollerStyles.noPointerEvents); }); - it('accounts for pan zoom in tooltip position ', () => { + 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}] @@ -1728,5 +1738,39 @@ describe('Hotspots', () => { 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/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index aac5f7afb..158ae5b02 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -175,7 +175,8 @@ export function HotspotsImage({ {panZoomEnabled && } + activeIndex={activeIndex} + onScrollButtonClick={index => activateArea(index)} />}
{displayFullscreenToggle && div { diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.js b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.js new file mode 100644 index 000000000..f5806f2f4 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.js @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; +import {ThemeIcon, useI18n} from 'pageflow-scrolled/frontend'; + +import styles from './ScrollButton.module.css'; + +const size = 60; + +export function ScrollButton({direction, disabled, onClick}) { + const {t} = useI18n(); + + return ( + + ); +} 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 000000000..3b473ba59 --- /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 index 4ce845f64..c2b633178 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js @@ -1,17 +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, noPointerEvents, setStepRef}, ref) { +export const Scroller = React.forwardRef(function Scroller( + {areas, activeIndex, onScrollButtonClick, noPointerEvents, setStepRef}, ref +) { return ( -
- {Array.from({length: areas.length + 2}, (_, index) => -
- )} -
+ <> +
+ 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 index 13c667daa..a5bfc51e8 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css @@ -21,3 +21,17 @@ scroll-snap-align: start; scroll-snap-stop: always; } + +.leftButton, +.rightButton { + z-index: 2; +} + + +.leftButton { + justify-self: start; +} + +.rightButton { + justify-self: end; +} From f8bc8d5c3e618450e7464d6b9e2fb2283f5096db Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 24 Jun 2024 10:44:09 +0200 Subject: [PATCH 19/25] Do not render figure and content element box in fullscreen mode Allow using full viewport for pan zoom. REDMINE-20673 --- .../src/contentElements/hotspots/Hotspots.js | 97 ++++++++++--------- .../hotspots/Hotspots.module.css | 26 ++--- 2 files changed, 68 insertions(+), 55 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 158ae5b02..adf3cc2b9 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -37,7 +37,17 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) configuration={configuration} displayFullscreenToggle={contentElementWidth !== contentElementWidths.full && configuration.enableFullscreen} - onFullscreenEnter={enterFullscreen} /> + onFullscreenEnter={enterFullscreen}> + {children => + + +
+ {children} +
+
+
+ } + } renderFullscreenChildren={() => children }) { const defaultImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'image' @@ -142,49 +153,47 @@ export function HotspotsImage({ fill={configuration.position === 'backdrop'} opaque={!imageFile}>
- - - -
-
- - {areas.map((area, index) => - setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => { - if (!isEditable || isSelected) { - activateArea(index) - } - }} /> - )} -
- {panZoomEnabled && activateArea(index)} />} + {children( + +
+
+ + {areas.map((area, index) => + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => { + if (!isEditable || isSelected) { + activateArea(index) + } + }} /> + )}
- {displayFullscreenToggle && - } - - - - + {panZoomEnabled && activateArea(index)} />} +
+ {displayFullscreenToggle && + } + +
+ )} {areas.map((area, index) => Date: Mon, 24 Jun 2024 12:31:05 +0200 Subject: [PATCH 20/25] Pictogram for hotspots element REDMINE-20673 --- .../src/contentElements/hotspots/editor/pictogram.svg | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 510c81b3c..27ebd23b2 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 @@ - + + + + From 247ba8f1e644cf36c50d08b15a12a6856781c3a3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 24 Jun 2024 12:34:53 +0200 Subject: [PATCH 21/25] Remove panZoominitially for now REDMINE-20673 --- .../package/src/contentElements/hotspots/editor/index.js | 5 ----- 1 file changed, 5 deletions(-) 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 40b8eb46d..e73cd7ec7 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: 'enablePanZoom', - disabled: enablePanZoom => enablePanZoom !== 'always', - displayUncheckedIfDisabled: true - }); this.view(SeparatorView); this.input('enableFullscreen', CheckBoxInputView, { disabledBinding: ['position', 'width'], From e78e935bc13c8b81f366250c0e5ba5d35f5b314e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 24 Jun 2024 13:17:08 +0200 Subject: [PATCH 22/25] Fix pan zoom steps memoization REDMINE-20673 --- .../scrolled/package/src/contentElements/hotspots/Hotspots.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index adf3cc2b9..c518b035a 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -82,7 +82,7 @@ export function HotspotsImage({ const [hoveredIndex, setHoveredIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1); - const portraitMode = portraitOrientation && portraitImageFile + const portraitMode = !!(portraitOrientation && portraitImageFile); const imageFile = portraitMode ? portraitImageFile : defaultImageFile; const panZoomEnabled = configuration.enablePanZoom === 'always' || From 8ca48f878c86b411ac2cf80d35c810647110a4f5 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Jun 2024 11:37:30 +0200 Subject: [PATCH 23/25] Animate indicators separately Preserve size during pan zoom transitions. REDMINE-20673 --- .../contentElements/hotspots/panZoom-spec.js | 2 +- .../src/contentElements/hotspots/Area.js | 3 -- .../src/contentElements/hotspots/Hotspots.js | 10 +++- .../src/contentElements/hotspots/Indicator.js | 10 ++-- .../hotspots/Indicator.module.css | 11 ++++ .../src/contentElements/hotspots/panZoom.js | 15 ++++-- .../hotspots/useScrollPanZoom.js | 50 +++++++++++++++---- 7 files changed, 79 insertions(+), 22 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js index e3e54fc32..b525d407e 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js @@ -14,7 +14,7 @@ describe('getPanZoomStepTransform', () => { expect(result).toMatchObject({ scale: 1, x: -300, - y: -100 + y: 0 }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index eb4bc57cc..b91d21094 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({ @@ -44,7 +42,6 @@ export function Area({ load={shouldLoad} variant={'large'} preferSvg={true} /> - {isEditable && isSelected && }
); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index c518b035a..822b26e15 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -19,6 +19,7 @@ import { import {Scroller} from './Scroller'; import {Area} from './Area'; +import {Indicator} from './Indicator'; import {Tooltip, insideTooltip} from './Tooltip'; import {useContentRect} from './useContentRect'; @@ -106,7 +107,7 @@ export function HotspotsImage({ enabled: panZoomEnabled && shouldLoad }); - const [wrapperRef, scrollerRef, setScrollerStepRef, scrollFromToArea] = useScrollPanZoom({ + const [wrapperRef, scrollerRef, setScrollerStepRef, setIndicatorRef, scrollFromToArea] = useScrollPanZoom({ containerRect, imageFile, areas, @@ -182,6 +183,13 @@ export function HotspotsImage({ }} /> )}
+ {areas.map((area, index) => +