Skip to content

Commit

Permalink
Allow both scrolling via swipe and clicking hotspots
Browse files Browse the repository at this point in the history
Render invisible areas as click targets in sticky container in
pan-zoom scroller. Apply same scroll timeline based transform
animation to keep invisible areas in sync.

REDMINE-20673
  • Loading branch information
tf committed Aug 7, 2024
1 parent f9f8bed commit 5dd7681
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import {Hotspots} from 'contentElements/hotspots/Hotspots';
import areaStyles from 'contentElements/hotspots/Area.module.css';
import imageAreaStyles from 'contentElements/hotspots/ImageArea.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';
Expand Down Expand Up @@ -260,11 +261,14 @@ describe('Hotspots', () => {
};

const {container} = renderInContentElement(
<Hotspots configuration={configuration} />, {seed}
<Hotspots configuration={configuration} />, {
seed,
editorState: {isSelected: true, isEditable: true}
}
);

expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({
'--color': 'var(--theme-palette-color-accent)',
expect(container.querySelector(`.${areaStyles.area} svg polygon`)).toHaveStyle({
'stroke': 'var(--theme-palette-color-accent)',
});
expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({
'--color': 'var(--theme-palette-color-accent)',
Expand Down Expand Up @@ -294,7 +298,7 @@ describe('Hotspots', () => {
<Hotspots configuration={configuration} />, {seed}
);

expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({
expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({
'--color': 'var(--theme-palette-color-primary)',
});
});
Expand All @@ -321,7 +325,7 @@ describe('Hotspots', () => {
<Hotspots configuration={configuration} />, {seed}
);

expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({
expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({
'--color': 'var(--theme-palette-color-accent)',
});
});
Expand All @@ -348,7 +352,7 @@ describe('Hotspots', () => {
<Hotspots configuration={configuration} />, {seed}
);

expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({
expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({
'--color': 'var(--theme-palette-color-accent)',
});
});
Expand Down Expand Up @@ -1158,11 +1162,11 @@ describe('Hotspots', () => {
<Hotspots configuration={configuration} />, {seed}
);

expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(imageAreaStyles.activeImageVisible);

await user.click(container.querySelector(`.${areaStyles.clip}`));

expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(imageAreaStyles.activeImageVisible);
});

it('does not hide other indicators when area is activated', async () => {
Expand Down Expand Up @@ -1221,15 +1225,15 @@ describe('Hotspots', () => {
<Hotspots configuration={configuration} />, {seed}
);

expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(imageAreaStyles.activeImageVisible);

await user.hover(container.querySelector(`.${areaStyles.clip}`));

expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(imageAreaStyles.activeImageVisible);

await user.unhover(container.querySelector(`.${areaStyles.clip}`));

expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(imageAreaStyles.activeImageVisible);
});

it('does not render area outline as svg by default', () => {
Expand Down Expand Up @@ -1643,7 +1647,7 @@ describe('Hotspots', () => {
source: expect.any(HTMLDivElement),
axis: 'inline'
});
expect(animateMock).toHaveBeenCalledTimes(2);
expect(animateMock).toHaveBeenCalledTimes(3);
expect(animateMock).toHaveBeenCalledWith(
Array.from({length: 3}, () => expect.objectContaining({
transform: expect.any(String),
Expand Down Expand Up @@ -1853,7 +1857,7 @@ describe('Hotspots', () => {
intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
.mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);

expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible);
expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(imageAreaStyles.activeImageVisible);

});

Expand Down Expand Up @@ -2083,59 +2087,7 @@ describe('Hotspots', () => {

await user.hover(container.querySelector(`.${areaStyles.clip}`));

expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible);
});

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(
<Hotspots configuration={configuration} />, {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(
<Hotspots configuration={configuration} />, {seed}
);
simulateScrollPosition('near viewport');
intersectionObserverByRoot(container.querySelector(`.${scrollerStyles.scroller}`))
.mockIntersecting(container.querySelectorAll(`.${scrollerStyles.step}`)[1]);

expect(container.querySelector(`.${scrollerStyles.scroller}`))
.not.toHaveClass(scrollerStyles.noPointerEvents);
expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(imageAreaStyles.activeImageVisible);
});

it('accounts for pan zoom in tooltip position', () => {
Expand Down
46 changes: 16 additions & 30 deletions entry_types/scrolled/package/src/contentElements/hotspots/Area.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,31 @@
import React from 'react';
import classNames from 'classnames';

import {
Image,
paletteColor,
useContentElementEditorState,
useContentElementLifecycle,
useFile
} from 'pageflow-scrolled/frontend';
import {paletteColor} from 'pageflow-scrolled/frontend';

import styles from './Area.module.css';

export function Area({
area, contentElementId, portraitMode, panZoomEnabled, highlighted, activeImageVisible,
area, portraitMode,
highlighted, outlined,
className, children,
onMouseEnter, onMouseLeave, onClick
}) {
const {isEditable, isSelected} = useContentElementEditorState();
const {shouldLoad} = useContentElementLifecycle();

const activeImageFile = useFile({
collectionName: 'imageFiles', permaId: area.activeImage
});
const portraitActiveImageFile = useFile({
collectionName: 'imageFiles', permaId: area.portraitActiveImage
});

const imageFile = portraitMode && portraitActiveImageFile ? portraitActiveImageFile : activeImageFile
const outline = portraitMode ? area.portraitOutline : area.outline;

return (
<div className={classNames(styles.area, {[styles.highlighted]: highlighted,
[styles.activeImageVisible]: activeImageVisible})}
style={{'--color': areaColor(area, portraitMode)}}>
<div className={classNames(styles.area,
className,
{[styles.highlighted]: highlighted})}>
<div className={styles.clip}
style={{clipPath: polygon(outline)}}
tabIndex="-1"
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} />
<Image imageFile={imageFile}
load={shouldLoad}
variant={panZoomEnabled ? 'ultra' : 'large'}
preferSvg={true} />
{isEditable && isSelected && <Outline points={outline} />}
{children}
{outlined && <Outline points={outline}
color={areaColor(area, portraitMode)} />}
</div>
);
}
Expand All @@ -51,10 +34,13 @@ export function areaColor(area, portraitMode) {
return paletteColor(portraitMode ? (area.portraitColor || area.color) : area.color);
}

function Outline({points}) {
function Outline({points, color}) {
return (
<svg className={styles.outline} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon points={(points || []).map(coords => coords.map(coord => coord).join(',')).join(' ')} />
<svg className={styles.outline} xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="none">
<polygon points={(points || []).map(coords => coords.map(coord => coord).join(',')).join(' ')}
style={{stroke: color}} />
</svg>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,11 @@
vector-effect: non-scaling-stroke;
stroke-width: 1px;
stroke-linejoin: round;
stroke: var(--color, #fff);
stroke: #fff;
fill: transparent;
opacity: 0.5;
}

.area.highlighted .outline polygon {
opacity: 1;
}

.area img {
opacity: 0;
transition: opacity 0.2s linear;
}

.activeImageVisible img {
opacity: 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {

import {Scroller} from './Scroller';
import {Area} from './Area';
import {ImageArea} from './ImageArea';
import {Indicator} from './Indicator';
import {Tooltip} from './Tooltip';

Expand Down Expand Up @@ -110,7 +111,7 @@ export function HotspotsImage({
enabled: shouldLoad
});

const [wrapperRef, scrollerRef, setScrollerStepRef, setIndicatorRef, scrollFromToArea] = useScrollPanZoom({
const [wrapperRef, scrollerRef, scrollerAreasRef, setScrollerStepRef, setIndicatorRef, scrollFromToArea] = useScrollPanZoom({
containerRect,
imageFile,
areas,
Expand Down Expand Up @@ -158,21 +159,25 @@ export function HotspotsImage({
variant={panZoomEnabled ? 'ultra' : 'large'}
preferSvg={true} />
{areas.map((area, index) =>
<Area key={index}
area={area}
contentElementId={contentElementId}
panZoomEnabled={panZoomEnabled}
portraitMode={portraitMode}
activeImageVisible={activeIndex === index ||
(!panZoomEnabled && activeIndex < 0 && hoveredIndex === index)}
highlighted={hoveredIndex === index || highlightedIndex === index || activeIndex === index}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onClick={() => {
if (!isEditable || isSelected) {
activateArea(index)
}
}} />
<ImageArea key={index}
area={area}
panZoomEnabled={panZoomEnabled}
portraitMode={portraitMode}
activeImageVisible={activeIndex === index ||
(!panZoomEnabled &&
activeIndex < 0 &&
hoveredIndex === index)}
outlined={isEditable && isSelected}
highlighted={hoveredIndex === index ||
highlightedIndex === index ||
activeIndex === index}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onClick={() => {
if (!isEditable || isSelected) {
activateArea(index)
}
}} />
)}
</div>
{areas.map((area, index) =>
Expand All @@ -182,11 +187,29 @@ export function HotspotsImage({
outerRef={setIndicatorRef(index)}
portraitMode={portraitMode} />
)}
{panZoomEnabled && <Scroller areas={areas}
ref={scrollerRef}
setStepRef={setScrollerStepRef}
activeIndex={activeIndex}
onScrollButtonClick={index => activateArea(index)} />}
{panZoomEnabled &&
<Scroller areas={areas}
ref={scrollerRef}
setStepRef={setScrollerStepRef}
activeIndex={activeIndex}
onScrollButtonClick={index => activateArea(index)}
containerRect={containerRect}>
<div className={styles.wrapper}
ref={scrollerAreasRef}>
{areas.map((area, index) =>
<Area key={index}
area={area}
portraitMode={portraitMode}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onClick={() => {
if (!isEditable || isSelected) {
activateArea(index)
}
}} />
)}
</div>
</Scroller>}
</div>
{displayFullscreenToggle &&
<ToggleFullscreenCornerButton isFullscreen={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import classNames from 'classnames';

import {Area} from './Area';

import {
Image,
useContentElementLifecycle,
useFile
} from 'pageflow-scrolled/frontend';

import styles from './ImageArea.module.css';

export function ImageArea({
panZoomEnabled,
activeImageVisible,
...props
}) {
const {shouldLoad} = useContentElementLifecycle();

const activeImageFile = useFile({
collectionName: 'imageFiles', permaId: props.area.activeImage
});
const portraitActiveImageFile = useFile({
collectionName: 'imageFiles', permaId: props.area.portraitActiveImage
});

const imageFile = props.portraitMode && portraitActiveImageFile ?
portraitActiveImageFile :
activeImageFile

return (
<Area {...props}
className={classNames(styles.area,
{[styles.activeImageVisible]: activeImageVisible})}>
<Image imageFile={imageFile}
load={shouldLoad}
variant={panZoomEnabled ? 'ultra' : 'large'}
preferSvg={true} />
</Area>
);
}
Loading

0 comments on commit 5dd7681

Please sign in to comment.