diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 4d2f37631c..7375759f16 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -87,6 +87,7 @@ de: modes: rect: Rechteck polygon: Polygon + centerIndicator: Indikator zentrieren hotspots_image: Hotspotbild double_click_to_delete: Doppelklick, um Punkt zu entfernen indicator_title: Ziehen um Indikator zu positionieren diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index e946fe78b0..6870a33736 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -87,6 +87,7 @@ en: modes: rect: Rectangle polygon: Polygon + centerIndicator: Center indicator hotspots_image: Hotspots image double_click_to_delete: Double click to remove point indicator_title: Drag to position indicator 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 index f6c3639b72..507d106ce9 100644 --- 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 @@ -4,13 +4,18 @@ import { handles, SET_MODE, DRAG, + CLICK_HANDLE, DRAG_HANDLE, DRAG_HANDLE_STOP, DOUBLE_CLICK_HANDLE, MOUSE_MOVE, DRAG_POTENTIAL_POINT, DRAG_POTENTIAL_POINT_STOP, - DRAG_INDICATOR + CLICK_INDICATOR, + DRAG_INDICATOR, + CENTER_INDICATOR, + UPDATE_SELECTION_POSITION, + BLUR_SELECTION_POSITION } from 'contentElements/hotspots/editor/EditAreaDialogView/reducer'; const initialState = { @@ -90,6 +95,18 @@ describe('reducer', () => { points: [[10, 10], [60, 10], [60, 50], [10, 50]] }); }); + + it('resets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: SET_MODE, value: 'rect'}); + + expect(state.selection).toBeNull(); + }); }); describe('DRAG', () => { @@ -139,6 +156,75 @@ describe('reducer', () => { }); }); + describe('CLICK_HANDLE', () => { + describe('in polygon mode', () => { + it('sets selection for handle', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20.123], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [20, 20.1] + }); + }); + }); + + describe('in rect mode', () => { + it('sets selection for top mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'y', + index: 1, + position: [15, 20] + }); + }); + + it('sets selection for left mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 7}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'x', + index: 7, + position: [10, 30] + }); + }); + + it('sets selection for corner handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20.543, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'both', + index: 2, + position: [20.5, 20] + }); + }); + }); + }); + describe('DRAG_HANDLE', () => { describe('in polygon mode', () => { it('updates points', () => { @@ -177,6 +263,21 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([15, 23]); }); + + it('sets selection for handle', () => { + 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.42, 25]}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [30.4, 25] + }); + }); }); describe('in rect mode', () => { @@ -193,6 +294,19 @@ describe('reducer', () => { ); }); + it('ignores cross-axis coordinate of 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: [25, 10]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 40], [10, 40]] + ); + }); + it('resizes rect via corner handle', () => { let state = { ...initialState, @@ -244,6 +358,66 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([15, 30]); }); + + it('sets selection for top 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: [14, 10]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'y', + index: 1, + position: [15, 10] + }); + }); + + it('sets selection for left mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 7, cursor: [8, 28]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'x', + index: 7, + position: [8, 30] + }); + }); + + it('sets selection for 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: [22.45, 19]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'both', + index: 2, + position: [22.5, 19] + }); + }); + + it('changes selected handle when moving top left corner handle past bottom right boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [10, 20]}); + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [30, 50]}); + + expect(state.selection.index).toEqual(4); + }); }); }); @@ -308,6 +482,18 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([14, 24]); }); + + it('resets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.selection).toBeNull(); + }); }); describe('MOUSE_MOVE', () => { @@ -358,6 +544,20 @@ describe('reducer', () => { expect(state.potentialPoint).toEqual([20, 10]); }); + + it('sets selection to 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.1234, 10]}); + + expect(state.selection).toEqual({ + type: 'potentialPoint', + position: [20.1, 10] + }); + }); }); describe('DRAG_POTENTIAL_POINT_STOP', () => { @@ -390,6 +590,40 @@ describe('reducer', () => { expect(state.potentialPoint).toEqual([15, 10]); }); + + it('selected handle of new 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.123, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT_STOP}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [20.1, 10] + }); + }); + }); + + describe('CLICK_INDICATOR', () => { + it('sets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11.123] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + + expect(state.selection).toEqual({ + type: 'indicator', + position: [11, 11.1] + }); + }); }); describe('DRAG_INDICATOR', () => { @@ -416,6 +650,287 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([10, 10]); }); + + it('sets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: DRAG_INDICATOR, cursor: [15, 11.123]}); + + expect(state.selection).toEqual({ + type: 'indicator', + position: [15, 11.1] + }); + }); + }); + + describe('CENTER_INDICATOR', () => { + it('centers indicator position', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 10], [20, 10], [20, 50], [10, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CENTER_INDICATOR}); + + expect(state.indicatorPosition).toEqual([15, 30]); + }); + }); + + describe('UPDATE_SELECTION_POSITION', () => { + it('updates indicator position', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [12, 11]}); + + expect(state.indicatorPosition).toEqual([12, 11]); + expect(state.selection.position).toEqual([12, 11]); + }); + + it('allows invalid indicator position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [0, 0]}); + + expect(state.indicatorPosition).toEqual([10, 10]); + expect(state.selection.position).toEqual([0, 0]); + }); + + it('allows invalid handle position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [-10, 120]}); + + expect(state.points[1]).toEqual([0, 100]); + expect(state.selection.position).toEqual([-10, 120]); + }); + + it('updates polygon handle position', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [12, 11]}); + + expect(state.points[1]).toEqual([12, 11]); + expect(state.selection.position).toEqual([12, 11]); + }); + + it('updates rect corner handle position', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [22, 22]}); + + expect(state.points).toEqual( + [[10, 22], [22, 22], [22, 40], [10, 40]] + ); + }); + + it('updates rect top mid point handle position', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 10]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 40], [10, 40]] + ); + }); + + it('changes selected handle when moving right mid point handle past left boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 3}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [20, 30]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [5, 30]}); + + expect(state.selection.index).toEqual(7); + }); + + it('changes selected handle when moving left mid point handle past right boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 7}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 30]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [25, 30]}); + + expect(state.selection.index).toEqual(3); + }); + + it('changes selected handle when moving top mid point handle past bottom boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 50]}); + + expect(state.selection.index).toEqual(5); + }); + + it('changes selected handle when moving bottom mid point handle past top boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 5}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 40]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 10]}); + + expect(state.selection.index).toEqual(1); + }); + + it('changes selected handle when moving top right corner handle past left boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [20, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [5, 20]}); + + expect(state.selection.index).toEqual(0); + }); + + it('changes selected handle when moving top left corner handle past bottom boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 50]}); + + expect(state.selection.index).toEqual(6); + }); + }); + + describe('BLUR_SELECTION_POSITION', () => { + it('sanitizes indicator position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [0, 0]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.indicatorPosition).toEqual([10, 10]); + expect(state.selection.position).toEqual([10, 10]); + }); + + it('keeps handle selection unchanged', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [12, 12] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 11]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([10, 11]); + }); + + it('sanitizes polygon handle position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [15, 50]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [-10, 120]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([0, 100]); + }); + + it('sanitizes rect handle position in selection', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [120, -10]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([100, 0]); + }); + + it('moves 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: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 20]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.indicatorPosition).toEqual([15, 20]); + }); + + it('resets start points for next drag handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 3}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [19, 30]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + state = reducer(state, {type: DRAG_HANDLE, index: 7, cursor: [11, 30]}); + + expect(state.points).toEqual([[11, 20], [19, 20], [19, 40], [11, 40]]); + }); }); }); 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 index ba7a16d636..c9ed3fd21e 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js @@ -14,19 +14,25 @@ import { handles, SET_MODE, DRAG, + CLICK_HANDLE, DRAG_HANDLE, DRAG_HANDLE_STOP, DOUBLE_CLICK_HANDLE, MOUSE_MOVE, DRAG_POTENTIAL_POINT, DRAG_POTENTIAL_POINT_STOP, - DRAG_INDICATOR + CLICK_INDICATOR, + DRAG_INDICATOR, + CENTER_INDICATOR, + UPDATE_SELECTION_POSITION, + BLUR_SELECTION_POSITION } from './reducer'; import styles from './DraggableEditorView.module.css'; import squareIcon from './images/square.svg'; import polygonIcon from './images/polygon.svg'; +import centerIcon from './images/center.svg'; const i18nPrefix = 'pageflow_scrolled.editor.content_elements.hotspots.edit_area_dialog'; @@ -82,7 +88,7 @@ function DraggableEditor({ }); const { - mode, points, potentialPoint, indicatorPosition + mode, points, potentialPoint, indicatorPosition, selection } = state; useEffect( @@ -113,7 +119,20 @@ function DraggableEditor({ return (
- +
+ + dispatch({ + type: UPDATE_SELECTION_POSITION, + position + })} + onBlur={() => dispatch({ + type: BLUR_SELECTION_POSITION + })}/> + dispatch({ + type: CENTER_INDICATOR + })} /> +
dispatch({ + type: CLICK_HANDLE, + index + })} onDoubleClick={event => dispatch({ type: DOUBLE_CLICK_HANDLE, index @@ -165,6 +189,7 @@ function DraggableEditor({ {potentialPoint && dispatch({ type: DRAG_POTENTIAL_POINT, cursor: clientToPercent(event) @@ -174,7 +199,11 @@ function DraggableEditor({ })} />} dispatch({ + type: CLICK_INDICATOR + })} onDrag={event => dispatch({ type: DRAG_INDICATOR, cursor: clientToPercent(event) @@ -192,7 +221,7 @@ const modeIcons = { function ModeButtons({mode, dispatch}) { return ( -
+
{['rect', 'polygon'].map(availableMode => + ); +} + +function Coordinates({selection, onChange, onBlur}) { + if (!selection) { + return null; + } + + const position = selection.position; + + return ( +
+ onChange([parseFloat(event.target.value || 0), position[1]])} + onBlur={onBlur} /> + onChange([position[0], parseFloat(event.target.value || 0)])} + onBlur={onBlur} /> +
+ ); +} + +function CoordinateInput({disabled, label, value, onChange, onBlur}) { + 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 index 6ff89e9cc0..b91ca58c82 100644 --- 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 @@ -13,26 +13,62 @@ } .buttons { + display: flex; + justify-content: space-between; + align-items: center; margin: 10px 0; - text-align: right; + gap: 15px; } -.buttons button:first-child { +.buttons button { + white-space: nowrap; +} + +.coordinates, +.coordinates label { + display: flex; + align-items: center; + margin: 0; +} + +.coordinates { + flex-wrap: wrap; + justify-content: flex-end; +} + +.coordinates { + gap: 10px; +} + +.coordinates label { + gap: 5px; +} + +.coordinates input[disabled] { + background-color: var(--ui-on-surface-color-lightest); +} + +.modeButtons { + flex: 1; + white-space: nowrap; +} + +.modeButtons button:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.buttons button:last-child { +.modeButtons button:last-child { margin-left: -1px; border-top-left-radius: 0; border-bottom-left-radius: 0; } -.buttons button[aria-pressed=true] { +.modeButtons button[aria-pressed=true] { background-color: var(--ui-selection-color-light); } -.buttons button img { +.buttons img { vertical-align: middle; margin-right: 6px; } @@ -144,6 +180,11 @@ z-index: -1; } +.selected { + box-shadow: 0 0 0 3px var(--ui-selection-color), + 0 0 0 4px var(--ui-primary-color-light); +} + @keyframes blink { 0% { transform: scale(1.7); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg new file mode 100644 index 0000000000..4462feb583 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg @@ -0,0 +1,18 @@ + + + + + + + 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 index cf10cde917..da085f220b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js @@ -1,12 +1,17 @@ export const SET_MODE = 'SET_MODE'; export const DRAG = 'DRAG'; +export const CLICK_HANDLE = 'CLICK_HANDLE'; 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 CLICK_INDICATOR = 'CLICK_INDICATOR'; export const DRAG_INDICATOR = 'DRAG_INDICATOR'; +export const CENTER_INDICATOR = 'CENTER_INDICATOR'; +export const UPDATE_SELECTION_POSITION = 'UPDATE_SELECTION_POSITION'; +export const BLUR_SELECTION_POSITION = 'BLUR_SELECTION_POSITION'; export function reducer(state, action) { switch (action.type) { @@ -19,14 +24,16 @@ export function reducer(state, action) { ...state, mode: 'rect', previousPolygonPoints: state.points, - points: getBoundingBox(state.points) + points: getBoundingBox(state.points), + selection: null }; } else { return { ...state, mode: 'polygon', - points: state.previousPolygonPoints || state.points + points: state.previousPolygonPoints || state.points, + selection: null }; } case DRAG: @@ -62,41 +69,34 @@ export function reducer(state, action) { state.indicatorPosition[1] + deltaY ] }; - case DRAG_HANDLE: + case CLICK_HANDLE: if (state.mode === 'polygon') { - state = { + return { ...state, - points: [ - ...state.points.slice(0, action.index), - action.cursor, - ...state.points.slice(action.index + 1) - ] + selection: { + type: 'handle', + index: action.index, + position: round(state.points[action.index]) + } }; } 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 = { + return { ...state, - startPoints, - previousPolygonPoints: null, - points: getBoundingBox([ - action.cursor, - ...startPoints - ]) + selection: rectHandleSelection(action.index, state.points) }; } + case DRAG_HANDLE: + state = updatePoints( + state, + action.index, + action.cursor + ); return { ...state, indicatorPosition: ensureInPolygon(state.points, state.indicatorPosition) }; - case DRAG_HANDLE_STOP: return { ...state, @@ -116,7 +116,8 @@ export function reducer(state, action) { ...state, points, potentialPoint: null, - indicatorPosition: ensureInPolygon(points, state.indicatorPosition) + indicatorPosition: ensureInPolygon(points, state.indicatorPosition), + selection: null }; case MOUSE_MOVE: if (state.mode !== 'polygon' || state.draggingPotentialPoint) { @@ -134,19 +135,95 @@ export function reducer(state, action) { return { ...state, draggingPotentialPoint: true, - potentialPoint: action.cursor + potentialPoint: action.cursor, + selection: { + type: 'potentialPoint', + position: round(action.cursor) + } }; case DRAG_POTENTIAL_POINT_STOP: + const newPoints = withPotentialPoint(state); + return { ...state, - points: withPotentialPoint(state), + points: newPoints, draggingPotentialPoint: false, - potentialPoint: null + potentialPoint: null, + selection: { + type: 'handle', + index: state.potentialPointInsertIndex, + position: round(newPoints[state.potentialPointInsertIndex]) + } }; + case CLICK_INDICATOR: + return { + ...state, + selection: { + type: 'indicator', + position: round(state.indicatorPosition) + } + } case DRAG_INDICATOR: + const indicatorPosition = ensureInPolygon(state.points, action.cursor); + + return { + ...state, + indicatorPosition, + selection: { + type: 'indicator', + position: round(indicatorPosition) + } + } + case CENTER_INDICATOR: return { ...state, - indicatorPosition: ensureInPolygon(state.points, action.cursor) + indicatorPosition: polygonCentroid(state.points) + }; + case UPDATE_SELECTION_POSITION: + if (state.selection?.type === 'indicator') { + return { + ...state, + indicatorPosition: ensureInPolygon(state.points, action.position), + selection: { + ...state.selection, + position: action.position + } + } + } + else if (state.selection?.type === 'handle') { + return updatePoints( + state, + state.selection.index, + ensureInBounds(action.position), + action.position + ); + } + else { + return state; + } + case BLUR_SELECTION_POSITION: + if (state.selection?.type === 'indicator') { + return { + ...state, + selection: { + ...state.selection, + position: state.indicatorPosition + } + } + } + else if (state.selection) { + return { + ...state, + startPoints: null, + selection: { + ...state.selection, + position: handles(state)[state.selection.index].point + }, + indicatorPosition: ensureInPolygon(state.points, state.indicatorPosition) + }; + } + else { + return state; } default: throw new Error(`Unknown action ${action.type}.`); @@ -189,6 +266,100 @@ export function handles(state) { } } +function updatePoints(state, index, position, selectionPosition) { + if (state.mode === 'polygon') { + return { + ...state, + points: [ + ...state.points.slice(0, index), + position, + ...state.points.slice(index + 1) + ], + selection: { + type: 'handle', + index: index, + position: selectionPosition || round(position) + } + }; + } + else { + const startPoints = + state.startPoints || + (rectHandleAxis(index) === 'both' ? + [state.points[(index / 2 + 2) % 4]] : + [state.points[((index + 3) / 2) % 4], + state.points[((index + 5) / 2) % 4]]); + + position = constrainToAxis(index, position, state.points); + + const points = getBoundingBox([ + position, + ...startPoints + ]); + + return { + ...state, + startPoints, + previousPolygonPoints: null, + points, + selection: rectHandleSelection( + mapIndexOfRectHandleCrossingOver(index, position, startPoints), + points, + selectionPosition + ) + }; + } +} + +function rectHandleSelection(index, points, selectionPosition) { + return { + type: 'handle', + index: index, + axis: rectHandleAxis(index), + position: selectionPosition || round( + index % 2 === 0 ? + points[index / 2] : + midpoint(points[(index - 1) / 2], points[(index + 1) / 2 % 4]) + ) + }; +} + +function constrainToAxis(index, cursor, points) { + const axis = rectHandleAxis(index); + + if (axis === 'x') { + return [cursor[0], points[(index - 1) / 2][1]]; + } + else if (axis === 'y') { + return [points[(index - 1) / 2][0], cursor[1]]; + } + else { + return cursor; + } +} + +function rectHandleAxis(index) { + return index % 2 === 0 ? + 'both' : + (index - 1) / 2 % 2 === 0 ? + 'y' : + 'x'; +} + +function mapIndexOfRectHandleCrossingOver(index, position, startPoints) { + if ((index >= 0 && index <= 3 && position[1] > startPoints[0][1]) || + (index >= 4 && index <= 6 && position[1] < startPoints[0][1])){ + index = 6 - index; + } + + if (((index === 0 || index >= 6) && position[0] > startPoints[0][0]) || + (index >= 2 && index <= 4 && position[0] < startPoints[0][0])) { + index = (10 - index) % 8; + } + + return index; +} + function withPotentialPoint(state) { return [ ...state.points.slice(0, state.potentialPointInsertIndex), @@ -227,6 +398,12 @@ function getBoundingBox(polygon) { ]; } +function ensureInBounds(point) { + return point.map(coord => + Math.min(100, Math.max(0, coord)) + ); +} + function ensureInPolygon(polygon, point) { return isPointInPolygon(polygon, point) ? point : @@ -286,3 +463,44 @@ function closestPointOnPolygon(polygon, c, maxDistance = 5) { return closest; } + +function polygonCentroid(points) { + let centroidX = 0; + let centroidY = 0; + let signedArea = 0; + let x0 = 0; + let y0 = 0; + let x1 = 0; + let y1 = 0; + let a = 0; + + for (let i = 0; i < points.length - 1; i++) { + x0 = points[i][0]; + y0 = points[i][1]; + x1 = points[i + 1][0]; + y1 = points[i + 1][1]; + a = x0 * y1 - x1 * y0; + signedArea += a; + centroidX += (x0 + x1) * a; + centroidY += (y0 + y1) * a; + } + + x0 = points[points.length - 1][0]; + y0 = points[points.length - 1][1]; + x1 = points[0][0]; + y1 = points[0][1]; + a = x0 * y1 - x1 * y0; + signedArea += a; + centroidX += (x0 + x1) * a; + centroidY += (y0 + y1) * a; + + signedArea *= 0.5; + centroidX /= (6 * signedArea); + centroidY /= (6 * signedArea); + + return [centroidX, centroidY]; +} + +function round(point) { + return point.map(coord => Math.round(coord * 10) / 10); +}