-
Notifications
You must be signed in to change notification settings - Fork 392
Improved drag and drop #1799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Improved drag and drop #1799
Changes from all commits
10148c4
69cb7e4
abf8488
182d33f
2092b79
507eeee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { useContext, useEffect, useRef } from 'react'; | ||
| import { createPortal } from 'react-dom'; | ||
| import AppContext from '../context/AppContext'; | ||
| import LoginContext from '../context/LoginContext'; | ||
| import { IMPORT_FOLDER_NAME } from '../manager/track/TracksManager'; | ||
| import useCloudGpxImport from '../util/hooks/useCloudGpxImport'; | ||
| import TracksMapDropOverlay from './TracksMapDropOverlay'; | ||
| import TracksMenuDropOverlay from './TracksMenuDropOverlay'; | ||
| import { resolveGpxDropTarget } from './TracksMapDropGeometry'; | ||
|
|
||
| const GPX_FILE_DRAG_IDLE = { active: false, hoverFolder: null, overMap: false }; | ||
|
|
||
| function hasFiles(e) { | ||
| return e.dataTransfer?.types?.includes('Files'); | ||
| } | ||
|
|
||
| export default function TracksFileDragController() { | ||
| const ctx = useContext(AppContext); | ||
| const ltx = useContext(LoginContext); | ||
| const { importGpxFiles } = useCloudGpxImport(); | ||
| const dragCounterRef = useRef(0); | ||
| const ctxRef = useRef(ctx); | ||
| ctxRef.current = ctx; | ||
|
Dima-1 marked this conversation as resolved.
|
||
|
|
||
| useEffect(() => { | ||
|
Dima-1 marked this conversation as resolved.
|
||
| 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 }); | ||
|
Dima-1 marked this conversation as resolved.
|
||
| }; | ||
|
Dima-1 marked this conversation as resolved.
|
||
|
|
||
| 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); | ||
|
Dima-1 marked this conversation as resolved.
|
||
| 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( | ||
| <> | ||
| <TracksMapDropOverlay /> | ||
| <TracksMenuDropOverlay /> | ||
| </>, | ||
| document.body | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Visible-map-area geometry already exists: MapStateLayer.calcVisibleBboxParamsPx / getVisibleBboxInfo and mtx.fitBoundsPadding. This reimplements the same left/top/bottom insets from the same constants, and the two already diverge (this uses MAIN_MENU_OPEN_SIZE on open menu while MapStateLayer always uses MAIN_MENU_MIN_SIZE). Reuse the existing source of truth instead of a parallel computation. |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Determining the hovered folder by scraping the DOM (elementFromPoint, getElementById, data-*, id-prefix selectors) duplicates state the React tree already owns. Set the hover target from onDragEnter/onDragOver handlers on the folder components into context instead of reading layout back out of the DOM. |
||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <DropOverlay insets={insets} />; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
openMainMenu was lifted from GlobalFrame local state into global AppContext solely so GpxMapDropGeometry can read it. But the canonical visible-area logic (MapStateLayer.calcVisibleBboxParamsPx) intentionally ignores menu-open state (the expanded menu is an overlay, not a layout shift), so the overlay shouldn't need it either. Drop the parallel geometry, reuse the existing visible-bounds, and keep openMainMenu local.