diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml
index 73adc4eaf7..2f37aef7e3 100644
--- a/entry_types/scrolled/config/locales/new/hotspots.de.yml
+++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml
@@ -5,6 +5,8 @@ de:
pageflow_scrolled:
public:
more: Mehr
+ next: Nächster
+ previous: Vorheriger
inline_editing:
select_link_destination: "Link-Ziel auswählen"
change_link_destination: "Link-Ziel ändern"
@@ -12,6 +14,9 @@ de:
content_elements:
hotspots:
edit_area:
+ back: Zurück
+ destroy: Löschen
+ confirm_delete_link: Soll der Bereich wirklich gelöscht werden?
tabs:
area: Hotspot-Breiech
portrait: Hochkant
@@ -27,6 +32,8 @@ de:
label: Aktives Bild
area:
label: Bereich
+ zoom:
+ label: Zoom
portraitTooltipPosition:
label: Tooltip-Position (Hochkant)
values:
@@ -38,6 +45,8 @@ de:
label: Aktives Bild (Hochkant)
portraitArea:
label: Bereich (Hochkant)
+ portraitZoom:
+ label: Zoom (Hochkant)
edit_area_dialog:
header: Bereichsumriss und Indikatorposition
tabs:
diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml
index adb6cc399c..df33f68e82 100644
--- a/entry_types/scrolled/config/locales/new/hotspots.en.yml
+++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml
@@ -5,6 +5,8 @@ en:
pageflow_scrolled:
public:
more: More
+ next: Next
+ previous: Previous
inline_editing:
select_link_destination: "Select link destination"
change_link_destination: "Change link destination"
@@ -12,6 +14,9 @@ en:
content_elements:
hotspots:
edit_area:
+ back: Back
+ destroy: Delete
+ confirm_delete_link: Are you sure you want to delete this area?
tabs:
area: Hotspot Area
portrait: Portrait
@@ -27,6 +32,8 @@ en:
label: Active image
area:
label: Area
+ zoom:
+ label: Zoom
portraitTooltipPosition:
label: Tooltip orientation (Portrait)
values:
@@ -38,6 +45,8 @@ en:
label: Active image (Portrait)
portraitArea:
label: Area (Portrait)
+ portraitZoom:
+ label: Zoom (Portrait)
edit_area_dialog:
header: Area outline and indicator position
tabs:
diff --git a/entry_types/scrolled/package/package.json b/entry_types/scrolled/package/package.json
index a5e3c37138..01ae09fadf 100644
--- a/entry_types/scrolled/package/package.json
+++ b/entry_types/scrolled/package/package.json
@@ -8,6 +8,7 @@
"license": "MIT",
"dependencies": {
"@egjs/view360": "^3.4.3",
+ "@floating-ui/react": "https://github.com/tf/floating-ui-react#react-16-focus-fix",
"@headlessui/react": "^1.6.6",
"core-js": "^3.6.5",
"debounce": "^1.2.0",
diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js
index ddc89d1d1e..9af9c3c615 100644
--- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js
+++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js
@@ -4,13 +4,25 @@ import {Hotspots} from 'contentElements/hotspots/Hotspots';
import areaStyles from 'contentElements/hotspots/Area.module.css';
import indicatorStyles from 'contentElements/hotspots/Indicator.module.css';
import tooltipStyles from 'contentElements/hotspots/Tooltip.module.css';
+import scrollerStyles from 'contentElements/hotspots/Scroller.module.css';
import {renderInContentElement} from 'pageflow-scrolled/testHelpers';
-import {within} from '@testing-library/react';
+import {useFakeTranslations} from 'pageflow/testHelpers';
+import {within, act} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event';
+import {getPanZoomStepTransform} from 'contentElements/hotspots/panZoom';
+jest.mock('contentElements/hotspots/panZoom');
+
+jest.mock('contentElements/hotspots/TooltipPortal');
+jest.mock('contentElements/hotspots/useTooltipTransitionStyles');
+
describe('Hotspots', () => {
+ useFakeTranslations({
+ 'pageflow_scrolled.public.next': 'Next'
+ });
+
it('does not render images by default', () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
@@ -75,11 +87,11 @@ describe('Hotspots', () => {
]
};
- const {getByRole} = renderInContentElement(
+ const {container} = renderInContentElement(
, {seed}
);
- expect(getByRole('button')).toHaveStyle(
+ expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle(
'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)'
);
});
@@ -101,11 +113,11 @@ describe('Hotspots', () => {
};
window.matchMedia.mockPortrait();
- const {getByRole} = renderInContentElement(
+ const {container} = renderInContentElement(
, {seed}
);
- expect(getByRole('button')).toHaveStyle(
+ expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle(
'clip-path: polygon(20% 20%, 20% 30%, 30% 30%, 30% 20%)'
);
});
@@ -126,14 +138,14 @@ describe('Hotspots', () => {
};
window.matchMedia.mockPortrait();
- const {getByRole} = renderInContentElement(
+ const {container} = renderInContentElement(
, {seed}
);
- expect(getByRole('button')).toHaveStyle(
+ expect(container.querySelector(`.${areaStyles.clip}`)).toHaveStyle(
'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)'
);
- });
+ });
it('renders area indicators', () => {
const seed = {
@@ -262,7 +274,7 @@ describe('Hotspots', () => {
});
});
- it('falls back to default indicator color for portrait indicator', () => {
+ it('falls back to default indicator color for portrait indicator', () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}]
@@ -316,7 +328,7 @@ describe('Hotspots', () => {
});
});
- it('renders tooltip', () => {
+ it('renders tooltip texts and links', async () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}]
@@ -341,10 +353,11 @@ describe('Hotspots', () => {
}
};
- const {queryByText, getByRole} = renderInContentElement(
+ const user = userEvent.setup();
+ const {container, queryByText, getByRole} = renderInContentElement(
, {seed}
);
-
+ await user.click(container.querySelector(`.${areaStyles.clip}`))
expect(queryByText('Some title')).not.toBeNull();
expect(queryByText('Some description')).not.toBeNull();
expect(queryByText('Some link')).not.toBeNull();
@@ -352,7 +365,7 @@ describe('Hotspots', () => {
expect(getByRole('link')).toHaveAttribute('target', '_blank');
});
- it('does not render tooltip link if link text is blank', () => {
+ it('does not render tooltip link if link text is blank', async () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}]
@@ -377,14 +390,16 @@ describe('Hotspots', () => {
}
};
- const {queryByRole} = renderInContentElement(
+ const user = userEvent.setup();
+ const {container, queryByRole} = renderInContentElement(
, {seed}
);
+ await user.click(container.querySelector(`.${areaStyles.clip}`))
expect(queryByRole('link')).toBeNull();
});
- it('positions tooltip based on indicator position', () => {
+ it('positions tooltip reference based on indicator position', () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}]
@@ -402,7 +417,7 @@ describe('Hotspots', () => {
, {seed}
);
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({
+ expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({
left: '10%',
top: '20%'
});
@@ -429,7 +444,7 @@ describe('Hotspots', () => {
, {seed}
);
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({
+ expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({
left: '20%',
top: '30%'
});
@@ -455,45 +470,13 @@ describe('Hotspots', () => {
, {seed}
);
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({
+ expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({
left: '10%',
top: '20%'
});
});
- it('shows tooltip on area click', async () => {
- const seed = {
- imageFileUrlTemplates: {large: ':id_partition/image.webp'},
- imageFiles: [{id: 1, permaId: 100}]
- };
- const configuration = {
- image: 100,
- areas: [
- {
- outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
- indicatorPosition: [20, 25],
- }
- ],
- tooltipTexts: {
- 1: {
- title: [{type: 'heading', children: [{text: 'Some title'}]}],
- }
- }
- };
-
- const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
- , {seed}
- );
-
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible);
-
- await user.click(getByRole('button'));
-
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
- });
-
- it('shows tooltip on area or tooltip hover', async () => {
+ it('shows tooltip on area hover', async () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}]
@@ -502,6 +485,7 @@ describe('Hotspots', () => {
image: 100,
areas: [
{
+ id: 1,
outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
indicatorPosition: [20, 25],
}
@@ -514,27 +498,15 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
+ const {queryByText, container} = renderInContentElement(
, {seed}
);
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible);
-
- await user.hover(getByRole('button'));
-
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
-
- await user.unhover(getByRole('button'));
-
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible);
-
- await user.hover(container.querySelector(`.${tooltipStyles.tooltip}`));
-
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
+ expect(queryByText('Some title')).toBeNull();
- await user.unhover(container.querySelector(`.${tooltipStyles.tooltip}`));
+ await user.hover(container.querySelector(`.${areaStyles.clip}`));
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible);
+ expect(queryByText('Some title')).not.toBeNull();
});
it('does not show other tooltip on hover after area has been clicked', async () => {
@@ -567,14 +539,14 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
+ const {container, queryByText} = renderInContentElement(
, {seed}
);
- await user.click(getByRole('button', {name: 'Area 1'}));
- await user.hover(getByRole('button', {name: 'Area 2'}));
+ await user.click(container.querySelectorAll(`.${areaStyles.clip}`)[0]);
+ await user.hover(container.querySelectorAll(`.${areaStyles.clip}`)[1]);
- expect(container.querySelectorAll(`.${tooltipStyles.visible}`).length).toEqual(1);
+ expect(queryByText('Area 1')).not.toBeNull();
});
it('hides tooltip when clicked outside area', async () => {
@@ -586,6 +558,7 @@ describe('Hotspots', () => {
image: 100,
areas: [
{
+ id: 1,
outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
indicatorPosition: [20, 25],
}
@@ -598,14 +571,14 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
+ const {container, queryByText} = renderInContentElement(
, {seed}
);
- await user.click(getByRole('button'));
+ await user.click(container.querySelector(`.${areaStyles.clip}`));
await user.click(document.body);
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible);
+ expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull();
});
it('does not hide tooltip on click inside tooltip', async () => {
@@ -630,14 +603,14 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {container, getByRole, getByText} = renderInContentElement(
+ const {container, getByText} = renderInContentElement(
, {seed}
);
- await user.click(getByRole('button'));
+ await user.click(container.querySelector(`.${areaStyles.clip}`));
await user.click(getByText('Some title'));
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
+ expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull();
});
it('does not hide tooltip on unhover after click in tooltip', async () => {
@@ -662,15 +635,15 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {container, getByRole, getByText} = renderInContentElement(
+ const {container, getByText} = renderInContentElement(
, {seed}
);
- await user.hover(getByRole('button'));
+ await user.hover(container.querySelector(`.${areaStyles.clip}`));
await user.click(getByText('Some title'));
- await user.unhover(getByRole('button'));
+ await user.unhover(container.querySelector(`.${areaStyles.clip}`));
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
+ expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull();
});
it('supports active image rendered inside area', async () => {
@@ -819,18 +792,18 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
+ const {container} = renderInContentElement(
, {seed}
);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
- await user.click(getByRole('button'));
+ await user.click(container.querySelector(`.${areaStyles.clip}`));
expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
});
- it('shows active image on area or tooltip hover', async () => {
+ it('shows active image on area hover', async () => {
const seed = {
imageFileUrlTemplates: {large: ':id_partition/image.webp'},
imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}]
@@ -852,25 +825,17 @@ describe('Hotspots', () => {
};
const user = userEvent.setup();
- const {getByRole, container} = renderInContentElement(
+ const {container} = renderInContentElement(
, {seed}
);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
- await user.hover(getByRole('button'));
+ await user.hover(container.querySelector(`.${areaStyles.clip}`));
expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
- await user.unhover(getByRole('button'));
-
- expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
-
- await user.hover(container.querySelector(`.${tooltipStyles.tooltip}`));
-
- expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
-
- await user.unhover(container.querySelector(`.${tooltipStyles.tooltip}`));
+ await user.unhover(container.querySelector(`.${areaStyles.clip}`));
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
});
@@ -993,7 +958,7 @@ describe('Hotspots', () => {
);
triggerEditorCommand({type: 'SET_ACTIVE_AREA', index: 0});
- expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible);
+ expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull();
});
it('sets active area id in transient state in editor', async () => {
@@ -1013,15 +978,755 @@ describe('Hotspots', () => {
const setTransientState = jest.fn();
const user = userEvent.setup();
- const {getByRole} = renderInContentElement(
+ const {container} = renderInContentElement(
,
{
seed,
- editorState: {isEditable: true, setTransientState}
+ editorState: {isEditable: true, isSelected: true, setTransientState}
}
);
- await user.click(getByRole('button'));
+ await user.click(container.querySelector(`.${areaStyles.clip}`));
expect(setTransientState).toHaveBeenCalledWith({activeAreaId: 1})
});
+
+ describe('pan and zoom', () => {
+ let animateMock;
+ let scrollTimelines;
+ let observeResizeMock;
+
+ let intersectionObservers;
+
+ function intersectionObserverByRoot(root) {
+ return intersectionObservers.find(intersectionObserver =>
+ intersectionObserver.root === root
+ );
+ }
+
+ beforeEach(() => {
+ animateMock = jest.fn(() => {
+ return {
+ cancel() {}
+ }
+ });
+ HTMLDivElement.prototype.animate = animateMock;
+
+ scrollTimelines = [];
+
+ window.ScrollTimeline = function(options) {
+ this.options = options;
+ scrollTimelines.push(this);
+ };
+
+ observeResizeMock = jest.fn(function() {
+ this.callback([
+ {
+ contentRect: observeResizeMock.mockContentRect || {width: 100, height: 100}
+ }
+ ]);
+ });
+
+ window.ResizeObserver = function(callback) {
+ this.callback = callback;
+ this.observe = observeResizeMock;
+ this.unobserve = function(element) {};
+ };
+
+ intersectionObservers = [];
+
+ window.IntersectionObserver = function(callback, {threshold, root}) {
+ if (intersectionObserverByRoot(root)) {
+ console.log(intersectionObservers.map(i => i.root?.outerHTML))
+ throw new Error('Did not except more than one intersection observer per root');
+ }
+
+ intersectionObservers.push(this);
+
+ this.root = root;
+ this.targets = new Set();
+
+ this.observe = function(target) {
+ this.targets.add(target);
+ };
+
+ this.unobserve = function(target) {
+ this.targets.delete(target);
+ };
+
+ this.mockIntersecting = function(target) {
+ if (!this.targets.has(target)) {
+ throw new Error(`Intersection observer does not currently ${target}.`);
+ }
+
+ act(() =>
+ callback([{target, isIntersecting: true, intersectionRatio: threshold}])
+ );
+ };
+
+ this.disconnect = function() {}
+ };
+
+ getPanZoomStepTransform.mockReset();
+ getPanZoomStepTransform.mockReturnValue({
+ x: 0,
+ y: 0,
+ scale: 1
+ });
+ });
+
+ it('does not render invisible scroller when pan zoom is disabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'never',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed}
+ );
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`)).toBeNull();
+ });
+
+ it('renders invisible scroller when pan zoom is enabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed}
+ );
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`)).not.toBeNull();
+ });
+
+ it('does not render invisible scroller if pan zoom enabled on phone platform', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'phonePlatform',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed}
+ );
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`)).toBeNull();
+ });
+
+ it('renders invisible scroller on phone platform if pan zoom enabled on phone platform', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'phonePlatform',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed, phonePlatform: true}
+ );
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`)).not.toBeNull();
+ });
+
+ it('scroller has one step per area plus two for overview states', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ },
+ {
+ id: 1,
+ outline: [[40, 20], [40, 30], [60, 30], [60, 20]]
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed}
+ );
+
+ expect(container.querySelectorAll(`.${scrollerStyles.step}`).length).toEqual(4);
+ });
+
+ it('does not observe resize by default', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(observeResizeMock).not.toHaveBeenCalled();
+ });
+
+ it('observes resize if pan zoom is enabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(observeResizeMock).toHaveBeenCalledTimes(1);
+ expect(observeResizeMock).toHaveBeenCalledWith(expect.any(HTMLDivElement));
+ });
+
+ it('neither calls animate nor sets up scroll timeline by default', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(scrollTimelines.length).toEqual(0);
+ expect(animateMock).not.toHaveBeenCalled();
+ });
+
+ it('calls animate with scroll timeline when near viewport and pan and zoom is enabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(scrollTimelines.length).toEqual(1);
+ expect(scrollTimelines[0].options).toEqual({
+ source: expect.any(HTMLDivElement),
+ axis: 'inline'
+ });
+ expect(animateMock).toHaveBeenCalledTimes(2);
+ expect(animateMock).toHaveBeenCalledWith(
+ Array.from({length: 3}, () => expect.objectContaining({
+ transform: expect.any(String),
+ easing: 'ease'
+ })),
+ expect.objectContaining({
+ timeline: scrollTimelines[0]
+ })
+ );
+ });
+
+ it('only sets up pan zoom scroll timeline when near viewport', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ renderInContentElement(
+ , {seed}
+ );
+
+ expect(scrollTimelines.length).toEqual(0);
+ expect(animateMock).not.toHaveBeenCalled();
+ });
+
+ it('calls getPanZoomStepTransform with relevant dimensions', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ zoom: 50
+ }
+ ]
+ };
+
+ observeResizeMock.mockContentRect = {width: 2000, height: 500};
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(getPanZoomStepTransform).toHaveBeenCalledWith({
+ areaOutline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ areaZoom: 50,
+ imageFileWidth: 1920,
+ imageFileHeight: 1080,
+ containerWidth: 2000,
+ containerHeight: 500
+ });
+ });
+
+ it('passes portrait outlines to getPanZoomStepTransform in portrait mode', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [
+ {id: 1, permaId: 100, width: 1920, height: 1080},
+ {id: 2, permaId: 101, width: 1080, height: 1920}
+ ]
+ };
+ const configuration = {
+ image: 100,
+ portraitImage: 101,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ portraitOutline: [[20, 20], [20, 30], [30, 30], [30, 20]],
+ zoom: 50,
+ portraitZoom: 40
+ }
+ ]
+ };
+
+ window.matchMedia.mockPortrait();
+ observeResizeMock.mockContentRect = {width: 2000, height: 500};
+ const {simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(getPanZoomStepTransform).toHaveBeenCalledWith({
+ areaOutline: [[20, 20], [20, 30], [30, 30], [30, 20]],
+ areaZoom: 40,
+ imageFileWidth: 1080,
+ imageFileHeight: 1920,
+ containerWidth: 2000,
+ containerHeight: 500
+ });
+ });
+
+ it('only sets up resize observer when near viewport', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ renderInContentElement(
+ , {seed}
+ );
+
+ expect(observeResizeMock).not.toHaveBeenCalled();
+ });
+
+ it('observes intersection of scroller steps', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ zoom: 50
+ }
+ ]
+ };
+
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`)).targets
+ ).toContain(container.querySelector(`.${scrollerStyles.step}`));
+ });
+
+ it('sets active section based on intersecting scroller step when pan zoom is enabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ zoom: 50
+ }
+ ]
+ };
+
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
+ .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);
+
+ expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull();
+ });
+
+ it('only sets up intersection observer when near viewport', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ zoom: 50
+ }
+ ]
+ };
+
+ const {container} = renderInContentElement(
+ , {seed}
+ );
+
+ expect(
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
+ ).toBeUndefined();
+ });
+
+ it('scrolls pan zoom scroller instead of setting active index directly when pan zoom is enabled', async () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 1920, height: 1080}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ zoom: 50
+ }
+ ],
+ tooltipTexts: {
+ 1: {
+ title: [{type: 'heading', children: [{text: 'Area 1'}]}],
+ }
+ }
+ };
+
+ const user = userEvent.setup();
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ const scroller = container.querySelector(`.${scrollerStyles.scroller}`);
+ scroller.scrollTo = jest.fn();
+ simulateScrollPosition('near viewport');
+ await user.click(container.querySelector(`.${areaStyles.clip}`));
+
+ expect(scroller.scrollTo).toHaveBeenCalled();
+ expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull();
+ });
+
+ it('scrolls pan zoom scroll when setting active area via command and pan zoom is enabled', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ id: 1,
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]]
+ }
+ ]
+ };
+
+ const {container, simulateScrollPosition, triggerEditorCommand} = renderInContentElement(
+ ,
+ {
+ seed,
+ editorState: {isSelected: true, isEditable: true}
+ }
+ );
+ simulateScrollPosition('near viewport');
+ const scroller = container.querySelector(`.${scrollerStyles.scroller}`);
+ scroller.scrollTo = jest.fn();
+ triggerEditorCommand({type: 'SET_ACTIVE_AREA', index: 0});
+
+ expect(scroller.scrollTo).toHaveBeenCalled();
+ expect(container.querySelector(`.${tooltipStyles.box}`)).toBeNull();
+ });
+
+ it('scrolls pan zoom scroller instead of resetting active area directly when pan zoom is enabled', async () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ indicatorPosition: [20, 25],
+ }
+ ],
+ tooltipTexts: {
+ 1: {
+ title: [{type: 'heading', children: [{text: 'Some title'}]}],
+ }
+ }
+ };
+
+ const user = userEvent.setup();
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
+ .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);
+ const scroller = container.querySelector(`.${scrollerStyles.scroller}`);
+ scroller.scrollTo = jest.fn();
+ await user.click(document.body);
+
+ expect(scroller.scrollTo).toHaveBeenCalled();
+ expect(container.querySelector(`.${tooltipStyles.box}`)).not.toBeNull();
+ });
+
+ it('scroller lets pointer events pass when no area is active', async () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ indicatorPosition: [20, 25],
+ }
+ ]
+ };
+
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`))
+ .toHaveClass(scrollerStyles.noPointerEvents);
+ });
+
+ it('scroller has pointer events once area is active', async () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ outline: [[10, 20], [10, 30], [40, 30], [40, 20]],
+ indicatorPosition: [20, 25],
+ }
+ ]
+ };
+
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
+ .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);
+
+ expect(container.querySelector(`.${scrollerStyles.scroller}`))
+ .not.toHaveClass(scrollerStyles.noPointerEvents);
+ });
+
+ it('accounts for pan zoom in tooltip position', () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 2000, height: 1000}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ outline: [[80, 45], [100, 45], [100, 55], [80, 55]],
+ zoom: 100,
+ indicatorPosition: [90, 50],
+ }
+ ]
+ };
+
+ observeResizeMock.mockContentRect = {width: 200, height: 100};
+ getPanZoomStepTransform.mockReturnValue({
+ x: -800,
+ y: -200,
+ scale: 5
+ })
+ const {container, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+ intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
+ .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);
+
+ expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({
+ left: '100px',
+ top: '50px'
+ });
+ });
+
+ it('allows changing active area via scroll button', async () => {
+ const seed = {
+ imageFileUrlTemplates: {large: ':id_partition/image.webp'},
+ imageFiles: [{id: 1, permaId: 100, width: 2000, height: 1000}]
+ };
+ const configuration = {
+ image: 100,
+ enablePanZoom: 'always',
+ areas: [
+ {
+ outline: [[80, 45], [100, 45], [100, 55], [80, 55]],
+ indicatorPosition: [90, 50],
+ },
+ {
+ outline: [[20, 45], [30, 45], [30, 55], [20, 55]],
+ indicatorPosition: [25, 50],
+ }
+ ]
+ };
+
+ const user = userEvent.setup();
+ const {container, getByRole, simulateScrollPosition} = renderInContentElement(
+ , {seed}
+ );
+ simulateScrollPosition('near viewport');
+ const scroller = container.querySelector(`.${scrollerStyles.scroller}`);
+ scroller.scrollTo = jest.fn();
+ intersectionObserverByRoot(scroller)
+ .mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);
+ await user.click(getByRole('button', {name: 'Next'}));
+
+ expect(scroller.scrollTo).toHaveBeenCalled();
+ });
+ });
});
diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js
index b21e6ebef5..e432793820 100644
--- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js
+++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js
@@ -1,10 +1,13 @@
import {SidebarEditAreaView} from 'contentElements/hotspots/editor/SidebarEditAreaView';
import {AreasCollection} from 'contentElements/hotspots/editor/models/AreasCollection';
-import {Tabs, useFakeTranslations} from 'pageflow/testHelpers';
-import {useEditorGlobals} from 'support';
+import {ConfigurationEditor, Tabs, useFakeTranslations} from 'pageflow/testHelpers';
+import {useEditorGlobals, useFakeXhr} from 'support';
+import {within} from '@testing-library/dom';
+import userEvent from '@testing-library/user-event';
describe('SidebarEditAreaView', () => {
+ useFakeXhr();
const {createEntry} = useEditorGlobals();
useFakeTranslations({
@@ -75,4 +78,84 @@ describe('SidebarEditAreaView', () => {
expect(tabs.tabLabels()).toEqual(['Area']);
});
+
+ it('renders zoom inputs if pan zoom is enabled', async () => {
+ const entry = createEntry({
+ imageFiles: [
+ {perma_id: 10},
+ {perma_id: 11}
+ ],
+ contentElements: [
+ {
+ id: 1,
+ typeName: 'hotspots',
+ configuration: {
+ image: 10,
+ portraitImage: 11,
+ enablePanZoom: 'always',
+ areas: [{id: 1, zoom: 0}]
+ }
+ }
+ ]
+ });
+ const contentElement = entry.contentElements.get(1);
+ const areas = AreasCollection.forContentElement(contentElement);
+ const view = new SidebarEditAreaView({
+ model: areas.get(1),
+ collection: areas,
+ entry,
+ contentElement
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = within(view.render().el);
+
+ let configurationEditor = ConfigurationEditor.find(view);
+ expect(configurationEditor.inputPropertyNames()).toContain('zoom');
+
+ await user.click(getByText('Portrait'));
+ configurationEditor = ConfigurationEditor.find(view);
+
+ expect(configurationEditor.inputPropertyNames()).toContain('portraitZoom');
+ });
+
+ it('does not render zoom inputs if pan zoom is disabled', async () => {
+ const entry = createEntry({
+ imageFiles: [
+ {perma_id: 10},
+ {perma_id: 11},
+ ],
+ contentElements: [
+ {
+ id: 1,
+ typeName: 'hotspots',
+ configuration: {
+ image: 10,
+ portraitImage: 11,
+ enablePanZoom: 'never',
+ areas: [{id: 1}]
+ }
+ }
+ ]
+ });
+ const contentElement = entry.contentElements.get(1);
+ const areas = AreasCollection.forContentElement(contentElement);
+ const view = new SidebarEditAreaView({
+ model: areas.get(1),
+ collection: areas,
+ entry,
+ contentElement
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = within(view.render().el);
+
+ let configurationEditor = ConfigurationEditor.find(view);
+ expect(configurationEditor.inputPropertyNames()).not.toContain('zoom');
+
+ await user.click(getByText('Portrait'));
+ configurationEditor = ConfigurationEditor.find(view);
+
+ expect(configurationEditor.inputPropertyNames()).not.toContain('portraitZoom');
+ });
});
diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js
index d35b273edf..6b66fec718 100644
--- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js
+++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js
@@ -51,6 +51,38 @@ describe('hotspots AreasCollection', () => {
])
});
+ it('prunes tooltip texts and link when removing an element with single change event', () => {
+ const contentElement = factories.contentElement({
+ configuration: {
+ areas: [
+ {id: 1},
+ {id: 2},
+ ],
+ tooltipTexts: {
+ 1: {title: [{children: [{text: 'Title for item 1'}]}]},
+ 2: {title: [{children: [{text: 'Title for item 2'}]}]},
+ },
+ tooltipLinks: {
+ 1: {href: 'https://example.com'},
+ 2: {href: 'https://other.example.com'},
+ }
+ }
+ });
+ const itemsCollection = AreasCollection.forContentElement(contentElement);
+ const listener = jest.fn();
+
+ contentElement.on('change:configuration', listener);
+ itemsCollection.remove(1);
+
+ expect(contentElement.configuration.get('tooltipTexts')).toEqual({
+ 2: {title: [{children: [{text: 'Title for item 2'}]}]}
+ });
+ expect(contentElement.configuration.get('tooltipLinks')).toEqual({
+ 2: {href: 'https://other.example.com'},
+ });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
it('posts content element command on highlight', () => {
const contentElement = factories.contentElement({
id: 10,
diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js
new file mode 100644
index 0000000000..b525d407ec
--- /dev/null
+++ b/entry_types/scrolled/package/spec/contentElements/hotspots/panZoom-spec.js
@@ -0,0 +1,37 @@
+import {getPanZoomStepTransform} from 'contentElements/hotspots/panZoom';
+
+describe('getPanZoomStepTransform', () => {
+ it('covers container such that area is centered', () => {
+ const result = getPanZoomStepTransform({
+ areaOutline: [[30, 20], [50, 20], [50, 100], [30, 100]],
+ areaZoom: 0,
+ containerWidth: 1000,
+ containerHeight: 1000,
+ imageFileWidth: 4000,
+ imageFileHeight: 2000
+ });
+
+ expect(result).toMatchObject({
+ scale: 1,
+ x: -300,
+ y: 0
+ });
+ });
+
+ it('supports zooming to fit area in container', () => {
+ const result = getPanZoomStepTransform({
+ areaOutline: [[10, 10], [30, 10], [30, 30], [10, 30]],
+ areaZoom: 100,
+ containerWidth: 1000,
+ containerHeight: 1000,
+ imageFileWidth: 4000,
+ imageFileHeight: 2000
+ });
+
+ expect(result).toMatchObject({
+ scale: 5,
+ x: -1500,
+ y: -500,
+ });
+ });
+});
diff --git a/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js b/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js
index 360d385a57..edd5ad24f3 100644
--- a/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js
+++ b/entry_types/scrolled/package/spec/frontend/usePhonePlatform/inEditorPreview-spec.js
@@ -1,19 +1,16 @@
-
-import {PhonePlatformProvider} from 'frontend';
import {usePhonePlatform} from 'frontend/usePhonePlatform';
import {loadInlineEditingComponents} from 'frontend/inlineEditing';
-import {renderHook} from '@testing-library/react-hooks';
-import {asyncHandlingOf} from 'support/asyncHandlingOf/forHooks';
+import {renderHookInEntry} from 'support';
+import {asyncHandlingOf} from 'support/asyncHandlingOf';
import '@testing-library/jest-dom/extend-expect'
-
describe('usePhonePlatform', () => {
beforeAll(loadInlineEditingComponents);
it('sets value when emulation mode is mobile', async () => {
- const {result} = renderHook(() => usePhonePlatform(), {wrapper: PhonePlatformProvider});
+ const {result} = renderHookInEntry(() => usePhonePlatform());
await asyncHandlingOf(() => {
window.postMessage({
@@ -26,7 +23,7 @@ describe('usePhonePlatform', () => {
});
it('sets value when emulation mode is desktop', async () => {
- const {result} = renderHook(() => usePhonePlatform(), {wrapper: PhonePlatformProvider});
+ const {result} = renderHookInEntry(() => usePhonePlatform());
await asyncHandlingOf(() => {
window.postMessage({
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js
index eb4bc57cc4..afae4df13f 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js
@@ -9,8 +9,6 @@ import {
useFile
} from 'pageflow-scrolled/frontend';
-import {Indicator} from './Indicator';
-
import styles from './Area.module.css';
export function Area({
@@ -34,17 +32,16 @@ export function Area({
-
+
-
{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 b30c807699..131ea06980 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js
@@ -1,4 +1,5 @@
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
+import {Composite, CompositeItem} from '@floating-ui/react';
import {
ContentElementBox,
@@ -12,12 +13,18 @@ import {
useContentElementLifecycle,
useFileWithInlineRights,
usePortraitOrientation,
+ usePhonePlatform,
InlineFileRights,
contentElementWidths
} from 'pageflow-scrolled/frontend';
+import {Scroller} from './Scroller';
import {Area} from './Area';
-import {Tooltip, insideTooltip} from './Tooltip';
+import {Indicator} from './Indicator';
+import {Tooltip} from './Tooltip';
+
+import {useContentRect} from './useContentRect';
+import {useScrollPanZoom} from './useScrollPanZoom';
import styles from './Hotspots.module.css';
@@ -32,21 +39,34 @@ export function Hotspots({contentElementId, contentElementWidth, configuration})
configuration={configuration}
displayFullscreenToggle={contentElementWidth !== contentElementWidths.full &&
configuration.enableFullscreen}
- onFullscreenEnter={enterFullscreen} />
+ onFullscreenEnter={enterFullscreen}>
+ {children =>
+
+
+
+ {children}
+
+
+
+ }
+
}
renderFullscreenChildren={() =>
+ displayFullscreenToggle={false}
+ flipTooltips={true} />
} />
);
}
export function HotspotsImage({
contentElementId, contentElementWidth, configuration,
- displayFullscreenToggle, onFullscreenEnter
+ flipTooltips,
+ displayFullscreenToggle, onFullscreenEnter,
+ children = children => children
}) {
const defaultImageFile = useFileWithInlineRights({
configuration, collectionName: 'imageFiles', propertyName: 'image'
@@ -55,37 +75,54 @@ export function HotspotsImage({
configuration, collectionName: 'imageFiles', propertyName: 'portraitImage'
});
const portraitOrientation = usePortraitOrientation();
+ const isPhonePlatform = usePhonePlatform();
const {shouldLoad} = useContentElementLifecycle();
- const {setTransientState} = useContentElementEditorState();
+ const {setTransientState, select, isEditable, isSelected} = 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);
- const portraitMode = portraitOrientation && portraitImageFile
+ const portraitMode = !!(portraitOrientation && portraitImageFile);
const imageFile = portraitMode ? portraitImageFile : defaultImageFile;
+ const panZoomEnabled = configuration.enablePanZoom === 'always' ||
+ (configuration.enablePanZoom === 'phonePlatform' && isPhonePlatform);
+
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]);
- useEffect(() => {
- if (hasActiveArea) {
- document.body.addEventListener('click', handleClick);
- return () => document.body.removeEventListener('click', handleClick);
- }
-
- function handleClick(event) {
- if (!insideTooltip(event.target)) {
- setActiveIndex(-1);
+ setActiveIndexState(activeIndex => {
+ if (activeIndex !== index && index >= 0 && isSelected) {
+ select();
}
- }
- }, [hasActiveArea, setActiveIndex]);
+ return index;
+ });
+ }, [setActiveIndexState, setTransientState, areas, select, isSelected]);
+
+ const [containerRect, contentRectRef] = useContentRect({
+ enabled: panZoomEnabled && shouldLoad
+ });
+
+ const [wrapperRef, scrollerRef, setScrollerStepRef, setIndicatorRef, scrollFromToArea] = useScrollPanZoom({
+ containerRect,
+ imageFile,
+ areas,
+ enabled: panZoomEnabled && shouldLoad,
+ portraitMode,
+ onChange: setActiveIndex
+ });
+
+ const scrollToArea = useCallback((index) => {
+ scrollFromToArea(activeIndex, index);
+ }, [scrollFromToArea, activeIndex]);
+
+ const activateArea = panZoomEnabled ? scrollToArea : setActiveIndex;
useContentElementEditorCommandSubscription(command => {
if (command.type === 'HIGHLIGHT_AREA') {
@@ -95,7 +132,7 @@ export function HotspotsImage({
setHighlightedIndex(-1);
}
else if (command.type === 'SET_ACTIVE_AREA') {
- setActiveIndex(command.index);
+ activateArea(command.index);
}
});
@@ -105,51 +142,76 @@ export function HotspotsImage({
aspectRatio={imageFile ? undefined : 0.75}
fill={configuration.position === 'backdrop'}
opaque={!imageFile}>
-
-
-
+ activateArea(index - 1)}>
+
+ {children(
-
-
+
+
+
+ {areas.map((area, index) =>
+
setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(-1)}
+ onClick={() => {
+ if (!isEditable || isSelected) {
+ activateArea(index)
+ }
+ }} />
+ )}
+
{areas.map((area, index) =>
-
setHoveredIndex(index)}
- onMouseLeave={() => setHoveredIndex(-1)}
- onClick={() => setActiveIndex(index)} />
+
)}
+ {panZoomEnabled &&
activateArea(index)} />}
{displayFullscreenToggle &&
}
-
-
- {areas.map((area, index) =>
-
setHoveredIndex(index)}
- onMouseLeave={() => setHoveredIndex(-1)}
- onClick={() => setActiveIndex(index)} />
- )}
-
+ )}
+ } />
+ {areas.map((area, index) =>
+ setHoveredIndex(index)}
+ onMouseLeave={() => setHoveredIndex(-1)}
+ onClick={() => setActiveIndex(index)}
+ onDismiss={() => activateArea(-1)} />
+ )}
+
+
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css
index bd387ab792..da81b62008 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css
@@ -1,3 +1,12 @@
+.compositeItem {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
.center {
display: flex;
flex-direction: column;
@@ -9,8 +18,26 @@
position: relative;
}
+.clip {
+ overflow: hidden;
+}
+
+.stack {
+ display: grid;
+ grid-auto-columns: 100%;
+ grid-auto-rows: 100%;
+ height: 100%;
+ isolation: isolate;
+}
+
+.stack > div {
+ grid-row: 1;
+ grid-column: 1;
+}
+
.wrapper {
- width: min-content;
+ transform-origin: 0 0;
+ width: 100%;
height: 100%;
}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js
index 665073a8be..f94759ab5b 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js
@@ -2,7 +2,7 @@ import React from 'react';
import styles from './Indicator.module.css';
-export function Indicator({area, portraitMode}) {
+export function Indicator({area, portraitMode, outerRef}) {
const indicatorPosition = (
portraitMode ?
area.portraitIndicatorPosition :
@@ -10,8 +10,10 @@ export function Indicator({area, portraitMode}) {
) || [50, 50];
return (
-
+
);
}
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 c0e71463de..a9d4a76c5d 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css
@@ -1,8 +1,20 @@
+.wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ transform-origin: 0 0;
+}
+
.indicator {
--size: 15px;
margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2);
animation: inner 2s infinite;
pointer-events: none;
+ z-index: 1;
+ transition: opacity 0.2s ease;
}
.indicator,
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 0000000000..770102c0e1
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.js
@@ -0,0 +1,34 @@
+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 (
+
+ );
+}
+
+export function insideScrollButton(element) {
+ return !!element.closest(`.${styles.button}`);
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css
new file mode 100644
index 0000000000..3b473ba595
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/ScrollButton.module.css
@@ -0,0 +1,60 @@
+.button {
+ height: 100%;
+ padding: 0;
+ background-color: transparent;
+ border: 0;
+ color: #fff;
+ transition: opacity 0.2s linear, visibility 0.2s;
+}
+
+.left {
+ text-align: left;
+}
+
+.right {
+ text-align: right;
+}
+
+.disabled {
+ opacity: 0;
+ visibility: hidden;
+}
+
+.button svg {
+ transition: transform 0.2s ease;
+ display: block;
+ width: var(--theme-hotspts-scroll-button-size);
+ height: var(--theme-hotspots-scroll-button-size);
+}
+
+.button:not(.disabled):hover svg,
+.button:not(.disabled):focus svg {
+ transform: scale(1.2);
+}
+
+.icon {
+ position: relative;
+}
+
+.icon::before {
+ content: ' ';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 50px;
+ height: 50px;
+ transform: translate(-50%, -50%);
+ background: radial-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%);
+ z-index: -1;
+}
+
+.visuallyHidden {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js
new file mode 100644
index 0000000000..c2b6331786
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import {ScrollButton} from './ScrollButton';
+
+import styles from './Scroller.module.css';
+
+export const Scroller = React.forwardRef(function Scroller(
+ {areas, activeIndex, onScrollButtonClick, noPointerEvents, setStepRef}, ref
+) {
+ return (
+ <>
+
+ onScrollButtonClick(activeIndex - 1)} />
+
+
+ onScrollButtonClick(
+ activeIndex >= areas.length - 1 ? -1 : activeIndex + 1
+ )}/>
+
+
+ {Array.from({length: areas.length + 2}, (_, index) =>
+
+ )}
+
+ >
+ );
+});
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css
new file mode 100644
index 0000000000..a5bfc51e86
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Scroller.module.css
@@ -0,0 +1,37 @@
+.scroller {
+ overflow: hidden;
+ overflow-x: auto;
+ display: grid;
+ grid-auto-columns: 100%;
+ grid-auto-flow: column;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ z-index: 1;
+}
+
+.scroller::-webkit-scrollbar {
+ display: none;
+}
+
+.noPointerEvents {
+ pointer-events: none;
+}
+
+.step {
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
+}
+
+.leftButton,
+.rightButton {
+ z-index: 2;
+}
+
+
+.leftButton {
+ justify-self: start;
+}
+
+.rightButton {
+ justify-self: end;
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js
index 4a7622aa83..20804c257c 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js
@@ -1,6 +1,18 @@
-import React, {useLayoutEffect, useRef, useState} from 'react';
+import React, {useRef} from 'react';
import classNames from 'classnames';
+import {
+ useFloating, useDismiss, useInteractions, useRole,
+ CompositeItem,
+ FloatingArrow, FloatingFocusManager,
+ arrow, shift, offset, flip,
+ autoUpdate
+} from '@floating-ui/react';
+
+import {TooltipPortal} from './TooltipPortal';
+import {useTooltipTransitionStyles} from './useTooltipTransitionStyles';
+import {insideScrollButton} from './ScrollButton';
+
import {
EditableText,
EditableInlineText,
@@ -12,26 +24,62 @@ import {
utils
} from 'pageflow-scrolled/frontend';
+import {getPanZoomStepTransform} from './panZoom';
+
import styles from './Tooltip.module.css';
export function Tooltip({
area,
contentElementId, portraitMode, configuration, visible, active,
- onMouseEnter, onMouseLeave, onClick
+ panZoomEnabled, imageFile, containerRect, flip: shouldFlip,
+ onMouseEnter, onMouseLeave, onClick, onDismiss,
}) {
- const {t} = useI18n();
+ const {t} = useI18n({locale: 'ui'});
const updateConfiguration = useContentElementConfigurationUpdate();
const {isEditable} = useContentElementEditorState();
- const indicatorPosition = (
- portraitMode ?
- area.portraitIndicatorPosition :
- area.indicatorPosition
- ) || [50, 50];
+ const indicatorPosition = getIndicatorPosition({
+ area,
+ contentElementId, portraitMode, configuration,
+ panZoomEnabled, imageFile, containerRect,
+ })
+
const tooltipTexts = configuration.tooltipTexts || {};
const tooltipLinks = configuration.tooltipLinks || {};
- const [ref, delta] = useKeepInViewport(visible);
+ const arrowRef = useRef();
+ const {refs, floatingStyles, context} = useFloating({
+ open: visible,
+ onOpenChange: open => !open && onDismiss(),
+ placement: area.tooltipPosition === 'above' ? 'top' : 'bottom',
+ middleware: [
+ offset(20),
+ shift(),
+ shouldFlip && flip(),
+ arrow({
+ element: arrowRef
+ })
+ ],
+ whileElementsMounted(referenceEl, floatingEl, update) {
+ return autoUpdate(referenceEl, floatingEl, update, {
+ elementResize: false,
+ layoutShift: false,
+ });
+ }
+ });
+
+ const role = useRole(context, {role: 'label'});
+
+ const dismiss = useDismiss(context, {
+ outsidePressEvent: 'mousedown',
+ outsidePress: event => !insideScrollButton(event.target)
+ });
+
+ const {getReferenceProps, getFloatingProps} = useInteractions([
+ role,
+ dismiss,
+ ]);
+ const {isMounted, styles: transitionStyles} = useTooltipTransitionStyles(context);
function handleTextChange(propertyName, value) {
updateConfiguration({
@@ -71,89 +119,90 @@ export function Tooltip({
}
return (
-
-
- {presentOrEditing('title') &&
-
-
- handleTextChange('title', value)}
- placeholder={t('pageflow_scrolled.inline_editing.type_heading')} />
-
-
}
- {presentOrEditing('description') &&
- handleTextChange('description', value)}
- scaleCategory="hotspotsTooltipDescription"
- placeholder={t('pageflow_scrolled.inline_editing.type_text')} />}
- {presentOrEditing('link') &&
-
- handleLinkChange(value)}>
- handleTextChange('link', value)}
- placeholder={t('pageflow_scrolled.inline_editing.type_text')} />
- ›
-
- }
-
-
- );
-}
-
-export function insideTooltip(element) {
- return !!element.closest(`.${styles.tooltip}`);
+ <>
+ }>
+
+
+ {isMounted &&
+
+
+
+
+
+ {presentOrEditing('title') &&
+
+
+ handleTextChange('title', value)}
+ placeholder={t('pageflow_scrolled.inline_editing.type_heading')} />
+
+
}
+ {presentOrEditing('description') &&
+ handleTextChange('description', value)}
+ scaleCategory="hotspotsTooltipDescription"
+ placeholder={t('pageflow_scrolled.inline_editing.type_text')} />}
+ {presentOrEditing('link') &&
+
+ handleLinkChange(value)}>
+ handleTextChange('link', value)}
+ placeholder={t('pageflow_scrolled.inline_editing.type_text')} />
+ ›
+
+ }
+
+
+
+ }
+ >
+);
}
-function useKeepInViewport(visible) {
- const ref = useRef();
- const [delta, setDelta] = useState(0);
-
- useLayoutEffect(() => {
- if (!visible) {
- return;
- }
-
- const current = ref.current;
-
- const intersectionObserver = new IntersectionObserver(
- entries => {
- if (entries[entries.length - 1].intersectionRatio < 1) {
- const rect = current.getBoundingClientRect();
-
- if (rect.left < 0) {
- setDelta(-rect.left);
- }
- else if (rect.right > document.body.clientWidth) {
- setDelta(document.body.clientWidth - rect.right);
- }
- }
- else {
- setDelta(0);
- }
- },
- {
- threshold: 1
- }
- );
+function getIndicatorPosition({
+ area,
+ portraitMode,
+ panZoomEnabled, imageFile, containerRect
+}) {
+ const indicatorPositionInPercent = (
+ portraitMode ?
+ area.portraitIndicatorPosition :
+ area.indicatorPosition
+ ) || [50, 50];
- intersectionObserver.observe(current);
+ if (panZoomEnabled) {
+ const transform = getPanZoomStepTransform({
+ areaOutline: portraitMode ? area.portraitOutline : area.outline,
+ areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0,
+ imageFileWidth: imageFile?.width,
+ imageFileHeight: imageFile?.height,
+ containerWidth: containerRect.width,
+ containerHeight: containerRect.height
+ });
- return () => intersectionObserver.unobserve(current);
- }, [visible]);
+ const indicatorPositionInPixels = [
+ containerRect.width * transform.scale * indicatorPositionInPercent[0] / 100 + transform.x,
+ containerRect.height * transform.scale * indicatorPositionInPercent[1] / 100 + transform.y
+ ];
- return [ref, delta];
+ return indicatorPositionInPixels.map(coord => `${coord}px`);
+ }
+ else {
+ return indicatorPositionInPercent.map(coord => `${coord}%`);
+ }
}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css
index 075473e688..1330696ef8 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css
@@ -1,52 +1,42 @@
-.tooltip {
+.compositeItem {
position: absolute;
- width: calc(100% - 2rem);
- max-width: 400px;
- transition: opacity 0.2s, visibility 0.2s;
- transition-delay: 0s;
- opacity: 0;
- visibility: hidden;
- z-index: 10;
- padding: 0 5px;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
}
-.tooltip::after {
- content: "";
+.reference {
position: absolute;
- left: calc(50% - 15px);
- border: solid 15px transparent;
-}
-
-.position-above {
- transform: translate(-50%, calc(-100% - 30px));
-}
-
-.position-above::after {
- top: 99%;
- border-top-color: #fff;
-}
-
-.position-below {
- transform: translate(-50%, 30px);
+ border: 0;
+ width: 0;
+ height: 0;
+ background: transparent;
}
-.position-below::after {
- bottom: 99%;
- border-bottom-color: #fff;
+.reference {
+ outline: none !important;
}
.box {
- transform: translateX(var(--delta));
+ font-family: var(--theme-entry-font-family);
background-color: #fff;
color: #000;
box-sizing: border-box;
padding: 1rem;
box-shadow: 0px 3px 3px -2px rgba(0,0,0,0.2), 0px 3px 4px 0px rgba(0,0,0,0.14), 0px 1px 8px 0px rgba(0,0,0,0.12);
border-radius: 5px;
+ width: calc(100% - 2rem);
+ max-width: 400px;
+}
+
+.box svg {
+ fill: #fff;
}
-.tooltip h3,
-.tooltip p {
+.box h3,
+.box p {
margin: 0;
}
@@ -77,12 +67,6 @@
margin-top: 0;
}
-.tooltip.visible {
- opacity: 1;
- visibility: visible;
- transition-delay: 0.2s;
-}
-
.editable .link {
opacity: 0.5;
}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js b/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js
new file mode 100644
index 0000000000..d56ee3afb1
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/TooltipPortal.js
@@ -0,0 +1,3 @@
+import {FloatingPortal} from '@floating-ui/react';
+
+export const TooltipPortal = FloatingPortal;
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js
new file mode 100644
index 0000000000..ae85b6b5fc
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/TooltipPortal.js
@@ -0,0 +1,3 @@
+export function TooltipPortal({children}) {
+ return children;
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js
new file mode 100644
index 0000000000..dc802d6cab
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/__mocks__/useTooltipTransitionStyles.js
@@ -0,0 +1,5 @@
+import {useTransitionStyles} from '@floating-ui/react';
+
+export function useTooltipTransitionStyles(context) {
+ return useTransitionStyles(context, {duration: 0});
+};
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js
index c97f2dc77b..677e20f3e2 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js
@@ -1,4 +1,4 @@
-import {ConfigurationEditorView, SelectInputView} from 'pageflow/ui';
+import {ConfigurationEditorView, SelectInputView, SliderInputView} from 'pageflow/ui';
import {editor, FileInputView} from 'pageflow/editor';
import Marionette from 'backbone.marionette';
import I18n from 'i18n-js';
@@ -9,8 +9,8 @@ import styles from './SidebarEditAreaView.module.css';
export const SidebarEditAreaView = Marionette.Layout.extend({
template: (data) => `
- ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.back')}
- ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.destroy')}
+ ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.back')}
+ ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.destroy')}
`,
@@ -38,6 +38,7 @@ export const SidebarEditAreaView = Marionette.Layout.extend({
const file = options.contentElement.configuration.getImageFile('image');
const portraitFile = options.contentElement.configuration.getImageFile('portraitImage');
+ const panZoomEnabled = options.contentElement.configuration.get('enablePanZoom') !== 'never';
if (file && portraitFile) {
this.previousEmulationMode = options.entry.get('emulation_mode') || 'desktop';
@@ -52,6 +53,11 @@ export const SidebarEditAreaView = Marionette.Layout.extend({
file,
portraitFile
});
+
+ if (panZoomEnabled) {
+ this.input('zoom', SliderInputView);
+ }
+
this.group('PaletteColor', {
propertyName: 'color',
entry: options.entry
@@ -81,6 +87,11 @@ export const SidebarEditAreaView = Marionette.Layout.extend({
portraitFile,
defaultTab: 'portrait'
});
+
+ if (panZoomEnabled) {
+ this.input('portraitZoom', SliderInputView);
+ }
+
this.group('PaletteColor', {
propertyName: 'portraitColor',
entry: options.entry
@@ -117,7 +128,7 @@ export const SidebarEditAreaView = Marionette.Layout.extend({
},
destroyLink: function () {
- if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.confirm_delete_link'))) {
+ if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.confirm_delete_link'))) {
this.options.collection.remove(this.model);
this.goBack();
}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js
index 256e8ad153..e73cd7ec72 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js
@@ -51,11 +51,6 @@ editor.contentElementTypes.register('hotspots', {
this.input('enablePanZoom', SelectInputView, {
values: ['phonePlatform', 'always', 'never']
});
- this.input('panZoomInitially', CheckBoxInputView, {
- disabledBinding: 'panZoom',
- disabled: panZoom => panZoom !== 'always',
- displayUncheckedIfDisabled: true
- });
this.view(SeparatorView);
this.input('enableFullscreen', CheckBoxInputView, {
disabledBinding: ['position', 'width'],
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js
index 062b5d8b83..7b00d1ae82 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js
@@ -1,4 +1,5 @@
import Backbone from 'backbone';
+import _ from 'underscore';
import {Area} from './Area';
@@ -10,11 +11,31 @@ export const AreasCollection = Backbone.Collection.extend({
this.entry = options.entry;
this.contentElement = options.contentElement;
- this.listenTo(this, 'add remove change sort', this.updateConfiguration);
+ this.listenTo(this, 'add change sort', this.updateConfiguration);
+ this.listenTo(this, 'remove', () => this.updateConfiguration({pruneTooltips: true}));
},
- updateConfiguration() {
- this.contentElement.configuration.set('areas', this.toJSON());
+ updateConfiguration({pruneTooltips}) {
+ let updatedAttributes = {areas: this.toJSON()};
+
+ if (pruneTooltips) {
+ updatedAttributes = {
+ ...updatedAttributes,
+ ...this.getPrunedProperty('tooltipTexts'),
+ ...this.getPrunedProperty('tooltipLinks')
+ };
+ }
+
+ this.contentElement.configuration.set(updatedAttributes);
+ },
+
+ getPrunedProperty(propertyName) {
+ return {
+ [propertyName]: _.pick(
+ this.contentElement.configuration.get(propertyName) || {},
+ ...this.pluck('id')
+ )
+ };
},
addWithId(model) {
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg
index 510c81b3c5..27ebd23b22 100644
--- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg
@@ -1 +1,9 @@
-
+
+
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js
new file mode 100644
index 0000000000..88bb112f87
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/panZoom.js
@@ -0,0 +1,48 @@
+export function getPanZoomStepTransform({
+ imageFileWidth, imageFileHeight, areaOutline, areaZoom, containerWidth, containerHeight, indicatorPositions = []
+}) {
+ const rect = getBoundingRect(areaOutline || []);
+
+ const displayImageWidth = imageFileWidth * containerHeight / imageFileHeight;
+ const displayImageHeight = containerHeight;
+
+ const displayAreaWidth = rect.width / 100 * displayImageWidth;
+ const displayAreaLeft = rect.left / 100 * displayImageWidth;
+ const displayAreaHeight = rect.height / 100 * displayImageHeight;
+ const displayAreaTop = rect.top / 100 * displayImageHeight;
+
+ const scale = (100 - areaZoom) / 100 + (areaZoom / 100) * containerHeight / (displayAreaHeight + 0);
+
+ let translateX = (containerWidth - displayAreaWidth * scale) / 2 - displayAreaLeft * scale;
+ let translateY = (containerHeight - displayAreaHeight * scale - 0) / 2 - displayAreaTop * scale;
+
+ translateX = Math.min(0, Math.max(containerWidth - displayImageWidth * scale, translateX));
+ translateY = Math.min(0, Math.max(containerHeight - displayImageHeight * scale, translateY));
+
+ return {
+ x: translateX,
+ y: translateY,
+ indicators: indicatorPositions.map(indicatorPosition => ({
+ x: translateX + displayImageWidth * indicatorPosition[0] / 100 * (scale - 1),
+ y: translateY + displayImageHeight * indicatorPosition[1] / 100 * (scale - 1)
+ })),
+ scale
+ };
+}
+
+function getBoundingRect(area) {
+ const xCoords = area.map(point => point[0]);
+ const yCoords = area.map(point => point[1]);
+
+ const minX = Math.min(...xCoords);
+ const maxX = Math.max(...xCoords);
+ const minY = Math.min(...yCoords);
+ const maxY = Math.max(...yCoords);
+
+ return {
+ left: minX,
+ top: minY,
+ width: maxX - minX,
+ height: maxY - minY
+ };
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/stories.js b/entry_types/scrolled/package/src/contentElements/hotspots/stories.js
new file mode 100644
index 0000000000..b5f1c58556
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/stories.js
@@ -0,0 +1,32 @@
+import './frontend';
+import {storiesOfContentElement, filePermaId} from 'pageflow-scrolled/spec/support/stories';
+
+storiesOfContentElement(module, {
+ typeName: 'hotspots',
+ baseConfiguration: {
+ image: filePermaId('imageFiles', 'turtle'),
+ width: 2,
+ initialActiveArea: 0,
+ areas: [
+ {
+ id: 1,
+ outline: [[20, 30], [50, 30], [50, 50], [20, 50]],
+ indicatorPosition: [25, 45]
+ }
+ ],
+ tooltipTexts: {
+ 1: {
+ title: [{children: [{text: 'Some Title'}]}],
+ description: [{type: 'paragraph', children: [{text: 'This text describes area'}]}],
+ link: [{children: [{text: 'Read more'}]}]
+ }
+ }
+ },
+ variants: [
+ {
+ name: 'With Caption',
+ configuration: {caption: 'Some text here'}
+ }
+ ],
+ inlineFileRights: true
+});
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js b/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js
new file mode 100644
index 0000000000..74759a7d28
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/useContentRect.js
@@ -0,0 +1,28 @@
+import {useEffect, useRef, useState} from 'react';
+
+export function useContentRect({enabled}) {
+ const [contentRect, setContentRect] = useState({width: 0, height: 0});
+ const ref = useRef();
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ const current = ref.current;
+
+ const resizeObserver = new ResizeObserver(
+ entries => {
+ setContentRect(entries[entries.length - 1].contentRect);
+ }
+ );
+
+ resizeObserver.observe(current);
+
+ return () => {
+ resizeObserver.unobserve(current);
+ };
+ }, [enabled]);
+
+ return [contentRect, ref];
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js b/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js
new file mode 100644
index 0000000000..cf5f0dc224
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/useIntersectionObserver.js
@@ -0,0 +1,65 @@
+import {useRef, useEffect} from 'react';
+
+export function useIntersectionObserver({threshold, onVisibleIndexChange, enabled}) {
+ const containerRef = useRef();
+ const childRefs = useRef([]);
+ const observerRef = useRef();
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ const observer = observerRef.current = new IntersectionObserver(
+ (entries) => {
+ const containerElement = containerRef.current;
+
+ let found = false;
+
+ entries.forEach((entry) => {
+ const entryIndex = Array.from(containerElement.children).findIndex(
+ (child) => child === entry.target
+ );
+
+ if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
+ found = true;
+ onVisibleIndexChange(entryIndex);
+ }
+ });
+
+ if (!found) {
+ onVisibleIndexChange(-1);
+ }
+ },
+ {
+ root: containerRef.current,
+ threshold
+ }
+ );
+
+ childRefs.current.forEach((child) => {
+ if (child) {
+ observer.observe(child);
+ }
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [enabled, threshold, onVisibleIndexChange]);
+
+ const setChildRef = (index) => (ref) => {
+ if (observerRef.current) {
+ if (ref) {
+ observerRef.current.observe(ref);
+ }
+ else {
+ observerRef.current.unobserve(childRefs.current[index]);
+ }
+ }
+
+ childRefs.current[index] = ref;
+ };
+
+ return [containerRef, setChildRef];
+};
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js
new file mode 100644
index 0000000000..85c3de89b6
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/useScrollPanZoom.js
@@ -0,0 +1,140 @@
+import {useRef, useCallback, useMemo} from 'react';
+import {useIsomorphicLayoutEffect} from 'pageflow-scrolled/frontend';
+
+import {useIntersectionObserver} from './useIntersectionObserver';
+import {getPanZoomStepTransform} from './panZoom';
+
+export function useScrollPanZoom({
+ imageFile, containerRect, areas,
+ enabled, portraitMode,
+ onChange
+}) {
+ const wrapperRef = useRef();
+ const indicatorRefs = useRef([]);
+
+ const onVisibleIndexChange = useCallback(index => onChange(index - 1), [onChange]);
+ const [scrollerRef, setStepRef] = useIntersectionObserver({
+ enabled,
+ threshold: 0.7,
+ onVisibleIndexChange
+ });
+
+ const imageFileWidth = imageFile?.width;
+ const imageFileHeight = imageFile?.height;
+
+ const containerWidth = containerRect.width;
+ const containerHeight = containerRect.height;
+
+ const steps = useMemo(() => {
+ if (!enabled || !containerWidth) {
+ return;
+ }
+
+ return [
+ {
+ x: 0,
+ y: 0,
+ scale: 1,
+ indicators: []
+ },
+ ...areas.map(area => getPanZoomStepTransform({
+ areaOutline: portraitMode ? area.portraitOutline : area.outline,
+ areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0,
+ indicatorPositions: areas.map(area => (portraitMode ? area.portraitIndicatorPosition : area.indicatorPosition) || [50, 50]),
+ imageFileWidth,
+ imageFileHeight,
+ containerWidth,
+ containerHeight
+ })),
+ {
+ x: 0,
+ y: 0,
+ scale: 1,
+ indicators: []
+ }
+ ];
+ }, [
+ areas,
+ enabled,
+ imageFileWidth,
+ imageFileHeight,
+ containerWidth,
+ containerHeight,
+ portraitMode
+ ]);
+
+ const scrollFromTo = useCallback((from, to) => {
+ const scroller = scrollerRef.current;
+ const step = scroller.children[to + 1];
+
+ scroller.scrollTo(Math.abs(scroller.offsetLeft - step.offsetLeft), 0);
+
+ wrapperRef.current.animate(
+ [
+ keyframe(steps[from + 1]),
+ keyframe(steps[to + 1])
+ ],
+ {
+ duration: 200
+ }
+ );
+
+ areas.forEach((area, index) => {
+ indicatorRefs.current[index].animate(
+ [
+ keyframe(steps[from + 1].indicators?.[index] || {x: 0, y: 0}),
+ keyframe(steps[to + 1].indicators?.[index] || {x: 0, y: 0})
+ ],
+ {
+ duration: 200
+ }
+ );
+ });
+ }, [scrollerRef, steps, areas]);
+
+ useIsomorphicLayoutEffect(() => {
+ if (!steps) {
+ return;
+ }
+
+ const scrollTimeline = new window.ScrollTimeline({
+ source: scrollerRef.current,
+ axis: 'inline'
+ });
+
+ const animations = []
+
+ animations.push(wrapperRef.current.animate(
+ steps.map(keyframe),
+ {
+ fill: 'both',
+ timeline: scrollTimeline
+ }
+ ));
+
+ areas.forEach((area, index) => {
+ animations.push(indicatorRefs.current[index].animate(
+ steps.map(step => keyframe(step.indicators?.[index] || {x: 0, y: 0})),
+ {
+ fill: 'both',
+ timeline: scrollTimeline
+ }
+ ));
+ });
+
+ return () => animations.forEach(animation => animation.cancel());
+ }, [areas, steps]);
+
+ const setIndicatorRef = index => ref => {
+ indicatorRefs.current[index] = ref;
+ }
+
+ return [wrapperRef, scrollerRef, setStepRef, setIndicatorRef, scrollFromTo];
+}
+
+function keyframe(step) {
+ return {
+ transform: `translate(${step.x}px, ${step.y}px) scale(${step.scale || 1})`,
+ easing: 'ease',
+ };
+}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js b/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js
new file mode 100644
index 0000000000..1d10e978b8
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/hotspots/useTooltipTransitionStyles.js
@@ -0,0 +1,3 @@
+import {useTransitionStyles} from '@floating-ui/react';
+
+export const useTooltipTransitionStyles = useTransitionStyles;
diff --git a/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js b/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js
index 5bc5fb28c8..033aa59103 100644
--- a/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js
+++ b/entry_types/scrolled/package/src/frontend/PhonePlatformContext.js
@@ -1,5 +1,3 @@
import React from 'react';
-const PhonePlatformContext = React.createContext(false);
-
-export default PhonePlatformContext;
\ No newline at end of file
+export const PhonePlatformContext = React.createContext(false);
diff --git a/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js b/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js
index 862a3e942a..4909bdeaa4 100644
--- a/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js
+++ b/entry_types/scrolled/package/src/frontend/PhonePlatformProvider.js
@@ -1,6 +1,6 @@
import React from 'react';
-import PhonePlatformContext from './PhonePlatformContext';
+import {PhonePlatformContext} from './PhonePlatformContext';
import {useBrowserFeature} from './useBrowserFeature';
import {withInlineEditingAlternative} from './inlineEditing';
diff --git a/entry_types/scrolled/package/src/frontend/global.module.css b/entry_types/scrolled/package/src/frontend/global.module.css
index 2c7b14c9c7..f47dba4ddb 100644
--- a/entry_types/scrolled/package/src/frontend/global.module.css
+++ b/entry_types/scrolled/package/src/frontend/global.module.css
@@ -19,3 +19,8 @@
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
+
+:global [data-floating-ui-portal] {
+ position: relative;
+ z-index: 30000;
+}
diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js
index 6f6f87a388..351a957b28 100644
--- a/entry_types/scrolled/package/src/frontend/index.js
+++ b/entry_types/scrolled/package/src/frontend/index.js
@@ -96,11 +96,12 @@ export {useMediaMuted, useOnUnmuteMedia} from './useMediaMuted';
export {usePortraitOrientation} from './usePortraitOrientation';
export {useScrollPosition} from './useScrollPosition';
export {usePhonePlatform} from './usePhonePlatform';
+export {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
export {EditableText} from './EditableText';
export {EditableInlineText} from './EditableInlineText';
export {EditableLink} from './EditableLink';
-export {PhonePlatformProvider} from './PhonePlatformProvider';
+export {PhonePlatformContext} from './PhonePlatformContext';
export {
OptIn as ThirdPartyOptIn,
OptOutInfo as ThirdPartyOptOutInfo,
diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js b/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js
index 9d5f81e451..91f6b1255d 100644
--- a/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js
+++ b/entry_types/scrolled/package/src/frontend/inlineEditing/PhonePlatformProvider.js
@@ -1,6 +1,6 @@
import React, {useEffect, useState} from 'react';
-import PhonePlatformContext from '../PhonePlatformContext';
+import {PhonePlatformContext} from '../PhonePlatformContext';
export function PhonePlatformProvider({children}) {
@@ -27,4 +27,4 @@ export function PhonePlatformProvider({children}) {
{children}
);
-}
\ No newline at end of file
+}
diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js
index 6d1af3aa62..3cfdce6d7a 100644
--- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js
@@ -50,7 +50,8 @@ export function SectionDecorator({backdrop, section, contentElements, transition
function selectIfOutsideContentItem(event) {
if (!event.target.closest(`.${contentElementStyles.wrapper}`) &&
!event.target.closest(`.${backdropStyles.wrapper}`) &&
- !event.target.closest('#fullscreenRoot')) {
+ !event.target.closest('#fullscreenRoot') &&
+ !event.target.closest('[data-floating-ui-portal]')) {
isSelected ? resetSelection() : select();
}
}
diff --git a/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js b/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js
index 2cb90d326c..b7d80f81bd 100644
--- a/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js
+++ b/entry_types/scrolled/package/src/frontend/useContentElementEditorState.js
@@ -3,7 +3,8 @@ import {useContext, createContext} from 'react';
export const ContentElementEditorStateContext = createContext({
isSelected: false,
isEditable: false,
- setTransientState() {}
+ setTransientState() {},
+ select() {}
});
/**
diff --git a/entry_types/scrolled/package/src/frontend/usePhonePlatform.js b/entry_types/scrolled/package/src/frontend/usePhonePlatform.js
index 320e09287c..f7f42ae3d8 100644
--- a/entry_types/scrolled/package/src/frontend/usePhonePlatform.js
+++ b/entry_types/scrolled/package/src/frontend/usePhonePlatform.js
@@ -1,5 +1,5 @@
import React from 'react';
-import PhonePlatformContext from './PhonePlatformContext';
+import {PhonePlatformContext} from './PhonePlatformContext';
export function usePhonePlatform() {
return React.useContext(PhonePlatformContext);
diff --git a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js
index 458f9b13f0..7a93883b4b 100644
--- a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js
+++ b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js
@@ -6,7 +6,8 @@ import {
ContentElementAttributesProvider,
ContentElementEditorCommandEmitterContext,
ContentElementEditorStateContext,
- ContentElementLifecycleContext
+ ContentElementLifecycleContext,
+ PhonePlatformContext
} from 'pageflow-scrolled/frontend';
import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycle';
@@ -20,6 +21,7 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl
* @param {Function} callback - React component or function returning a React component.
* @param {Object} [options] - Supports all options supported by {@link `renderInEntry`}.
* @param {Object} [options.editorState] - Fake result of `useContentElementEditorState`.
+ * @param {Object} [options.phonePlatform] - Fake result of `usePhonePlatform`.
*
* @example
*
@@ -30,22 +32,27 @@ import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycl
* simulateScrollPosition('near viewport');
* triggerEditorCommand({type: 'HIGHLIGHT'});
*/
-export function renderInContentElement(ui, {editorState, wrapper, ...options}) {
+export function renderInContentElement(ui, {editorState,
+ phonePlatform = false,
+ wrapper: OriginalWrapper,
+ ...options}) {
const emitter = Object.assign({}, BackboneEvents);
function Wrapper({children}) {
const defaultEditorState = useContext(ContentElementEditorStateContext);
return (
-
-
-
- {wrapper ? : children}
-
-
-
+
+
+
+
+ {OriginalWrapper ? : children}
+
+
+
+
);
}
diff --git a/yarn.lock b/yarn.lock
index 01cc2f03e7..f382674064 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1530,6 +1530,41 @@
resolved "https://registry.yarnpkg.com/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz#c05ed35ad82df8e6ac616c68b92c2282bd083ba4"
integrity sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==
+"@floating-ui/core@^1.6.0":
+ version "1.6.4"
+ resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.4.tgz#0140cf5091c8dee602bff9da5ab330840ff91df6"
+ integrity sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==
+ dependencies:
+ "@floating-ui/utils" "^0.2.4"
+
+"@floating-ui/dom@^1.0.0":
+ version "1.6.7"
+ resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.7.tgz#85d22f731fcc5b209db504478fb1df5116a83015"
+ integrity sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==
+ dependencies:
+ "@floating-ui/core" "^1.6.0"
+ "@floating-ui/utils" "^0.2.4"
+
+"@floating-ui/react-dom@^2.1.1":
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.1.tgz#cca58b6b04fc92b4c39288252e285e0422291fb0"
+ integrity sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+
+"@floating-ui/react@https://github.com/tf/floating-ui-react#react-16-focus-fix":
+ version "0.26.20"
+ resolved "https://github.com/tf/floating-ui-react#47ea5ce39b43c58be2e341c27e22680b7d36ec8d"
+ dependencies:
+ "@floating-ui/react-dom" "^2.1.1"
+ "@floating-ui/utils" "^0.2.5"
+ tabbable "^6.0.0"
+
+"@floating-ui/utils@^0.2.4", "@floating-ui/utils@^0.2.5":
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.5.tgz#105c37d9d9620ce69b7f692a20c821bf1ad2cbf9"
+ integrity sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==
+
"@headlessui/react@^1.6.6":
version "1.6.6"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.6.tgz#3073c066b85535c9d28783da0a4d9288b5354d0c"
@@ -11999,7 +12034,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -12020,13 +12055,6 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -12189,6 +12217,11 @@ synchronous-promise@^2.0.15:
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.17.tgz#38901319632f946c982152586f2caf8ddc25c032"
integrity sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==
+tabbable@^6.0.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
+ integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
+
table@^5.2.3:
version "5.4.6"
resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
@@ -13015,16 +13048,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
-wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==