diff --git a/web/client/components/geostory/builder/SectionsPreview.jsx b/web/client/components/geostory/builder/SectionsPreview.jsx
index 3f7fac9e9b3..c3a02aaaef0 100644
--- a/web/client/components/geostory/builder/SectionsPreview.jsx
+++ b/web/client/components/geostory/builder/SectionsPreview.jsx
@@ -75,7 +75,7 @@ const ConnectedIcon = compose(
const previewContents = {
title: () => null,
- paragraph: ({ id, contents, isCollapsed, scrollTo, onSort, sectionId, onUpdate }) => (
+ paragraph: ({ id, contents, isCollapsed, scrollTo, onSort, sectionId, onUpdate, onDuplicate }) => (
,
@@ -103,6 +104,14 @@ const previewContents = {
className: 'square-button no-border'
}}
buttons={[
+ {
+ glyph: 'duplicate',
+ tooltipId: "geostory.duplicateSection",
+ onClick: (evt) => {
+ evt.stopPropagation();
+ if (onDuplicate) onDuplicate(containerPath, content.id);
+ }
+ },
{
glyph: 'zoom-to',
tooltipId: "geostory.zoomToContent",
@@ -119,12 +128,13 @@ const previewContents = {
onSort={onSort}
scrollTo={scrollTo}
isCollapsed={isCollapsed}
+ onDuplicate={onDuplicate}
contents={content.contents}/>
};
})} />
),
- column: ({ sectionId, id, contents, isCollapsed, scrollTo, onSort, onUpdate, sectionType }) => (
+ column: ({ sectionId, id, contents, isCollapsed, scrollTo, onSort, onUpdate, sectionType, onDuplicate }) => (
,
@@ -152,6 +163,14 @@ const previewContents = {
className: 'square-button no-border'
}}
buttons={[
+ {
+ glyph: 'duplicate',
+ tooltipId: "geostory.duplicateSection",
+ onClick: (evt) => {
+ evt.stopPropagation();
+ if (onDuplicate) onDuplicate(containerPath, content.id);
+ }
+ },
{
glyph: 'zoom-to',
visible: sectionType !== SectionTypes.CAROUSEL,
@@ -164,12 +183,12 @@ const previewContents = {
onUpdate={(text) => onUpdate(`sections[{"id": "${sectionId}"}].contents[{"id": "${id}"}].contents[{"id": "${content.id}"}]`, {title: text}, "merge")}/>,
description: `type: ${content.type}`,
body: !isCollapsed && PreviewContents
- &&
+ &&
};
})} />
),
- immersive: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage }) => (
+ immersive: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage, onDuplicate }) => (
{
+ evt.stopPropagation();
+ if (onDuplicate) onDuplicate(containerPath, content.id);
+ }
+ },
{
glyph: 'zoom-to',
tooltipId: "geostory.zoomToContent",
@@ -218,12 +246,13 @@ const previewContents = {
onUpdate={onUpdate}
scrollTo={scrollTo}
isCollapsed={isCollapsed}
+ onDuplicate={onDuplicate}
contents={content.contents}/>
};
})} />
),
- carousel: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage }) => (
+ carousel: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage, onDuplicate }) => (
,
tools: null,
title:
onUpdate(`sections[{"id": "${id}"}].contents[{"id":"${content.id}"}]`, {title: text}, "merge")}/>,
@@ -264,6 +292,7 @@ const previewContents = {
onUpdate={onUpdate}
scrollTo={scrollTo}
isCollapsed={isCollapsed}
+ onDuplicate={onDuplicate}
sectionType={SectionTypes.CAROUSEL}
contents={content.contents}/>
};
@@ -283,6 +312,7 @@ const sectionToItem = ({
onSort,
onSelect,
onUpdate,
+ onDuplicate = () => {},
selected = "title_section_id1"
}) => ({
contents,
@@ -304,6 +334,14 @@ const sectionToItem = ({
className: 'square-button no-border'
}}
buttons={[
+ {
+ glyph: 'duplicate',
+ tooltipId: "geostory.duplicateSection",
+ onClick: (evt) => {
+ evt.stopPropagation();
+ onDuplicate('sections', id);
+ }
+ },
{
onClick: scrollToHandler(id, scrollTo),
glyph: 'zoom-to',
@@ -324,6 +362,7 @@ const sectionToItem = ({
selected={selected}
scrollTo={scrollTo}
isCollapsed={isCollapsed}
+ onDuplicate={onDuplicate}
contents={contents}/>
: null
};
@@ -335,7 +374,7 @@ const sectionToItem = ({
* @SectionsPreview
* @param {object[]} [sections=[]] Array of sections to display
*/
-export default ({ sections = [], scrollTo, onSelect = () => {}, isCollapsed, currentPage, selected, onSort, onUpdate }) => ( {}, isCollapsed, currentPage, selected, onSort, onUpdate, onDuplicate }) => ( {
if (itemDataTo.containerId === 'story-sections'
@@ -351,5 +390,5 @@ export default ({ sections = [], scrollTo, onSelect = () => {}, isCollapsed, cur
cardComponent={DraggableSideCard}
size="sm"
items={
- sections.map(sectionToItem({ currentPage, onSelect, isCollapsed, scrollTo, selected, onUpdate, onSort }))
+ sections.map(sectionToItem({ currentPage, onSelect, isCollapsed, scrollTo, selected, onUpdate, onSort, onDuplicate }))
} />);
diff --git a/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx b/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx
index e6f3a0ae04c..470a67ccd92 100644
--- a/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx
+++ b/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx
@@ -8,6 +8,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
+import ReactTestUtils from 'react-dom/test-utils';
import expect from 'expect';
import {Provider} from 'react-redux';
import HTML5Backend from 'react-dnd-html5-backend';
@@ -56,4 +57,31 @@ describe('SectionsPreview component', () => {
expect(el.querySelectorAll('.items-list > div').length).toBe(19); // one for each card in the side grid
expect(el.querySelectorAll('.mapstore-side-preview').length).toBe(19);
});
+ it('SectionsPreview renders duplicate buttons for sections', () => {
+ ReactDOM.render(
+ {}, getState: () => ({})}}>
+
+ , document.getElementById("container"));
+ const container = document.getElementById('container');
+ const duplicateButtons = container.querySelectorAll('.glyphicon-duplicate');
+ expect(duplicateButtons.length).toBeGreaterThan(0);
+ });
+ it('SectionsPreview calls onDuplicate when duplicate button is clicked', () => {
+ const actions = {
+ onDuplicate: () => {}
+ };
+ const spy = expect.spyOn(actions, 'onDuplicate');
+ ReactDOM.render(
+ {}, getState: () => ({})}}>
+
+ , document.getElementById("container"));
+ const container = document.getElementById('container');
+ const duplicateButtons = container.querySelectorAll('.glyphicon-duplicate');
+ expect(duplicateButtons.length).toBeGreaterThan(0);
+ if (duplicateButtons.length > 0) {
+ const btn = duplicateButtons[0].closest('button') || duplicateButtons[0];
+ ReactTestUtils.Simulate.click(btn);
+ expect(spy).toHaveBeenCalled();
+ }
+ });
});
diff --git a/web/client/components/geostory/common/MapEditor.jsx b/web/client/components/geostory/common/MapEditor.jsx
index 4f0be9972a2..08683fad9bf 100644
--- a/web/client/components/geostory/common/MapEditor.jsx
+++ b/web/client/components/geostory/common/MapEditor.jsx
@@ -69,7 +69,9 @@ const MapEditor = ({
closeNodeEditor,
CloseBtn = () => (null),
isDrawEnabled,
- hideIdentifyOptions
+ hideIdentifyOptions,
+ onApplyToMaps = () => {},
+ isCarouselSection = false
}) => {
return (mode === Modes.EDIT && isFocused ?
]}
}
diff --git a/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx b/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx
index d9fc37f3330..84258143891 100644
--- a/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx
+++ b/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx
@@ -13,8 +13,10 @@ import ReactTestUtils from 'react-dom/test-utils';
import {createSink} from 'recompose';
import Toolbar from '../../../../misc/toolbar/Toolbar';
-import withMapEnhancer, { withLocalMapState, withMapEditingAndLocalMapState, withToolbar } from '../map';
+import withMapEnhancer, { withLocalMapState, withMapEditingAndLocalMapState, withToolbar, handleMapUpdate } from '../map';
import { Provider } from 'react-redux';
+import { APPLY_TO_MAPS } from '../../../../../actions/geostory';
+import {SectionTypes} from '../../../../../utils/GeoStoryUtils';
describe("geostory media map component enhancers", () => {
beforeEach((done) => {
@@ -163,6 +165,119 @@ describe("geostory media map component enhancers", () => {
done();
});
+ it('handleMapUpdate provides onChangeMap, onApplyToMaps, and isCarouselSection props', (done) => {
+ const focusedContentPath = 'sections[{"id":"s1"}].contents[{"id":"c1"}].background';
+ const store = {
+ subscribe: () => {},
+ dispatch: () => {},
+ getState: () => ({
+ geostory: {
+ focusedContent: {
+ path: focusedContentPath
+ },
+ currentStory: {
+ sections: [
+ { id: 's1', type: SectionTypes.TITLE, contents: [] }
+ ]
+ }
+ }
+ })
+ };
+
+ const Sink = handleMapUpdate(createSink(props => {
+ expect(props.onChangeMap).toBeA('function');
+ expect(props.onApplyToMaps).toBeA('function');
+ expect(props.isCarouselSection).toBe(false);
+ done();
+ }));
+
+ ReactDOM.render(
+
+ {}}
+ focusedContent={{ path: focusedContentPath }}
+ />
+ ,
+ document.getElementById("container")
+ );
+ });
+
+ it('handleMapUpdate sets isCarouselSection true for carousel sections', (done) => {
+ const focusedContentPath = 'sections[{"id":"carousel1"}].contents[{"id":"c1"}].background';
+ const store = {
+ subscribe: () => {},
+ dispatch: () => {},
+ getState: () => ({
+ geostory: {
+ focusedContent: {
+ path: focusedContentPath
+ },
+ currentStory: {
+ sections: [
+ { id: 'carousel1', type: SectionTypes.CAROUSEL, contents: [] }
+ ]
+ }
+ }
+ })
+ };
+
+ const Sink = handleMapUpdate(createSink(props => {
+ expect(props.isCarouselSection).toBe(true);
+ done();
+ }));
+
+ ReactDOM.render(
+
+ {}}
+ focusedContent={{ path: focusedContentPath }}
+ />
+ ,
+ document.getElementById("container")
+ );
+ });
+
+ it('handleMapUpdate onApplyToMaps dispatches APPLY_TO_MAPS action', (done) => {
+ const focusedContentPath = 'sections[{"id":"s1"}].contents[{"id":"c1"}].background';
+ const store = {
+ subscribe: () => {},
+ dispatch: (action) => {
+ if (action.type === APPLY_TO_MAPS) {
+ expect(action.property).toBe('center');
+ expect(action.value).toEqual({x: 1, y: 2});
+ expect(action.currentContentPath).toBe(focusedContentPath);
+ done();
+ }
+ },
+ getState: () => ({
+ geostory: {
+ focusedContent: {
+ path: focusedContentPath
+ },
+ currentStory: {
+ sections: [
+ { id: 's1', type: SectionTypes.TITLE, contents: [] }
+ ]
+ }
+ }
+ })
+ };
+
+ const Sink = handleMapUpdate(createSink(props => {
+ props.onApplyToMaps('center', {x: 1, y: 2});
+ }));
+
+ ReactDOM.render(
+
+ {}}
+ focusedContent={{ path: focusedContentPath }}
+ />
+ ,
+ document.getElementById("container")
+ );
+ });
+
});
diff --git a/web/client/components/geostory/common/enhancers/map.jsx b/web/client/components/geostory/common/enhancers/map.jsx
index a62ce623c6a..77d680a0118 100644
--- a/web/client/components/geostory/common/enhancers/map.jsx
+++ b/web/client/components/geostory/common/enhancers/map.jsx
@@ -13,8 +13,9 @@ import {branch, compose, createEventHandler, mapPropsStream, withHandlers, withP
import {createSelector} from 'reselect';
import uuid from "uuid";
-import {getCurrentFocusedContentEl, isFocusOnContentSelector, resourcesSelector} from '../../../../selectors/geostory';
-import {createMapObject} from '../../../../utils/GeoStoryUtils';
+import {getCurrentFocusedContentEl, isFocusOnContentSelector, resourcesSelector, getFocusedContentSelector, isGeoCarouselSection} from '../../../../selectors/geostory';
+import {createMapObject, getIdFromPath} from '../../../../utils/GeoStoryUtils';
+import {applyToMaps} from '../../../../actions/geostory';
import {isNearlyEqual} from '../../../../utils/MapUtils';
import Message from '../../../I18N/Message';
import ToolbarButton from '../../../misc/toolbar/ToolbarButton';
@@ -59,16 +60,35 @@ export const withFocusedContentMap = compose(
/**
* It Adjusts the path to update content map config obj
*/
-export const handleMapUpdate = withHandlers({
- onChangeMap: ({update, focusedContent = {}}) =>
- (path, value, mode = "merge") => {
- update(`${focusedContent.path}.map.${path}`, value, mode);
- },
- onChange: ({update, focusedContent = {}}) =>
- (path, value, mode = 'merge') => {
- update(focusedContent.path + `.${path}`, value, mode);
- }
-});
+export const handleMapUpdate = compose(
+ connect(
+ createSelector(
+ getFocusedContentSelector,
+ state => state,
+ (focusedContent, state) => {
+ const { sectionId } = getIdFromPath(focusedContent?.path);
+ return {
+ isCarouselSection: sectionId ? isGeoCarouselSection(sectionId)(state) : false
+ };
+ }
+ ),
+ { applyToMaps }
+ ),
+ withHandlers({
+ onChangeMap: ({update, focusedContent = {}}) =>
+ (path, value, mode = "merge") => {
+ update(`${focusedContent.path}.map.${path}`, value, mode);
+ },
+ onChange: ({update, focusedContent = {}}) =>
+ (path, value, mode = 'merge') => {
+ update(focusedContent.path + `.${path}`, value, mode);
+ },
+ onApplyToMaps: ({ applyToMaps: applyToMapsAction, focusedContent = {} }) =>
+ (property, value) => {
+ applyToMapsAction(property, value, focusedContent.path);
+ }
+ })
+);
/**
* Handle edit map toggle, map rest and open AdvancedMapEditor.
diff --git a/web/client/components/geostory/common/map/Controls.jsx b/web/client/components/geostory/common/map/Controls.jsx
index c85e5c9e44b..e8ae0190b42 100644
--- a/web/client/components/geostory/common/map/Controls.jsx
+++ b/web/client/components/geostory/common/map/Controls.jsx
@@ -6,23 +6,27 @@
* LICENSE file in the root directory of this source tree.
*/
-import React from 'react';
+import React, { useMemo } from 'react';
import {Form, FormGroup, ControlLabel} from 'react-bootstrap';
import Message from '../../../I18N/Message';
import Select from "react-select";
import {isNil} from "lodash";
import { applyDefaults } from '../../../../utils/GeoStoryUtils';
import { is3DVisualizationMode } from '../../../../utils/MapTypeUtils';
+import { getScales } from '../../../../utils/MapUtils';
+import Button from '../../../misc/Button';
import SwitchButton from '../../../misc/switch/SwitchButton';
import localizedProps from '../../../misc/enhancers/localizedProps';
import FeatureInfoFormatSelector from '../../../misc/FeatureInfoFormatSelector';
+import CoordinatesEditor from './CoordinatesEditor';
const SelectLocalized = localizedProps(["placeholder", "options"])(Select);
export const Controls = ({
map = {zoomControl: true, mapInfoControl: false},
onChangeMap = () => { },
+ onApplyToMaps = () => {},
...props
} = {}) => {
const mapOptions = map && map.mapOptions || {};
@@ -32,7 +36,54 @@ export const Controls = ({
mapInfoControl: !isNil(map.mapInfoControl) ? map.mapInfoControl : false
});
const is3D = is3DVisualizationMode(map);
+ const projection = map.projection || 'EPSG:3857';
+ const scales = useMemo(() =>
+ getScales(projection).map((scale, idx) => ({
+ value: idx,
+ label: `1 : ${Math.round(scale)}`,
+ scale: Math.round(scale)
+ })), [projection]);
+ const center = map.center || {};
+ const zoom = map.zoom;
+ const currentZoom = zoom !== undefined ? Math.round(zoom) : 0;
+
return ( : null;
};
@@ -169,6 +172,7 @@ export default createPlugin('GeoStoryEditor', {
onSelect: selectCard,
onSort: move,
onUpdate: update,
+ onDuplicate: duplicateItem,
onBasicError: basicError
}
)(GeoStoryEditor),
diff --git a/web/client/themes/default/less/geostory.less b/web/client/themes/default/less/geostory.less
index 092930ccdf3..cf60029a0f8 100644
--- a/web/client/themes/default/less/geostory.less
+++ b/web/client/themes/default/less/geostory.less
@@ -269,6 +269,35 @@
}
.ms-geostory-map-controls {
padding-top: 8px;
+ .ms-geostory-map-controls-center,
+ .ms-geostory-map-controls-scale {
+ margin-bottom: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ .control-label {
+ margin-bottom: 0;
+ }
+ }
+ .ms-geostory-center-coordinates {
+ clear: both;
+ .input-group-container {
+ margin-bottom: 4px;
+ }
+ .input-group {
+ .input-group-addon {
+ min-width: 42px;
+ text-align: center;
+ }
+ .form-group {
+ display: flex;
+ margin: 0;
+ .react-numeric-input {
+ width: 100%;
+ }
+ }
+ }
+ }
}
.ms-geostory-settings-switch, .ms-geostory-map-controls-switch {
float: right;
diff --git a/web/client/translations/data.ca-ES.json b/web/client/translations/data.ca-ES.json
index ed32184f821..07e87bf8c02 100644
--- a/web/client/translations/data.ca-ES.json
+++ b/web/client/translations/data.ca-ES.json
@@ -2990,6 +2990,8 @@
"addMediaContent": "Afegiu contingut de suports",
"addWebPageContent": "Afegiu contingut de la p\u00e0gina web",
"zoomToContent": "Zoom al contingut",
+ "duplicateSection": "Duplica la secci\u00f3",
+ "copyOfPrefix": "Copia de",
"delete": "Suprimeix Geostory",
"emptyTitle": "Aquesta hist\u00f2ria est\u00e0 buida",
"emptyDescription": "Comenceu a crear un Geostory impressionant afegint contingut nou",
@@ -3131,6 +3133,10 @@
"toc": "Capes",
"pan": "Interacci\u00f3 PAN",
"zoom": "Interaccions de zoom/sortida",
+ "center": "Centre",
+ "scale": "Escala",
+ "applyToOtherMaps": "Aplicar a altres mapes",
+ "appliedSuccessfully": "Aplicat correctament a altres mapes",
"topLeft": "Superior esquerra",
"topRight": "Superior dreta",
"bottomLeft": "Part inferior esquerra",
diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json
index e0db09ffe9e..ada2fe269d8 100644
--- a/web/client/translations/data.da-DK.json
+++ b/web/client/translations/data.da-DK.json
@@ -3115,6 +3115,8 @@
"addMediaContent": "Tilføj medieindhold",
"addWebPageContent": "Tilføj en indlejret hjemmeside",
"zoomToContent": "Zoom ind til indholdet",
+ "duplicateSection": "Duplikér afsnit",
+ "copyOfPrefix": "Kopi af",
"delete": "Slet Geostory",
"emptyTitle": "Denne historie er tom",
"emptyDescription": "Start en fantastisk Geostory ved at tilføje indhold",
@@ -3256,6 +3258,10 @@
"toc": "Lag",
"pan": "Aktivér panorering",
"zoom": "Aktivér zoom ind/ud-interaktion",
+ "center": "Centrum",
+ "scale": "Skala",
+ "applyToOtherMaps": "Anvend på andre kort",
+ "appliedSuccessfully": "Anvendt succesfuldt på andre kort",
"topLeft": "Øverst til venstre",
"topRight": "Øverst til højre",
"bottomLeft": "Nederst til venstre",
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index 48a6a020efe..31ab937c4b4 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -3231,6 +3231,8 @@
"addMediaContent": "Medieninhalt hinzufügen",
"zoomToContent": "Zum Inhalt zoomen",
"addWebPageContent": "Webseitenabschnitt hinzufügen",
+ "duplicateSection": "Abschnitt duplizieren",
+ "copyOfPrefix": "Kopie von",
"emptyTitle": "Diese GeoStory ist leer",
"delete": "Löschen geostory",
"emptyDescription": "Erstellen Sie eine fantastische Geostory, indem Sie neuen Inhalt hinzufügen",
@@ -3372,6 +3374,10 @@
"toc": "Ebenen",
"pan": "Verschieben-Interaktion",
"zoom": "Vergrößern / Verkleinern",
+ "center": "Zentrum",
+ "scale": "Skala",
+ "applyToOtherMaps": "Auf andere Karten anwenden",
+ "appliedSuccessfully": "Erfolgreich auf andere Karten angewendet",
"topLeft": "Oben links",
"topRight": "Oben rechts",
"bottomLeft": "Unten links",
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index 94266349ae3..d55e6b0eea0 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -3200,6 +3200,8 @@
"addMediaContent": "Add Media Content",
"addWebPageContent": "Add Web Page Content",
"zoomToContent": "Zoom to content",
+ "duplicateSection": "Duplicate section",
+ "copyOfPrefix": "Copy of",
"delete": "Delete geostory",
"emptyTitle": "This story is empty",
"emptyDescription": "Start creating an awesome geostory by adding new content",
@@ -3339,6 +3341,10 @@
"settings": "Settings",
"settingsSubTitle": "Manage user interactions",
"toc": "Layers",
+ "center": "Center",
+ "scale": "Scale",
+ "applyToOtherMaps": "Apply to other maps",
+ "appliedSuccessfully": "Successfully applied to other maps",
"pan": "Pan interaction",
"zoom": "Zoom In/Out interactions",
"topLeft": "Top Left",
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index ced8b994326..bb71cbb6508 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -3190,6 +3190,8 @@
"addWebPageContent": "Agregar contenido de página web",
"emptyTitle": "Esta historia esta vacia",
"delete": "Eliminar geostory",
+ "duplicateSection": "Duplicar sección",
+ "copyOfPrefix": "Copia de",
"emptyDescription": "Comience a crear una increíble geografía agregando nuevo contenido",
"closeFullscreenMap": "Cerrar mapa a pantalla completa",
"showFullscreenMap": "Ver mapa en pantalla completa",
@@ -3329,6 +3331,10 @@
"toc": "Capas",
"pan": "interacción pan",
"zoom": "Acercar/alejar interacciones",
+ "center": "Centro",
+ "scale": "Escala",
+ "applyToOtherMaps": "Aplicar a otros mapas",
+ "appliedSuccessfully": "Aplicado correctamente a otros mapas",
"topLeft": "Arriba a la izquierda",
"topRight": "Parte superior derecha",
"bottomLeft": "Abajo a la izquierda",
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index 652223ca43c..ab769b80822 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -3194,6 +3194,8 @@
"addWebPageContent": "Ajouter du contenu de page web",
"emptyTitle": "Cette story est vide",
"delete": "Supprimer geostory",
+ "duplicateSection": "Dupliquer la section",
+ "copyOfPrefix": "Copie de",
"emptyDescription": "Commencer une story géniale en ajoutant du nouveau contenu",
"closeFullscreenMap": "Fermer la carte plein écran",
"showFullscreenMap": "Montrer la carte en plein écran",
@@ -3333,6 +3335,10 @@
"toc": "Couches",
"pan": "Interaction pan",
"zoom": "Interactions zoom avant / arrière",
+ "center": "Centre",
+ "scale": "Échelle",
+ "applyToOtherMaps": "Appliquer à d'autres cartes",
+ "appliedSuccessfully": "Appliqué avec succès à d'autres cartes",
"topLeft": "En haut à gauche",
"topRight": "En haut à droite",
"bottomLeft": "En bas à gauche",
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index b23a5dcb52a..6b5d79492dc 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -3191,6 +3191,8 @@
"addTextContent": "Aggiungi testo",
"addMediaContent": "Aggiungi Media",
"zoomToContent": "Zoom al contenuto",
+ "duplicateSection": "Duplica sezione",
+ "copyOfPrefix": "Copia di ",
"addWebPageContent": "Aggiungi contenuto alla pagina Web",
"emptyTitle": "Questa storia è vuota",
"delete": "Cancellare geostory",
@@ -3331,6 +3333,10 @@
"settings": "Impostazioni",
"settingsSubTitle": "Gestisci le interazioni dell'utente",
"toc": "Toc",
+ "center": "Centro",
+ "scale": "Scala",
+ "applyToOtherMaps": "Applica alle altre mappe",
+ "appliedSuccessfully": "Applicato con successo alle altre mappe",
"pan": "Interazione di scorrimento",
"zoom": "Interazione di zoom",
"topLeft": "Angolo in alto a sinistra",
diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json
index b33f98e4c21..86f5d6b6145 100644
--- a/web/client/translations/data.nl-NL.json
+++ b/web/client/translations/data.nl-NL.json
@@ -3048,6 +3048,8 @@
"addWebPageContent": "Voeg webpagina-inhoud toe",
"zoomToContent": "Zoom in op inhoud",
"delete": "Verwijder geostory",
+ "duplicateSection": "Dupliceer sectie",
+ "copyOfPrefix": "Kopie van",
"emptyTitle": "Dit verhaal is leeg",
"emptyDescription": "Begin met het maken van een geweldige geostory door nieuwe inhoud toe te voegen",
"closeFullscreenMap": "Kaart op volledig scherm sluiten",
@@ -3188,6 +3190,10 @@
"toc": "Lagen",
"pan": "Pan-interactie",
"zoom": "In / uitzoomen interacties",
+ "center": "Centrum",
+ "scale": "Schaal",
+ "applyToOtherMaps": "Toepassen op andere kaarten",
+ "appliedSuccessfully": "Succesvol toegepast op andere kaarten",
"topLeft": "Linksboven",
"topRight": "Rechtsboven",
"bottomLeft": "Linksonder",
diff --git a/web/client/translations/data.pt-BR.json b/web/client/translations/data.pt-BR.json
index 4e1aff0d517..44885877cce 100644
--- a/web/client/translations/data.pt-BR.json
+++ b/web/client/translations/data.pt-BR.json
@@ -3061,6 +3061,8 @@
"addWebPageContent": "Adicionar Conteúdo de Página Web",
"zoomToContent": "Zoom para conteúdo",
"delete": "Excluir geohistória",
+ "duplicateSection": "Duplicar seção",
+ "copyOfPrefix": "Copia de",
"emptyTitle": "Esta história está vazia",
"emptyDescription": "Comece a criar uma geohistória incrível adicionando novo conteúdo",
"closeFullscreenMap": "Fechar mapa em tela cheia",
@@ -3201,6 +3203,10 @@
"toc": "Camadas",
"pan": "Interação de arrastar",
"zoom": "Interações de Zoom In/Out",
+ "center": "Centro",
+ "scale": "Escala",
+ "applyToOtherMaps": "Aplicar a outros mapas",
+ "appliedSuccessfully": "Aplicado com sucesso a outros mapas",
"topLeft": "Superior Esquerdo",
"topRight": "Superior Direito",
"bottomLeft": "Inferior Esquerdo",
diff --git a/web/client/translations/data.sv-SE.json b/web/client/translations/data.sv-SE.json
index 253f14598ee..2fde5e3a912 100644
--- a/web/client/translations/data.sv-SE.json
+++ b/web/client/translations/data.sv-SE.json
@@ -3044,6 +3044,8 @@
"addWebPageContent": "Lägg till innehåll på webbsidan",
"zoomToContent": "Zooma till innehåll",
"delete": "Ta bort geostory",
+ "duplicateSection": "Duplidera avsnitt",
+ "copyOfPrefix": "Kopiera av",
"emptyTitle": "Den här historien är tom",
"emptyDescription": "Börja skapa en fantastisk kartberättelse genom att lägga till nytt innehåll",
"closeFullscreenMap": "Stäng helskärmskarta",
@@ -3184,6 +3186,10 @@
"toc": "Lager",
"pan": "Pan interaktion",
"zoom": "Zooma in/ut-interaktioner",
+ "center": "Centrum",
+ "scale": "Skala",
+ "applyToOtherMaps": "Applikat på andra kartor",
+ "appliedSuccessfully": "Applikat på andra kartor",
"topLeft": "Överst till vänster",
"topRight": "Överst till höger",
"bottomLeft": "Nedre vänster",
diff --git a/web/client/utils/GeoStoryUtils.js b/web/client/utils/GeoStoryUtils.js
index 01a0f3b9994..fc1ac209dbe 100644
--- a/web/client/utils/GeoStoryUtils.js
+++ b/web/client/utils/GeoStoryUtils.js
@@ -683,3 +683,30 @@ export const getContentsFeatureStyle = (markerStyle, contents, content, contentI
...feature.style,
highlight: contentId === content.id
});
+
+/**
+ * Collects all content paths that have a map configuration (resourceId indicating a map).
+ * Returns an array of objects with `path` and `sectionType`.
+ * @param {object[]} sections the story sections
+ * @returns {object[]} paths to all map-containing contents
+ */
+export const collectMapContentPaths = (sections = []) => {
+ const paths = [];
+ const traverse = (items, basePath, sectionType) => {
+ (items || []).forEach(item => {
+ const itemPath = `${basePath}[{"id":"${item.id}"}]`;
+ const currentSectionType = sectionType || item.type;
+ if (item.resourceId || (item.map && (item.map.center || item.map.zoom !== undefined))) {
+ paths.push({ path: itemPath, sectionType: currentSectionType });
+ }
+ if (item.background && (item.background.resourceId || item.background.map)) {
+ paths.push({ path: itemPath + '.background', sectionType: currentSectionType });
+ }
+ if (item.contents) {
+ traverse(item.contents, itemPath + '.contents', currentSectionType);
+ }
+ });
+ };
+ traverse(sections, 'sections');
+ return paths;
+};
diff --git a/web/client/utils/__tests__/GeoStoryUtils-test.js b/web/client/utils/__tests__/GeoStoryUtils-test.js
index 1a774edeb8e..75d9fdbe3d9 100644
--- a/web/client/utils/__tests__/GeoStoryUtils-test.js
+++ b/web/client/utils/__tests__/GeoStoryUtils-test.js
@@ -35,7 +35,8 @@ import {
createWebFontLoaderConfig,
extractFontNames,
getVectorLayerFromContents,
- getContentsFeatureStyle
+ getContentsFeatureStyle,
+ collectMapContentPaths
} from "../GeoStoryUtils";
describe("GeoStory Utils", () => {
@@ -822,5 +823,110 @@ describe("GeoStory Utils", () => {
highlight: true
});
});
+ describe('collectMapContentPaths', () => {
+ it('returns empty array for empty sections', () => {
+ expect(collectMapContentPaths([])).toEqual([]);
+ expect(collectMapContentPaths()).toEqual([]);
+ });
+ it('collects paths for sections with background maps', () => {
+ const sections = [
+ {
+ id: 's1',
+ type: 'title',
+ contents: [{
+ id: 'c1',
+ background: {
+ resourceId: 'res1',
+ map: { center: {x: 0, y: 0}, zoom: 5 }
+ }
+ }]
+ }
+ ];
+ const result = collectMapContentPaths(sections);
+ expect(result.length).toBe(1);
+ expect(result[0].path).toBe('sections[{"id":"s1"}].contents[{"id":"c1"}].background');
+ expect(result[0].sectionType).toBe('title');
+ });
+ it('collects paths for contents with resourceId', () => {
+ const sections = [
+ {
+ id: 's1',
+ type: 'paragraph',
+ contents: [{
+ id: 'c1',
+ resourceId: 'map-resource-1'
+ }]
+ }
+ ];
+ const result = collectMapContentPaths(sections);
+ expect(result.length).toBe(1);
+ expect(result[0].path).toBe('sections[{"id":"s1"}].contents[{"id":"c1"}]');
+ expect(result[0].sectionType).toBe('paragraph');
+ });
+ it('collects paths from nested contents', () => {
+ const sections = [
+ {
+ id: 's1',
+ type: 'immersive',
+ contents: [{
+ id: 'c1',
+ contents: [{
+ id: 'inner1',
+ background: {
+ resourceId: 'res1',
+ map: { center: {x: 1, y: 1} }
+ }
+ }]
+ }]
+ }
+ ];
+ const result = collectMapContentPaths(sections);
+ expect(result.length).toBe(1);
+ expect(result[0].path).toBe('sections[{"id":"s1"}].contents[{"id":"c1"}].contents[{"id":"inner1"}].background');
+ expect(result[0].sectionType).toBe('immersive');
+ });
+ it('preserves section type for carousel contents', () => {
+ const sections = [
+ {
+ id: 'carousel1',
+ type: 'carousel',
+ contents: [{
+ id: 'cc1',
+ background: {
+ resourceId: 'res1',
+ map: { zoom: 3 }
+ }
+ }]
+ }
+ ];
+ const result = collectMapContentPaths(sections);
+ expect(result.length).toBe(1);
+ expect(result[0].sectionType).toBe('carousel');
+ });
+ it('collects multiple paths across multiple sections', () => {
+ const sections = [
+ {
+ id: 's1',
+ type: 'title',
+ contents: [{
+ id: 'c1',
+ background: { resourceId: 'r1', map: {} }
+ }]
+ },
+ {
+ id: 's2',
+ type: 'paragraph',
+ contents: [{
+ id: 'c2',
+ background: { resourceId: 'r2', map: {} }
+ }]
+ }
+ ];
+ const result = collectMapContentPaths(sections);
+ expect(result.length).toBe(2);
+ expect(result[0].path).toContain('s1');
+ expect(result[1].path).toContain('s2');
+ });
+ });
});