{
+ const resetDrag = () => {
+ dragCounterRef.current = 0;
+ ctxRef.current.setGpxFileDrag(GPX_FILE_DRAG_IDLE);
+ };
+
+ const onDragEnter = (e) => {
+ if (!hasFiles(e) || !ltx.isProAccount()) {
+ return;
+ }
+ e.preventDefault();
+ dragCounterRef.current += 1;
+ };
+
+ const onDragOver = (e) => {
+ if (!hasFiles(e) || !ltx.isProAccount()) {
+ return;
+ }
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+
+ const currentCtx = ctxRef.current;
+ const { hoverFolder, overMap } = resolveGpxDropTarget(e.clientX, e.clientY, currentCtx);
+ currentCtx.setGpxFileDrag({ active: true, hoverFolder, overMap });
+ };
+
+ const onDragLeave = (e) => {
+ if (!hasFiles(e) || !ltx.isProAccount()) {
+ return;
+ }
+ dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
+ if (dragCounterRef.current === 0) {
+ resetDrag();
+ }
+ };
+
+ const onDrop = (e) => {
+ if (!hasFiles(e) || !ltx.isProAccount()) {
+ return;
+ }
+ e.preventDefault();
+
+ const currentCtx = ctxRef.current;
+ const files = Array.from(e.dataTransfer?.files || []);
+ const { hoverFolder, overMap } = resolveGpxDropTarget(e.clientX, e.clientY, currentCtx);
+ if (hoverFolder !== null) {
+ importGpxFiles(files, hoverFolder);
+ } else if (overMap) {
+ importGpxFiles(files, IMPORT_FOLDER_NAME);
+ }
+ resetDrag();
+ };
+
+ const onDragEnd = () => {
+ resetDrag();
+ };
+
+ window.addEventListener('dragenter', onDragEnter);
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('dragleave', onDragLeave);
+ window.addEventListener('drop', onDrop);
+ window.addEventListener('dragend', onDragEnd);
+ return () => {
+ window.removeEventListener('dragenter', onDragEnter);
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('dragleave', onDragLeave);
+ window.removeEventListener('drop', onDrop);
+ window.removeEventListener('dragend', onDragEnd);
+ };
+ }, [importGpxFiles, ltx.loginUser, ltx.accountInfo?.account]);
+
+ return createPortal(
+ <>
+
+
+ >,
+ document.body
+ );
+}
diff --git a/map/src/frame/TracksMapDropGeometry.js b/map/src/frame/TracksMapDropGeometry.js
new file mode 100644
index 000000000..87f8585f5
--- /dev/null
+++ b/map/src/frame/TracksMapDropGeometry.js
@@ -0,0 +1,159 @@
+import { HEADER_SIZE, MAIN_MENU_MIN_SIZE, MAIN_MENU_OPEN_SIZE } from '../manager/GlobalManager';
+
+export const OVERLAY_MARGIN = 16;
+
+export function getVisibleMapInsets(ctx) {
+ const infoBlockWidthPx = Number.parseInt(String(ctx.infoBlockWidth), 10) || 0;
+ const bottomPx = ctx.globalGraph?.show ? ctx.globalGraph.size : 0;
+
+ const leftChromePx =
+ infoBlockWidthPx > 0
+ ? MAIN_MENU_MIN_SIZE + infoBlockWidthPx
+ : ctx.openMainMenu
+ ? MAIN_MENU_OPEN_SIZE
+ : MAIN_MENU_MIN_SIZE;
+
+ return {
+ top: HEADER_SIZE + OVERLAY_MARGIN,
+ left: leftChromePx + OVERLAY_MARGIN,
+ right: OVERLAY_MARGIN,
+ bottom: bottomPx + OVERLAY_MARGIN,
+ };
+}
+
+export function isPointInVisibleMap(ctx, clientX, clientY) {
+ const insets = getVisibleMapInsets(ctx);
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+
+ return (
+ clientX >= insets.left &&
+ clientX <= width - insets.right &&
+ clientY >= insets.top &&
+ clientY <= height - insets.bottom
+ );
+}
+
+export function getMenuDropContainers() {
+ return [
+ document.getElementById('se-tracks-folder'),
+ document.getElementById('se-track-menu'),
+ ].filter((el) => el?.hasAttribute('data-cloud-track-folder'));
+}
+
+export function getMenuOverlayContainer(hoverFolder) {
+ if (hoverFolder === null) {
+ return null;
+ }
+
+ const openFolder = document.getElementById('se-tracks-folder');
+ if (openFolder?.hasAttribute('data-cloud-track-folder')) {
+ const openFolderPath = openFolder.getAttribute('data-cloud-track-folder') ?? '';
+ if (hoverFolder === openFolderPath) {
+ return openFolder;
+ }
+ }
+
+ if (hoverFolder === '') {
+ return document.getElementById('se-track-menu');
+ }
+
+ return null;
+}
+
+export function getMenuDropOverlayTop(container) {
+ const folders = container.querySelectorAll('[id^="se-menu-cloud-"]');
+ if (folders.length > 0) {
+ return folders[folders.length - 1].getBoundingClientRect().bottom;
+ }
+
+ const appBar = container.querySelector('.MuiAppBar-root');
+ if (appBar) {
+ return appBar.getBoundingClientRect().bottom;
+ }
+
+ const listAnchors = container.querySelectorAll('#se-visible-tracks-menu, [id^="se-shared-folder-"]');
+ if (listAnchors.length > 0) {
+ let bottom = 0;
+ listAnchors.forEach((el) => {
+ bottom = Math.max(bottom, el.getBoundingClientRect().bottom);
+ });
+ return bottom;
+ }
+
+ return container.getBoundingClientRect().top;
+}
+
+export function getMenuDropOverlayRect(container, ctx) {
+ if (!container) {
+ return null;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const bottomPx = (ctx.globalGraph?.show ? ctx.globalGraph.size : 0) + OVERLAY_MARGIN;
+ const topPx = getMenuDropOverlayTop(container) + OVERLAY_MARGIN;
+
+ if (topPx >= window.innerHeight - bottomPx) {
+ return null;
+ }
+
+ return {
+ top: topPx,
+ left: containerRect.left + OVERLAY_MARGIN,
+ right: window.innerWidth - containerRect.right + OVERLAY_MARGIN,
+ bottom: bottomPx,
+ };
+}
+
+function isPointInOverlayRect(clientX, clientY, rect) {
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+
+ return (
+ clientX >= rect.left &&
+ clientX <= width - rect.right &&
+ clientY >= rect.top &&
+ clientY <= height - rect.bottom
+ );
+}
+
+function getMenuDropFolderAtPoint(clientX, clientY, ctx) {
+ for (const container of getMenuDropContainers()) {
+ const rect = getMenuDropOverlayRect(container, ctx);
+ if (rect && isPointInOverlayRect(clientX, clientY, rect)) {
+ return container.getAttribute('data-cloud-track-folder') ?? '';
+ }
+ }
+
+ return null;
+}
+
+function getCloudTrackFolderFromElement(el) {
+ if (!el) {
+ return null;
+ }
+ const folderEl = el.closest('[data-cloud-track-folder]');
+ if (!folderEl) {
+ return null;
+ }
+
+ return folderEl.getAttribute('data-cloud-track-folder') ?? '';
+}
+
+export function resolveGpxDropTarget(clientX, clientY, ctx) {
+ const folderFromDom = getCloudTrackFolderFromElement(document.elementFromPoint(clientX, clientY));
+ if (folderFromDom !== null) {
+ return { hoverFolder: folderFromDom, overMap: false };
+ }
+
+ const folderFromRect = getMenuDropFolderAtPoint(clientX, clientY, ctx);
+ if (folderFromRect !== null) {
+ return { hoverFolder: folderFromRect, overMap: false };
+ }
+
+ if (isPointInVisibleMap(ctx, clientX, clientY)) {
+ return { hoverFolder: null, overMap: true };
+ }
+
+ return { hoverFolder: null, overMap: false };
+}
diff --git a/map/src/frame/TracksMapDropOverlay.jsx b/map/src/frame/TracksMapDropOverlay.jsx
new file mode 100644
index 000000000..5edb79037
--- /dev/null
+++ b/map/src/frame/TracksMapDropOverlay.jsx
@@ -0,0 +1,22 @@
+import { useContext, useMemo } from 'react';
+import AppContext from '../context/AppContext';
+import DropOverlay from './components/DropOverlay';
+import { getVisibleMapInsets } from './TracksMapDropGeometry';
+
+export default function TracksMapDropOverlay() {
+ const ctx = useContext(AppContext);
+ const active =
+ ctx.gpxFileDrag?.active && ctx.gpxFileDrag?.overMap && ctx.gpxFileDrag?.hoverFolder === null;
+ const insets = useMemo(() => getVisibleMapInsets(ctx), [
+ ctx.infoBlockWidth,
+ ctx.globalGraph?.show,
+ ctx.globalGraph?.size,
+ ctx.openMainMenu,
+ ]);
+
+ if (!active) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/map/src/frame/TracksMenuDropOverlay.jsx b/map/src/frame/TracksMenuDropOverlay.jsx
new file mode 100644
index 000000000..08886cfa2
--- /dev/null
+++ b/map/src/frame/TracksMenuDropOverlay.jsx
@@ -0,0 +1,50 @@
+import { useContext, useEffect, useState } from 'react';
+import AppContext from '../context/AppContext';
+import DropOverlay from './components/DropOverlay';
+import { getMenuDropOverlayRect, getMenuOverlayContainer } from './TracksMapDropGeometry';
+
+export default function TracksMenuDropOverlay() {
+ const ctx = useContext(AppContext);
+ const [overlayInsets, setOverlayInsets] = useState(null);
+
+ const hoverFolder = ctx.gpxFileDrag?.hoverFolder;
+ const active = ctx.gpxFileDrag?.active && !ctx.gpxFileDrag?.overMap && hoverFolder !== null;
+
+ useEffect(() => {
+ if (!active) {
+ setOverlayInsets(null);
+ return;
+ }
+
+ const update = () => {
+ const container = getMenuOverlayContainer(ctx.gpxFileDrag?.hoverFolder);
+ if (!container) {
+ setOverlayInsets(null);
+ return;
+ }
+ const rect = getMenuDropOverlayRect(container, ctx);
+ setOverlayInsets(rect);
+ };
+
+ update();
+ window.addEventListener('resize', update);
+ window.addEventListener('scroll', update, true);
+ return () => {
+ window.removeEventListener('resize', update);
+ window.removeEventListener('scroll', update, true);
+ };
+ }, [
+ active,
+ hoverFolder,
+ ctx.infoBlockWidth,
+ ctx.globalGraph?.show,
+ ctx.globalGraph?.size,
+ ctx.openMainMenu,
+ ]);
+
+ if (!active) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/map/src/frame/components/DropOverlay.jsx b/map/src/frame/components/DropOverlay.jsx
new file mode 100644
index 000000000..ee94ca7c0
--- /dev/null
+++ b/map/src/frame/components/DropOverlay.jsx
@@ -0,0 +1,25 @@
+import { Typography } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import styles from './dropOverlay.module.css';
+
+export default function DropOverlay({ insets }) {
+ const { t } = useTranslation();
+
+ if (!insets) {
+ return null;
+ }
+
+ return (
+
+ {t('web:drop_gpx_to_import')}
+
+ );
+}
diff --git a/map/src/frame/components/dropOverlay.module.css b/map/src/frame/components/dropOverlay.module.css
new file mode 100644
index 000000000..30fab27e6
--- /dev/null
+++ b/map/src/frame/components/dropOverlay.module.css
@@ -0,0 +1,22 @@
+.dropOverlay {
+ position: fixed;
+ z-index: 3001;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ border: 3px dashed var(--selected-color);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--selected-color) 30%, transparent);
+}
+
+.text {
+ padding: 9px 12px;
+ border-radius: 40px;
+ background: var(--selected-color);
+ color: #fff;
+ font-size: 16px !important;
+ font-weight: 400;
+ line-height: 120%;
+ letter-spacing: 0.01em;
+}
diff --git a/map/src/frame/util/CloudGpxUploader.jsx b/map/src/frame/util/CloudGpxUploader.jsx
index 989aa441d..4143a2fe5 100644
--- a/map/src/frame/util/CloudGpxUploader.jsx
+++ b/map/src/frame/util/CloudGpxUploader.jsx
@@ -1,69 +1,16 @@
-import React, { useContext, useEffect } from 'react';
-import AppContext from '../../context/AppContext';
-import { useMutator } from '../../util/Utils';
+import React, { useContext } from 'react';
import { styled } from '@mui/material/styles';
-import { removeFileExtension, createTrackFreeName, saveTrackToCloud } from '../../manager/track/SaveTrackManager';
import { FREE_ACCOUNT } from '../../manager/LoginManager';
+import { DEFAULT_GROUP_NAME } from '../../manager/track/TracksManager';
import LoginContext from '../../context/LoginContext';
-import { GPX_FILE_EXT, KMZ_FILE_EXT } from '../../manager/track/TracksManager';
+import useCloudGpxImport from '../../util/hooks/useCloudGpxImport';
-export default function CloudGpxUploader({ children, folder = null, style = null }) {
- const ctx = useContext(AppContext);
+export default function CloudGpxUploader({ children, folder = DEFAULT_GROUP_NAME, style = null }) {
const ltx = useContext(LoginContext);
+ const { importGpxFiles } = useCloudGpxImport();
- const [uploadedFiles, mutateUploadedFiles] = useMutator({});
-
- function validName(name) {
- return name !== '' && name.trim().length > 0;
- }
-
- useEffect(() => {
- for (const file in uploadedFiles) {
- let open = uploadedFiles[file].selected;
- let fileName = uploadedFiles[file].name;
- if (validName(fileName)) {
- fileName = removeFileExtension(fileName);
- fileName = createTrackFreeName(fileName, ctx.tracksGroups, folder);
- saveTrackToCloud({
- ctx,
- ltx,
- currentFolder: folder,
- fileName,
- type: 'GPX',
- uploadedFile: uploadedFiles[file],
- open,
- }).then();
- mutateUploadedFiles((o) => delete o[file]);
- break; // process 1 file per 1 render
- }
- }
- }, [uploadedFiles]);
-
- const fileSelected = async (e) => {
- const selected = e.target.files.length === 1;
- ctx.setTrackLoading(Array.from(e.target.files).map((track) => removeFileExtension(track.name) + GPX_FILE_EXT));
- Array.from(e.target.files).forEach((file) => {
- const reader = new FileReader();
- reader.addEventListener('load', async (e) => {
- const data = e.target.result;
- if (data) {
- mutateUploadedFiles(
- (o) => (o[file.name] = { file, selected, data: data, name: file.name, originalName: file.name })
- );
- } else {
- ctx.setTrackErrorMsg({
- title: 'Import error',
- msg: `Unable to import ${file.name}`,
- });
- ctx.setTrackLoading([...ctx.trackLoading.filter((n) => n !== file.name)]);
- }
- });
- if (file.name.toLowerCase().endsWith(KMZ_FILE_EXT)) {
- reader.readAsArrayBuffer(file);
- } else {
- reader.readAsText(file);
- }
- });
+ const fileSelected = (e) => {
+ importGpxFiles(e.target.files, folder);
};
const HiddenInput = styled('input')({ display: 'none' });
diff --git a/map/src/manager/track/SaveTrackManager.js b/map/src/manager/track/SaveTrackManager.js
index e62f2f05c..342f47ff3 100644
--- a/map/src/manager/track/SaveTrackManager.js
+++ b/map/src/manager/track/SaveTrackManager.js
@@ -441,6 +441,9 @@ async function downloadAfterUpload(ctx, file, showOnMap) {
});
newGpxFiles[file.name].analysis = TracksManager.prepareAnalysis(newGpxFiles[file.name].analysis);
newGpxFiles[file.name].showOnMap = showOnMap;
+ if (showOnMap) {
+ newGpxFiles[file.name].zoomToTrack = true;
+ }
ctx.setGpxFiles(newGpxFiles);
ctx.setSelectedGpxFile({ ...newGpxFiles[file.name] });
ctx.setProcessingSaveTrack(false);
diff --git a/map/src/manager/track/TracksManager.js b/map/src/manager/track/TracksManager.js
index c49b78f86..7028d5396 100644
--- a/map/src/manager/track/TracksManager.js
+++ b/map/src/manager/track/TracksManager.js
@@ -58,6 +58,7 @@ const AUTO_SRTM_MAX_POINTS = 10000; // don't overload Auto-SRTM API with huge OS
const AUTO_SRTM_MIN_BAD_POINTS_PERCENT = 10; // limit by % of no-elevation points (type=osmand might have up to 10%)
export const FIT_BOUNDS_MAX_ZOOM = 17;
export const DEFAULT_GROUP_NAME = '';
+export const IMPORT_FOLDER_NAME = 'Import';
export function fitBoundsOptions(mtx) {
return {
diff --git a/map/src/map/components/GpxMapDropOverlay.jsx b/map/src/map/components/GpxMapDropOverlay.jsx
deleted file mode 100644
index e130481a2..000000000
--- a/map/src/map/components/GpxMapDropOverlay.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Typography } from '@mui/material';
-import { useTranslation } from 'react-i18next';
-import styles from './gpxMapDropOverlay.module.css';
-
-export default function GpxMapDropOverlay({ active }) {
- const { t } = useTranslation();
-
- if (!active) {
- return null;
- }
-
- return (
-
- {t('web:drop_gpx_to_import')}
-
- );
-}
diff --git a/map/src/map/components/gpxMapDropOverlay.module.css b/map/src/map/components/gpxMapDropOverlay.module.css
deleted file mode 100644
index f01655988..000000000
--- a/map/src/map/components/gpxMapDropOverlay.module.css
+++ /dev/null
@@ -1,22 +0,0 @@
-.overlay {
- position: absolute;
- top: 61px;
- right: 0;
- bottom: 0;
- left: 67px;
- z-index: 1200;
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- border: 2px dashed #1976d2;
- background: rgba(25, 118, 210, 0.12);
-}
-
-.text {
- padding: 10px 16px;
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.65);
- color: #fff;
- font-size: 16px !important;
-}
diff --git a/map/src/map/layers/LocalClientTrackLayer.js b/map/src/map/layers/LocalClientTrackLayer.js
index e6decb5be..6f5494d6f 100644
--- a/map/src/map/layers/LocalClientTrackLayer.js
+++ b/map/src/map/layers/LocalClientTrackLayer.js
@@ -2,8 +2,6 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea
import AppContext, { isLocalTrack, OBJECT_TYPE_LOCAL_TRACK } from '../../context/AppContext';
import MapContext from '../../context/MapContext';
import { useMap } from 'react-leaflet';
-import useLocalGpxImport from '../../util/hooks/useLocalGpxImport';
-import GpxMapDropOverlay from '../components/GpxMapDropOverlay';
import L from 'leaflet';
import TrackLayerProvider, {
redrawWptsOnLayer,
@@ -48,7 +46,6 @@ export default function LocalClientTrackLayer() {
const ctx = useContext(AppContext);
const mtx = useContext(MapContext);
const map = useMap();
- const { importGpxFiles } = useLocalGpxImport();
const [registeredLayers, setRegisteredLayers] = useState({});
@@ -71,48 +68,8 @@ export default function LocalClientTrackLayer() {
const [prevZoom, setPrevZoom] = useState(null);
const [move, setMove] = useState(false);
- const [isDragOver, setIsDragOver] = useState(false);
- const dragCounterRef = useRef(0);
const pendingZoomRef = useRef(false);
- useEffect(() => {
- const container = map.getContainer();
- const hasFiles = (e) => e.dataTransfer?.types?.includes('Files');
-
- const onDragEnter = (e) => {
- e.preventDefault();
- if (!hasFiles(e)) return;
- dragCounterRef.current += 1;
- setIsDragOver(true);
- };
- const onDragOver = (e) => {
- e.preventDefault();
- if (hasFiles(e)) e.dataTransfer.dropEffect = 'copy';
- };
- const onDragLeave = () => {
- dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
- if (dragCounterRef.current === 0) setIsDragOver(false);
- };
- const onDrop = (e) => {
- e.preventDefault();
- const files = Array.from(e.dataTransfer?.files || []).filter((f) => f.name.toLowerCase().endsWith('.gpx'));
- dragCounterRef.current = 0;
- setIsDragOver(false);
- if (files.length > 0) importGpxFiles(files);
- };
-
- container.addEventListener('dragenter', onDragEnter);
- container.addEventListener('dragover', onDragOver);
- container.addEventListener('dragleave', onDragLeave);
- container.addEventListener('drop', onDrop);
- return () => {
- container.removeEventListener('dragenter', onDragEnter);
- container.removeEventListener('dragover', onDragOver);
- container.removeEventListener('dragleave', onDragLeave);
- container.removeEventListener('drop', onDrop);
- };
- }, [map, importGpxFiles]);
-
useZoomMoveMapHandlers(map, setZoom, setMove);
const getLocalWptLayers = useCallback(() => {
@@ -918,14 +875,11 @@ export default function LocalClientTrackLayer() {
}, [JSON.stringify(lastSegmentGeoProfile)]);
return (
- <>
-
- {openAddRoutingToTrackDialog && (
-
- )}
- >
+ openAddRoutingToTrackDialog && (
+
+ )
);
}
diff --git a/map/src/menu/trackfavmenu.module.css b/map/src/menu/trackfavmenu.module.css
index 857103a5c..403eeb39d 100644
--- a/map/src/menu/trackfavmenu.module.css
+++ b/map/src/menu/trackfavmenu.module.css
@@ -79,6 +79,15 @@
padding: 10px 16px !important;
gap: 24px !important;
}
+.groupDropTarget {
+ background: color-mix(in srgb, var(--selected-color) 30%, transparent) !important;
+ outline: 3px dashed var(--selected-color) !important;
+ outline-offset: -3px !important;
+ border-radius: 12px !important;
+}
+.folderDropTarget {
+ position: relative !important;
+}
.action {
padding: 10px 16px !important;
gap: 20px !important;
diff --git a/map/src/menu/tracks/CloudTrackGroup.jsx b/map/src/menu/tracks/CloudTrackGroup.jsx
index 54a33e5d2..872f3001e 100644
--- a/map/src/menu/tracks/CloudTrackGroup.jsx
+++ b/map/src/menu/tracks/CloudTrackGroup.jsx
@@ -65,12 +65,16 @@ export default function CloudTrackGroup({ index, group }) {
return `${fmt.monthShortDay(group.lastModifiedDate)}, ${t('shared_string_gpx_files').toLowerCase()} ${group.realSize}`;
};
+ const isDropTarget = group.type !== SMART_TYPE;
+ const isDropHover = isDropTarget && ctx.gpxFileDrag?.hoverFolder === group.fullName;
+
return (
<>