diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml new file mode 100644 index 0000000000..73adc4eaf7 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -0,0 +1,79 @@ +de: + pageflow: + hotspots_content_element: + feature_name: Hotspots Inhaltselement + pageflow_scrolled: + public: + more: Mehr + inline_editing: + select_link_destination: "Link-Ziel auswählen" + change_link_destination: "Link-Ziel ändern" + editor: + content_elements: + hotspots: + edit_area: + tabs: + area: Hotspot-Breiech + portrait: Hochkant + attributes: + tooltipPosition: + label: Tooltip-Position + values: + below: Unterhalb + above: Oberhalb + color: + label: Farbe + activeImage: + label: Aktives Bild + area: + label: Bereich + portraitTooltipPosition: + label: Tooltip-Position (Hochkant) + values: + below: Below + above: Above + portraitColor: + label: Farbe (Hochkant) + portraitActiveImage: + label: Aktives Bild (Hochkant) + portraitArea: + label: Bereich (Hochkant) + edit_area_dialog: + header: Bereichsumriss und Indikatorposition + tabs: + default: Standard-Bild + portrait: Hochkant-Bild + modes: + rect: Rechteck + polygon: Polygon + hotspots_image: Hotspotbild + double_click_to_delete: Doppelklick, um Punkt zu entfernen + indicator_title: Ziehen um Indikator zu positionieren + save: Speichern + cancel: Abbrechen + areas: + add: Hinzufügen + label: Bereiche + confirm_delete: Soll der Bereich wirklich gelöscht werden? + area_input: + edit: Umriss und Indikatorposition bearbeiten + attributes: + image: + label: Bild + portraitImage: + inline_help: Wird gezeigt, wenn der Browser-Viewport höher als breit ist - zum Beispiel auf Smartphones oder Tablets in Portrait-Ausrichtung. Kann als Alternative zu einem querformatigen Bild konfiguriert werden, das ansonsten zu klein dargestellt würde. + label: Bild (Hochkant) + enablePanZoom: + label: Pan & Zoom + values: + phonePlatform: Im Phone-Layout + always: Immer + never: Nie + panZoomInitially: + label: Pan & Zoom bei erstem Bereich starten + enableFullscreen: + label: Vollbildmodus erlauben + description: Bild mit anklickbaren Bereichen + name: Hotspots + tabs: + general: Hotspots diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml new file mode 100644 index 0000000000..adb6cc399c --- /dev/null +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -0,0 +1,79 @@ +en: + pageflow: + hotspots_content_element: + feature_name: Hotspots content element + pageflow_scrolled: + public: + more: More + inline_editing: + select_link_destination: "Select link destination" + change_link_destination: "Change link destination" + editor: + content_elements: + hotspots: + edit_area: + tabs: + area: Hotspot Area + portrait: Portrait + attributes: + tooltipPosition: + label: Tooltip orientation + values: + below: Below + above: Above + color: + label: Color + activeImage: + label: Active image + area: + label: Area + portraitTooltipPosition: + label: Tooltip orientation (Portrait) + values: + below: Below + above: Above + portraitColor: + label: Color (Portrait) + portraitActiveImage: + label: Active image (Portrait) + portraitArea: + label: Area (Portrait) + edit_area_dialog: + header: Area outline and indicator position + tabs: + default: Default image + portrait: Portrait image + modes: + rect: Rectangle + polygon: Polygon + hotspots_image: Hotspots image + double_click_to_delete: Double click to remove point + indicator_title: Drag to position indicator + save: Save + cancel: Cancel + areas: + add: Add + label: Areas + confirm_delete: Are you sure you want to delete this area? + area_input: + edit: Edit outline and indicator position + attributes: + image: + label: Image + portraitImage: + inline_help: Displayed when the browser viewport is taller than wide, for example on phones or tablets in portrait orientation. Can be used to provide an alternative to a landscape image that would otherwise be displayed too small. + label: Image (Portrait) + enablePanZoom: + label: Pan zoom + values: + phonePlatform: In phone layout + always: Always + never: Never + panZoomInitially: + label: Pan to first area initially + enableFullscreen: + label: Enable fullscreen + description: Image with clickable areas + name: Hotspots + tabs: + general: Hotspots diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 91d1640e01..5b7aecd6c2 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -14,10 +14,12 @@ def configure(config) c.revision_components.register(Storyline) - c.additional_frontend_packs.register( - 'pageflow-scrolled/contentElements/tikTokEmbed-frontend', - content_element_type_names: ['tikTokEmbed'] - ) + ['tikTokEmbed', 'hotspots'].each do |name| + c.additional_frontend_packs.register( + "pageflow-scrolled/contentElements/#{name}-frontend", + content_element_type_names: [name] + ) + end c.widget_types.register(ReactWidgetType.new(name: 'defaultNavigation', role: 'header'), @@ -35,6 +37,7 @@ def configure(config) c.features.register('datawrapper_chart_embed_opt_in') c.features.enable_by_default('datawrapper_chart_embed_opt_in') c.features.register('iframe_embed_content_element') + c.features.register('hotspots_content_element') c.features.register('image_gallery_content_element') c.features.register('frontend_v2') c.features.register('scrolled_entry_fragment_caching') diff --git a/entry_types/scrolled/package/config/webpack.js b/entry_types/scrolled/package/config/webpack.js index 79579994a6..97bfdd44fd 100644 --- a/entry_types/scrolled/package/config/webpack.js +++ b/entry_types/scrolled/package/config/webpack.js @@ -19,6 +19,12 @@ module.exports = { 'pageflow-scrolled/contentElements/tikTokEmbed-frontend.css' ] }, + 'pageflow-scrolled/contentElements/hotspots-frontend': { + import: [ + 'pageflow-scrolled/contentElements/hotspots-frontend', + 'pageflow-scrolled/contentElements/hotspots-frontend.css' + ] + }, 'pageflow-scrolled/widgets/defaultNavigation': { import: [ 'pageflow-scrolled/widgets/defaultNavigation', diff --git a/entry_types/scrolled/package/contentElements-server.js b/entry_types/scrolled/package/contentElements-server.js index 1c9f2aaa3e..b0c399cd70 100644 --- a/entry_types/scrolled/package/contentElements-server.js +++ b/entry_types/scrolled/package/contentElements-server.js @@ -1,2 +1,3 @@ import 'pageflow-scrolled/contentElements-frontend'; +import 'pageflow-scrolled/contentElements/hotspots-frontend'; import 'pageflow-scrolled/contentElements/tikTokEmbed-frontend'; diff --git a/entry_types/scrolled/package/documentation.yml b/entry_types/scrolled/package/documentation.yml index 07fb594b44..e64df9c4c4 100644 --- a/entry_types/scrolled/package/documentation.yml +++ b/entry_types/scrolled/package/documentation.yml @@ -16,6 +16,7 @@ toc: children: - AudioPlayer - ContentElementBox + - ContentElementFigure - Figure - FitViewport - Image @@ -57,6 +58,7 @@ toc: children: - normalizeSeed - renderInEntry + - renderInContentElement - renderHookInEntry - name: Storybook Support description: | diff --git a/entry_types/scrolled/package/jest.config.js b/entry_types/scrolled/package/jest.config.js index 88127c3697..1dff8a5acc 100644 --- a/entry_types/scrolled/package/jest.config.js +++ b/entry_types/scrolled/package/jest.config.js @@ -11,8 +11,10 @@ module.exports = { globals: { ...globals }, - setupFiles: ['/spec/support/matchMediaStub.js'], - setupFilesAfterEnv: ['/spec/support/fakeBrowserFeatures.js'], + setupFilesAfterEnv: [ + '/spec/support/matchMediaStub.js', + '/spec/support/fakeBrowserFeatures.js' + ], modulePaths: ['/src', '/spec'], testURL: 'https://story.example.com', @@ -27,6 +29,8 @@ module.exports = { }, transform: { ...transform, + '^.+/editor/.+/images/.+\\.svg$': '/spec/support/jest/image-transform', + '^.+/pictogram\\.svg$': '/spec/support/jest/image-transform', '^.+\\.svg$': '/spec/support/jest/svg-transform' } }; diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js new file mode 100644 index 0000000000..ddc89d1d1e --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -0,0 +1,1027 @@ +import React from 'react'; + +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 {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import {within} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect' +import userEvent from '@testing-library/user-event'; + +describe('Hotspots', () => { + it('does not render images by default', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100 + }; + + const {queryByRole} = renderInContentElement( + , {seed} + ); + + expect(queryByRole('img')).toBeNull(); + }); + + it('renders image when element should load', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100 + }; + + const {getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/image.webp'); + }); + + it('supports portrait image', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 101 + }; + + window.matchMedia.mockPortrait(); + const {getByRole, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/002/image.webp'); + }); + + it('renders areas with clip path based on area outline', () => { + 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]]} + ] + }; + + const {getByRole} = renderInContentElement( + , {seed} + ); + + expect(getByRole('button')).toHaveStyle( + 'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)' + ); + }); + + it('supports separate portrait area outline', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 101, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + portraitOutline: [[20, 20], [20, 30], [30, 30], [30, 20]] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {getByRole} = renderInContentElement( + , {seed} + ); + + expect(getByRole('button')).toHaveStyle( + 'clip-path: polygon(20% 20%, 20% 30%, 30% 30%, 30% 20%)' + ); + }); + + it('ignores portrait outline if portrait image is missing', () => { + 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]], + portraitOutline: [[20, 20], [20, 30], [30, 30], [30, 20]] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {getByRole} = renderInContentElement( + , {seed} + ); + + expect(getByRole('button')).toHaveStyle( + 'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)' + ); + }); + + it('renders area indicators', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + {indicatorPosition: [10, 20]} + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({ + left: '10%', + top: '20%' + }); + }); + + it('supports separate portrait indicator positon', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 101, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({ + left: '20%', + top: '30%' + }); + }); + + it('ignores portrait indicator position if portrait image is missing', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${indicatorStyles.indicator}`)).toHaveStyle({ + left: '10%', + top: '20%' + }); + }); + + it('sets custom property for indicator color', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + indicatorPosition: [10, 20], + color: 'accent' + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({ + '--color': 'var(--theme-palette-color-accent)', + }); + }); + + it('supports separate color for portrait indicator', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 101, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30], + color: 'accent', + portraitColor: 'primary' + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({ + '--color': 'var(--theme-palette-color-primary)', + }); + }); + + 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}] + }; + const configuration = { + image: 100, + portraitImage: 101, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30], + color: 'accent' + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({ + '--color': 'var(--theme-palette-color-accent)', + }); + }); + + it('ignores portrait indicator color if portrait image is missing', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30], + color: 'accent', + portraitColor: 'primary' + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveStyle({ + '--color': 'var(--theme-palette-color-accent)', + }); + }); + + it('renders tooltip', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}], + link: [{type: 'paragraph', children: [{text: 'Some link'}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const {queryByText, getByRole} = renderInContentElement( + , {seed} + ); + + expect(queryByText('Some title')).not.toBeNull(); + expect(queryByText('Some description')).not.toBeNull(); + expect(queryByText('Some link')).not.toBeNull(); + expect(getByRole('link')).toHaveAttribute('href', 'https://example.com'); + expect(getByRole('link')).toHaveAttribute('target', '_blank'); + }); + + it('does not render tooltip link if link text is blank', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}], + link: [{type: 'paragraph', children: [{text: ''}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const {queryByRole} = renderInContentElement( + , {seed} + ); + + expect(queryByRole('link')).toBeNull(); + }); + + it('positions tooltip based on indicator position', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + indicatorPosition: [10, 20], + } + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({ + left: '10%', + top: '20%' + }); + }); + + it('uses separate portrait indicator positon for tooltips', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 101, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveStyle({ + left: '20%', + top: '30%' + }); + }); + + it('ignores portrait indicator position for tooltips if portrait image is missing', () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + indicatorPosition: [10, 20], + portraitIndicatorPosition: [20, 30] + } + ] + }; + + window.matchMedia.mockPortrait(); + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).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 () => { + 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.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); + + await user.unhover(container.querySelector(`.${tooltipStyles.tooltip}`)); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + }); + + it('does not show other tooltip on hover after area has been clicked', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + }, + { + id: 2, + outline: [[50, 20], [50, 30], [60, 30], [60, 20]], + indicatorPosition: [55, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Area 1'}]}], + }, + 2: { + title: [{type: 'heading', children: [{text: 'Area 2'}]}], + } + } + }; + + const user = userEvent.setup(); + const {getByRole, container} = renderInContentElement( + , {seed} + ); + + await user.click(getByRole('button', {name: 'Area 1'})); + await user.hover(getByRole('button', {name: 'Area 2'})); + + expect(container.querySelectorAll(`.${tooltipStyles.visible}`).length).toEqual(1); + }); + + it('hides tooltip when clicked outside area', 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} + ); + + await user.click(getByRole('button')); + await user.click(document.body); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).not.toHaveClass(tooltipStyles.visible); + }); + + it('does not hide tooltip on click inside tooltip', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const user = userEvent.setup(); + const {container, getByRole, getByText} = renderInContentElement( + , {seed} + ); + + await user.click(getByRole('button')); + await user.click(getByText('Some title')); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + }); + + it('does not hide tooltip on unhover after click in tooltip', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const user = userEvent.setup(); + const {container, getByRole, getByText} = renderInContentElement( + , {seed} + ); + + await user.hover(getByRole('button')); + await user.click(getByText('Some title')); + await user.unhover(getByRole('button')); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + }); + + it('supports active image rendered inside area', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + const {getByRole} = within(container.querySelector(`.${areaStyles.area}`)); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/002/image.webp'); + }); + + it('lazy loads active images', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const {container} = renderInContentElement( + , {seed} + ); + const {queryByRole} = within(container.querySelector(`.${areaStyles.area}`)); + + expect(queryByRole('img')).toBeNull(); + }); + + it('supports separate portrait active image', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}, {id: 3, permaId: 102}] + }; + const configuration = { + image: 100, + portraitImage: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101, + portraitActiveImage: 102 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + window.matchMedia.mockPortrait(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + const {getByRole} = within(container.querySelector(`.${areaStyles.area}`)); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/003/image.webp'); + }); + + it('falls back to default active image in portrait mode', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + portraitImage: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + window.matchMedia.mockPortrait(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + const {getByRole} = within(container.querySelector(`.${areaStyles.area}`)); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/002/image.webp'); + }); + + it('shows active image on area click', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const user = userEvent.setup(); + const {getByRole, container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); + + await user.click(getByRole('button')); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.activeImageVisible); + }); + + it('shows active image on area or tooltip hover', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] + }; + const configuration = { + image: 100, + areas: [ + { + outline: [[10, 20], [10, 30], [40, 30], [40, 20]], + indicatorPosition: [20, 25], + activeImage: 101 + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + } + } + }; + + const user = userEvent.setup(); + const {getByRole, container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); + + await user.hover(getByRole('button')); + + 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}`)); + + expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.activeImageVisible); + }); + + it('does not render area outline as svg by default', () => { + 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]]} + ] + }; + + const {container} = renderInContentElement( + , {seed} + ); + + expect(container.querySelector('svg polygon')).toBeNull(); + }); + + it('renders area outline as svg when selected in editor', () => { + 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]]} + ] + }; + + const {container} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + + expect(container.querySelector('svg polygon')).toHaveAttribute( + 'points', + '10,20 10,30 40,30 40,20' + ) + }); + + it('supports highlighting area via command', () => { + 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]]} + ] + }; + + const {container, triggerEditorCommand} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + triggerEditorCommand({type: 'HIGHLIGHT_AREA', index: 0}); + + expect(container.querySelector(`.${areaStyles.area}`)).toHaveClass(areaStyles.highlighted); + }); + + it('supports resetting area highlight via command', () => { + 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]]} + ] + }; + + const {container, triggerEditorCommand} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + triggerEditorCommand({type: 'HIGHLIGHT_AREA', index: 0}); + triggerEditorCommand({type: 'RESET_AREA_HIGHLIGHT'}); + + expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.highlighted); + }); + + it('supports setting active area via command', () => { + 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 {container, triggerEditorCommand} = renderInContentElement( + , + { + seed, + editorState: {isSelected: true, isEditable: true} + } + ); + triggerEditorCommand({type: 'SET_ACTIVE_AREA', index: 0}); + + expect(container.querySelector(`.${tooltipStyles.tooltip}`)).toHaveClass(tooltipStyles.visible); + }); + + it('sets active area id in transient state in editor', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + outline: [[10, 20], [10, 30], [40, 30], [40, 20]] + } + ] + }; + const setTransientState = jest.fn(); + + const user = userEvent.setup(); + const {getByRole} = renderInContentElement( + , + { + seed, + editorState: {isEditable: true, setTransientState} + } + ); + await user.click(getByRole('button')); + + expect(setTransientState).toHaveBeenCalledWith({activeAreaId: 1}) + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView-spec.js new file mode 100644 index 0000000000..2c2ea8f602 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView-spec.js @@ -0,0 +1,334 @@ +import {EditAreaDialogView} from 'contentElements/hotspots/editor/EditAreaDialogView'; +import styles from 'contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css'; + +import Backbone from 'backbone'; + +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import {fireEvent} from '@testing-library/dom'; +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {useReactBasedBackboneViews, factories} from 'support'; + +describe('EditAreaDialogView', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.tabs.default': 'Default', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.tabs.portrait': 'Portrait', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.hotspots_image': 'Hotspots image', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.modes.rect': 'Rect', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.modes.polygon': 'Polygon', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.indicator_title': 'Drag to position indicator', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog.save': 'Save' + }); + + const {render} = useReactBasedBackboneViews(); + + it('renders default image', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const {getByRole} = render(view); + + expect(getByRole('img', {name: 'Hotspots image'})).toHaveAttribute('src', 'some/image.webp'); + }); + + it('renders landscape/portrait tabs if both files are present', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const portraitImageFile = factories.imageFile({ + url: 'some/portrait.webp' + }); + const model = new Backbone.Model(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + portraitFile: portraitImageFile + }); + + const {queryByText} = render(view); + + expect(queryByText('Default')).not.toBeNull(); + expect(queryByText('Portrait')).not.toBeNull(); + }); + + it('does not render tabs if portrait file is missing', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const {queryByText} = render(view); + + expect(queryByText('Default')).toBeNull(); + expect(queryByText('Portrait')).toBeNull(); + }); + + it('renders portrait image on portrait tab', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const portraitImageFile = factories.imageFile({ + url: 'some/portrait.webp' + }); + const model = new Backbone.Model(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + portraitFile: portraitImageFile + }); + + const user = userEvent.setup(); + const {getByText, getByRole} = render(view); + await user.click(getByText('Portrait')); + + expect(getByRole('img', {name: 'Hotspots image'})).toHaveAttribute('src', 'some/portrait.webp'); + }); + + it('supports default tab', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const portraitImageFile = factories.imageFile({ + url: 'some/portrait.webp' + }); + const model = new Backbone.Model(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + portraitFile: portraitImageFile, + defaultTab: 'portrait' + }); + + const {getByRole} = render(view); + + expect(getByRole('img', {name: 'Hotspots image'})).toHaveAttribute('src', 'some/portrait.webp'); + }); + + it('renders rect area', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [20, 15], [20, 50], [10, 50]], + mode: 'rect', + indicatorPosition: [15, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const {getByRole} = render(view); + + expect(getByRole('button', {name: 'Rect'})).toHaveAttribute('aria-pressed', 'true'); + expect( + queryHandleByCoordinates(view.el, {left: 10, top: 15}) + ).not.toBeNull(); + }); + + it('renders polygon area', () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [25, 15], [25, 50]], + mode: 'polygon', + indicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const {getByRole} = render(view); + + expect(getByRole('button', {name: 'Polygon'})).toHaveAttribute('aria-pressed', 'true'); + expect( + queryHandleByCoordinates(view.el, {left: 10, top: 15}) + ).not.toBeNull(); + }); + + it('renders portrait area', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const portraitImageFile = factories.imageFile({ + url: 'some/portrait.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [20, 15], [20, 50], [10, 50]], + mode: 'rect', + indicatorPosition: [15, 20], + portraitOutline: [[5, 15], [25, 15], [25, 50]], + portraitMode: 'polygon', + portraitIndicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + portraitFile: portraitImageFile + }); + + const user = userEvent.setup(); + const {getByText, getByRole} = render(view); + await user.click(getByText('Portrait')); + + expect(getByRole('button', {name: 'Polygon'})).toHaveAttribute('aria-pressed', 'true'); + expect( + queryHandleByCoordinates(view.el, {left: 5, top: 15}) + ).not.toBeNull(); + }); + + it('allows moving points', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [25, 15], [25, 50]], + mode: 'polygon', + indicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const user = userEvent.setup(); + const {getByRole} = render(view); + + const overlay = getOverlay(view.el); + overlay.getBoundingClientRect = function() { + return {top: 0, left: 0, width: 100, height: 100}; + } + const point = queryHandleByCoordinates(view.el, {left: 10, top: 15}); + fireEvent.mouseDown(point, {clientX: 100, clientY: 100}); + fireEvent.mouseMove(point, {clientX: 50, clientY: 10}); + fireEvent.mouseUp(point, {clientX: 50, clientY: 10}); + + await user.click(getByRole('button', {name: 'Save'})); + + expect(model.get('outline')).toEqual([[50, 10], [25, 15], [25, 50]]); + }); + + it('allows switching mode', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [25, 15], [25, 50]], + mode: 'polygon', + indicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const user = userEvent.setup(); + const {getByRole} = render(view); + + await user.click(getByRole('button', {name: 'Rect'})); + await user.click(getByRole('button', {name: 'Save'})); + + expect(model.get('mode')).toEqual('rect'); + expect(model.get('outline')).toEqual([[10, 15], [25, 15], [25, 50], [10, 50]]); + }); + + it('allows moving indicator', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model({ + outline: [[10, 15], [25, 15], [25, 50]], + mode: 'polygon', + indicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile + }); + + const user = userEvent.setup(); + const {getByRole, getByTitle} = render(view); + + const overlay = getOverlay(view.el); + overlay.getBoundingClientRect = function() { + return {top: 0, left: 0, width: 100, height: 100}; + } + const indicator = getByTitle('Drag to position indicator'); + fireEvent.mouseDown(indicator, {clientX: 20, clientY: 20}); + fireEvent.mouseMove(indicator, {clientX: 10, clientY: 15}); + fireEvent.mouseUp(indicator, {clientX: 10, clientY: 15}); + + await user.click(getByRole('button', {name: 'Save'})); + + expect(model.get('indicatorPosition')).toEqual([10, 15]); + }); + + it('allows changing portrait settings', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const portraitImageFile = factories.imageFile({ + url: 'some/portrait.webp' + }); + const model = new Backbone.Model({ + portraitOutline: [[10, 15], [25, 15], [25, 50]], + portraitMode: 'polygon', + portraitIndicatorPosition: [20, 20] + }); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + portraitFile: portraitImageFile + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = render(view); + + await user.click(getByText('Portrait')); + await user.click(getByRole('button', {name: 'Rect'})); + await user.click(getByRole('button', {name: 'Save'})); + + expect(model.get('portraitMode')).toEqual('rect'); + expect(model.get('portraitOutline')).toEqual([[10, 15], [25, 15], [25, 50], [10, 50]]); + }); + + it('calls onSave', async () => { + const imageFile = factories.imageFile({ + url: 'some/image.webp' + }); + const model = new Backbone.Model(); + const callback = jest.fn(); + const view = new EditAreaDialogView({ + model: model, + file: imageFile, + onSave: callback + }); + + const user = userEvent.setup(); + const {getByRole} = render(view); + + await user.click(getByRole('button', {name: 'Save'})); + + expect(callback).toHaveBeenCalled(); + }); +}); + +function getOverlay(el) { + return el.querySelector(`.${styles.overlay}`); +} + +function queryHandleByCoordinates(el, {left, top}) { + return el.querySelector(`.${styles.handle}[style*="left: ${left}%"][style*="top: ${top}%"]`); +} diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js new file mode 100644 index 0000000000..f6c3639b72 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js @@ -0,0 +1,505 @@ +import { + reducer, + drawnOutlinePoints, + handles, + SET_MODE, + DRAG, + DRAG_HANDLE, + DRAG_HANDLE_STOP, + DOUBLE_CLICK_HANDLE, + MOUSE_MOVE, + DRAG_POTENTIAL_POINT, + DRAG_POTENTIAL_POINT_STOP, + DRAG_INDICATOR +} from 'contentElements/hotspots/editor/EditAreaDialogView/reducer'; + +const initialState = { + indicatorPosition: [50, 50] +}; + +describe('reducer', () => { + describe('SET_MODE', () => { + it('sets points to bounding box when switching to rect mode', () => { + const state = reducer({ + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }, {type: SET_MODE, value: 'rect'}); + + expect(state).toMatchObject({ + mode: 'rect', + points: [[10, 10], [50, 10], [50, 50], [10, 50]] + }); + }); + + it('keeps points when switching to polygon', () => { + const state = reducer({ + ...initialState, + mode: 'rect', + points: [[10, 10], [50, 10], [50, 50], [10, 50]] + }, {type: SET_MODE, value: 'polygon'}); + + expect(state).toMatchObject({ + mode: 'polygon', + points: [[10, 10], [50, 10], [50, 50], [10, 50]] + }); + }); + + it('restores points when switching back to polygon', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: SET_MODE, value: 'rect'}); + state = reducer(state, {type: SET_MODE, value: 'polygon'}); + + expect(state.points).toEqual( + [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + ); + }); + + it('does not restore points when setting mode to current mode', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: SET_MODE, value: 'rect'}); + state = reducer(state, {type: SET_MODE, value: 'polygon'}); + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [0, 20]}); + state = reducer(state, {type: SET_MODE, value: 'polygon'}); + + expect(state.points).toEqual( + [[0, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + ); + }); + + it('forgets polygon when resizing rect', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: SET_MODE, value: 'rect'}); + state = reducer(state, {type: DRAG_HANDLE, index: 2, cursor: [60, 10]}); + state = reducer(state, {type: SET_MODE, value: 'polygon'}); + + expect(state).toMatchObject({ + mode: 'polygon', + points: [[10, 10], [60, 10], [60, 50], [10, 50]] + }); + }); + }); + + describe('DRAG', () => { + it('updates points and indicatorPosition', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: DRAG, delta: [10, 20]}); + + expect(state).toMatchObject({ + points: [[20, 40], [30, 40], [60, 30], [60, 70], [20, 70]], + indicatorPosition: [20, 40] + }); + }); + + it('does not allow moving beyond top/left bounds', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [20, 50]], + indicatorPosition: [15, 20] + }; + state = reducer(state, {type: DRAG, delta: [-20, -30]}); + + expect(state).toMatchObject({ + points: [[0, 0], [10, 0], [10, 30]], + indicatorPosition: [5, 0] + }); + }); + + it('does not allow moving beyond bottom/rights bounds', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [20, 50]], + indicatorPosition: [15, 20] + }; + state = reducer(state, {type: DRAG, delta: [100, 100]}); + + expect(state).toMatchObject({ + points: [[90, 70], [100, 70], [100, 100]], + indicatorPosition: [95, 70] + }); + }); + }); + + describe('DRAG_HANDLE', () => { + describe('in polygon mode', () => { + it('updates points', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [30, 25]}); + + expect(state.points).toEqual( + [[10, 20], [30, 25], [50, 10], [50, 50], [10, 50]] + ); + }); + + it('keeps indicator inside area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [15, 50]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [15, 20]}); + + expect(state.indicatorPosition).toEqual([15, 20]); + }); + + it('does not move indicator if still inside area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]], + indicatorPosition: [15, 23] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [15, 20]}); + + expect(state.indicatorPosition).toEqual([15, 23]); + }); + }); + + describe('in rect mode', () => { + it('resizes rect via mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [15, 10]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 40], [10, 40]] + ); + }); + + it('resizes rect via corner handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 2, cursor: [25, 15]}); + + expect(state.points).toEqual( + [[10, 15], [25, 15], [25, 40], [10, 40]] + ); + }); + + it('allows resizing in two directions one after the other', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [15, 10]}); + state = reducer(state, {type: DRAG_HANDLE_STOP}); + state = reducer(state, {type: DRAG_HANDLE, index: 5, cursor: [15, 50]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 50], [10, 50]] + ); + }); + + it('keeps indicator inside area', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [15, 20] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [15, 30]}); + + expect(state.indicatorPosition).toEqual([15, 30]); + }); + + it('does not move indicator if still inside area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [15, 30] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [15, 25]}); + + expect(state.indicatorPosition).toEqual([15, 30]); + }); + }); + }); + + describe('DOUBLE_CLICK_HANDLE', () => { + it('removes point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.points).toEqual( + [[10, 20], [50, 10], [50, 50], [10, 50]] + ); + }); + + it('resets potential point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]], + potentialPoint: [15, 20] + }; + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.potentialPoint).toBeNull(); + }); + + it('does not remove point if less than four are left', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10]] + }; + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.points).toEqual( + [[10, 20], [20, 20], [50, 10]] + ); + }); + + it('is noop in rect mode', () => { + const state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 50], [10, 50]] + }; + const newState = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(newState).toBe(state); + }); + + it('keeps indicator inside area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [20, 30], [10, 30]], + indicatorPosition: [19, 19] + }; + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.indicatorPosition).toEqual([14, 24]); + }); + }); + + describe('MOUSE_MOVE', () => { + it('updates potential point in polygon mode', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + + expect(state.potentialPoint).toEqual([15, 15]); + }); + + it('noop in rect mode', () => { + const state = { + ...initialState, + mode: 'rect', + points: [[10, 10], [20, 10], [20, 20], [10, 20]] + }; + const newState = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + + expect(newState).toBe(state); + }); + }); + + describe('DRAG_POTENTIAL_POINT', () => { + it('updats potential point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20, 10]}); + + expect(state.potentialPoint).toEqual([20, 10]); + }); + + it('ignores subsequent MOUSE_MOVE actions', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20, 10]}); + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + + expect(state.potentialPoint).toEqual([20, 10]); + }); + }); + + describe('DRAG_POTENTIAL_POINT_STOP', () => { + it('reset potential point and adds point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT_STOP}); + + expect(state).toMatchObject({ + points: [[10, 10], [20, 10], [20, 20], [50, 10], [50, 50], [10, 50]], + potentialPoint: null + }); + }); + + it('resumes handling MOUSE_MOVE actions', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT_STOP, cursor: [20, 10]}); + state = reducer(state, {type: MOUSE_MOVE, cursor: [15, 5]}); + + expect(state.potentialPoint).toEqual([15, 10]); + }); + }); + + describe('DRAG_INDICATOR', () => { + it('updates indicator position', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: DRAG_INDICATOR, cursor: [15, 11]}); + + expect(state.indicatorPosition).toEqual([15, 11]); + }); + + it('does not allow dragging indicator out of area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: DRAG_INDICATOR, cursor: [5, 5]}); + + expect(state.indicatorPosition).toEqual([10, 10]); + }); + }); +}); + +describe('drawnOutlinePoints', () => { + it('returns points by default', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + + expect(drawnOutlinePoints(state)).toEqual( + [[10, 10], [20, 20], [50, 50]] + ); + }); + + it('includes potential point while dragging', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20, 10]}); + + expect(drawnOutlinePoints(state)).toEqual( + [[10, 10], [20, 10], [20, 20], [50, 50]] + ); + }); +}); + +describe('handles', () => { + it('maps to points in polygon mode', () => { + const state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 50], [30, 50]] + }; + + expect(handles(state)).toEqual( + [ + {point: [10, 10], deletable: true, cursor: 'move', circle: true}, + {point: [20, 20], deletable: true, cursor: 'move', circle: true}, + {point: [50, 50], deletable: true, cursor: 'move', circle: true}, + {point: [30, 50], deletable: true, cursor: 'move', circle: true} + ] + ); + }); + + it('marks polygon points as not deletable if too few are left', () => { + const state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 50]] + }; + + expect(handles(state)).toMatchObject( + [ + {point: [10, 10], deletable: false}, + {point: [20, 20], deletable: false}, + {point: [50, 50], deletable: false} + ] + ); + }); + + it('includes mid points in rect mode', () => { + const state = { + ...initialState, + mode: 'rect', + points: [[10, 10], [30, 10], [30, 30], [10, 30]] + }; + + expect(handles(state)).toEqual( + [ + {point: [10, 10], deletable: false, cursor: 'nwse-resize', axis: null}, + {point: [20, 10], deletable: false, cursor: 'ns-resize', axis: 'y'}, + {point: [30, 10], deletable: false, cursor: 'nesw-resize', axis: null}, + {point: [30, 20], deletable: false, cursor: 'ew-resize', axis: 'x'}, + {point: [30, 30], deletable: false, cursor: 'nwse-resize', axis: null}, + {point: [20, 30], deletable: false, cursor: 'ns-resize', axis: 'y'}, + {point: [10, 30], deletable: false, cursor: 'nesw-resize', axis: null}, + {point: [10, 20], deletable: false, cursor: 'ew-resize', axis: 'x'} + ] + ); + }); +}); 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 new file mode 100644 index 0000000000..b21e6ebef5 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js @@ -0,0 +1,78 @@ +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'; + +describe('SidebarEditAreaView', () => { + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs.area': 'Area', + 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs.portrait': 'Portrait' + }); + + it('renders portrait tab if portrait image is present', () => { + const entry = createEntry({ + imageFiles: [ + {perma_id: 10}, + {perma_id: 11} + ], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + portraitImage: 11, + 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 + }); + + view.render(); + const tabs = Tabs.find(view); + + expect(tabs.tabLabels()).toEqual(['Area', 'Portrait']); + }); + + it('does not render portrait tab if portrait image is blank', () => { + const entry = createEntry({ + imageFiles: [ + {perma_id: 10} + ], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + 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 + }); + + view.render(); + const tabs = Tabs.find(view); + + expect(tabs.tabLabels()).toEqual(['Area']); + }); +}); 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 new file mode 100644 index 0000000000..d35b273edf --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js @@ -0,0 +1,122 @@ +import {Area} from 'contentElements/hotspots/editor/models/Area'; +import {AreasCollection} from 'contentElements/hotspots/editor/models/AreasCollection'; +import {factories} from 'support'; + +describe('hotspots AreasCollection', () => { + it('updates content element configuration when area is added', () => { + const contentElement = factories.contentElement(); + const areasCollection = AreasCollection.forContentElement(contentElement); + + areasCollection.addWithId(new Area()); + areasCollection.addWithId(new Area()); + + expect(contentElement.configuration.get('areas')).toEqual([ + {id: 1}, + {id: 2} + ]); + }); + + it('updates content element configuration when item is removed', () => { + const contentElement = factories.contentElement({ + configuration: { + areas: [ + {id: 1}, + {id: 2}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + + areasCollection.remove(1); + + expect(contentElement.configuration.get('areas')).toEqual([ + {id: 2} + ]) + }); + + it('updates content element configuration when item changes', () => { + const contentElement = factories.contentElement({ + configuration: { + areas: [ + {id: 1}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + + areasCollection.get(1).set('tooltipPosition', 'above'); + + expect(contentElement.configuration.get('areas')).toEqual([ + {id: 1, tooltipPosition: 'above'} + ]) + }); + + it('posts content element command on highlight', () => { + const contentElement = factories.contentElement({ + id: 10, + configuration: { + areas: [ + {id: 1}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + const listener = jest.fn(); + + contentElement.on('postCommand', listener); + areasCollection.get(1).highlight(); + + expect(listener).toHaveBeenCalledWith(10, {type: 'HIGHLIGHT_AREA', index: 0}); + }); + + it('posts content element command on resetHighlight', () => { + const contentElement = factories.contentElement({ + id: 10, + configuration: { + areas: [ + {id: 1}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + const listener = jest.fn(); + + contentElement.on('postCommand', listener); + areasCollection.get(1).resetHighlight(); + + expect(listener).toHaveBeenCalledWith(10, {type: 'RESET_AREA_HIGHLIGHT'}); + }); + + it('return empty title by default', () => { + const contentElement = factories.contentElement({ + id: 10, + configuration: { + areas: [ + {id: 1}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + + expect(areasCollection.get(1).title()).toBeUndefined(); + }); + + it('extracts title from tooltip texts', () => { + const contentElement = factories.contentElement({ + id: 10, + configuration: { + tooltipTexts: { + 1: { + title: [{children: [{text: 'Some title'}]}] + } + }, + areas: [ + {id: 1}, + ] + } + }); + const areasCollection = AreasCollection.forContentElement(contentElement); + + expect(areasCollection.get(1).title()).toEqual('Some title'); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index 76709cdcdb..15b1b26490 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -1,4 +1,5 @@ import 'editor/config'; +import {editor} from 'pageflow-scrolled/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {PreviewMessageController} from 'editor/controllers/PreviewMessageController'; import {InsertContentElementDialogView} from 'editor/views/InsertContentElementDialogView'; @@ -13,6 +14,8 @@ import {setupGlobals} from 'pageflow/testHelpers'; import {useFakeXhr, normalizeSeed, factories, createIframeWindow} from 'support'; describe('PreviewMessageController', () => { + beforeAll(() => editor.contentElementTypes.register('textBlock', {})); + let controller, testContext; beforeEach(() => testContext = {}); diff --git a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js index b2f5ef45c8..fde5e702cb 100644 --- a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js @@ -453,6 +453,46 @@ describe('ContentElement', () => { expect(listener).not.toHaveBeenCalled(); }); + }); + + describe('#getEditorPath', () => { + it('returns content element path by default', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 5, typeName: 'inlineImage'} + ] + }) + } + ); + const contentElement = entry.contentElements.get(5); + + expect(contentElement.getEditorPath()).toEqual('/scrolled/content_elements/5'); + }); + it('can be overriden via content element type', () => { + editor.contentElementTypes.register('customElement', { + editorPath(contentElement) { + return `/scrolled/custom/${contentElement.id}`; + } + }); + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 5, typeName: 'customElement'} + ] + }) + } + ); + const contentElement = entry.contentElements.get(5); + + expect(contentElement.getEditorPath()).toEqual('/scrolled/custom/5'); + }); }); }); diff --git a/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js b/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js new file mode 100644 index 0000000000..6d28056634 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/EditableLink-spec.js @@ -0,0 +1,92 @@ +import React from 'react'; + +import {EditableLink} from 'frontend'; + +import {render} from '@testing-library/react'; +import {renderInEntry} from 'support'; +import '@testing-library/jest-dom/extend-expect' + +describe('EditableLink', () => { + it('renders link', () => { + const {getByRole} = render( + Some link + ); + + expect(getByRole('link')).toHaveTextContent('Some link') + expect(getByRole('link')).toHaveAttribute('href', 'https://example.com') + expect(getByRole('link')).not.toHaveAttribute('target') + expect(getByRole('link')).not.toHaveAttribute('rel') + }); + + it('supports className', () => { + const {getByRole} = render( + Some link + ); + + expect(getByRole('link')).toHaveClass('custom') + }); + + it('supports rendering link with target blank', () => { + const {getByRole} = render( + Some link + ); + + expect(getByRole('link')).toHaveTextContent('Some link') + expect(getByRole('link')).toHaveAttribute('target', '_blank') + expect(getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer') + }); + + it('supports rendering internal chapter links', () => { + const seed = { + chapters: [{id: 1, permaId: 10, configuration: {title: 'The Intro'}}] + }; + + const {getByRole} = renderInEntry( + Some link, + {seed} + ); + + expect(getByRole('link')).toHaveTextContent('Some link') + expect(getByRole('link')).toHaveAttribute('href', '#the-intro') + expect(getByRole('link')).not.toHaveAttribute('target') + expect(getByRole('link')).not.toHaveAttribute('rel') + }); + + it('supports rendering internal section links', () => { + const seed = { + sections: [{id: 1, permaId: 10}] + }; + + const {getByRole} = renderInEntry( + Some link, + {seed} + ); + + expect(getByRole('link')).toHaveTextContent('Some link') + expect(getByRole('link')).toHaveAttribute('href', '#section-10') + expect(getByRole('link')).not.toHaveAttribute('target') + expect(getByRole('link')).not.toHaveAttribute('rel') + }); + + it('supports rendering file links', () => { + const seed = { + imageFileUrlTemplates: { + original: ':id_partition/original/:basename.:extension' + }, + sections: [{id: 1, permaId: 10}], + imageFiles: [{id: 1, permaId: 100}] + }; + + const {getByRole} = renderInEntry( + + Some link + , + {seed} + ); + + expect(getByRole('link')).toHaveTextContent('Some link') + expect(getByRole('link')).toHaveAttribute('href', '000/000/001/original/image.jpg') + expect(getByRole('link')).toHaveAttribute('target', '_blank') + expect(getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer') + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js new file mode 100644 index 0000000000..e3d0f96626 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js @@ -0,0 +1,45 @@ +import React from 'react'; + +import {EditableLink} from 'frontend'; +import {loadInlineEditingComponents} from 'frontend/inlineEditing'; + +import {render} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect' + +describe('EditableText', () => { + beforeAll(loadInlineEditingComponents); + + it('renders children with className', () => { + const {getByText} = render( + Some link + ); + + expect(getByText('Some link')).toHaveClass('custom'); + }); + + it('displays tooltip on hover if href is present', async () => { + const {getByText, getByRole} = render( + Some link + ); + + const user = userEvent.setup(); + await user.hover(getByText('Some link')); + + expect(getByRole('link')).toHaveAttribute('href', 'https://example.com'); + }); + + it('supports disabling hover tooltip', async () => { + const {getByText, queryByRole} = render( + + Some link + + ); + + const user = userEvent.setup(); + await user.hover(getByText('Some link')); + + expect(queryByRole('link')).toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/LinkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js similarity index 82% rename from entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/LinkTooltip-spec.js rename to entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js index fdfe00d3a1..6cfc85e7cd 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -1,5 +1,5 @@ import React from 'react'; -import {LinkTooltipProvider, LinkPreview} from 'frontend/inlineEditing/EditableText/LinkTooltip'; +import {LinkTooltipProvider, LinkPreview} from 'frontend/inlineEditing/LinkTooltip'; import {renderInEntry} from 'support'; import {useFakeTranslations} from 'pageflow/testHelpers'; @@ -15,9 +15,8 @@ describe('LinkTooltip', () => { }); it('displays tooltip for external link on hover', async () => { - const editor = {}; const {getByText, queryByRole, queryByText} = render( - + A link @@ -32,9 +31,8 @@ describe('LinkTooltip', () => { }); it('does not display tooltip when disabled', async () => { - const editor = {}; const {getByText, queryByRole} = render( - + A link @@ -47,16 +45,10 @@ describe('LinkTooltip', () => { expect(queryByRole('link')).toBeNull(); }); - it('does not display tooltip when editor has non-collapsed selection', async () => { - const editor = { - selection: { - anchor: {path: [0], offset: 0}, - focus: {path: [0], offset: 10} - } - }; - const {getByText, queryByRole} = render( - - + it('does not display tooltip when href is missing', async () => { + const {getByText, container} = render( + + A link @@ -65,13 +57,12 @@ describe('LinkTooltip', () => { const user = userEvent.setup(); await user.hover(getByText('A link')); - expect(queryByRole('link')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); }); it('displays note about opening in new tab', async () => { - const editor = {}; const {getByText, queryByRole, queryByText} = render( - + A link @@ -86,14 +77,13 @@ describe('LinkTooltip', () => { }); it('displays tooltip for chapter link', async () => { - const editor = {}; const seed = { chapters: [ {permaId: 5, configuration: {title: 'The Intro'}} ] } const {getByText, queryByRole} = renderInEntry( - + A link @@ -109,14 +99,13 @@ describe('LinkTooltip', () => { }); it('displays tooltip for section link', async () => { - const editor = {}; const seed = { sections: [ {permaId: 5} ] } const {getByText, queryByRole} = renderInEntry( - + A link @@ -131,7 +120,6 @@ describe('LinkTooltip', () => { }); it('displays tooltip for file link', async () => { - const editor = {}; const seed = { imageFileUrlTemplates: { original: ':id_partition/original/:basename.:extension' @@ -142,7 +130,7 @@ describe('LinkTooltip', () => { ] } const {getByText, queryByRole} = renderInEntry( - + A link diff --git a/entry_types/scrolled/package/spec/support/jest/image-transform.js b/entry_types/scrolled/package/spec/support/jest/image-transform.js new file mode 100644 index 0000000000..620e8b077d --- /dev/null +++ b/entry_types/scrolled/package/spec/support/jest/image-transform.js @@ -0,0 +1,7 @@ +const path = require('path') + +module.exports = { + process(src, filename) { + return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';' + }, +} diff --git a/entry_types/scrolled/package/spec/support/matchMediaStub.js b/entry_types/scrolled/package/spec/support/matchMediaStub.js index 0580f983d4..071c45dfe8 100644 --- a/entry_types/scrolled/package/spec/support/matchMediaStub.js +++ b/entry_types/scrolled/package/spec/support/matchMediaStub.js @@ -1,13 +1,32 @@ +let mockOrientation; + Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), + value: jest.fn().mockImplementation(query => { + if (query === '(orientation: portrait)') { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: mockOrientation === 'portrait' + }; + } + else if (query === '(orientation: landscape)') { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: mockOrientation !== 'portrait' + }; + } + else { + return { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: false + }; + } + }) }); + +beforeEach(() => mockOrientation = 'landscape'); + +window.matchMedia.mockPortrait = () => mockOrientation = 'portrait'; diff --git a/entry_types/scrolled/package/spec/support/reactBasedBackboneViews.js b/entry_types/scrolled/package/spec/support/reactBasedBackboneViews.js index 03d93517cb..1ed376575a 100644 --- a/entry_types/scrolled/package/spec/support/reactBasedBackboneViews.js +++ b/entry_types/scrolled/package/spec/support/reactBasedBackboneViews.js @@ -7,7 +7,7 @@ export function useReactBasedBackboneViews(context) { let currentElement; afterEach(() => { - if (currentElement) { + if (currentElement && currentElement.parentNode) { document.body.removeChild(currentElement); currentElement = null; } diff --git a/entry_types/scrolled/package/spec/support/scrollPositionLifecycle.js b/entry_types/scrolled/package/spec/support/scrollPositionLifecycle.js index 1a6bba5f76..2c056a51e9 100644 --- a/entry_types/scrolled/package/spec/support/scrollPositionLifecycle.js +++ b/entry_types/scrolled/package/spec/support/scrollPositionLifecycle.js @@ -1,11 +1,8 @@ -import React, {useEffect, useState} from 'react'; -import BackboneEvents from 'backbone-events-standalone'; -import {act} from '@testing-library/react' - -import {renderInEntry} from './index'; import {SectionLifecycleContext} from 'frontend/useSectionLifecycle'; import {isActiveProbe} from 'frontend/useScrollPositionLifecycle.module.css'; +import {renderInEntryWithScrollPositionLifecycle} from 'testHelpers/scrollPositionLifecycle'; + export function findIsActiveProbe(el) { return findProbe(el, isActiveProbe); } @@ -29,79 +26,9 @@ function findProbe(el, className) { } } -export function renderInEntryWithSectionLifecycle(ui, {wrapper, ...options} = {}) { - const emitter = createEmitter(); - - return withSimulateScrollPositionHelper( - emitter, - renderInEntry(ui, { - wrapper: createScrollPositionProvider(SectionLifecycleContext, - emitter, - wrapper), - ...options - }) +export function renderInEntryWithSectionLifecycle(ui, options) { + return renderInEntryWithScrollPositionLifecycle( + ui, + {lifecycleContext: SectionLifecycleContext, ...options} ); } - -function createScrollPositionProvider(Context, emitter, originalWrapper) { - const OriginalWrapper = originalWrapper || - function Noop({children}) { return children; }; - - return function ScrollPositionProvider({children}) { - const [value, setValue] = useState({shouldLoad: false, shouldPrepare: false, isVisible: false, isActive: false}); - - useEffect(() => { - function handle(scrollPosition) { - switch (scrollPosition) { - case 'near viewport': - setValue({shouldLoad: true, shouldPrepare: true, isVisible: false, isActive: false}); - break; - case 'in viewport': - setValue({shouldLoad: true, shouldPrepare: true, isVisible: true, isActive: false}); - break; - case 'center of viewport': - setValue({shouldLoad: true, shouldPrepare: true, isVisible: true, isActive: true}); - break; - default: - setValue({isVisible: false, isActive: false}); - break; - } - } - - emitter.on('scroll', handle); - - return () => emitter.off('scroll', handle); - }) - - return ( - - - {children} - - - ); - }; -} - -const allowedScrollPositions = ['outside viewport', 'near viewport', 'in viewport', 'center of viewport']; - -function withSimulateScrollPositionHelper(emitter, result) { - return { - ...result, - - simulateScrollPosition(scrollPosition) { - if (!allowedScrollPositions.includes(scrollPosition)) { - throw new Error(`Invalid scrollPosition '${scrollPosition}'. ` + - `Allowed values: ${allowedScrollPositions.join(', ')}`) - } - - act(() => { - emitter.trigger('scroll', scrollPosition) - }); - } - } -} - -function createEmitter() { - return {...BackboneEvents}; -} diff --git a/entry_types/scrolled/package/src/contentElements/editor.js b/entry_types/scrolled/package/src/contentElements/editor.js index 16b90e3cf6..ff58b04319 100644 --- a/entry_types/scrolled/package/src/contentElements/editor.js +++ b/entry_types/scrolled/package/src/contentElements/editor.js @@ -8,6 +8,7 @@ import './soundDisclaimer/editor'; import './dataWrapperChart/editor'; import './inlineBeforeAfter/editor'; import './externalLinkList/editor'; +import './hotspots/editor' import './vrImage/editor'; import './iframeEmbed/editor'; import './imageGallery/editor' diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js new file mode 100644 index 0000000000..eb4bc57cc4 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -0,0 +1,67 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { + Image, + paletteColor, + useContentElementEditorState, + useContentElementLifecycle, + useFile +} from 'pageflow-scrolled/frontend'; + +import {Indicator} from './Indicator'; + +import styles from './Area.module.css'; + +export function Area({ + area, contentElementId, portraitMode, highlighted, activeImageVisible, + 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 ( +
+
+ ); +} + +export function areaColor(area, portraitMode) { + return paletteColor(portraitMode ? (area.portraitColor || area.color) : area.color); +} + +function Outline({points}) { + return ( + + coords.map(coord => coord).join(',')).join(' ')} /> + + ); +} + +function polygon(points) { + return `polygon(${(points || []).map(coords => coords.map(coord => `${coord}%`).join(' ')).join(', ')})`; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css new file mode 100644 index 0000000000..2e3cdbdeec --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css @@ -0,0 +1,42 @@ +.area, +.clip, +.outline { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.area { + pointer-events: none; +} + +.clip { + pointer-events: auto; + border: none; + background-color: transparent; + cursor: pointer; +} + +.outline polygon { + vector-effect: non-scaling-stroke; + stroke-width: 1px; + stroke-linejoin: round; + stroke: var(--color, #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; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js new file mode 100644 index 0000000000..b30c807699 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -0,0 +1,157 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; + +import { + ContentElementBox, + Image, + ContentElementFigure, + FitViewport, + FullscreenViewer, + ToggleFullscreenCornerButton, + useContentElementEditorState, + useContentElementEditorCommandSubscription, + useContentElementLifecycle, + useFileWithInlineRights, + usePortraitOrientation, + InlineFileRights, + contentElementWidths +} from 'pageflow-scrolled/frontend'; + +import {Area} from './Area'; +import {Tooltip, insideTooltip} from './Tooltip'; + +import styles from './Hotspots.module.css'; + +export function Hotspots({contentElementId, contentElementWidth, configuration}) { + return ( + + + } + renderFullscreenChildren={() => + + } /> + ); +} + +export function HotspotsImage({ + contentElementId, contentElementWidth, configuration, + displayFullscreenToggle, onFullscreenEnter +}) { + const defaultImageFile = useFileWithInlineRights({ + configuration, collectionName: 'imageFiles', propertyName: 'image' + }); + const portraitImageFile = useFileWithInlineRights({ + configuration, collectionName: 'imageFiles', propertyName: 'portraitImage' + }); + const portraitOrientation = usePortraitOrientation(); + + const {shouldLoad} = useContentElementLifecycle(); + const {setTransientState} = useContentElementEditorState(); + + const [activeIndex, setActiveIndexState] = useState(-1); + const [hoveredIndex, setHoveredIndex] = useState(-1); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + + const portraitMode = portraitOrientation && portraitImageFile + const imageFile = portraitMode ? portraitImageFile : defaultImageFile; + + 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); + } + } + }, [hasActiveArea, setActiveIndex]); + + useContentElementEditorCommandSubscription(command => { + if (command.type === 'HIGHLIGHT_AREA') { + setHighlightedIndex(command.index); + } + else if (command.type === 'RESET_AREA_HIGHLIGHT') { + setHighlightedIndex(-1); + } + else if (command.type === 'SET_ACTIVE_AREA') { + setActiveIndex(command.index); + } + }); + + return ( +
+ +
+ + + +
+ + {areas.map((area, index) => + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => setActiveIndex(index)} /> + )} +
+ {displayFullscreenToggle && + } + +
+
+
+ {areas.map((area, index) => + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => setActiveIndex(index)} /> + )} +
+ +
+
+ ); +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css new file mode 100644 index 0000000000..bd387ab792 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css @@ -0,0 +1,19 @@ +.center { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; +} + +.outer { + position: relative; +} + +.wrapper { + width: min-content; + height: 100%; +} + +.wrapper > img { + height: 100%; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js new file mode 100644 index 0000000000..665073a8be --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import styles from './Indicator.module.css'; + +export function Indicator({area, portraitMode}) { + const indicatorPosition = ( + portraitMode ? + area.portraitIndicatorPosition : + area.indicatorPosition + ) || [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 new file mode 100644 index 0000000000..c0e71463de --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css @@ -0,0 +1,51 @@ +.indicator { + --size: 15px; + margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2); + animation: inner 2s infinite; + pointer-events: none; +} + +.indicator, +.indicator::before { + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + border-radius: 100%; + background-color: var(--color, #fff); +} + +.indicator::before { + content: ""; + animation: outer 2s infinite; +} + +@keyframes inner { + 0% { + transform: scale(1.3); + } + + 20% { + transform: scale(1); + } + + 80% { + transform: scale(1.3); + } + + 100% { + transform: scale(1.3); + } +} + +@keyframes outer { + 20% { + transform: scale(1); + } + + 100% { + transform: scale(3); + opacity: 0; + } +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js new file mode 100644 index 0000000000..4a7622aa83 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -0,0 +1,159 @@ +import React, {useLayoutEffect, useRef, useState} from 'react'; +import classNames from 'classnames'; + +import { + EditableText, + EditableInlineText, + EditableLink, + Text, + useContentElementEditorState, + useContentElementConfigurationUpdate, + useI18n, + utils +} from 'pageflow-scrolled/frontend'; + +import styles from './Tooltip.module.css'; + +export function Tooltip({ + area, + contentElementId, portraitMode, configuration, visible, active, + onMouseEnter, onMouseLeave, onClick +}) { + const {t} = useI18n(); + const updateConfiguration = useContentElementConfigurationUpdate(); + const {isEditable} = useContentElementEditorState(); + + const indicatorPosition = ( + portraitMode ? + area.portraitIndicatorPosition : + area.indicatorPosition + ) || [50, 50]; + const tooltipTexts = configuration.tooltipTexts || {}; + const tooltipLinks = configuration.tooltipLinks || {}; + + const [ref, delta] = useKeepInViewport(visible); + + function handleTextChange(propertyName, value) { + updateConfiguration({ + tooltipTexts: { + ...tooltipTexts, + [area.id]: { + ...tooltipTexts[area.id], + [propertyName]: value + } + } + }); + } + + function handleLinkChange(value) { + if (utils.isBlankEditableTextValue(tooltipTexts[area.id]?.link)) { + handleTextChange('link', [{ + type: 'heading', + children: [{text: t('pageflow_scrolled.public.more')}] + }]); + } + + updateConfiguration({ + tooltipLinks: { + ...tooltipLinks, + [area.id]: value + } + }); + } + + function presentOrEditing(propertyName) { + return !utils.isBlankEditableTextValue(tooltipTexts[area.id]?.[propertyName]) || + (isEditable && active) || + (isEditable && + utils.isBlankEditableTextValue(tooltipTexts[area.id]?.title) && + utils.isBlankEditableTextValue(tooltipTexts[area.id]?.description) && + utils.isBlankEditableTextValue(tooltipTexts[area.id]?.link)); + } + + 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}`); +} + +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 + } + ); + + intersectionObserver.observe(current); + + return () => intersectionObserver.unobserve(current); + }, [visible]); + + return [ref, delta]; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css new file mode 100644 index 0000000000..075473e688 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -0,0 +1,93 @@ +.tooltip { + 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; +} + +.tooltip::after { + content: ""; + 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); +} + +.position-below::after { + bottom: 99%; + border-bottom-color: #fff; +} + +.box { + transform: translateX(var(--delta)); + 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; +} + +.tooltip h3, +.tooltip p { + margin: 0; +} + +.box > h3, +.box > div { + margin-bottom: 0.5em; +} + +.box > :last-child { + margin-bottom: 0; +} + +.link { + display: flex; + justify-content: center; + gap: 0.5em; + border-radius: 5px; + text-decoration: none; + padding: 0.75rem; + background-color: var(--theme-widget-primary-color); + color: var(--theme-widget-on-primary-color); + font-size: 18px; + margin-top: 1rem; + font-weight: bold; +} + +.box > :first-child .link { + margin-top: 0; +} + +.tooltip.visible { + opacity: 1; + visibility: visible; + transition-delay: 0.2s; +} + +.editable .link { + opacity: 0.5; +} + +.editable .link:has([data-slate-string]), +.editable .link:focus-within { + opacity: 1; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.js new file mode 100644 index 0000000000..8446677ce7 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.js @@ -0,0 +1,33 @@ +import Marionette from 'backbone.marionette'; +import {buttonStyles} from 'pageflow-scrolled/editor'; +import {cssModulesUtils, inputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; + +import {EditAreaDialogView} from './EditAreaDialogView'; + +import styles from './AreaInputView.module.css'; + +export const AreaInputView = Marionette.Layout.extend({ + template: (data) => ` + + + `, + + mixins: [inputView], + + events: cssModulesUtils.events(buttonStyles, { + 'click targetButton': function () { + EditAreaDialogView.show({ + model: this.model, + file: this.options.file, + portraitFile: this.options.portraitFile, + defaultTab: this.options.defaultTab + }); + } + }) +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.module.css new file mode 100644 index 0000000000..47cce6e616 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.module.css @@ -0,0 +1,3 @@ +.button { + width: 100%; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js new file mode 100644 index 0000000000..ecf060924d --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js @@ -0,0 +1,57 @@ +import Marionette from 'backbone.marionette'; +import {editor, buttonStyles} from 'pageflow-scrolled/editor'; +import {ListView} from 'pageflow/editor'; +import {cssModulesUtils} from 'pageflow/ui'; +import I18n from 'i18n-js'; + +import {EditAreaDialogView} from './EditAreaDialogView'; +import {Area} from './models/Area'; + +import styles from './AreasListView.module.css'; + +export const AreasListView = Marionette.Layout.extend({ + template: (data) => ` +
+ + `, + + regions: cssModulesUtils.ui(styles, 'listContainer'), + + events: cssModulesUtils.events(buttonStyles, { + 'click addButton': function () { + const model = new Area(); + + EditAreaDialogView.show({ + model, + file: this.model.getImageFile('image'), + portraitFile: this.model.getImageFile('portraitImage'), + onSave: () => this.collection.addWithId(model) + }); + } + }), + + onRender() { + this.listContainer.show(new ListView({ + label: I18n.t('pageflow_scrolled.editor.content_elements.hotspots.areas.label'), + collection: this.collection, + sortable: true, + highlight: true, + + onEdit: (model) => { + this.options.contentElement.postCommand({type: 'SET_ACTIVE_AREA', + index: this.collection.indexOf(model)}) + editor.navigate( + `/scrolled/hotspots/${this.options.contentElement.id}/${model.id}`, + {trigger: true} + ) + }, + onRemove: (model) => { + if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.hotspots.areas.confirm_delete'))) { + this.collection.remove(model); + } + } + })); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.module.css new file mode 100644 index 0000000000..ba49f7a9bb --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.module.css @@ -0,0 +1 @@ +.listContainer {} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView.module.css new file mode 100644 index 0000000000..327984c575 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView.module.css @@ -0,0 +1,9 @@ +.box { + width: min-content; + min-height: 310px; + min-width: 400px; +} + +.wrapper {} + +.save {} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js new file mode 100644 index 0000000000..adb68a69a2 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js @@ -0,0 +1,231 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; +import React, {useEffect, useReducer, useRef} from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import {DraggableCore} from 'react-draggable'; + +import {utils} from 'pageflow-scrolled/frontend' +import {buttonStyles} from 'pageflow-scrolled/editor' + +import { + reducer, + drawnOutlinePoints, + handles, + SET_MODE, + DRAG, + DRAG_HANDLE, + DRAG_HANDLE_STOP, + DOUBLE_CLICK_HANDLE, + MOUSE_MOVE, + DRAG_POTENTIAL_POINT, + DRAG_POTENTIAL_POINT_STOP, + DRAG_INDICATOR +} from './reducer'; + +import styles from './DraggableEditorView.module.css'; + +import squareIcon from './images/square.svg'; +import polygonIcon from './images/polygon.svg'; + +const i18nPrefix = 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog'; + +export const DraggableEditorView = Marionette.View.extend({ + render() { + ReactDOM.render( + this.mode = mode} + onPointsChange={points => this.points = points} + onIndicatorPositionChange={indicatorPosition => this.indicatorPosition = indicatorPosition} />, + this.el + ); + + return this; + }, + + save() { + if (this.mode) { + this.model.set(this.getPropertyName('mode'), this.mode); + } + + if (this.points) { + this.model.set(this.getPropertyName('outline'), this.points); + } + + if (this.indicatorPosition) { + this.model.set(this.getPropertyName('indicatorPosition'), this.indicatorPosition); + } + }, + + getPropertyName(suffix) { + return this.options.portrait ? + `portrait${utils.capitalize(suffix)}` : + suffix; + } +}); + +function DraggableEditor({ + imageSrc, portrait, + initialMode, initialPoints, initialIndicatorPosition, + onModeChange, onPointsChange, onIndicatorPositionChange +}) { + const [state, dispatch] = useReducer(reducer, { + mode: initialMode || 'rect', + points: initialPoints || [[40, 40], [60, 40], [60, 60], [40, 60]], + indicatorPosition: initialIndicatorPosition || [50, 50] + }); + + const { + mode, points, potentialPoint, indicatorPosition + } = state; + + useEffect( + () => { onModeChange(mode); }, + [onModeChange, mode] + ); + + useEffect( + () => { onPointsChange(points); }, + [onPointsChange, points] + ); + + useEffect( + () => { onIndicatorPositionChange(indicatorPosition); }, + [onIndicatorPositionChange, indicatorPosition] + ); + + const ref = useRef(); + + function clientToPercent(event) { + const rect = ref.current.getBoundingClientRect(); + + return [ + Math.max(0, Math.min(100, (event.clientX - rect.left) / rect.width * 100)), + Math.max(0, Math.min(100, (event.clientY - rect.top) / rect.height * 100)) + ]; + } + + return ( +
+ + +
+ {I18n.t(`${i18nPrefix}.hotspots_image`)} +
dispatch({type: MOUSE_MOVE, cursor: clientToPercent(event)})}> + + { + const rect = ref.current.getBoundingClientRect(); + + dispatch({ + type: DRAG, + delta: [ + dragEvent.deltaX / rect.width * 100, + dragEvent.deltaY / rect.height * 100 + ] + }) + }}> + + coords.map(coord => coord).join(',') + ).join(' ')} /> + + + + {handles(state).map((handle, index) => + dispatch({ + type: DOUBLE_CLICK_HANDLE, + index + })} + onDrag={event => dispatch({ + type: DRAG_HANDLE, + index, + cursor: clientToPercent(event) + })} + onDragStop={event => dispatch({ + type: DRAG_HANDLE_STOP + })} /> + )} + + {potentialPoint && dispatch({ + type: DRAG_POTENTIAL_POINT, + cursor: clientToPercent(event) + })} + onDragStop={event => dispatch({ + type: DRAG_POTENTIAL_POINT_STOP + })} />} + + dispatch({ + type: DRAG_INDICATOR, + cursor: clientToPercent(event) + })} /> +
+
+
+ ); +} + +const modeIcons = { + rect: squareIcon, + polygon: polygonIcon +}; + +function ModeButtons({mode, dispatch}) { + return ( +
+ {['rect', 'polygon'].map(availableMode => + + )} +
+ ); +} + +function Handle({point, circle, potential, title, cursor, onDrag, onDragStop, onDoubleClick}) { + return ( + +
+ + ); +} + +function Indicator({position, onDrag}) { + return ( + +
+ + ); +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css new file mode 100644 index 0000000000..36181531ff --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css @@ -0,0 +1,151 @@ +.wrapper { + position: relative; + display: inline-block; + overflow: hidden; +} + +.buttons { + margin: 10px 0; + text-align: right; +} + +.buttons button:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.buttons button:last-child { + margin-left: -1px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.buttons button[aria-pressed=true] { + background-color: var(--ui-selection-color-light); +} + +.buttons button img { + vertical-align: middle; + margin-right: 6px; +} + +.image { + display: block; + height: calc(100vh - 250px); + max-height: 600px; + min-height: 200px; +} + +.portraitImage { + max-height: 1200px; +} + +.overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.overlay svg { + position: absolute; + width: 100%; + height: 100%; +} + +.overlay polygon { + vector-effect: non-scaling-stroke; + stroke-width: 1px; + stroke-linejoin: round; + stroke: #fff; + fill: transparent; + opacity: 0.9; + cursor: move; +} + +.handle { + position: absolute; + width: 10px; + height: 10px; + background-color: #fff; + transform: translate(-50%, -50%); + border: solid 1px var(--ui-primary-color); + border-radius: 2px; + opacity: 0.9; + z-index: 2; +} + +.handle::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 30px; + height: 30px; + margin: -10px 0 0 -10px; + border-radius: 100%; + z-index: 1; +} + +.circle { + border-radius: 100%; + cursor: move; +} + +.potential { + opacity: 0; + z-index: 1; + cursor: default; +} + +.handle:hover { + opacity: 1; +} + +.indicator { + --size: 15px; + position: absolute; + left: var(--center-x); + top: var(--center-y); + margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2); + border-radius: 50%; + width: var(--size); + height: var(--size); + background-color: #fff; + transition: transform 0.2s ease; + cursor: move; + z-index: 3; +} + +.indicator:hover { + transform: scale(1.2); +} + +.indicator::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + border-radius: 50%; + background-color: #fff; + animation: blink 1s infinite; + opacity: 0.3; + z-index: -1; +} + +@keyframes blink { + 0% { + transform: scale(1.7); + } + + 50% { + transform: scale(2); + } + + 100% { + transform: scale(1.7); + } +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/polygon.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/polygon.svg new file mode 100644 index 0000000000..dd3d121414 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/polygon.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/square.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/square.svg new file mode 100644 index 0000000000..1d0610044a --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/square.svg @@ -0,0 +1,10 @@ + + + + diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/index.js new file mode 100644 index 0000000000..28f69b8e29 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/index.js @@ -0,0 +1,95 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; + +import {buttonStyles, dialogView, dialogViewStyles} from 'pageflow-scrolled/editor' +import {app, cssModulesUtils} from 'pageflow/editor'; +import {TabsView} from 'pageflow/ui'; + +import {DraggableEditorView} from './DraggableEditorView'; + +import styles from '../EditAreaDialogView.module.css'; + +const i18nPrefix = 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog'; + +export const EditAreaDialogView = Marionette.ItemView.extend({ + template: () => ` +
+
+

+ ${I18n.t(`${i18nPrefix}.header`)} +

+ +
+
+ +
+ + +
+
+
+ `, + + mixins: [dialogView], + + ui: cssModulesUtils.ui(styles, 'wrapper'), + + events: cssModulesUtils.events(styles, { + 'click save': function() { + this.save(); + this.close(); + + if (this.options.onSave) { + this.options.onSave(); + } + } + }), + + onRender() { + if (this.options.portraitFile) { + const tabsView = new TabsView({ + translationKeyPrefixes: [`${i18nPrefix}.tabs`], + defaultTab: this.options.defaultTab + }); + + this.editorViews = [ + new DraggableEditorView({ + model: this.model, + file: this.options.file, + }), + new DraggableEditorView({ + model: this.model, + file: this.options.portraitFile, + portrait: true + }) + ]; + + tabsView.tab('default', () => this.editorViews[0]); + tabsView.tab('portrait', () => this.editorViews[1]); + + this.appendSubview(tabsView.render(), {to: this.ui.wrapper}); + } + else { + this.editorViews = [ + new DraggableEditorView({ + model: this.model, + file: this.options.file + }) + ]; + + this.appendSubview(this.editorViews[0].render(), {to: this.ui.wrapper}); + } + }, + + save() { + this.editorViews.forEach(view => view.save()); + } +}); + +EditAreaDialogView.show = function(options) { + app.dialogRegion.show(new EditAreaDialogView(options)); +}; diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js new file mode 100644 index 0000000000..cf10cde917 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js @@ -0,0 +1,288 @@ +export const SET_MODE = 'SET_MODE'; +export const DRAG = 'DRAG'; +export const DRAG_HANDLE = 'DRAG_HANDLE'; +export const DRAG_HANDLE_STOP = 'DRAG_HANDLE_STOP'; +export const DOUBLE_CLICK_HANDLE = 'DOUBLE_CLICK_HANDLE'; +export const MOUSE_MOVE = 'MOUSE_MOVE'; +export const DRAG_POTENTIAL_POINT = 'DRAG_POTENTIAL_POINT'; +export const DRAG_POTENTIAL_POINT_STOP = 'DRAG_POTENTIAL_POINT_STOP'; +export const DRAG_INDICATOR = 'DRAG_INDICATOR'; + +export function reducer(state, action) { + switch (action.type) { + case SET_MODE: + if (action.value === state.mode) { + return state; + } + else if (action.value === 'rect') { + return { + ...state, + mode: 'rect', + previousPolygonPoints: state.points, + points: getBoundingBox(state.points) + }; + } + else { + return { + ...state, + mode: 'polygon', + points: state.previousPolygonPoints || state.points + }; + } + case DRAG: + let [deltaX, deltaY] = action.delta; + + state.points.forEach(point => { + if (point[0] + deltaX > 100) { + deltaX = 100 - point[0]; + } + + if (point[0] + deltaX < 0) { + deltaX = -point[0]; + } + + if (point[1] + deltaY > 100) { + deltaY = 100 - point[1]; + } + + if (point[1] + deltaY < 0) { + deltaY = -point[1]; + } + }); + + return { + ...state, + points: state.points.map(point => + [ + point[0] + deltaX, + point[1] + deltaY] + ), + indicatorPosition: [ + state.indicatorPosition[0] + deltaX, + state.indicatorPosition[1] + deltaY + ] + }; + case DRAG_HANDLE: + if (state.mode === 'polygon') { + state = { + ...state, + points: [ + ...state.points.slice(0, action.index), + action.cursor, + ...state.points.slice(action.index + 1) + ] + }; + } + else { + const startPoints = + state.startPoints || + (action.index % 2 === 0 ? + [state.points[(action.index / 2 + 2) % 4]] : + [state.points[((action.index + 3) / 2) % 4], + state.points[((action.index + 5) / 2) % 4]]); + + state = { + ...state, + startPoints, + previousPolygonPoints: null, + points: getBoundingBox([ + action.cursor, + ...startPoints + ]) + }; + } + + return { + ...state, + indicatorPosition: ensureInPolygon(state.points, state.indicatorPosition) + }; + + case DRAG_HANDLE_STOP: + return { + ...state, + startPoints: null + }; + case DOUBLE_CLICK_HANDLE: + if (state.mode !== 'polygon' || state.points.length <= 3) { + return state; + } + + const points = [ + ...state.points.slice(0, action.index), + ...state.points.slice(action.index + 1) + ]; + + return { + ...state, + points, + potentialPoint: null, + indicatorPosition: ensureInPolygon(points, state.indicatorPosition) + }; + case MOUSE_MOVE: + if (state.mode !== 'polygon' || state.draggingPotentialPoint) { + return state; + } + + const [index, potentialPoint] = closestPointOnPolygon(state.points, action.cursor); + + return { + ...state, + potentialPointInsertIndex: index, + potentialPoint + }; + case DRAG_POTENTIAL_POINT: + return { + ...state, + draggingPotentialPoint: true, + potentialPoint: action.cursor + }; + case DRAG_POTENTIAL_POINT_STOP: + return { + ...state, + points: withPotentialPoint(state), + draggingPotentialPoint: false, + potentialPoint: null + }; + case DRAG_INDICATOR: + return { + ...state, + indicatorPosition: ensureInPolygon(state.points, action.cursor) + } + default: + throw new Error(`Unknown action ${action.type}.`); + } +} + +export function drawnOutlinePoints(state) { + if (state.draggingPotentialPoint) { + return withPotentialPoint(state); + } + else { + return state.points;} +} + +const rectCursors = [ + 'nwse-resize', + 'ns-resize', + 'nesw-resize', + 'ew-resize' +]; + +export function handles(state) { + if (state.mode === 'rect') { + return state.points.flatMap((point, index) => ( + [point, midpoint(point, state.points[(index + 1) % state.points.length])] + )).map((point, index) => ({ + point, + axis: index % 4 === 1 ? 'y' : index % 4 === 3 ? 'x' : null, + cursor: rectCursors[index % 4], + deletable: false + })); + } + else { + return state.points.map(point => ({ + point, + circle: true, + cursor: 'move', + deletable: state.points.length > 3 + })); + } +} + +function withPotentialPoint(state) { + return [ + ...state.points.slice(0, state.potentialPointInsertIndex), + state.potentialPoint, + ...state.points.slice(state.potentialPointInsertIndex) + ]; +} + +function midpoint(p1, p2) { + return [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2]; +} + +function getBoundingBox(polygon) { + if (polygon.length === 0) { + return null; + } + + let minX = polygon[0][0]; + let minY = polygon[0][1]; + let maxX = polygon[0][0]; + let maxY = polygon[0][1]; + + for (let i = 1; i < polygon.length; i++) { + let [x, y] = polygon[i]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + return [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY] + ]; +} + +function ensureInPolygon(polygon, point) { + return isPointInPolygon(polygon, point) ? + point : + closestPointOnPolygon(polygon, point)[1] +} + +function isPointInPolygon(polygon, point) { + let x = point[0], y = point[1]; + let inside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + let xi = polygon[i][0], yi = polygon[i][1]; + let xj = polygon[j][0], yj = polygon[j][1]; + + let intersect = ((yi > y) !== (yj > y)) && + (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + if (intersect) inside = !inside; + } + + return inside; +} + +function closestPointOnPolygon(polygon, c, maxDistance = 5) { + function distance(p1, p2) { + return Math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2); + } + + function closestPoint(A, B, C) { + const AB = [B[0] - A[0], B[1] - A[1]]; + const AC = [C[0] - A[0], C[1] - A[1]]; + const abLength = AB[0] * AB[0] + AB[1] * AB[1]; // Dot product of AB with itself + + if (abLength === 0) return A; // A and B are the same points + + const proj = (AC[0] * AB[0] + AC[1] * AB[1]) / abLength; // Projection ratio of AC on AB + + if (proj < 0) return A; // Closer to A + else if (proj > 1) return B; // Closer to B + else return [A[0] + proj * AB[0], A[1] + proj * AB[1]]; // Point on the segment + } + + let closest = null; + let minDistance = Infinity; + + for (let i = 0; i < polygon.length; i++) { + const A = polygon[i]; + const B = polygon[(i + 1) % polygon.length]; + + const point = closestPoint(A, B, c); + const dist = distance(c, point); + + if (dist < minDistance) { + minDistance = dist; + closest = [i + 1, point]; + } + } + + return closest; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarController.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarController.js new file mode 100644 index 0000000000..f693c61632 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarController.js @@ -0,0 +1,23 @@ +import {SidebarEditAreaView} from './SidebarEditAreaView'; +import {AreasCollection} from './models/AreasCollection'; +import Marionette from 'backbone.marionette'; + +export const SidebarController = Marionette.Controller.extend({ + initialize: function(options) { + this.entry = options.entry; + this.region = options.region; + }, + + area: function(id, areaId, tab) { + const contentElement = this.entry.contentElements.get(id); + const areasCollection = AreasCollection.forContentElement(contentElement, this.entry); + + this.region.show(new SidebarEditAreaView({ + model: areasCollection.get(areaId), + collection: areasCollection, + entry: this.entry, + contentElement, + tab + })); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js new file mode 100644 index 0000000000..c97f2dc77b --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -0,0 +1,125 @@ +import {ConfigurationEditorView, SelectInputView} from 'pageflow/ui'; +import {editor, FileInputView} from 'pageflow/editor'; +import Marionette from 'backbone.marionette'; +import I18n from 'i18n-js'; + +import {AreaInputView} from './AreaInputView'; + +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')} + +
+ `, + + className: styles.view, + + regions: { + formContainer: '.form_container', + }, + + events: { + 'click a.back': 'goBack', + 'click a.destroy': 'destroyLink' + }, + + onRender: function () { + const options = this.options; + + const configurationEditor = new ConfigurationEditorView({ + model: this.model, + attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.content_elements.hotspots.edit_area.attributes'], + tabTranslationKeyPrefix: 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs', + tab: options.tab || (options.entry.get('emulation_mode') === 'phone' ? 'portrait' : 'area') + }); + + const file = options.contentElement.configuration.getImageFile('image'); + const portraitFile = options.contentElement.configuration.getImageFile('portraitImage'); + + if (file && portraitFile) { + this.previousEmulationMode = options.entry.get('emulation_mode') || 'desktop'; + } + + configurationEditor.tab('area', function() { + if (file && portraitFile) { + options.entry.unset('emulation_mode'); + } + + this.input('area', AreaInputView, { + file, + portraitFile + }); + this.group('PaletteColor', { + propertyName: 'color', + entry: options.entry + }); + this.input('tooltipPosition', SelectInputView, { + values: ['below', 'above'] + }); + this.input('activeImage', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'hotspotsArea', + fileSelectionHandlerOptions: { + contentElementId: options.contentElement.get('id'), + tab: 'area' + }, + positioning: false + }); + }); + + if (portraitFile) { + configurationEditor.tab('portrait', function() { + if (file && portraitFile) { + options.entry.set('emulation_mode', 'phone'); + } + + this.input('portraitArea', AreaInputView, { + file, + portraitFile, + defaultTab: 'portrait' + }); + this.group('PaletteColor', { + propertyName: 'portraitColor', + entry: options.entry + }); + this.input('portraitTooltipPosition', SelectInputView, { + values: ['below', 'above'] + }); + this.input('portraitActiveImage', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'hotspotsArea', + fileSelectionHandlerOptions: { + contentElementId: options.contentElement.get('id'), + tab: 'portrait' + }, + positioning: false + }); + }); + } + + this.formContainer.show(configurationEditor); + }, + + onClose() { + if (this.previousEmulationMode === 'phone') { + this.options.entry.set('emulation_mode', 'phone'); + } + else if (this.previousEmulationMode === 'desktop') { + this.options.entry.unset('emulation_mode'); + } + }, + + goBack: function() { + editor.navigate(`/scrolled/content_elements/${this.options.contentElement.get('id')}`, {trigger: true}); + }, + + destroyLink: function () { + if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.confirm_delete_link'))) { + this.options.collection.remove(this.model); + this.goBack(); + } + }, +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.module.css new file mode 100644 index 0000000000..b25231a7d5 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.module.css @@ -0,0 +1,10 @@ +.view :global(.tabs_view-headers) li:nth-child(2) { + margin-left: 5px; +} + +.view :global(.tabs_view-headers) li:nth-child(2)::before { + content: "›"; + margin-left: -15px; + margin-right: 10px; + font-weight: normal; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarRouter.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarRouter.js new file mode 100644 index 0000000000..37d65c12f2 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarRouter.js @@ -0,0 +1,8 @@ +import Marionette from 'backbone.marionette'; + +export const SidebarRouter = Marionette.AppRouter.extend({ + appRoutes: { + 'scrolled/hotspots/:id/:area_id': 'area', + 'scrolled/hotspots/:id/:area_id/:tab': 'area', + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js new file mode 100644 index 0000000000..256e8ad153 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -0,0 +1,85 @@ +import {editor, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; +import {contentElementWidths} from 'pageflow-scrolled/frontend'; +import {CheckBoxInputView, FileInputView, SelectInputView, SeparatorView} from 'pageflow/editor'; + +import {AreasListView} from './AreasListView'; +import {AreasCollection} from './models/AreasCollection'; + +import {SidebarRouter} from './SidebarRouter'; +import {SidebarController} from './SidebarController'; + +import pictogram from './pictogram.svg'; + +editor.registerSideBarRouting({ + router: SidebarRouter, + controller: SidebarController +}); + +editor.contentElementTypes.register('hotspots', { + pictogram, + category: 'links', + featureName: 'hotspots_content_element', + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], + supportedWidthRange: ['xxs', 'full'], + + editorPath(contentElement) { + const activeAreaId = contentElement.transientState.get('activeAreaId'); + + if (activeAreaId) { + return `/scrolled/hotspots/${contentElement.id}/${activeAreaId}`; + } + }, + + configurationEditor({entry, contentElement}) { + this.tab('general', function() { + this.input('image', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'contentElementConfiguration', + positioning: false, + dropDownMenuItems: [InlineFileRightsMenuItem] + }); + this.input('portraitImage', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'contentElementConfiguration', + positioning: false + }); + this.view(AreasListView, { + configuration: this.model, + contentElement, + collection: AreasCollection.forContentElement(contentElement, entry) + }); + 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'], + disabled: () => contentElement.getWidth() === contentElementWidths.full, + displayUncheckedIfDisabled: true + }); + this.group('ContentElementPosition'); + }); + }, + + defaultConfig: { + enablePanZoom: 'phonePlatform' + } +}); + +editor.registerFileSelectionHandler('hotspotsArea', function (options) { + const contentElement = options.entry.contentElements.get(options.contentElementId); + const areas = AreasCollection.forContentElement(contentElement, options.entry) + + this.call = function(file) { + areas.get(options.id).setReference(options.attributeName, file); + }; + + this.getReferer = function() { + return '/scrolled/hotspots/' + contentElement.id + '/' + options.id + '/' + options.tab; + }; +}); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js new file mode 100644 index 0000000000..9b91acf5ee --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js @@ -0,0 +1,32 @@ +import Backbone from 'backbone'; +import {transientReferences} from 'pageflow/editor'; + +export const Area = Backbone.Model.extend({ + mixins: [transientReferences], + + thumbnailFile() { + return this.imageFile()?.thumbnailFile(); + }, + + title() { + const tooltipTexts = this.collection.contentElement.configuration.get('tooltipTexts'); + return tooltipTexts?.[this.id]?.title?.[0]?.children?.[0]?.text; + }, + + imageFile() { + return this.collection.entry.imageFiles.getByPermaId(this.get('activeImage')); + }, + + highlight() { + this.collection.contentElement.postCommand({ + type: 'HIGHLIGHT_AREA', + index: this.collection.indexOf(this) + }); + }, + + resetHighlight() { + this.collection.contentElement.postCommand({ + type: 'RESET_AREA_HIGHLIGHT' + }); + } +}); 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 new file mode 100644 index 0000000000..062b5d8b83 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js @@ -0,0 +1,33 @@ +import Backbone from 'backbone'; + +import {Area} from './Area'; + +export const AreasCollection = Backbone.Collection.extend({ + model: Area, + comparator: 'position', + + initialize(models, options) { + this.entry = options.entry; + this.contentElement = options.contentElement; + + this.listenTo(this, 'add remove change sort', this.updateConfiguration); + }, + + updateConfiguration() { + this.contentElement.configuration.set('areas', this.toJSON()); + }, + + addWithId(model) { + model.set('id', this.length ? Math.max(...this.pluck('id')) + 1 : 1); + this.add(model); + }, + + saveOrder() {} +}); + +AreasCollection.forContentElement = function(contentElement, entry) { + return new AreasCollection(contentElement.configuration.get('areas') || [], { + entry: entry, + contentElement: contentElement + }); +}; diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg new file mode 100644 index 0000000000..510c81b3c5 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/frontend.js b/entry_types/scrolled/package/src/contentElements/hotspots/frontend.js new file mode 100644 index 0000000000..9704113e60 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/frontend.js @@ -0,0 +1,7 @@ +import {frontend} from 'pageflow-scrolled/frontend'; +import {Hotspots} from './Hotspots'; + +frontend.contentElementTypes.register('hotspots', { + component: Hotspots, + lifecycle: true +}); diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index be750bd389..3b7480bd45 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -114,7 +114,8 @@ export const PreviewMessageController = Object.extend({ const {type, id} = message.data.payload; if (type === 'contentElement') { - this.editor.navigate(`/scrolled/content_elements/${id}`, {trigger: true}) + const contentElement = this.entry.contentElements.get(id); + this.editor.navigate(contentElement.getEditorPath(), {trigger: true}) } else if (type === 'sectionSettings') { this.editor.navigate(`/scrolled/sections/${id}`, {trigger: true}) diff --git a/entry_types/scrolled/package/src/editor/index.js b/entry_types/scrolled/package/src/editor/index.js index e8fb9effda..22b342cc40 100644 --- a/entry_types/scrolled/package/src/editor/index.js +++ b/entry_types/scrolled/package/src/editor/index.js @@ -10,6 +10,9 @@ import './config'; export {editor} from './api'; export {default as buttonStyles} from './views/buttons.module.css'; +export {default as dialogViewStyles} from './views/mixins/dialogView.module.css'; +export {dialogView} from './views/mixins/dialogView'; + export {NoOptionsHintView} from './views/NoOptionsHintView'; export {EditMotifAreaDialogView} from './views/EditMotifAreaDialogView'; export {InlineFileRightsMenuItem} from './models/InlineFileRightsMenuItem'; diff --git a/entry_types/scrolled/package/src/editor/models/ContentElement.js b/entry_types/scrolled/package/src/editor/models/ContentElement.js index 5c8f4bc061..1489fff8b8 100644 --- a/entry_types/scrolled/package/src/editor/models/ContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ContentElement.js @@ -139,5 +139,10 @@ export const ContentElement = Backbone.Model.extend({ else { return width; } + }, + + getEditorPath() { + return this.getType().editorPath?.call(null, this) || + `/scrolled/content_elements/${this.id}`; } }); diff --git a/entry_types/scrolled/package/src/editor/views/buttons.module.css b/entry_types/scrolled/package/src/editor/views/buttons.module.css index 85003f13d9..f94e067ff7 100644 --- a/entry_types/scrolled/package/src/editor/views/buttons.module.css +++ b/entry_types/scrolled/package/src/editor/views/buttons.module.css @@ -23,6 +23,11 @@ composes: cancel from './icons.module.css'; } +.targetButton { + composes: secondaryIconButton; + composes: target from './icons.module.css'; +} + .saveButton { composes: primaryIconButton; composes: check from './icons.module.css'; diff --git a/entry_types/scrolled/package/src/editor/views/icons.module.css b/entry_types/scrolled/package/src/editor/views/icons.module.css index ca73ab2852..a09491e27c 100644 --- a/entry_types/scrolled/package/src/editor/views/icons.module.css +++ b/entry_types/scrolled/package/src/editor/views/icons.module.css @@ -15,6 +15,7 @@ .rightOpen, .star, .starOutlined, +.target, .trash { composes: icon; } @@ -79,6 +80,10 @@ content: "\2606"; } +.target::before { + content: "\1f3af"; +} + .trash::before { content: "\e729"; } diff --git a/entry_types/scrolled/package/src/editor/views/mixins/dialogView.js b/entry_types/scrolled/package/src/editor/views/mixins/dialogView.js index 3b65fa72c3..930f905d2a 100644 --- a/entry_types/scrolled/package/src/editor/views/mixins/dialogView.js +++ b/entry_types/scrolled/package/src/editor/views/mixins/dialogView.js @@ -4,12 +4,10 @@ import styles from './dialogView.module.css'; export const dialogView = { events: cssModulesUtils.events(styles, { - 'mousedown backdrop': function() { - this.close() - }, - - 'mousedown box': function(event) { - event.stopPropagation(); + 'mousedown backdrop': function(event) { + if (!event.target.closest(`.${styles.box}`)) { + this.close(); + } }, 'click close': function() { diff --git a/entry_types/scrolled/package/src/frontend/EditableLink.js b/entry_types/scrolled/package/src/frontend/EditableLink.js new file mode 100644 index 0000000000..6218f3d866 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/EditableLink.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import {withInlineEditingAlternative} from './inlineEditing'; +import {Link} from './Link'; + +export const EditableLink = withInlineEditingAlternative( + 'EditableLink', + function EditableLink({className, href, openInNewTab, children}) { + return ( + + ); + } +); diff --git a/entry_types/scrolled/package/src/frontend/EditableText.js b/entry_types/scrolled/package/src/frontend/EditableText.js index 7f64af1d18..169d0a44d2 100644 --- a/entry_types/scrolled/package/src/frontend/EditableText.js +++ b/entry_types/scrolled/package/src/frontend/EditableText.js @@ -4,12 +4,12 @@ import classNames from 'classnames'; import {camelize} from './utils/camelize'; import {paletteColor} from './paletteColor'; import {withInlineEditingAlternative} from './inlineEditing'; -import {useChapter, useFile} from '../entryState'; import {useDarkBackground} from './backgroundColor'; import {Text} from './Text'; -import textStyles from './Text.module.css'; +import {Link} from './Link'; import styles from './EditableText.module.css'; +import textStyles from './Text.module.css'; const defaultValue = [{ type: 'paragraph', @@ -120,69 +120,15 @@ function Heading({attributes, variantClassName, styles: inlineStyles, children}) } function renderLink({attributes, children, element}) { - if (element?.href?.chapter) { - const {key, ...otherAttributes} = attributes; + const {key, ...otherAttributes} = attributes; - return ( - - {children} - - ); - } - else if (element?.href?.section) { - return - {children} - ; - } - if (element?.href?.file) { - const {key, ...otherAttributes} = attributes; - - return ( - - {children} - - ); - } - else { - const targetAttributes = element.openInNewTab ? - {target: '_blank', rel: 'noopener noreferrer'} : - {}; - - return - {children} - ; - } -} - -function ChapterLink({attributes, children, chapterPermaId}) { - const chapter = useChapter({permaId: chapterPermaId}); - - return - {children} - ; -} - -function FileLink({attributes, children, fileOptions}) { - const file = useFile(fileOptions); - - return - {children} - ; + return ( + + ); } export function renderLeaf({attributes, children, leaf}) { diff --git a/entry_types/scrolled/package/src/frontend/Link.js b/entry_types/scrolled/package/src/frontend/Link.js new file mode 100644 index 0000000000..06898c9d49 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/Link.js @@ -0,0 +1,59 @@ +import React from 'react'; + +import {useChapter, useFile} from '../entryState'; + +export function Link({attributes, children, href, openInNewTab}) { + if (href?.chapter) { + return ( + + {children} + + ); + } + else if (href?.section) { + return + {children} + ; + } + if (href?.file) { + return ( + + {children} + + ); + } + else { + const targetAttributes = openInNewTab ? + {target: '_blank', rel: 'noopener noreferrer'} : + {}; + + return + {children} + ; + } +} + +function ChapterLink({attributes, children, chapterPermaId}) { + const chapter = useChapter({permaId: chapterPermaId}); + + return + {children} + ; +} + +function FileLink({attributes, children, fileOptions}) { + const file = useFile(fileOptions); + + return + {children} + ; +} diff --git a/entry_types/scrolled/package/src/frontend/Text.js b/entry_types/scrolled/package/src/frontend/Text.js index 8f3013c27f..1b6b127f0c 100644 --- a/entry_types/scrolled/package/src/frontend/Text.js +++ b/entry_types/scrolled/package/src/frontend/Text.js @@ -15,6 +15,7 @@ import styles from './Text.module.css'; * `'quoteText-lg'`, `'quoteText-md'`, `'quoteText-sm'`, `'quoteText-xs'`, `'quoteAttribution'`, * `'counterNumber-lg'`, `'counterNumber-md'`, `'counterNumber-sm'`, * `'counterNumber-xs'`, `'counterDescription`'. + * `'hotspotsTooltipTitle'`, `'hotspotsTooltipDescription`', `'hotspotsTooltipLink`'. * @param {string} [props.inline] - Render a span instread of a div. * @param {string} props.children - Nodes to render with specified typography. */ @@ -31,7 +32,9 @@ Text.propTypes = { 'heading-lg', 'heading-md', 'heading-sm', 'heading-xs', 'headingTagline-lg', 'headingTagline-md', 'headingTagline-sm', 'headingSubtitle-lg', 'headingSubtitle-md', 'headingSubtitle-sm', - 'quoteText-lg', 'quoteText-md', 'quoteText-sm', 'quoteText-xs', 'quoteAttribution', + 'quoteText-lg', 'quoteText-md', 'quoteText-sm', 'quoteText-xs', + 'quoteAttribution-lg', 'quoteAttribution-md', 'quoteAttribution-sm', 'quoteAttribution-xs', + 'hotspotsTooltipTitle', 'hotspotsTooltipDescription', 'hotspotsTooltipLink', 'counterNumber-lg', 'counterNumber-md', 'counterNumber-sm', 'counterNumber-xs', 'counterDescription', 'body', 'caption', 'question' diff --git a/entry_types/scrolled/package/src/frontend/Text.module.css b/entry_types/scrolled/package/src/frontend/Text.module.css index 9a0919fed9..e2cf7cfe6c 100644 --- a/entry_types/scrolled/package/src/frontend/Text.module.css +++ b/entry_types/scrolled/package/src/frontend/Text.module.css @@ -1,3 +1,4 @@ +@value text-xs: 18px; @value text-s: 20px; @value text-base: 22px; @value text-md: 33px; @@ -185,6 +186,26 @@ line-height: 1.4; } +.hotspotsTooltipTitle { + composes: typography-hotspotTooltipTitle from global; + font-size: text-s; + line-height: 1.4; + font-weight: bold; +} + +.hotspotsTooltipDescription { + composes: typography-hotspotTooltipDescription from global; + font-size: text-s; + line-height: 1.4; +} + +.hotspotsTooltipLink { + composes: typography-hotspotTooltipLink from global; + font-size: text-xs; + line-height: 1.4; + font-weight: bold; +} + @media (max-width: 600px) { .heading-lg { font-size: text-xl; diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 16436f995c..f15c600911 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -75,10 +75,20 @@ export { useShareUrl } from '../entryState'; +export {ContentElementAttributesProvider} from './useContentElementAttributes'; export {useContentElementConfigurationUpdate} from './useContentElementConfigurationUpdate'; -export {useContentElementEditorCommandSubscription} from './useContentElementEditorCommandSubscription'; -export {useContentElementEditorState} from './useContentElementEditorState'; -export {useContentElementLifecycle} from './useContentElementLifecycle'; +export { + useContentElementEditorCommandSubscription, + ContentElementEditorCommandEmitterContext +} from './useContentElementEditorCommandSubscription'; +export { + useContentElementEditorState, + ContentElementEditorStateContext +} from './useContentElementEditorState'; +export { + useContentElementLifecycle, + ContentElementLifecycleContext +} from './useContentElementLifecycle'; export {useCurrentChapter} from './useCurrentChapter'; export {useIsStaticPreview} from './useScrollPositionLifecycle'; export {useMediaMuted, useOnUnmuteMedia} from './useMediaMuted'; @@ -88,6 +98,7 @@ export {usePhonePlatform} from './usePhonePlatform'; export {EditableText} from './EditableText'; export {EditableInlineText} from './EditableInlineText'; +export {EditableLink} from './EditableLink'; export {PhonePlatformProvider} from './PhonePlatformProvider'; export { OptIn as ThirdPartyOptIn, diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js index a0dc1639b1..482aaf8d39 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js @@ -6,10 +6,12 @@ import styles from './ActionButton.module.css'; import background from './images/background.svg'; import foreground from './images/foreground.svg'; import pencil from './images/pencil.svg'; +import link from './images/link.svg'; const icons = { background, foreground, + link, pencil }; diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js new file mode 100644 index 0000000000..1c519dc85b --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js @@ -0,0 +1,35 @@ +import React from 'react'; + +import {LinkTooltipProvider, LinkPreview} from './LinkTooltip'; +import {ActionButton} from './ActionButton' +import {useSelectLinkDestination} from './useSelectLinkDestination'; +import {useI18n} from '../i18n'; + +import styles from './EditableLink.module.css'; + +export function EditableLink({className, href, openInNewTab, children, linkPreviewDisabled, onChange}) { + const selectLinkDestination = useSelectLinkDestination(); + const {t} = useI18n({locale: 'ui'}); + + function handleButtonClick() { + selectLinkDestination().then(onChange, () => {}); + } + + return ( +
+ + + + + {children} + + + +
+ ); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.module.css new file mode 100644 index 0000000000..d5b080d286 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.module.css @@ -0,0 +1,3 @@ +.wrapper { + position: relative; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js index e02def6631..d7fcb1704b 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js @@ -1,6 +1,6 @@ import React, {useMemo, useEffect} from 'react'; import classNames from 'classnames'; -import {createEditor, Transforms, Node, Text as SlateText} from 'slate'; +import {createEditor, Transforms, Node, Text as SlateText, Range} from 'slate'; import {Slate, Editable, withReact, ReactEditor} from 'slate-react'; import {Text} from '../../Text'; @@ -18,7 +18,7 @@ import {useDropTargetsActive} from './useDropTargetsActive'; import {HoveringToolbar} from './HoveringToolbar'; import {Selection} from './Selection'; import {DropTargets} from './DropTargets'; -import {LinkTooltipProvider} from './LinkTooltip'; +import {LinkTooltipProvider} from '../LinkTooltip'; import { applyTypograpyhVariant, applyColor, @@ -94,7 +94,8 @@ export const EditableText = React.memo(function EditableText({
- + {selectionRect && } {dropTargetsActive && } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.module.css index e9883f9702..681ffc95eb 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.module.css @@ -43,69 +43,3 @@ width: 100%; pointer-events: none; } - -.linkTooltip { - background-color: #222; - color: #fff; - border-radius: 4px; - font-family: Helvetica, Arial, "Sans-Serif"; - font-size: 13px; - line-height: 1; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} - -.linkTooltip::before { - content: ""; - display: block; - position: absolute; - left: 20px; - border: solid 4px transparent; -} - -.linkTooltip-below::before { - bottom: 100%; - border-bottom: solid 4px #222; -} - -.linkTooltip-above::before { - top: 100%; - border-top: solid 4px #222; -} - -.linkTooltip > a, -.linkTooltip > span { - color: #fff; - background-color: transparent; - border: 0; - display: inline-block; - padding: 10px 10px; -} - -.linkTooltip > a svg { - padding-left: 7px; -} - -.linkTooltipThumbnail { - width: 200px; - height: 120px; - position: relative; - margin: 5px; -} - -.linkTooltipThumbnailClickMask { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; -} - -.linkTooltipNewTab { - opacity: 0.7; - padding: 0 10px 10px; - text-decoration: none; -} - -.linkTooltipChapterNumber { - font-weight: bold; -} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js index 86834a6d9e..9f7d56adf8 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/withLinks.js @@ -1,7 +1,7 @@ import React from 'react'; import {renderElement} from '../../EditableText'; -import {LinkPreview} from './LinkTooltip'; +import {LinkPreview} from '../LinkTooltip'; export function withLinks(editor) { const { isInline } = editor diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js similarity index 74% rename from entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js rename to entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js index ee42613fde..07f11ef17e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js @@ -1,18 +1,19 @@ import React, {useContext, useState, createContext, useMemo, useRef} from 'react'; import classNames from 'classnames'; -import {Range} from 'slate'; -import {useI18n} from '../../i18n'; -import {useChapter, useFile} from '../../../entryState'; -import {SectionThumbnail} from '../../SectionThumbnail'; +import {useI18n} from '../i18n'; +import {useChapter, useFile} from '../../entryState'; +import {SectionThumbnail} from '../SectionThumbnail'; -import styles from './index.module.css'; +import styles from './LinkTooltip.module.css'; -import ExternalLinkIcon from '../images/externalLink.svg'; +import ExternalLinkIcon from './images/externalLink.svg'; const UpdateContext = createContext(); -export function LinkTooltipProvider({editor, disabled, position, children}) { +export function LinkTooltipProvider({ + disabled, position, children, align = 'left', gap = 10 +}) { const [state, setState] = useState(); const outerRef = useRef(); @@ -30,9 +31,14 @@ export function LinkTooltipProvider({editor, disabled, position, children}) { setState({ href, openInNewTab, - top: position === 'below' ? linkRect.bottom - outerRect.top + 10 : 'auto', - bottom: position === 'above' ? outerRect.bottom - linkRect.top + 10 : 'auto', - left: linkRect.left - outerRect.left + top: position === 'below' ? + linkRect.bottom - outerRect.top + gap : + 'auto', + bottom: position === 'above' ? + outerRect.bottom - linkRect.top + gap : + 'auto', + left: linkRect.left - outerRect.left + + (align === 'center' ? linkRect.width / 2 : 0) }); }, @@ -50,12 +56,15 @@ export function LinkTooltipProvider({editor, disabled, position, children}) { } } } - }, [position]); + }, [position, align, gap]); return (
- + {children}
@@ -75,18 +84,20 @@ export function LinkPreview({href, openInNewTab, children}) { ); } -export function LinkTooltip({editor, disabled, position, state}) { +export function LinkTooltip({disabled, position, align, state}) { const {keep, deactivate} = useContext(UpdateContext); - if (disabled || !state || (editor.selection && !Range.isCollapsed(editor.selection))) { + if (disabled || !state || !state.href) { return null; } return ( -
+ style={{top: state.top, bottom: state.bottom, left: state.left}}>
); @@ -128,7 +139,7 @@ function ChapterLinkDestination({permaId}) { return ( - + {t('pageflow_scrolled.inline_editing.link_tooltip.chapter_number', {number: chapter.index + 1})} {chapter.title} @@ -140,10 +151,10 @@ function SectionLinkDestination({permaId}) { const {t} = useI18n({locale: 'ui'}); return ( -
+ @@ -161,7 +172,7 @@ function ExternalLinkDestination({href, openInNewTab}) { {href} -
+
{openInNewTab ? t('pageflow_scrolled.inline_editing.link_tooltip.opens_in_new_tab') : t('pageflow_scrolled.inline_editing.link_tooltip.opens_in_same_tab')} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css new file mode 100644 index 0000000000..4dd49117cd --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css @@ -0,0 +1,79 @@ +.linkTooltip { + position: absolute; + z-index: 11; + white-space: nowrap; + background-color: #222; + color: #fff; + border-radius: 4px; + font-family: Helvetica, Arial, "Sans-Serif"; + font-size: 13px; + line-height: 1; + box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); +} + +.align-center { + transform: translateX(-50%); +} + +.linkTooltip::before { + content: ""; + display: block; + position: absolute; + border: solid 4px transparent; +} + +.position-below::before { + bottom: 100%; + border-bottom: solid 4px #222; +} + +.position-above::before { + top: 100%; + border-top: solid 4px #222; +} + +.align-left::before { + left: 20px; +} + +.align-center::before { + left: calc(50% - 4px); +} + +.linkTooltip > a, +.linkTooltip > span { + color: #fff; + background-color: transparent; + border: 0; + display: inline-block; + padding: 10px 10px; +} + +.linkTooltip > a svg { + padding-left: 7px; +} + +.thumbnail { + width: 200px; + height: 120px; + position: relative; + margin: 5px; +} + +.thumbnailClickMask { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.newTab { + opacity: 0.7; + padding: 0 10px 10px; + text-decoration: none; +} + +.chapterNumber { + font-weight: bold; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/components.js b/entry_types/scrolled/package/src/frontend/inlineEditing/components.js index 1af5055f41..89ba8be5b0 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/components.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/components.js @@ -6,6 +6,7 @@ export {LayoutWithPlaceholder} from './LayoutWithPlaceholder'; export {EditableText} from './EditableText'; export {EditableInlineText} from './EditableInlineText'; +export {EditableLink} from './EditableLink'; export {ActionButton} from './ActionButton'; diff --git a/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js b/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js index 7760ea7733..25836abf17 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js @@ -7,7 +7,7 @@ import { import {api} from './api'; -const ContentElementLifecycleContext = createContext(); +export const ContentElementLifecycleContext = createContext(); const LifecycleProvider = createScrollPositionLifecycleProvider( ContentElementLifecycleContext diff --git a/entry_types/scrolled/package/src/frontend/utils/capitalize.js b/entry_types/scrolled/package/src/frontend/utils/capitalize.js new file mode 100644 index 0000000000..71c6732cb9 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/utils/capitalize.js @@ -0,0 +1,3 @@ +export function capitalize(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} diff --git a/entry_types/scrolled/package/src/frontend/utils/index.js b/entry_types/scrolled/package/src/frontend/utils/index.js index d461a63544..9bbd383aaf 100644 --- a/entry_types/scrolled/package/src/frontend/utils/index.js +++ b/entry_types/scrolled/package/src/frontend/utils/index.js @@ -1,3 +1,4 @@ +import {capitalize} from './capitalize'; import {camelize} from './camelize'; import { isBlank, @@ -6,6 +7,7 @@ import { } from './blank'; export const utils = { + capitalize, camelize, isBlank, isBlankEditableTextValue, diff --git a/entry_types/scrolled/package/src/testHelpers/index.js b/entry_types/scrolled/package/src/testHelpers/index.js index c0990f7411..ca55f3d8f1 100644 --- a/entry_types/scrolled/package/src/testHelpers/index.js +++ b/entry_types/scrolled/package/src/testHelpers/index.js @@ -1,2 +1,4 @@ export * from './normalizeSeed'; +export * from './renderInContentElement'; export * from './rendering'; +export * from './scrollPositionLifecycle'; diff --git a/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js new file mode 100644 index 0000000000..458f9b13f0 --- /dev/null +++ b/entry_types/scrolled/package/src/testHelpers/renderInContentElement.js @@ -0,0 +1,67 @@ +import React, {useContext} from 'react'; +import BackboneEvents from 'backbone-events-standalone'; +import {act} from '@testing-library/react' + +import { + ContentElementAttributesProvider, + ContentElementEditorCommandEmitterContext, + ContentElementEditorStateContext, + ContentElementLifecycleContext +} from 'pageflow-scrolled/frontend'; + +import {renderInEntryWithScrollPositionLifecycle} from './scrollPositionLifecycle'; + +/** + * Provide context as if component was rendered inside of a content element. + * + * Returns two additionals functions to control content element scroll + * lifecycle and editor commands: `simulateScrollPosition` and `triggerEditorCommand`. + * + * @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`. + * + * @example + * + * const {getByRole, simulateScrollPosition, triggerEditorCommand} = + * renderInContentElement(, { + * seed: {...} + * }); + * simulateScrollPosition('near viewport'); + * triggerEditorCommand({type: 'HIGHLIGHT'}); + */ +export function renderInContentElement(ui, {editorState, wrapper, ...options}) { + const emitter = Object.assign({}, BackboneEvents); + + function Wrapper({children}) { + const defaultEditorState = useContext(ContentElementEditorStateContext); + + return ( + + + + {wrapper ? : children} + + + + ); + } + + return { + ...renderInEntryWithScrollPositionLifecycle( + ui, + { + lifecycleContext: ContentElementLifecycleContext, + wrapper: Wrapper, + ...options + } + ), + triggerEditorCommand(command) { + act(() => { + emitter.trigger(`command:42`, command) + }) + } + }; +} diff --git a/entry_types/scrolled/package/src/testHelpers/scrollPositionLifecycle.js b/entry_types/scrolled/package/src/testHelpers/scrollPositionLifecycle.js new file mode 100644 index 0000000000..09676ba23b --- /dev/null +++ b/entry_types/scrolled/package/src/testHelpers/scrollPositionLifecycle.js @@ -0,0 +1,99 @@ +import React, {useEffect, useState} from 'react'; +import BackboneEvents from 'backbone-events-standalone'; +import {act} from '@testing-library/react' + +import {renderInEntry} from './rendering'; +import {ContentElementLifecycleContext} from 'pageflow-scrolled/frontend'; + +/** + * Takes the same options as {@link renderInEntry} but returns + * additional helper function to the return value of the + * {@link `useContentElementLifecycle`} hook: + * + * const {simulateScrollPosition} = renderInEntry(...) + * simulateScrollPosition('near viewport') + * // => Turns `shouldLoad` and `shouldPrepare` to true + */ +export function renderInEntryWithContentElementLifecycle(ui, options) { + return renderInEntryWithScrollPositionLifecycle( + ui, + {lifecycleContext: ContentElementLifecycleContext, ...options} + ); +} + +export function renderInEntryWithScrollPositionLifecycle(ui, {lifecycleContext, wrapper, ...options} = {}) { + const emitter = createEmitter(); + + return withSimulateScrollPositionHelper( + emitter, + renderInEntry(ui, { + wrapper: createScrollPositionProvider(lifecycleContext, + emitter, + wrapper), + ...options + }) + ); +} + +function createScrollPositionProvider(Context, emitter, originalWrapper) { + const OriginalWrapper = originalWrapper || + function Noop({children}) { return children; }; + + return function ScrollPositionProvider({children}) { + const [value, setValue] = useState({shouldLoad: false, shouldPrepare: false, isVisible: false, isActive: false}); + + useEffect(() => { + function handle(scrollPosition) { + switch (scrollPosition) { + case 'near viewport': + setValue({shouldLoad: true, shouldPrepare: true, isVisible: false, isActive: false}); + break; + case 'in viewport': + setValue({shouldLoad: true, shouldPrepare: true, isVisible: true, isActive: false}); + break; + case 'center of viewport': + setValue({shouldLoad: true, shouldPrepare: true, isVisible: true, isActive: true}); + break; + default: + setValue({isVisible: false, isActive: false}); + break; + } + } + + emitter.on('scroll', handle); + + return () => emitter.off('scroll', handle); + }) + + return ( + + + {children} + + + ); + }; +} + +const allowedScrollPositions = ['outside viewport', 'near viewport', 'in viewport', 'center of viewport']; + +function withSimulateScrollPositionHelper(emitter, result) { + return { + ...result, + + simulateScrollPosition(scrollPosition) { + if (!allowedScrollPositions.includes(scrollPosition)) { + throw new Error(`Invalid scrollPosition '${scrollPosition}'. ` + + `Allowed values: ${allowedScrollPositions.join(', ')}`) + } + + act(() => { + emitter.trigger('scroll', scrollPosition) + }); + } + } +} + +function createEmitter() { + return {...BackboneEvents}; +} diff --git a/package/src/editor/models/Configuration.js b/package/src/editor/models/Configuration.js index 0a20d7b06b..241ed4aa5a 100644 --- a/package/src/editor/models/Configuration.js +++ b/package/src/editor/models/Configuration.js @@ -6,8 +6,6 @@ import {app} from '../app'; import {transientReferences} from './mixins/transientReferences'; -import {state} from '$state'; - export const Configuration = Backbone.Model.extend({ modelName: 'page', i18nKey: 'pageflow/page', @@ -46,7 +44,7 @@ export const Configuration = Backbone.Model.extend({ }, getImageFile: function(attribute) { - return this.getReference(attribute, state.imageFiles); + return this.getReference(attribute, 'image_files'); }, getFilePosition: function(attribute, coord) { @@ -83,7 +81,7 @@ export const Configuration = Backbone.Model.extend({ }, getVideoFile: function(attribute) { - return this.getReference(attribute, state.videoFiles); + return this.getReference(attribute, 'video_files'); }, getAudioFileSources: function(attribute) { @@ -97,12 +95,12 @@ export const Configuration = Backbone.Model.extend({ }, getAudioFile: function(attribute) { - return this.getReference(attribute, state.audioFiles); + return this.getReference(attribute, 'audio_files'); }, getVideoPosterUrl: function() { - var posterFile = this.getReference('poster_image_id', state.imageFiles), - videoFile = this.getReference('video_file_id', state.videoFiles); + var posterFile = this.getReference('poster_image_id', 'image_files'), + videoFile = this.getReference('video_file_id', 'video_files'); if (posterFile) { return posterFile.get('url'); diff --git a/rollup.config.js b/rollup.config.js index f9009f4fbb..33014fbe0b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -296,7 +296,10 @@ const pageflowScrolled = [ }, external, plugins: [ - image({include: '**/pictogram.svg'}), + image({include: [ + '**/editor/**/images/*.svg', + '**/pictogram.svg' + ]}), ...plugins() ] }, @@ -324,7 +327,7 @@ const pageflowScrolled = [ } ))), - ...(['tikTokEmbed'].map(name => ( + ...(['tikTokEmbed', 'hotspots'].map(name => ( { input: `${pageflowScrolledPackageRoot}/src/contentElements/${name}/frontend.js`, output: {