From ed8fae502713b5083c2a3eb37cb1d76a2c1adae1 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 30 Apr 2024 16:04:20 +0200 Subject: [PATCH 01/34] Make image import in Jest more realistic While in scrolled frontend code importing an SVG returns a React component, in editor code we instead use Rollup's image plugin to return the path. Make it work the same way in tests. REDMINE-20673 --- entry_types/scrolled/package/jest.config.js | 2 ++ .../scrolled/package/spec/support/jest/image-transform.js | 7 +++++++ rollup.config.js | 5 ++++- 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/spec/support/jest/image-transform.js diff --git a/entry_types/scrolled/package/jest.config.js b/entry_types/scrolled/package/jest.config.js index 88127c3697..79fe602e8e 100644 --- a/entry_types/scrolled/package/jest.config.js +++ b/entry_types/scrolled/package/jest.config.js @@ -27,6 +27,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/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/rollup.config.js b/rollup.config.js index f9009f4fbb..6874011379 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() ] }, From 4b35cd427443775792d23fec91190914c6294018 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 30 Apr 2024 16:07:25 +0200 Subject: [PATCH 02/34] Fix React mouse events in editor dialog views Do not stop propagation to prevent `mousedown` events inside dialog box from closing the box. REDMINE-20673 --- .../package/src/editor/views/mixins/dialogView.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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() { From 07f4832adc58ae3b9c893729b6a4755b5f0e5cfd Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 3 May 2024 16:31:50 +0200 Subject: [PATCH 03/34] Add editor dialog to edit hotspot area outline and indicator position REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 18 + .../config/locales/new/hotspots.en.yml | 18 + .../editor/EditAreaDialogView-spec.js | 334 ++++++++++++ .../editor/EditAreaDialogView/reducer-spec.js | 505 ++++++++++++++++++ .../editor/EditAreaDialogView.module.css | 9 + .../EditAreaDialogView/DraggableEditorView.js | 231 ++++++++ .../DraggableEditorView.module.css | 151 ++++++ .../EditAreaDialogView/images/polygon.svg | 1 + .../EditAreaDialogView/images/square.svg | 10 + .../editor/EditAreaDialogView/index.js | 95 ++++ .../editor/EditAreaDialogView/reducer.js | 288 ++++++++++ .../scrolled/package/src/editor/index.js | 3 + .../package/src/frontend/utils/capitalize.js | 3 + .../package/src/frontend/utils/index.js | 2 + 14 files changed, 1668 insertions(+) create mode 100644 entry_types/scrolled/config/locales/new/hotspots.de.yml create mode 100644 entry_types/scrolled/config/locales/new/hotspots.en.yml create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView-spec.js create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/polygon.svg create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/square.svg create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/index.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js create mode 100644 entry_types/scrolled/package/src/frontend/utils/capitalize.js 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..21728acb07 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -0,0 +1,18 @@ +de: + pageflow_scrolled: + editor: + content_elements: + hotspots: + 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 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..57b71ccf02 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -0,0 +1,18 @@ +en: + pageflow_scrolled: + editor: + content_elements: + hotspots: + 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 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/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/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/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, From f0dd1d48afd3a4f13bfd31b4f138c5f7762fa8d3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 3 May 2024 16:37:48 +0200 Subject: [PATCH 04/34] Do not fail if element has already been removed When a dialog is closed during the test the element is no longer present in the document. --- .../scrolled/package/spec/support/reactBasedBackboneViews.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From e80197f06cdbf133ea7c8c06ad13a81919f19a41 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 3 May 2024 16:50:37 +0200 Subject: [PATCH 05/34] Add editor sidebar views for hotspot element REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 49 ++++++++ .../config/locales/new/hotspots.en.yml | 49 ++++++++ .../editor/SidebarEditAreaView-spec.js | 78 ++++++++++++ .../editor/models/AreasCollection-spec.js | 53 ++++++++ .../package/src/contentElements/editor.js | 1 + .../hotspots/editor/AreaInputView.js | 33 +++++ .../hotspots/editor/AreaInputView.module.css | 3 + .../hotspots/editor/AreasListView.js | 52 ++++++++ .../hotspots/editor/AreasListView.module.css | 1 + .../hotspots/editor/SidebarController.js | 23 ++++ .../hotspots/editor/SidebarEditAreaView.js | 117 ++++++++++++++++++ .../editor/SidebarEditAreaView.module.css | 10 ++ .../hotspots/editor/SidebarRouter.js | 8 ++ .../contentElements/hotspots/editor/index.js | 76 ++++++++++++ .../hotspots/editor/models/Area.js | 18 +++ .../hotspots/editor/models/AreasCollection.js | 33 +++++ .../hotspots/editor/pictogram.svg | 1 + .../src/editor/views/buttons.module.css | 5 + .../package/src/editor/views/icons.module.css | 5 + package/src/editor/models/Configuration.js | 12 +- 20 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/AreaInputView.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarController.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarRouter.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/models/AreasCollection.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/editor/pictogram.svg diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 21728acb07..eb6bd4d13d 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -3,6 +3,29 @@ de: editor: content_elements: hotspots: + edit_area: + tabs: + area: Hotspot-Breiech + portrait: Hochkant + attributes: + tooltipPosition: + label: Tooltip-Position + values: + below: Unterhalb + above: Oberhalb + activeImage: + label: Aktives Bild + area: + label: Bereich + portraitTooltipPosition: + label: Tooltip-Position (Hochkant) + values: + below: Below + above: Above + portraitActiveImage: + label: Aktives Bild (Hochkant) + portraitArea: + label: Bereich (Hochkant) edit_area_dialog: header: Bereichsumriss und Indikatorposition tabs: @@ -16,3 +39,29 @@ de: 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 index 57b71ccf02..31b69992af 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -3,6 +3,29 @@ en: editor: content_elements: hotspots: + edit_area: + tabs: + area: Hotspot Area + portrait: Portrait + attributes: + tooltipPosition: + label: Tooltip orientation + values: + below: Below + above: Above + activeImage: + label: Active image + area: + label: Area + portraitTooltipPosition: + label: Tooltip orientation (Portrait) + values: + below: Below + above: Above + portraitActiveImage: + label: Active image (Portrait) + portraitArea: + label: Area (Portrait) edit_area_dialog: header: Area outline and indicator position tabs: @@ -16,3 +39,29 @@ en: 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/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..83ae82c1ec --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js @@ -0,0 +1,53 @@ +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'} + ]) + }); +}); 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/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..3601471a85 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js @@ -0,0 +1,52 @@ +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, + + onEdit: (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/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..15739861e9 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -0,0 +1,117 @@ +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.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.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..5c03374c87 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -0,0 +1,76 @@ +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', + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], + supportedWidthRange: ['xxs', 'full'], + + 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..20a0a9e3fc --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js @@ -0,0 +1,18 @@ +import Backbone from 'backbone'; +import {transientReferences} from 'pageflow/editor'; + +export const Area = Backbone.Model.extend({ + mixins: [transientReferences], + + thumbnailFile() { + return this.imageFile()?.thumbnailFile(); + }, + + title() { + return this.get('title'); + }, + + imageFile() { + return this.collection.entry.imageFiles.getByPermaId(this.get('image')); + } +}); 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/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/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'); From 83fa1a6fdf4d1a0f64b3f631d4903d5c09fdafec Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 8 May 2024 10:51:36 +0200 Subject: [PATCH 06/34] Build hotspots element via separate pack REDMINE-20673 --- .../scrolled/lib/pageflow_scrolled/plugin.rb | 10 +++-- .../scrolled/package/config/webpack.js | 6 +++ .../package/contentElements-server.js | 1 + .../contentElements/hotspots/Hotspots-spec.js | 28 +++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 41 +++++++++++++++++++ .../hotspots/Hotspots.module.css | 8 ++++ .../src/contentElements/hotspots/frontend.js | 7 ++++ rollup.config.js | 2 +- 8 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/frontend.js diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 91d1640e01..8fa1299b4c 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'), 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/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js new file mode 100644 index 0000000000..79ab2c898f --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import {Hotspots} from 'contentElements/hotspots/Hotspots'; + +import {renderInEntry} from 'pageflow-scrolled/testHelpers'; +import '@testing-library/jest-dom/extend-expect' + +describe('Hotspots', () => { + it('renders image', () => { + const seed = { + imageFileUrlTemplates: { + large: ':id_partition/image.webp' + }, + imageFiles: [ + {id: 1, permaId: 100} + ] + }; + const configuration = { + image: 100 + }; + + const {getByRole} = renderInEntry( + , {seed} + ) + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/image.webp') + }); +}) 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..53f8fc299f --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import { + ContentElementBox, + Image, + ContentElementFigure, + FitViewport, + useFileWithInlineRights, + InlineFileRights +} from 'pageflow-scrolled/frontend'; + +import styles from './Hotspots.module.css'; + +export function Hotspots({contentElementId, contentElementWidth, configuration}) { + const imageFile = useFileWithInlineRights({ + configuration, collectionName: 'imageFiles', propertyName: 'image' + }); + + return ( + + + + +
+ +
+ +
+
+
+ +
+ ); +} 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..89a1db5bc5 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css @@ -0,0 +1,8 @@ +.wrapper { + width: min-content; + height: 100%; +} + +.wrapper > img { + height: 100%; +} 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/rollup.config.js b/rollup.config.js index 6874011379..33014fbe0b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -327,7 +327,7 @@ const pageflowScrolled = [ } ))), - ...(['tikTokEmbed'].map(name => ( + ...(['tikTokEmbed', 'hotspots'].map(name => ( { input: `${pageflowScrolledPackageRoot}/src/contentElements/${name}/frontend.js`, output: { From a23bb57ff83419c5f929331f3f80765c2a74f609 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 8 May 2024 11:17:32 +0200 Subject: [PATCH 07/34] Add renderInContentElement test helper Provide content element specific contexts for editor state, editor commands and content element attributes to allow testing content element specific components in isolation. REDMINE-20673 --- .../scrolled/package/documentation.yml | 2 + .../spec/support/scrollPositionLifecycle.js | 85 ++-------------- .../scrolled/package/src/frontend/index.js | 16 ++- .../frontend/useContentElementLifecycle.js | 2 +- .../scrolled/package/src/testHelpers/index.js | 2 + .../src/testHelpers/renderInContentElement.js | 67 +++++++++++++ .../testHelpers/scrollPositionLifecycle.js | 99 +++++++++++++++++++ 7 files changed, 190 insertions(+), 83 deletions(-) create mode 100644 entry_types/scrolled/package/src/testHelpers/renderInContentElement.js create mode 100644 entry_types/scrolled/package/src/testHelpers/scrollPositionLifecycle.js 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/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/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 16436f995c..85c1d3fd5b 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'; 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/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}; +} From 3ab62c12f9922d762677be234a3333a74eb2b3ff Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 8 May 2024 11:21:22 +0200 Subject: [PATCH 08/34] Lazy load hotspot image REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 29 ++++++++++++++++--- .../src/contentElements/hotspots/Hotspots.js | 4 +++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 79ab2c898f..bbdfdedff0 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -2,11 +2,11 @@ import React from 'react'; import {Hotspots} from 'contentElements/hotspots/Hotspots'; -import {renderInEntry} from 'pageflow-scrolled/testHelpers'; +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import '@testing-library/jest-dom/extend-expect' describe('Hotspots', () => { - it('renders image', () => { + it('does not render images by default', () => { const seed = { imageFileUrlTemplates: { large: ':id_partition/image.webp' @@ -19,9 +19,30 @@ describe('Hotspots', () => { image: 100 }; - const {getByRole} = renderInEntry( + 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') }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 53f8fc299f..2bdd4411e2 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -5,6 +5,7 @@ import { Image, ContentElementFigure, FitViewport, + useContentElementLifecycle, useFileWithInlineRights, InlineFileRights } from 'pageflow-scrolled/frontend'; @@ -16,6 +17,8 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) configuration, collectionName: 'imageFiles', propertyName: 'image' }); + const {shouldLoad} = useContentElementLifecycle(); + return (
Date: Wed, 8 May 2024 11:45:00 +0200 Subject: [PATCH 09/34] Render clipped hotspot areas REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 37 +++++++++++++------ .../src/contentElements/hotspots/Area.js | 16 ++++++++ .../contentElements/hotspots/Area.module.css | 18 +++++++++ .../src/contentElements/hotspots/Hotspots.js | 8 ++++ 4 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Area.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index bbdfdedff0..6b5fef9d4d 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -8,12 +8,8 @@ import '@testing-library/jest-dom/extend-expect' describe('Hotspots', () => { it('does not render images by default', () => { const seed = { - imageFileUrlTemplates: { - large: ':id_partition/image.webp' - }, - imageFiles: [ - {id: 1, permaId: 100} - ] + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] }; const configuration = { image: 100 @@ -28,12 +24,8 @@ describe('Hotspots', () => { it('renders image when element should load', () => { const seed = { - imageFileUrlTemplates: { - large: ':id_partition/image.webp' - }, - imageFiles: [ - {id: 1, permaId: 100} - ] + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] }; const configuration = { image: 100 @@ -46,4 +38,25 @@ describe('Hotspots', () => { expect(getByRole('img')).toHaveAttribute('src', '000/000/001/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%)' + ) + }); }) 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..17986314fc --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import styles from './Area.module.css'; + +export function Area({area}) { + return ( +
+
+ ); +} + +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..a3bfef5c89 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css @@ -0,0 +1,18 @@ +.area, +.clip { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.area { + pointer-events: none; +} + +.clip { + pointer-events: auto; + border: none; + background-color: transparent; +} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 2bdd4411e2..7efc60dc85 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -10,6 +10,8 @@ import { InlineFileRights } from 'pageflow-scrolled/frontend'; +import {Area} from './Area'; + import styles from './Hotspots.module.css'; export function Hotspots({contentElementId, contentElementWidth, configuration}) { @@ -19,6 +21,8 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) const {shouldLoad} = useContentElementLifecycle(); + const areas = configuration.areas || []; + return ( + {areas.map((area, index) => + + )}
From d525331f98fe3c6ca48f2c93b3ce60a45656eb1f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 8 May 2024 13:41:11 +0200 Subject: [PATCH 10/34] Render area outlines as SVG when hotspots element in editor REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 47 ++++++++++++++++++- .../src/contentElements/hotspots/Area.js | 15 ++++++ .../contentElements/hotspots/Area.module.css | 16 ++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 6b5fef9d4d..4ad379a698 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -36,7 +36,7 @@ describe('Hotspots', () => { ); simulateScrollPosition('near viewport'); - expect(getByRole('img')).toHaveAttribute('src', '000/000/001/image.webp') + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/image.webp'); }); it('renders areas with clip path based on area outline', () => { @@ -57,6 +57,51 @@ describe('Hotspots', () => { expect(getByRole('button')).toHaveStyle( 'clip-path: polygon(10% 20%, 10% 30%, 40% 30%, 40% 20%)' + ); + }); + + 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' ) }); }) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index 17986314fc..1341ac45df 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -1,16 +1,31 @@ import React from 'react'; +import { + useContentElementEditorState +} from 'pageflow-scrolled/frontend'; + import styles from './Area.module.css'; export function Area({area}) { + const {isEditable, isSelected} = useContentElementEditorState(); + return (
); } +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 index a3bfef5c89..d6a48b9602 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css @@ -1,5 +1,6 @@ .area, -.clip { +.clip, +.outline { position: absolute; top: 0; left: 0; @@ -16,3 +17,16 @@ border: none; background-color: transparent; } + +.outline polygon { + vector-effect: non-scaling-stroke; + stroke-width: 1px; + stroke-linejoin: round; + stroke: #fff; + fill: transparent; + opacity: 0.5; +} + +.area:hover .outline polygon { + opacity: 1; +} From 229825fdb69528c51f061c8af68addfe28b23802 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 8 May 2024 17:12:05 +0200 Subject: [PATCH 11/34] Hightlight area outline when hovering areas list items REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 52 ++++++++++++++++++- .../editor/models/AreasCollection-spec.js | 36 +++++++++++++ .../src/contentElements/hotspots/Area.js | 5 +- .../contentElements/hotspots/Area.module.css | 1 + .../src/contentElements/hotspots/Hotspots.js | 15 +++++- .../hotspots/editor/AreasListView.js | 1 + .../hotspots/editor/models/Area.js | 13 +++++ 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 4ad379a698..9febf910e4 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -1,6 +1,7 @@ import React from 'react'; import {Hotspots} from 'contentElements/hotspots/Hotspots'; +import areaStyles from 'contentElements/hotspots/Area.module.css'; import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import '@testing-library/jest-dom/extend-expect' @@ -104,4 +105,53 @@ describe('Hotspots', () => { '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); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js index 83ae82c1ec..0e0a091d45 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js @@ -50,4 +50,40 @@ describe('hotspots AreasCollection', () => { {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'}); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index 1341ac45df..f89d55a7f8 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import { useContentElementEditorState @@ -6,11 +7,11 @@ import { import styles from './Area.module.css'; -export function Area({area}) { +export function Area({area, highlighted}) { const {isEditable, isSelected} = useContentElementEditorState(); return ( -
+
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js index 3601471a85..581116b77a 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js @@ -37,6 +37,7 @@ export const AreasListView = Marionette.Layout.extend({ label: I18n.t('pageflow_scrolled.editor.content_elements.hotspots.areas.label'), collection: this.collection, sortable: true, + highlight: true, onEdit: (model) => editor.navigate( `/scrolled/hotspots/${this.options.contentElement.id}/${model.id}`, 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 index 20a0a9e3fc..26106f6b9c 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js @@ -14,5 +14,18 @@ export const Area = Backbone.Model.extend({ imageFile() { return this.collection.entry.imageFiles.getByPermaId(this.get('image')); + }, + + highlight() { + this.collection.contentElement.postCommand({ + type: 'HIGHLIGHT_AREA', + index: this.collection.indexOf(this) + }); + }, + + resetHighlight() { + this.collection.contentElement.postCommand({ + type: 'RESET_AREA_HIGHLIGHT' + }); } }); From 9b146155a48865f16a56d3bcf8da0abe20e57600 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 07:45:14 +0200 Subject: [PATCH 12/34] Support potrait image and outlines for hotspots element REDMINE-20673 --- entry_types/scrolled/package/jest.config.js | 6 +- .../contentElements/hotspots/Hotspots-spec.js | 70 +++++++++++++++++++ .../package/spec/support/matchMediaStub.js | 39 ++++++++--- .../src/contentElements/hotspots/Area.js | 8 ++- .../src/contentElements/hotspots/Hotspots.js | 15 +++- 5 files changed, 120 insertions(+), 18 deletions(-) diff --git a/entry_types/scrolled/package/jest.config.js b/entry_types/scrolled/package/jest.config.js index 79fe602e8e..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', diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 9febf910e4..88113e7cf5 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -40,6 +40,25 @@ describe('Hotspots', () => { 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'}, @@ -61,6 +80,57 @@ describe('Hotspots', () => { ); }); + 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('does not render area outline as svg by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, 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/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index f89d55a7f8..6d543d24fc 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -7,14 +7,16 @@ import { import styles from './Area.module.css'; -export function Area({area, highlighted}) { +export function Area({area, portraitMode, highlighted}) { const {isEditable, isSelected} = useContentElementEditorState(); + const outline = portraitMode ? area.portraitOutline : area.outline; + return (
); } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 1ae5daa7f3..f348dfea10 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -8,6 +8,7 @@ import { useContentElementEditorCommandSubscription, useContentElementLifecycle, useFileWithInlineRights, + usePortraitOrientation, InlineFileRights } from 'pageflow-scrolled/frontend'; @@ -16,14 +17,21 @@ import {Area} from './Area'; import styles from './Hotspots.module.css'; export function Hotspots({contentElementId, contentElementWidth, configuration}) { - const imageFile = useFileWithInlineRights({ + const defaultImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'image' }); + const portraitImageFile = useFileWithInlineRights({ + configuration, collectionName: 'imageFiles', propertyName: 'portraitImage' + }); + const portraitOrientation = usePortraitOrientation(); const {shouldLoad} = useContentElementLifecycle(); const [highlightedIndex, setHighlightedIndex] = useState(-1); + const portraitMode = portraitOrientation && portraitImageFile + const imageFile = portraitMode ? portraitImageFile : defaultImageFile; + useContentElementEditorCommandSubscription(command => { if (command.type === 'HIGHLIGHT_AREA') { setHighlightedIndex(command.index); @@ -52,8 +60,9 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) preferSvg={true} /> {areas.map((area, index) => + area={area} + portraitMode={portraitMode} + highlighted={highlightedIndex === index} /> )}
From 52a3fbce16e290b29c9675d4b7203c688473bb82 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 08:05:13 +0200 Subject: [PATCH 13/34] Add hotspot area indicators REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 76 +++++++++++++++++++ .../src/contentElements/hotspots/Area.js | 3 + .../src/contentElements/hotspots/Indicator.js | 17 +++++ .../hotspots/Indicator.module.css | 50 ++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Indicator.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 88113e7cf5..e43ee558a4 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -2,6 +2,7 @@ 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 {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import '@testing-library/jest-dom/extend-expect' @@ -131,6 +132,81 @@ describe('Hotspots', () => { ); }); + 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('does not render area outline as svg by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index 6d543d24fc..3504b7d502 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -5,6 +5,8 @@ import { useContentElementEditorState } from 'pageflow-scrolled/frontend'; +import {Indicator} from './Indicator'; + import styles from './Area.module.css'; export function Area({area, portraitMode, highlighted}) { @@ -16,6 +18,7 @@ export function Area({area, portraitMode, highlighted}) {
); 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..0d1e4a0434 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css @@ -0,0 +1,50 @@ +.indicator { + --size: 15px; + margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2); + animation: inner 2s infinite; +} + +.indicator, +.indicator::before { + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + border-radius: 100%; + background-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; + } +} From fc8046ede80dd7a2cabecb64b3d440bc6f11f68e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 10:49:31 +0200 Subject: [PATCH 14/34] Add hotspot area tooltips REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 108 ++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 7 ++ .../src/contentElements/hotspots/Tooltip.js | 58 ++++++++++ .../hotspots/Tooltip.module.css | 62 ++++++++++ 4 files changed, 235 insertions(+) create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js create mode 100644 entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index e43ee558a4..84ed185a6c 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -3,6 +3,7 @@ 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 '@testing-library/jest-dom/extend-expect' @@ -207,6 +208,113 @@ describe('Hotspots', () => { }); }); + 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'}]}] + } + } + }; + + const {queryByText} = renderInContentElement( + , {seed} + ); + + expect(queryByText('Some title')).not.toBeNull(); + expect(queryByText('Some description')).not.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('does not render area outline as svg by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index f348dfea10..d11a082373 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -13,6 +13,7 @@ import { } from 'pageflow-scrolled/frontend'; import {Area} from './Area'; +import {Tooltip} from './Tooltip'; import styles from './Hotspots.module.css'; @@ -69,6 +70,12 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) + {areas.map((area, index) => + + )} ); 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..796508dc94 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -0,0 +1,58 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { + EditableText, + EditableInlineText, + useContentElementConfigurationUpdate, + useI18n +} from 'pageflow-scrolled/frontend'; + +import styles from './Tooltip.module.css'; + +export function Tooltip({area, portraitMode, configuration}) { + const {t} = useI18n(); + const updateConfiguration = useContentElementConfigurationUpdate(); + + const indicatorPosition = ( + portraitMode ? + area.portraitIndicatorPosition : + area.indicatorPosition + ) || [50, 50]; + const tooltipTexts = configuration.tooltipTexts || {}; + + function handleTextChange(propertyName, value) { + updateConfiguration({ + tooltipTexts: { + ...tooltipTexts, + [area.id]: { + ...tooltipTexts[area.id], + [propertyName]: value + } + } + }); + } + + return ( +
+
+

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

+ handleTextChange('description', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> + + handleTextChange('link', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> + › + +
+
+ ); +} 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..a406e7b580 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -0,0 +1,62 @@ +.tooltip { + position: absolute; + transform: translateX(-50%); + width: calc(100% - 2rem); + max-width: 400px; + margin-top: 30px; + 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; + bottom: 99%; + left: calc(50% - 15px); + border: solid 15px transparent; + 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 { + font-size: 20px; + margin: 0; +} + +.tooltip h3 { + margin-bottom: 0.5em; +} + +.tooltip a { + 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; +} + +.tooltip.visible { + opacity: 1; + visibility: visible; + transition-delay: 0.2s; +} From 430bc5855b489759bcb6b115d5a45a117300ba6d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 11:55:04 +0200 Subject: [PATCH 15/34] Show hotspot area tooltips on hover and click REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 213 ++++++++++++++++++ .../src/contentElements/hotspots/Area.js | 13 +- .../contentElements/hotspots/Area.module.css | 4 +- .../src/contentElements/hotspots/Hotspots.js | 35 ++- .../hotspots/Indicator.module.css | 1 + .../src/contentElements/hotspots/Tooltip.js | 19 +- 6 files changed, 272 insertions(+), 13 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 84ed185a6c..2af8964af3 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -7,6 +7,7 @@ import tooltipStyles from 'contentElements/hotspots/Tooltip.module.css'; import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import '@testing-library/jest-dom/extend-expect' +import userEvent from '@testing-library/user-event'; describe('Hotspots', () => { it('does not render images by default', () => { @@ -315,6 +316,218 @@ describe('Hotspots', () => { }); }); + 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 toottip 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('does not render area outline as svg by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index 3504b7d502..d2979407b7 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -9,15 +9,22 @@ import {Indicator} from './Indicator'; import styles from './Area.module.css'; -export function Area({area, portraitMode, highlighted}) { +export function Area({ + area, contentElementId, portraitMode, highlighted, + onMouseEnter, onMouseLeave, onClick +}) { const {isEditable, isSelected} = useContentElementEditorState(); const outline = portraitMode ? area.portraitOutline : area.outline; return (
-
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css index fe99673a0d..f37a04a8c3 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css @@ -16,6 +16,7 @@ pointer-events: auto; border: none; background-color: transparent; + cursor: pointer; } .outline polygon { @@ -27,7 +28,6 @@ opacity: 0.5; } -.area.highlighted .outline polygon, -.area:hover .outline polygon { +.area.highlighted .outline polygon { opacity: 1; } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index d11a082373..1e1a615c61 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import { ContentElementBox, @@ -13,7 +13,7 @@ import { } from 'pageflow-scrolled/frontend'; import {Area} from './Area'; -import {Tooltip} from './Tooltip'; +import {Tooltip, insideTooltip} from './Tooltip'; import styles from './Hotspots.module.css'; @@ -28,11 +28,28 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) const {shouldLoad} = useContentElementLifecycle(); + const [activeIndex, setActiveIndex] = useState(-1); + const [hoveredIndex, setHoveredIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1); const portraitMode = portraitOrientation && portraitImageFile const imageFile = portraitMode ? portraitImageFile : defaultImageFile; + const hasActiveArea = activeIndex >= 0; + + useEffect(() => { + if (hasActiveArea) { + document.body.addEventListener('click', handleClick); + return () => document.body.removeEventListener('click', handleClick); + } + + function handleClick(event) { + if (!insideTooltip(event.target)) { + setActiveIndex(-1); + } + } + }, [hasActiveArea]); + useContentElementEditorCommandSubscription(command => { if (command.type === 'HIGHLIGHT_AREA') { setHighlightedIndex(command.index); @@ -62,8 +79,12 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) {areas.map((area, index) => + highlighted={hoveredIndex === index || highlightedIndex === index || activeIndex === index} + onMouseEnter={() => setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => setActiveIndex(index)} /> )}
@@ -73,8 +94,14 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) {areas.map((area, index) => + configuration={configuration} + visible={activeIndex === index || + (activeIndex < 0 && hoveredIndex === index)} + onMouseEnter={() => setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(-1)} + onClick={() => setActiveIndex(index)} /> )}
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css index 0d1e4a0434..9ab2c5a4b6 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Indicator.module.css @@ -2,6 +2,7 @@ --size: 15px; margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2); animation: inner 2s infinite; + pointer-events: none; } .indicator, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 796508dc94..e1764788f9 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -10,7 +10,11 @@ import { import styles from './Tooltip.module.css'; -export function Tooltip({area, portraitMode, configuration}) { +export function Tooltip({ + area, + contentElementId, portraitMode, configuration, visible, + onMouseEnter, onMouseLeave, onClick +}) { const {t} = useI18n(); const updateConfiguration = useContentElementConfigurationUpdate(); @@ -34,11 +38,14 @@ export function Tooltip({area, portraitMode, configuration}) { } return ( -
+ top: `${indicatorPosition[1]}%`}} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onClick={onClick}>
-

+

handleTextChange('title', value)} placeholder={t('pageflow_scrolled.inline_editing.type_heading')} /> @@ -56,3 +63,7 @@ export function Tooltip({area, portraitMode, configuration}) {

); } + +export function insideTooltip(element) { + return !!element.closest(`.${styles.tooltip}`); +} From e274c294ae53d8f2df07b60d98b49ceadc083fd8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 12:34:06 +0200 Subject: [PATCH 16/34] Keep hotspot area tooltip boxes in viewport horizontally REDMINE-20673 --- .../src/contentElements/hotspots/Tooltip.js | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index e1764788f9..2022f153c8 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useLayoutEffect, useRef, useState} from 'react'; import classNames from 'classnames'; import { @@ -25,6 +25,8 @@ export function Tooltip({ ) || [50, 50]; const tooltipTexts = configuration.tooltipTexts || {}; + const [ref, delta] = useKeepInViewport(visible); + function handleTextChange(propertyName, value) { updateConfiguration({ tooltipTexts: { @@ -38,9 +40,11 @@ export function Tooltip({ } return ( -
@@ -67,3 +71,43 @@ export function Tooltip({ 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]; +} From f12f9f91236c60da7bc492e01f618a5fd8eb4433 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 14:03:01 +0200 Subject: [PATCH 17/34] Navigate to hotspot area editor route when activating area REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 29 ++++++++++++++ .../PreviewMessageController-spec.js | 3 ++ .../spec/editor/models/ContentElement-spec.js | 40 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 16 +++++--- .../contentElements/hotspots/editor/index.js | 8 ++++ .../controllers/PreviewMessageController.js | 3 +- .../src/editor/models/ContentElement.js | 5 +++ 7 files changed, 98 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 2af8964af3..e97497ebd5 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -621,4 +621,33 @@ describe('Hotspots', () => { expect(container.querySelector(`.${areaStyles.area}`)).not.toHaveClass(areaStyles.highlighted); }); + + 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/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/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 1e1a615c61..2b5206a262 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -1,10 +1,11 @@ -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import { ContentElementBox, Image, ContentElementFigure, FitViewport, + useContentElementEditorState, useContentElementEditorCommandSubscription, useContentElementLifecycle, useFileWithInlineRights, @@ -27,15 +28,22 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) const portraitOrientation = usePortraitOrientation(); const {shouldLoad} = useContentElementLifecycle(); + const {setTransientState} = useContentElementEditorState(); - const [activeIndex, setActiveIndex] = useState(-1); + 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) { @@ -48,7 +56,7 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) setActiveIndex(-1); } } - }, [hasActiveArea]); + }, [hasActiveArea, setActiveIndex]); useContentElementEditorCommandSubscription(command => { if (command.type === 'HIGHLIGHT_AREA') { @@ -59,8 +67,6 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) } }); - const areas = configuration.areas || []; - return ( Date: Fri, 10 May 2024 14:09:32 +0200 Subject: [PATCH 18/34] Activate hotspot area when clicking edit button in areas list REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 27 +++++++++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 3 +++ .../hotspots/editor/AreasListView.js | 12 ++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index e97497ebd5..4a195a9b42 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -622,6 +622,33 @@ describe('Hotspots', () => { 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'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index 2b5206a262..b56ee2ae81 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -65,6 +65,9 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) else if (command.type === 'RESET_AREA_HIGHLIGHT') { setHighlightedIndex(-1); } + else if (command.type === 'SET_ACTIVE_AREA') { + setActiveIndex(command.index); + } }); return ( diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js index 581116b77a..ecf060924d 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/AreasListView.js @@ -39,10 +39,14 @@ export const AreasListView = Marionette.Layout.extend({ sortable: true, highlight: true, - onEdit: (model) => editor.navigate( - `/scrolled/hotspots/${this.options.contentElement.id}/${model.id}`, - {trigger: 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); From 1457fb3cef3e3a21e9bcc462860a4fd2cf15d3a6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 10 May 2024 16:55:40 +0200 Subject: [PATCH 19/34] Make hotspot area indicator color configurable REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 4 + .../config/locales/new/hotspots.en.yml | 4 + .../contentElements/hotspots/Hotspots-spec.js | 106 ++++++++++++++++++ .../src/contentElements/hotspots/Area.js | 8 +- .../contentElements/hotspots/Area.module.css | 2 +- .../hotspots/Indicator.module.css | 2 +- .../hotspots/editor/SidebarEditAreaView.js | 8 ++ 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index eb6bd4d13d..540935af48 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -13,6 +13,8 @@ de: values: below: Unterhalb above: Oberhalb + color: + label: Farbe activeImage: label: Aktives Bild area: @@ -22,6 +24,8 @@ de: values: below: Below above: Above + portraitColor: + label: Farbe (Hochkant) portraitActiveImage: label: Aktives Bild (Hochkant) portraitArea: diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index 31b69992af..f24d374516 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -13,6 +13,8 @@ en: values: below: Below above: Above + color: + label: Color activeImage: label: Active image area: @@ -22,6 +24,8 @@ en: values: below: Below above: Above + portraitColor: + label: Color (Portrait) portraitActiveImage: label: Active image (Portrait) portraitArea: diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 4a195a9b42..9a9122640a 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -209,6 +209,112 @@ describe('Hotspots', () => { }); }); + 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'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js index d2979407b7..4db48920ce 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.js @@ -2,6 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import { + paletteColor, useContentElementEditorState } from 'pageflow-scrolled/frontend'; @@ -18,7 +19,8 @@ export function Area({ const outline = portraitMode ? area.portraitOutline : area.outline; return ( -
+
diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css index e1193fa696..2e3cdbdeec 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Area.module.css @@ -31,3 +31,12 @@ .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 index b56ee2ae81..ad549bd58b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -90,6 +90,8 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) area={area} contentElementId={contentElementId} portraitMode={portraitMode} + activeImageVisible={activeIndex === index || + (activeIndex < 0 && hoveredIndex === index)} highlighted={hoveredIndex === index || highlightedIndex === index || activeIndex === index} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} From 8d87e91c2ebfa3527782f0ba8fb526e1a42aa6a6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 13 May 2024 09:40:38 +0200 Subject: [PATCH 22/34] Add fullscreen mode for hotspots element REDMINE-20673 --- .../src/contentElements/hotspots/Hotspots.js | 128 +++++++++++------- .../hotspots/Hotspots.module.css | 11 ++ 2 files changed, 93 insertions(+), 46 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index ad549bd58b..f760269851 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -5,12 +5,15 @@ import { Image, ContentElementFigure, FitViewport, + FullscreenViewer, + ToggleFullscreenCornerButton, useContentElementEditorState, useContentElementEditorCommandSubscription, useContentElementLifecycle, useFileWithInlineRights, usePortraitOrientation, - InlineFileRights + InlineFileRights, + contentElementWidths } from 'pageflow-scrolled/frontend'; import {Area} from './Area'; @@ -19,6 +22,32 @@ 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' }); @@ -71,50 +100,57 @@ export function Hotspots({contentElementId, contentElementWidth, configuration}) }); return ( - - - - -
- - {areas.map((area, index) => - setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => setActiveIndex(index)} /> - )} -
- -
-
-
- {areas.map((area, index) => - setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(-1)} - onClick={() => setActiveIndex(index)} /> - )} - -
+
+ +
+ + + +
+ + {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 index 89a1db5bc5..bd387ab792 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.module.css @@ -1,3 +1,14 @@ +.center { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; +} + +.outer { + position: relative; +} + .wrapper { width: min-content; height: 100%; From 1e36b594ad2b79fc653bccb40a22e63a26a46a2d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 14 May 2024 15:26:33 +0200 Subject: [PATCH 23/34] Make alignment and gap of link tooltip configurable REDMINE-20673 --- .../inlineEditing/EditableText/LinkTooltip.js | 30 ++++++++++++++----- .../EditableText/index.module.css | 13 +++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js index ee42613fde..db201db692 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js @@ -12,7 +12,9 @@ import ExternalLinkIcon from '../images/externalLink.svg'; const UpdateContext = createContext(); -export function LinkTooltipProvider({editor, disabled, position, children}) { +export function LinkTooltipProvider({ + editor, disabled, position, children, align = 'left', gap = 10 +}) { const [state, setState] = useState(); const outerRef = useRef(); @@ -30,9 +32,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 +57,16 @@ export function LinkTooltipProvider({editor, disabled, position, children}) { } } } - }, [position]); + }, [position, align, gap]); return (
- + {children}
@@ -75,7 +86,7 @@ export function LinkPreview({href, openInNewTab, children}) { ); } -export function LinkTooltip({editor, disabled, position, state}) { +export function LinkTooltip({editor, disabled, position, align, state}) { const {keep, deactivate} = useContext(UpdateContext); if (disabled || !state || (editor.selection && !Range.isCollapsed(editor.selection))) { @@ -83,7 +94,10 @@ export function LinkTooltip({editor, disabled, position, state}) { } return ( -
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..6c5e6702f4 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 @@ -54,11 +54,14 @@ box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); } +.linkTooltip-center { + transform: translateX(-50%); +} + .linkTooltip::before { content: ""; display: block; position: absolute; - left: 20px; border: solid 4px transparent; } @@ -72,6 +75,14 @@ border-top: solid 4px #222; } +.linkTooltip-left::before { + left: 20px; +} + +.linkTooltip-center::before { + left: calc(50% - 4px); +} + .linkTooltip > a, .linkTooltip > span { color: #fff; From 8a49f15005c5884ac15812e257fb25e8e119ac1a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 07:26:21 +0200 Subject: [PATCH 24/34] Extract link tooltip from editable text REDMINE-20673 --- .../{EditableText => }/LinkTooltip-spec.js | 41 ++-------- .../inlineEditing/EditableText/index.js | 7 +- .../EditableText/index.module.css | 77 ------------------ .../inlineEditing/EditableText/withLinks.js | 2 +- .../{EditableText => }/LinkTooltip.js | 35 ++++---- .../inlineEditing/LinkTooltip.module.css | 79 +++++++++++++++++++ 6 files changed, 107 insertions(+), 134 deletions(-) rename entry_types/scrolled/package/spec/frontend/inlineEditing/{EditableText => }/LinkTooltip-spec.js (78%) rename entry_types/scrolled/package/src/frontend/inlineEditing/{EditableText => }/LinkTooltip.js (82%) create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css 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 78% 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..8f9ab3da29 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,30 +31,8 @@ describe('LinkTooltip', () => { }); it('does not display tooltip when disabled', async () => { - const editor = {}; const {getByText, queryByRole} = render( - - - A link - - - ); - - const user = userEvent.setup(); - await user.hover(getByText('A link')); - - 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( - + A link @@ -69,9 +46,8 @@ describe('LinkTooltip', () => { }); it('displays note about opening in new tab', async () => { - const editor = {}; const {getByText, queryByRole, queryByText} = render( - + A link @@ -86,14 +62,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 +84,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 +105,6 @@ describe('LinkTooltip', () => { }); it('displays tooltip for file link', async () => { - const editor = {}; const seed = { imageFileUrlTemplates: { original: ':id_partition/original/:basename.:extension' @@ -142,7 +115,7 @@ describe('LinkTooltip', () => { ] } const {getByText, queryByRole} = renderInEntry( - + A link 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 6c5e6702f4..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,80 +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-center { - transform: translateX(-50%); -} - -.linkTooltip::before { - content: ""; - display: block; - position: absolute; - 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-left::before { - left: 20px; -} - -.linkTooltip-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; -} - -.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 82% rename from entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js rename to entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js index db201db692..eb7b2f6126 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js @@ -1,19 +1,18 @@ 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, align = 'left', gap = 10 + disabled, position, children, align = 'left', gap = 10 }) { const [state, setState] = useState(); const outerRef = useRef(); @@ -62,8 +61,7 @@ export function LinkTooltipProvider({ return (
- @@ -86,21 +84,20 @@ export function LinkPreview({href, openInNewTab, children}) { ); } -export function LinkTooltip({editor, disabled, position, align, 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) { return null; } return (
+ style={{top: state.top, bottom: state.bottom, left: state.left}}>
); @@ -142,7 +139,7 @@ function ChapterLinkDestination({permaId}) { return ( - + {t('pageflow_scrolled.inline_editing.link_tooltip.chapter_number', {number: chapter.index + 1})} {chapter.title} @@ -154,10 +151,10 @@ function SectionLinkDestination({permaId}) { const {t} = useI18n({locale: 'ui'}); return ( -
+ @@ -175,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..1734a4e8fc --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css @@ -0,0 +1,79 @@ +.linkTooltip { + position: absolute; + z-index: 2; + 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; +} From 267f1dbfb27fb94b2c24b7a463d0ed722b727d04 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 07:52:54 +0200 Subject: [PATCH 25/34] Extract link component from editable text REDMINE-20673 --- .../package/src/frontend/EditableText.js | 74 +++---------------- .../scrolled/package/src/frontend/Link.js | 59 +++++++++++++++ 2 files changed, 69 insertions(+), 64 deletions(-) create mode 100644 entry_types/scrolled/package/src/frontend/Link.js 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} + ; +} From a14ca0f4385f09b3b065c02373f1d1187305d02b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 10:18:37 +0200 Subject: [PATCH 26/34] Do not display link tooltip if href is blank REDMINE-20673 --- .../frontend/inlineEditing/LinkTooltip-spec.js | 15 +++++++++++++++ .../src/frontend/inlineEditing/LinkTooltip.js | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js index 8f9ab3da29..6cfc85e7cd 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -45,6 +45,21 @@ describe('LinkTooltip', () => { expect(queryByRole('link')).toBeNull(); }); + it('does not display tooltip when href is missing', async () => { + const {getByText, container} = render( + + + A link + + + ); + + const user = userEvent.setup(); + await user.hover(getByText('A link')); + + expect(container.querySelector('a')).toBeNull(); + }); + it('displays note about opening in new tab', async () => { const {getByText, queryByRole, queryByText} = render( diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js index eb7b2f6126..07f11ef17e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js @@ -87,7 +87,7 @@ export function LinkPreview({href, openInNewTab, children}) { export function LinkTooltip({disabled, position, align, state}) { const {keep, deactivate} = useContext(UpdateContext); - if (disabled || !state) { + if (disabled || !state || !state.href) { return null; } From c179aed69ae9ac2d17f15de453cabdfe648eebc4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 10:19:46 +0200 Subject: [PATCH 27/34] Add EditableLink component Reusable component to edit and display links in the editor. REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 3 + .../config/locales/new/hotspots.en.yml | 3 + .../spec/frontend/EditableLink-spec.js | 92 +++++++++++++++++++ .../inlineEditing/EditableLink-spec.js | 45 +++++++++ .../src/contentElements/hotspots/Tooltip.js | 1 + .../package/src/frontend/EditableLink.js | 16 ++++ .../scrolled/package/src/frontend/index.js | 1 + .../frontend/inlineEditing/ActionButton.js | 2 + .../frontend/inlineEditing/EditableLink.js | 35 +++++++ .../inlineEditing/EditableLink.module.css | 3 + .../src/frontend/inlineEditing/components.js | 1 + 11 files changed, 202 insertions(+) create mode 100644 entry_types/scrolled/package/spec/frontend/EditableLink-spec.js create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableLink-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/EditableLink.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.module.css diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 540935af48..925e0b32cd 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -1,5 +1,8 @@ de: pageflow_scrolled: + inline_editing: + select_link_destination: "Link-Ziel auswählen" + change_link_destination: "Link-Ziel ändern" editor: content_elements: hotspots: diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index f24d374516..dc431b3fe9 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -1,5 +1,8 @@ en: pageflow_scrolled: + inline_editing: + select_link_destination: "Select link destination" + change_link_destination: "Change link destination" editor: content_elements: hotspots: 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/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index a45110b153..dc5f1d41b2 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { EditableText, EditableInlineText, + EditableLink, useContentElementConfigurationUpdate, useI18n } from 'pageflow-scrolled/frontend'; 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/index.js b/entry_types/scrolled/package/src/frontend/index.js index 85c1d3fd5b..f15c600911 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -98,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..0189ba224b --- /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/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'; From 94da58304866156545688e46c507ee2e0bde38eb Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 15:33:18 +0200 Subject: [PATCH 28/34] Render editable link in hotspot area tooltip REDMINE-20673 --- .../config/locales/new/hotspots.de.yml | 2 ++ .../config/locales/new/hotspots.en.yml | 2 ++ .../contentElements/hotspots/Hotspots-spec.js | 8 +++++- .../src/contentElements/hotspots/Tooltip.js | 28 +++++++++++++++++-- .../hotspots/Tooltip.module.css | 2 +- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 925e0b32cd..0fd1a67b81 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -1,5 +1,7 @@ de: pageflow_scrolled: + public: + more: Mehr inline_editing: select_link_destination: "Link-Ziel auswählen" change_link_destination: "Link-Ziel ändern" diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index dc431b3fe9..270be8811b 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -1,5 +1,7 @@ en: pageflow_scrolled: + public: + more: More inline_editing: select_link_destination: "Select link destination" change_link_destination: "Change link destination" diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 3a3ae3d2ff..0dada590c9 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -335,15 +335,21 @@ describe('Hotspots', () => { description: [{type: 'paragraph', children: [{text: 'Some description'}]}], link: [{type: 'paragraph', children: [{text: 'Some link'}]}] } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} } }; - const {queryByText} = renderInContentElement( + 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('positions tooltip based on indicator position', () => { diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index dc5f1d41b2..23300c128c 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -6,7 +6,8 @@ import { EditableInlineText, EditableLink, useContentElementConfigurationUpdate, - useI18n + useI18n, + utils } from 'pageflow-scrolled/frontend'; import styles from './Tooltip.module.css'; @@ -25,6 +26,7 @@ export function Tooltip({ area.indicatorPosition ) || [50, 50]; const tooltipTexts = configuration.tooltipTexts || {}; + const tooltipLinks = configuration.tooltipLinks || {}; const [ref, delta] = useKeepInViewport(visible); @@ -40,6 +42,22 @@ export function Tooltip({ }); } + 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 + } + }); + } + return (
handleTextChange('description', value)} placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> - + handleLinkChange(value)}> handleTextChange('link', value)} placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> › - +
); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css index f5d17c03d1..2096949a0a 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -55,7 +55,7 @@ margin-bottom: 0.5em; } -.tooltip a { +.link { display: flex; justify-content: center; gap: 0.5em; From 56b40e445e9b3e928d88a0ae005092a0cb178431 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 15:44:37 +0200 Subject: [PATCH 29/34] Fade out blank placeholder button in hotspot tooltips REDMINE-20673 --- .../package/src/contentElements/hotspots/Tooltip.js | 5 ++++- .../src/contentElements/hotspots/Tooltip.module.css | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 23300c128c..b4e55ad795 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -5,6 +5,7 @@ import { EditableText, EditableInlineText, EditableLink, + useContentElementEditorState, useContentElementConfigurationUpdate, useI18n, utils @@ -19,6 +20,7 @@ export function Tooltip({ }) { const {t} = useI18n(); const updateConfiguration = useContentElementConfigurationUpdate(); + const {isEditable} = useContentElementEditorState(); const indicatorPosition = ( portraitMode ? @@ -62,7 +64,8 @@ export function Tooltip({
Date: Wed, 15 May 2024 16:03:45 +0200 Subject: [PATCH 30/34] Ensure link tooltip displays above action buttons REDMINE-20673 --- .../src/frontend/inlineEditing/EditableLink.js | 14 +++++++------- .../frontend/inlineEditing/LinkTooltip.module.css | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js index 0189ba224b..1c519dc85b 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableLink.js @@ -17,18 +17,18 @@ export function EditableLink({className, href, openInNewTab, children, linkPrevi return (
- + + {children} -
); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css index 1734a4e8fc..4dd49117cd 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.module.css @@ -1,6 +1,6 @@ .linkTooltip { position: absolute; - z-index: 2; + z-index: 11; white-space: nowrap; background-color: #222; color: #fff; From fcb678450a22fe54dc2011b923e3037a31f6f1ab Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 16:08:44 +0200 Subject: [PATCH 31/34] Hide blank elements in hotspot area tooltips REDMINE-20673 --- .../contentElements/hotspots/Hotspots-spec.js | 32 ++++++++++++ .../src/contentElements/hotspots/Hotspots.js | 1 + .../src/contentElements/hotspots/Tooltip.js | 50 ++++++++++++------- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index 0dada590c9..ddc89d1d1e 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -352,6 +352,38 @@ describe('Hotspots', () => { 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'}, diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js index f760269851..b30c807699 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Hotspots.js @@ -144,6 +144,7 @@ export function HotspotsImage({ configuration={configuration} visible={activeIndex === index || (activeIndex < 0 && hoveredIndex === index)} + active={activeIndex === index} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(-1)} onClick={() => setActiveIndex(index)} /> diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index b4e55ad795..5aa461ec46 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -15,7 +15,7 @@ import styles from './Tooltip.module.css'; export function Tooltip({ area, - contentElementId, portraitMode, configuration, visible, + contentElementId, portraitMode, configuration, visible, active, onMouseEnter, onMouseLeave, onClick }) { const {t} = useI18n(); @@ -60,6 +60,15 @@ export function Tooltip({ }); } + 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 (
-

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

- handleTextChange('description', value)} - placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> - handleLinkChange(value)}> - handleTextChange('link', value)} - placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> - › - + {presentOrEditing('title') && +

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

} + {presentOrEditing('description') && + handleTextChange('description', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} />} + {presentOrEditing('link') && + handleLinkChange(value)}> + handleTextChange('link', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> + › + }
); From 7e0232a4d7e703dcb93b70aa3f361be1e03ab286 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 16:53:40 +0200 Subject: [PATCH 32/34] Improve hotspot area tooltip typography add scale categories and handle margins when not all elements of a tooltip are present. REDMINE-20673 --- .../src/contentElements/hotspots/Tooltip.js | 32 +++++++++++-------- .../hotspots/Tooltip.module.css | 12 +++++-- .../scrolled/package/src/frontend/Text.js | 5 ++- .../package/src/frontend/Text.module.css | 21 ++++++++++++ 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 5aa461ec46..4a7622aa83 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -5,6 +5,7 @@ import { EditableText, EditableInlineText, EditableLink, + Text, useContentElementEditorState, useContentElementConfigurationUpdate, useI18n, @@ -84,25 +85,30 @@ export function Tooltip({
{presentOrEditing('title') &&

- handleTextChange('title', value)} - placeholder={t('pageflow_scrolled.inline_editing.type_heading')} /> + + 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')} /> - › - } + + handleLinkChange(value)}> + handleTextChange('link', value)} + placeholder={t('pageflow_scrolled.inline_editing.type_text')} /> + › + + }
); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css index 90a2809c24..075473e688 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -47,14 +47,18 @@ .tooltip h3, .tooltip p { - font-size: 20px; margin: 0; } -.tooltip h3 { +.box > h3, +.box > div { margin-bottom: 0.5em; } +.box > :last-child { + margin-bottom: 0; +} + .link { display: flex; justify-content: center; @@ -69,6 +73,10 @@ font-weight: bold; } +.box > :first-child .link { + margin-top: 0; +} + .tooltip.visible { opacity: 1; visibility: visible; 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; From 111cae7be3910a544856b840fa37280c276e1314 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 17:06:26 +0200 Subject: [PATCH 33/34] Use tooltip titles as titles in hotspot area list REDMINE-20673 --- .../editor/models/AreasCollection-spec.js | 33 +++++++++++++++++++ .../hotspots/editor/models/Area.js | 5 +-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js index 0e0a091d45..d35b273edf 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/models/AreasCollection-spec.js @@ -86,4 +86,37 @@ describe('hotspots AreasCollection', () => { 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/src/contentElements/hotspots/editor/models/Area.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js index 26106f6b9c..9b91acf5ee 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/models/Area.js @@ -9,11 +9,12 @@ export const Area = Backbone.Model.extend({ }, title() { - return this.get('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('image')); + return this.collection.entry.imageFiles.getByPermaId(this.get('activeImage')); }, highlight() { From 72eb860a4aad0b144ff88455382270a1a289432b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 15 May 2024 17:14:00 +0200 Subject: [PATCH 34/34] Add feature flag for hotspots element REDMINE-20673 --- entry_types/scrolled/config/locales/new/hotspots.de.yml | 3 +++ entry_types/scrolled/config/locales/new/hotspots.en.yml | 3 +++ entry_types/scrolled/lib/pageflow_scrolled/plugin.rb | 1 + .../package/src/contentElements/hotspots/editor/index.js | 1 + 4 files changed, 8 insertions(+) diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 0fd1a67b81..73adc4eaf7 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -1,4 +1,7 @@ de: + pageflow: + hotspots_content_element: + feature_name: Hotspots Inhaltselement pageflow_scrolled: public: more: Mehr diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index 270be8811b..adb6cc399c 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -1,4 +1,7 @@ en: + pageflow: + hotspots_content_element: + feature_name: Hotspots content element pageflow_scrolled: public: more: More diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 8fa1299b4c..5b7aecd6c2 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -37,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/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index 93fbd00c16..256e8ad153 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -18,6 +18,7 @@ editor.registerSideBarRouting({ editor.contentElementTypes.register('hotspots', { pictogram, category: 'links', + featureName: 'hotspots_content_element', supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'],