-
-
+
- {icons.map((icon, index) => {
+ {row.icons.map((icon) => {
const isSelected = selectedIcon === icon;
-
return (
-
+
onSelect(icon)}
>
@@ -213,12 +210,11 @@ function IconCategorySection({
);
})}
-
+
);
}
function formatCategoryName(name) {
if (!name) return '';
-
return name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
}
diff --git a/map/src/infoblock/components/favorite/structure/WptIconPreview.jsx b/map/src/infoblock/components/favorite/structure/WptIconPreview.jsx
index 7480446bc..7aa5d22ad 100644
--- a/map/src/infoblock/components/favorite/structure/WptIconPreview.jsx
+++ b/map/src/infoblock/components/favorite/structure/WptIconPreview.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@mui/material';
-import MarkerOptions, { ICONS_PREFIX, POI_ICONS_FOLDER } from '../../../../map/markers/MarkerOptions';
+import MarkerOptions, { getIconUrlByName } from '../../../../map/markers/MarkerOptions';
import { ReactComponent as SelectionRing } from '../../../../assets/wpt/selection.svg';
import { hexToRgba } from '../../../../util/ColorUtil';
import styles from '../wptEditPanel.module.css';
@@ -55,7 +55,7 @@ export default function WptIconPreview({
({ ...prev, markerCurrent: null, favItem: false, name: null }));
diff --git a/map/src/login/garmin/GarminConnectedView.jsx b/map/src/login/garmin/GarminConnectedView.jsx
index 7e3c88a86..87988ccba 100644
--- a/map/src/login/garmin/GarminConnectedView.jsx
+++ b/map/src/login/garmin/GarminConnectedView.jsx
@@ -1,15 +1,14 @@
import React, { useContext } from 'react';
-import { Box, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as FolderIcon } from '../../assets/icons/ic_action_folder.svg';
import { ReactComponent as SyncIcon } from '../../assets/icons/ic_action_update.svg';
import { ReactComponent as LogoutIcon } from '../../assets/icons/ic_action_logout.svg';
import { ReactComponent as ExternalLinkIcon } from '../../assets/icons/ic_action_external_link.svg';
-import { ReactComponent as ChevronIcon } from '../../assets/icons/ic_action_arrow_up.svg';
import { ReactComponent as FilterIcon } from '../../assets/icons/ic_action_filter.svg';
import ThickDivider from '../../frame/components/dividers/ThickDivider';
import SubTitleMenu from '../../frame/components/titles/SubTitleMenu';
+import ChevronItem from '../../frame/components/items/ChevronItem';
import DefaultItem from '../../frame/components/items/DefaultItem';
import DividerWithMargin from '../../frame/components/dividers/DividerWithMargin';
import { MAIN_URL_WITH_SLASH, TRACKS_URL } from '../../manager/GlobalManager';
@@ -19,7 +18,6 @@ import AppContext from '../../context/AppContext';
import { GARMIN_FOLDER_NAME } from './garminApi';
import { GARMIN_ACTIVITY_GROUPS } from './GarminActivitiesToSync';
import { useRecentDataSaver } from '../../util/hooks/menu/useRecentDataSaver';
-import styles from '../../frame/components/items/items.module.css';
export default function GarminConnectedView({
syncTimeMs,
@@ -76,18 +74,18 @@ export default function GarminConnectedView({
return (
<>
- }
- name={t('web:garmin_activities')}
+ title={t('web:garmin_activities')}
+ value={String(activitiesCount)}
onClick={handleActivitiesClick}
- rightSlot={}
/>
- }
- name={t('web:garmin_last_sync')}
+ title={t('web:garmin_last_sync')}
+ value={syncTimeMs ? formatTimeAgo(syncTimeMs, t) : '—'}
onClick={handleLastActivityClick}
- rightSlot={}
/>
- }
- name={t('web:garmin_activities_to_sync')}
+ title={t('web:garmin_activities_to_sync')}
+ value={activityTypesLabel}
onClick={onActivitiesToSyncClick}
- rightSlot={}
/>
- {text}
-
-
- );
-}
-
function handleViewOnGarminClick() {
globalThis.open('https://connect.garmin.com/app/activities', '_blank', 'noopener,noreferrer');
}
diff --git a/map/src/manager/ColorPaletteManager.js b/map/src/manager/ColorPaletteManager.js
index 85e1414c1..3a99ecbb0 100644
--- a/map/src/manager/ColorPaletteManager.js
+++ b/map/src/manager/ColorPaletteManager.js
@@ -5,6 +5,7 @@ import { parseColorToRgba, toColorString } from '../util/ColorUtil';
const COLOR_PALETTE_FILE = 'color-palette/user_palette_default.txt';
const COLOR_PALETTE_TYPE = 'FILE';
const PALETTE_HEADER = '# Index,R,G,B,A';
+const LOCAL_PALETTE_KEY = 'osmand_color_palette';
// Each entry: { id: number, value: string }
export function parsePalette(text) {
@@ -75,3 +76,25 @@ export async function saveColorPalette(items, setNotification) {
export function nextPaletteId(items) {
return (items?.length ?? 0) + 1;
}
+
+export function loadLocalPalette() {
+ try {
+ const raw = localStorage.getItem(LOCAL_PALETTE_KEY);
+ if (!raw) return [];
+ return JSON.parse(raw);
+ } catch {
+ return [];
+ }
+}
+
+export function saveLocalPalette(items) {
+ try {
+ localStorage.setItem(LOCAL_PALETTE_KEY, JSON.stringify(items));
+ } catch {
+ // localStorage unavailable — silently ignore
+ }
+}
+
+export function clearLocalPalette() {
+ localStorage.removeItem(LOCAL_PALETTE_KEY);
+}
diff --git a/map/src/manager/FavoritesManager.js b/map/src/manager/FavoritesManager.js
index 78c6de211..1a7074521 100644
--- a/map/src/manager/FavoritesManager.js
+++ b/map/src/manager/FavoritesManager.js
@@ -19,7 +19,6 @@ export const FAVORITE_FILE_TYPE = 'FAVOURITES';
export const DEFAULT_FAV_GROUP_NAME = 'favorites';
export const PERSONAL_FAV_GROUP_NAME = 'personal';
const DEFAULT_TAB_ICONS = 'used';
-const FAVORITE_GROUP_FOLDER = '/map/images/poi_categories';
const DEFAULT_GROUP_WPT_COLOR = '#eecc22';
const FAV_FILE_PREFIX = 'favorites-';
export const LOCATION_UNAVAILABLE = 'loc_unavailable';
@@ -806,7 +805,6 @@ const FavoritesManager = {
createDefaultWptGroup,
getGroupSize,
DEFAULT_TAB_ICONS: DEFAULT_TAB_ICONS,
- FAVORITE_GROUP_FOLDER: FAVORITE_GROUP_FOLDER,
DEFAULT_GROUP_NAME: DEFAULT_FAV_GROUP_NAME,
DEFAULT_GROUP_WPT_COLOR: DEFAULT_GROUP_WPT_COLOR,
FAVORITE_FILE_TYPE: FAVORITE_FILE_TYPE,
diff --git a/map/src/manager/GlobalManager.js b/map/src/manager/GlobalManager.js
index 577584ad7..d921b9407 100644
--- a/map/src/manager/GlobalManager.js
+++ b/map/src/manager/GlobalManager.js
@@ -6,6 +6,7 @@ export const MAIN_MENU_OPEN_SIZE = 240;
export const MENU_INFO_OPEN_SIZE = 360;
export const MENU_INFO_CLOSE_SIZE = 0;
export const HEADER_SIZE = 60;
+export const PANEL_HEADER_HEIGHT = 56;
export const INSTALL_BANNER_SIZE = 60;
export const GLOBAL_GRAPH_HEIGHT_SIZE = 200;
diff --git a/map/src/manager/PoiManager.js b/map/src/manager/PoiManager.js
index 3eca6283c..5e7d9dc0d 100644
--- a/map/src/manager/PoiManager.js
+++ b/map/src/manager/PoiManager.js
@@ -1,5 +1,5 @@
import { apiGet } from '../util/HttpApi';
-import icons from '../resources/generated/poiicons.json';
+import iconsRaw from '../resources/generated/poiicons.json';
import isEmpty from 'lodash-es/isEmpty';
import {
CATEGORY_ICON,
@@ -27,6 +27,8 @@ import { SEARCH_BRAND } from './SearchManager';
import { MAIN_URL_WITH_SLASH, POI_URL } from './GlobalManager';
import { getPropsFromSearchResultItem, preparedType } from '../menu/search/search/SearchResultItem';
+const icons = new Set(iconsRaw);
+
const POI_CATEGORIES = 'poiCategories';
const TOP_POI_FILTERS = 'topPoiFilters';
export const DEFAULT_POI_ICON = 'craft_default';
@@ -122,13 +124,13 @@ export function getIconNameForPoiType({
iconName = '',
useDefault = true,
}) {
- if (icons.includes(`mx_${typeOsmTag}_${typeOsmValue}.svg`)) {
+ if (icons.has(`mx_${typeOsmTag}_${typeOsmValue}.svg`)) {
return `${typeOsmTag}_${typeOsmValue}`;
- } else if (icons.includes(`mx_${iconKeyName}.svg`)) {
+ } else if (icons.has(`mx_${iconKeyName}.svg`)) {
return iconKeyName;
- } else if (icons.includes(`mx_topo_${iconKeyName}.svg`)) {
+ } else if (icons.has(`mx_topo_${iconKeyName}.svg`)) {
return `topo_${iconKeyName}`;
- } else if (iconName !== 'null' && icons.includes(`mx_${iconName}.svg`)) {
+ } else if (iconName !== 'null' && icons.has(`mx_${iconName}.svg`)) {
return iconName;
} else {
return useDefault ? DEFAULT_POI_ICON : null;
@@ -154,9 +156,9 @@ export function getCatPoiIconName(props) {
}
export function getIconName(obj) {
- if (icons.includes(`mx_${obj.key}.svg`)) {
+ if (icons.has(`mx_${obj.key}.svg`)) {
return obj.key;
- } else if (icons.includes(`mx_${obj.value}.svg`)) {
+ } else if (icons.has(`mx_${obj.value}.svg`)) {
return obj.value;
}
return null;
diff --git a/map/src/map/components/ContextMenu.jsx b/map/src/map/components/ContextMenu.jsx
index 8f9d5e4eb..ace5da635 100644
--- a/map/src/map/components/ContextMenu.jsx
+++ b/map/src/map/components/ContextMenu.jsx
@@ -66,8 +66,13 @@ export default function ContextMenu({ setGeocodingData, setRegionData }) {
};
const handleMenuItemClick = (callback) => {
- callback(clickLatLng);
+ const latlng = clickLatLng;
handleClose();
+ if (ctx.exitGuards.wptEdit) {
+ ctx.exitGuards.wptEdit.guard(() => callback(latlng));
+ } else {
+ callback(latlng);
+ }
};
const openLogin = () => {
diff --git a/map/src/map/layers/FavoriteLayer.js b/map/src/map/layers/FavoriteLayer.js
index 838eefe67..2e2314f8b 100644
--- a/map/src/map/layers/FavoriteLayer.js
+++ b/map/src/map/layers/FavoriteLayer.js
@@ -19,6 +19,7 @@ import {
resetSelectedPin,
} from '../util/MarkerSelectionService';
import { panToIfNeeded } from '../util/MapManager';
+import { panToVisibleCenter } from './MapStateLayer';
import { useSelectMarkerOnMap } from '../../util/hooks/map/useSelectMarkerOnMap';
import MarkerOptions, {
createPoiIcon,
@@ -37,6 +38,13 @@ import LoginContext from '../../context/LoginContext';
import { MARKER_Z_INDEX_MAIN, MENU_INFO_OPEN_SIZE, NAVIGATE_URL } from '../../manager/GlobalManager';
import { NAVIGATION_OBJECT_TYPE_FAVORITE } from '../../manager/NavigationManager';
+function getAddFavoritePinLatLng(addFavorite) {
+ const { location, editWpt } = addFavorite;
+ if (location) return { lat: location.lat, lng: location.lng };
+ if (editWpt) return { lat: editWpt.latlon?.lat ?? editWpt.lat, lng: editWpt.latlon?.lon ?? editWpt.lon };
+ return null;
+}
+
export function filterPointsInBounds(points, map) {
if (!map) return [];
if (!Array.isArray(points) || points.length === 0) return [];
@@ -130,6 +138,13 @@ const FavoriteLayer = () => {
return () => resetSelectedPin({ ctx, map, force: true });
}, [map, ctx.addFavorite?.location, ctx.addFavorite?.editWpt]);
+ // Pan map to keep the preview pin in the visible center: on open, secondary drawer open/close.
+ useEffect(() => {
+ const req = ctx.addFavorite?.panRequest;
+ if (!req || !map) return;
+ panToVisibleCenter(map, getAddFavoritePinLatLng(ctx.addFavorite), req.infoBlockWidthPx);
+ }, [ctx.addFavorite?.panRequest?.key]);
+
// Updates the selected pin icon in real-time when user changes appearance (color, icon, shape).
// Works for both add mode (preview pin) and edit mode (selected existing pin).
useEffect(() => {
diff --git a/map/src/map/layers/MapStateLayer.js b/map/src/map/layers/MapStateLayer.js
index f24d6937f..abe5a9308 100644
--- a/map/src/map/layers/MapStateLayer.js
+++ b/map/src/map/layers/MapStateLayer.js
@@ -28,21 +28,25 @@ const MAP_SPIN_COLOR = '#1976d2';
const TOP_PADDING = HEADER_SIZE;
const BOTTOM_PADDING = 0;
-function centerPercentsForInfoBlockPx(map, infoBlockWidthPx) {
- if (!map?.getSize) {
- return { left: '50%', top: '50%' };
- }
+function calcVisibleCenterPx(map, infoBlockWidthPx) {
const containerSize = map.getSize();
- if (!containerSize?.x || !containerSize?.y) {
- return { left: '50%', top: '50%' };
- }
+ if (!containerSize?.x || !containerSize?.y) return null;
const infoColumnWidthPx = Number.isFinite(infoBlockWidthPx) ? infoBlockWidthPx : 0;
const leftChromeWidthPx = infoColumnWidthPx + MAIN_MENU_MIN_SIZE;
- const centerX = leftChromeWidthPx + (containerSize.x - leftChromeWidthPx) / 2;
- const centerY = TOP_PADDING + (containerSize.y - TOP_PADDING - BOTTOM_PADDING) / 2;
return {
- left: `${(centerX / containerSize.x) * 100}%`,
- top: `${(centerY / containerSize.y) * 100}%`,
+ x: leftChromeWidthPx + (containerSize.x - leftChromeWidthPx) / 2,
+ y: TOP_PADDING + (containerSize.y - TOP_PADDING - BOTTOM_PADDING) / 2,
+ containerSize,
+ };
+}
+
+function centerPercentsForInfoBlockPx(map, infoBlockWidthPx) {
+ if (!map?.getSize) return { left: '50%', top: '50%' };
+ const center = calcVisibleCenterPx(map, infoBlockWidthPx);
+ if (!center) return { left: '50%', top: '50%' };
+ return {
+ left: `${(center.x / center.containerSize.x) * 100}%`,
+ top: `${(center.y / center.containerSize.y) * 100}%`,
};
}
@@ -54,6 +58,14 @@ export function getVisibleBboxCenterPercents(map, ctx) {
return centerPercentsForInfoBlockPx(map, infoBlockWidthPx);
}
+export function panToVisibleCenter(map, latlng, infoBlockWidthPx) {
+ if (!map || !latlng) return;
+ const center = calcVisibleCenterPx(map, infoBlockWidthPx);
+ if (!center) return;
+ const pinPoint = map.latLngToContainerPoint(L.latLng(latlng.lat, latlng.lng ?? latlng.lon));
+ map.panBy([pinPoint.x - center.x, pinPoint.y - center.y], { animate: true });
+}
+
export function mapSpinOptionsForVisibleBbox(map, ctx, options = {}) {
const { isInfoBlockOpen, ...rest } = options;
const positionPercents = isInfoBlockOpen
diff --git a/map/src/map/markers/MarkerOptions.js b/map/src/map/markers/MarkerOptions.js
index 56f4c5f69..301d29b54 100644
--- a/map/src/map/markers/MarkerOptions.js
+++ b/map/src/map/markers/MarkerOptions.js
@@ -3,12 +3,22 @@ import { hexToRgba } from '../../util/ColorUtil';
import poiicons from '../../resources/generated/poiicons.json';
import mapicons from '../../resources/generated/mapicons.json';
import shadersicons from '../../resources/generated/shadersicons.json';
-import PoiManager from '../../manager/PoiManager';
+import poiTypes from '../../resources/generated/poi-types.json';
+import poiCategoriesData from '../../resources/generated/poi_categories.json';
import backgrounds from '../../resources/generated/poiBackgroundIcons.json';
+import PoiManager from '../../manager/PoiManager';
import { startPointIcon } from './StartPointMarker';
import { intermediatePointIcon } from './IntermediatePointMarker';
import { destinationPointIcon } from './DestinationPointMarker';
+const poiIconsSet = new Set(poiicons);
+const mapIconsSet = new Set(mapicons);
+const shaderIconsSet = new Set(shadersicons);
+
+const poiTypeFallbackMap = Object.fromEntries(
+ poiTypes.filter((pt) => pt.tag && pt.value).map((pt) => [pt.name, `${pt.tag}_${pt.value}`])
+);
+
const BACKGROUND_WPT_SHAPE_CIRCLE = 'circle';
const BACKGROUND_WPT_SHAPE_OCTAGON = 'octagon';
const BACKGROUND_WPT_SHAPE_SQUARE = 'square';
@@ -414,13 +424,40 @@ export function changeIconSizeWpt(svgHtml, iconSize, shapeSize, shape = null) {
return svgHtml;
}
+// Resolves a poi_type name to the icon key that has an SVG available.
+export function resolvePoiIconKey(name) {
+ if (poiIconsSet.has(`${ICONS_PREFIX}${name}.svg`)) {
+ return name;
+ }
+ const fallback = poiTypeFallbackMap[name];
+ if (fallback && poiIconsSet.has(`${ICONS_PREFIX}${fallback}.svg`)) {
+ return fallback;
+ }
+ return null;
+}
+
+// Pre-computed resolved icon categories: each category's raw icon names are resolved
+// and filtered to only those with an available SVG.
+export const resolvedPoiCategories = (() => {
+ const result = {};
+ const cats = poiCategoriesData?.categories;
+ if (!cats) return result;
+ for (const [category, data] of Object.entries(cats)) {
+ const resolved = (data.icons ?? []).map(resolvePoiIconKey).filter(Boolean);
+ if (resolved.length > 0) {
+ result[category] = resolved;
+ }
+ }
+ return result;
+})();
+
export function getIconUrlByName(type, name) {
if (type === 'poi') {
- if (poiicons.includes(`${ICONS_PREFIX}${name}.svg`)) {
+ if (poiIconsSet.has(`${ICONS_PREFIX}${name}.svg`)) {
return `/map/images/${POI_ICONS_FOLDER}/${ICONS_PREFIX}${name}.svg`;
} else return `/map/images/${POI_ICONS_FOLDER}/${COLORED_ICONS_PREFIX}${name}.svg`;
} else if (type === 'map') {
- if (mapicons.includes(`${ICONS_PREFIX}${name}.svg`)) {
+ if (mapIconsSet.has(`${ICONS_PREFIX}${name}.svg`)) {
return `/map/images/${MAP_ICONS_FOLDER}/${ICONS_PREFIX}${name}.svg`;
} else return `/map/images/${MAP_ICONS_FOLDER}/${COLORED_ICONS_PREFIX}${name}.svg`;
}
@@ -428,7 +465,7 @@ export function getIconUrlByName(type, name) {
}
export function getShaderUrlByName(name) {
- if (shadersicons.includes(`${ICONS_PREFIX}${name}.svg`)) {
+ if (shaderIconsSet.has(`${ICONS_PREFIX}${name}.svg`)) {
return `/map/images/${SHADERS_FOLDER}/${SHADERS_PREFIX}${name}.svg`;
} else return `/map/images/${SHADERS_FOLDER}/${COLORED_SHADERS_PREFIX}${name}.svg`;
}
diff --git a/map/src/map/util/TrackLayerProvider.js b/map/src/map/util/TrackLayerProvider.js
index 17a294812..076bc73a2 100644
--- a/map/src/map/util/TrackLayerProvider.js
+++ b/map/src/map/util/TrackLayerProvider.js
@@ -8,7 +8,7 @@ import TracksManager, {
isProtectedSegment,
isWptGroupShown,
} from '../../manager/track/TracksManager';
-import { getFavoriteId, resolveWptAppearance } from '../../manager/FavoritesManager';
+import { FAVORITE_FILE_TYPE, getFavoriteId, resolveWptAppearance } from '../../manager/FavoritesManager';
import EditablePolyline from './creator/EditablePolyline';
import { clusterMarkers, addMarkerTooltip, removeTooltip } from './Clusterizer';
import Utils from '../../util/Utils';
@@ -527,6 +527,14 @@ function parseWpt({
return;
}
marker.options.idObj = getFavoriteId(marker);
+ if (type === FAVORITE_FILE_TYPE && point.name) {
+ marker.on('add', () => {
+ const el = marker.getElement();
+ if (el) {
+ el.id = `se-fav-map-marker-${point.name}`;
+ }
+ });
+ }
bindWptVisibilityOnAdd(marker, resolvedPointsGroups);
if (ctx && map && data) {
if (type === GPX_FILE_TYPE) {
diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js
index 5475485c2..e8068bbd0 100644
--- a/map/src/menu/MainMenu.js
+++ b/map/src/menu/MainMenu.js
@@ -742,12 +742,22 @@ export default function MainMenu({
return res.join(' ');
}
- function selectMenu({ item }) {
- closeSubPages({ ctx, ltx });
+ function selectMenu({ item, openFromUrl = false }) {
+ doSelectMenu({ item });
+ }
+
+ function doSelectMenu({ item }) {
+ const editInProgress = !!ctx.exitGuards.wptEdit?.hasChanges;
+ if (!editInProgress) {
+ closeSubPages({ ctx, ltx });
+ }
let currentType;
if (menuInfo) {
// update menu
- setShowInfoBlock(false);
+ if (!editInProgress) {
+ setShowInfoBlock(false);
+ ctx.setCurrentObjectType(null);
+ }
ctx.setOpenNavigationSettings(false);
ctx.setSearchSettings({ ...ctx.searchSettings, showExploreMarkers: false });
closeCloudSettings(openCloudSettings, setOpenCloudSettings, ctx);
@@ -758,7 +768,6 @@ export default function MainMenu({
}
setMenuInfo(menu?.component);
currentType = menu?.type;
- ctx.setCurrentObjectType(null);
} else {
// select first menu
setMenuInfo(item.component);
diff --git a/map/src/menu/actions/FavoriteItemActions.jsx b/map/src/menu/actions/FavoriteItemActions.jsx
index 0f0faf235..dc0ef8d04 100644
--- a/map/src/menu/actions/FavoriteItemActions.jsx
+++ b/map/src/menu/actions/FavoriteItemActions.jsx
@@ -50,8 +50,15 @@ const FavoriteItemActions = forwardRef(({ marker, group, setOpenActions }, ref)
id={'se-edit-fav-item'}
className={styles.action}
onClick={() => {
- ctx.setAddFavorite({ editWpt: favorite, openKey: Date.now() });
- setOpenActions(false);
+ const action = () => {
+ ctx.setAddFavorite({ editWpt: favorite, openKey: Date.now() });
+ setOpenActions(false);
+ };
+ if (ctx.exitGuards.wptEdit) {
+ ctx.exitGuards.wptEdit.guard(action);
+ } else {
+ action();
+ }
}}
>
diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json
index 45bec1ead..3a8e760c4 100644
--- a/map/src/resources/translations/en/web-translation.json
+++ b/map/src/resources/translations/en/web-translation.json
@@ -396,8 +396,13 @@
"color_palette_save_failed": "Could not save color palette to cloud",
"fav_folder_location": "Location",
"fav_folder_name": "Folder name",
+ "fav_top_level": "– Top level –",
"folder_already_exists": "A folder with this name already exists.",
"shared_string_advanced": "Advanced",
"focus_on": "Focus: hide other items",
- "focus_off": "Focus: dim other items"
+ "focus_off": "Focus: dim other items",
+ "exit_without_saving": "Exit without saving?",
+ "all_changes_will_be_lost": "All changes will be lost.",
+ "keep_editing": "Keep Editing",
+ "shared_string_exit": "Exit"
}
diff --git a/map/src/util/hooks/map/useSelectMarkerOnMap.js b/map/src/util/hooks/map/useSelectMarkerOnMap.js
index d12291f6f..a84db56a6 100644
--- a/map/src/util/hooks/map/useSelectMarkerOnMap.js
+++ b/map/src/util/hooks/map/useSelectMarkerOnMap.js
@@ -83,6 +83,10 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type,
useEffect(() => {
if (!map) return;
+ if (ctx.addFavorite?.editWpt && ctx.exitGuards.wptEdit?.hasChanges) {
+ return;
+ }
+
if (!selectedObjId) {
if (isAddFavoritePreviewActive(ctx)) {
return;
diff --git a/map/src/util/hooks/useExitGuard.js b/map/src/util/hooks/useExitGuard.js
new file mode 100644
index 000000000..1b1962bf0
--- /dev/null
+++ b/map/src/util/hooks/useExitGuard.js
@@ -0,0 +1,64 @@
+import { useLayoutEffect, useState } from 'react';
+
+/**
+ * Guards actions with an "Exit without saving?" dialog.
+ *
+ * Handles two kinds of triggers:
+ * 1. Local (back button, close icon): call guardAction(fn) directly.
+ * 2. External (map marker click, context menu, etc.): pass a `register`
+ * callback — the hook registers itself in context so any caller can do
+ * ctx.exitGuards.wptEdit.guard(fn). Re-registers automatically when hasChanges
+ * changes so callers always see the current state.
+ * For URL navigations use React Router's useBlocker.
+ *
+ * Usage:
+ * const { guardAction, dialog } = useExitGuard({
+ * hasChanges,
+ * renderDialog: ({ onKeepEditing, onExit }) => (
+ *
+ * ),
+ * register: (g) => ctx.setExitGuards((prev) => ({ ...prev, wptEdit: g ?? undefined })), // optional
+ * });
+ *
+ * guardAction(() => doSomething()); // wrap any local action
+ * {dialog} // place once in JSX
+ *
+ * @param {boolean} hasChanges - whether there are unsaved changes
+ * @param {Function} renderDialog - ({ onKeepEditing, onExit }) => ReactElement
+ * @param {Function} [register] - (guard | null) => void — called with
+ * { hasChanges, guard: fn } on mount/update
+ * and with null on unmount
+ */
+export default function useExitGuard({ hasChanges, renderDialog, register }) {
+ const [pendingAction, setPendingAction] = useState(null);
+
+ function guardAction(action) {
+ if (hasChanges) {
+ setPendingAction(() => action);
+ return;
+ }
+ action();
+ }
+
+ // Register in context using useLayoutEffect (fires synchronously after DOM
+ // update, before paint) so callers never see a stale or missing guard.
+ // Re-runs whenever hasChanges flips to keep the registered snapshot current.
+ useLayoutEffect(() => {
+ register?.({ hasChanges, guard: guardAction });
+ return () => register?.(null);
+ }, [hasChanges]);
+
+ const dialog =
+ pendingAction !== null
+ ? renderDialog({
+ onKeepEditing: () => setPendingAction(null),
+ onExit: () => {
+ const action = pendingAction;
+ setPendingAction(null);
+ action();
+ },
+ })
+ : null;
+
+ return { guardAction, dialog };
+}
diff --git a/tests/selenium/favorites/favorites-shops.gpx b/tests/selenium/favorites/favorites-shops.gpx
index 4e091595c..d9b3ea997 100644
--- a/tests/selenium/favorites/favorites-shops.gpx
+++ b/tests/selenium/favorites/favorites-shops.gpx
@@ -150,17 +150,6 @@
#43a047ff
-
-
- Spuistraat (Oude Binnenstad) 96
- shops
-
- Vlissingen
- amenity_fire_station
- circle
- #43a047ff
-
-
Waterlooplein (Amsterdam) 1
diff --git a/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs b/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs
index b4cdd84ba..25a0c6825 100644
--- a/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs
+++ b/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs
@@ -59,6 +59,8 @@ export default async function test() {
await waitBy(By.id('se-add-fav-dialog'));
await sendKeysBy(By.id('se-fav-name-input'), 'ShouldNotExist');
await clickBy(By.id('se-close-add-wpt-panel'));
+ await waitBy(By.id('se-exit-dialog-exit'));
+ await clickBy(By.id('se-exit-dialog-exit'));
await waitByRemoved(By.id('se-add-fav-dialog'));
// verify no default 'favorites' group was created (favorite was not saved)
const notSaved = await waitBy(By.id('se-menu-fav-favorites'), { optional: true, idle: true });
diff --git a/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs
new file mode 100644
index 000000000..ebcc2c344
--- /dev/null
+++ b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs
@@ -0,0 +1,121 @@
+import actionOpenMap from '../../actions/map/actionOpenMap.mjs';
+import actionLogIn from '../../actions/login/actionLogIn.mjs';
+import actionFinish from '../../actions/actionFinish.mjs';
+import actionOpenContextMenu from '../../actions/map/actionOpenContextMenu.mjs';
+import { assert, clickBy, sendKeysBy, waitBy, waitByRemoved } from '../../lib.mjs';
+import { By } from 'selenium-webdriver';
+import { getFiles } from '../../util.mjs';
+import actionOpenFavorites from '../../actions/favorites/actionOpenFavorites.mjs';
+import actionDeleteAllFavorites from '../../actions/favorites/actionDeleteAllFavorites.mjs';
+import actionDeleteFavGroup from '../../actions/favorites/actionDeleteFavGroup.mjs';
+import actionsUploadFavorites from '../../actions/favorites/actionsUploadFavorites.mjs';
+import actionIdleWait from '../../actions/actionIdleWait.mjs';
+
+export default async function test() {
+ await actionOpenMap();
+ await actionLogIn();
+
+ const favorites = getFiles({ folder: 'favorites' });
+ const shopGroupName = 'shops';
+ const wptName = 'Test wpt';
+ const wptName2 = 'Michael Kors';
+
+ const { path: shopsPath } = favorites.find((t) => t.name === 'favorites-shops');
+
+ // prepare
+ await actionOpenFavorites();
+ await actionDeleteAllFavorites(favorites);
+ await clickBy(By.id('se-import-fav-group'));
+ await actionsUploadFavorites({ files: shopsPath });
+ await waitBy(By.id(`se-menu-fav-${shopGroupName}`));
+
+ // --- Test 1: Edit → change name → tracks menu → keep editing → back button → exit → edit closes ---
+ await clickBy(By.id(`se-menu-fav-${shopGroupName}`));
+ await waitByRemoved(By.id(`se-menu-fav-${shopGroupName}`));
+ await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`));
+ await waitBy(By.id(`se-actions-${wptName}`), { idle: true });
+ await clickBy(By.id(`se-actions-${wptName}`));
+ await waitBy(By.id('se-fav-item-actions'));
+ await clickBy(By.id('se-edit-fav-item'));
+ await waitBy(By.id('se-edit-fav-dialog'));
+ await sendKeysBy(By.id('se-fav-name-input'), ' edited');
+ // click tracks menu → navigates to tracks URL → guard intercepts → dialog appears
+ await clickBy(By.id('se-show-menu-tracks'));
+ await waitBy(By.id('se-exit-dialog-keep-editing'));
+ await clickBy(By.id('se-exit-dialog-keep-editing'));
+ // edit panel is still mounted (changes not saved)
+ await waitBy(By.id('se-edit-fav-dialog'));
+ // click back button in the edit panel → guard fires → exit → panel closes
+ await clickBy(By.id('se-back-edit-wpt-panel'));
+ await waitBy(By.id('se-exit-dialog-exit'));
+ await clickBy(By.id('se-exit-dialog-exit'));
+ await waitByRemoved(By.id('se-edit-fav-dialog'));
+ // navigate back to favorites root
+ await actionOpenFavorites();
+ await clickBy(By.id('se-back-folder-button-favorites'));
+ await waitBy(By.id(`se-menu-fav-${shopGroupName}`));
+ // --- Test 2: Add from map → type name → open context menu again → add favorite → guard fires → dialog ---
+ await actionOpenContextMenu();
+ await clickBy(By.id('se-add-favorite-action'));
+ await waitBy(By.id('se-add-fav-dialog'));
+ await sendKeysBy(By.id('se-fav-name-input'), 'My New Favorite');
+ await actionOpenContextMenu();
+ await clickBy(By.id('se-add-favorite-action'));
+ await waitBy(By.id('se-exit-dialog-exit'));
+ await clickBy(By.id('se-exit-dialog-exit'));
+ await clickBy(By.id('se-close-add-wpt-panel'));
+ await waitBy(By.id(`se-menu-fav-${shopGroupName}`));
+ // --- Test 3: Edit → no changes → add favorite from context menu → NO dialog ---
+ await clickBy(By.id(`se-menu-fav-${shopGroupName}`));
+ await waitByRemoved(By.id(`se-menu-fav-${shopGroupName}`));
+ await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`));
+ await clickBy(By.id(`se-fav-item-name-${wptName}`));
+ await waitBy(By.id(`se-fav-item-info-${wptName}`));
+ await clickBy(By.id('se-edit-fav-item'));
+ await waitBy(By.id('se-edit-fav-dialog'));
+ await actionOpenContextMenu();
+ await clickBy(By.id('se-add-favorite-action'));
+ const unexpectedDialog = await waitBy(By.id('se-exit-dialog-exit'), { optional: true, idle: true });
+ await assert(!unexpectedDialog, 'No exit dialog should appear when there are no changes');
+ await waitByRemoved(By.id('se-edit-fav-dialog'));
+ await waitBy(By.id('se-add-fav-dialog'));
+ // close the add panel and go back to favorites root
+ await clickBy(By.id('se-close-add-wpt-panel'));
+ await clickBy(By.id('se-back-wpt-details'));
+ // --- Test 4: Click map marker → edit → change name → click another map marker → guard → exit → wptName2 opens ---
+ await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`));
+ // pan map to favorites: click wptName in list → map scrolls to it → go back to list
+ await clickBy(By.id(`se-fav-item-name-${wptName}`));
+ await waitBy(By.id(`se-fav-item-info-${wptName}`));
+ await clickBy(By.id('se-back-wpt-details'));
+ await waitByRemoved(By.id(`se-fav-item-info-${wptName}`));
+ await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`));
+ // now markers are in viewport — click wptName map marker → WptDetails in right panel, list stays on left
+ await waitBy(By.id(`se-fav-map-marker-${wptName}`), { idle: true });
+ await clickBy(By.id(`se-fav-map-marker-${wptName}`));
+ await waitBy(By.id(`se-fav-item-info-${wptName}`));
+ await clickBy(By.id('se-edit-fav-item'));
+ await waitBy(By.id('se-edit-fav-dialog'));
+ await sendKeysBy(By.id('se-fav-name-input'), ' changed');
+ // click wptName2 map marker → guard fires, edit panel stays open
+ await clickBy(By.id(`se-fav-map-marker-${wptName2}`));
+
+ // temp fix for tests (closing dialog after pan to marker)
+ await actionIdleWait({ idle: 3000 });
+ await actionIdleWait({ idle: 3000 });
+ await actionIdleWait({ idle: 3000 });
+ await clickBy(By.id(`se-fav-map-marker-${wptName2}`));
+
+ await clickBy(By.id('se-exit-dialog-exit'));
+ await waitByRemoved(By.id('se-edit-fav-dialog'));
+ // after exit wptName2 details open
+ await waitBy(By.id(`se-fav-item-info-${wptName2}`));
+ // clean up
+ await clickBy(By.id('se-close-wpt-details'));
+ await clickBy(By.id('se-back-folder-button-favorites'));
+ await waitBy(By.id(`se-menu-fav-${shopGroupName}`), { idle: true });
+ await actionDeleteFavGroup(shopGroupName);
+ await waitBy(By.id('se-empty-page'));
+
+ await actionFinish();
+}