@@ -42,12 +42,18 @@ import {
4242} from "react" ;
4343import {
4444 isTauri ,
45+ loadDroppedPhotoFiles ,
46+ loadDroppedPhotoPaths ,
4547 loadDroppedRasterFiles ,
4648 loadDroppedRasterPaths ,
4749 loadDroppedVectorFiles ,
4850 loadDroppedVectorPaths ,
4951 type DroppedRaster ,
5052} from "../../lib/tauri-io" ;
53+ import {
54+ isPhotoDropFileName ,
55+ type GeotaggedPhotoResult ,
56+ } from "../../lib/geotagged-photos" ;
5157import type { LargeVectorDataset } from "../../lib/duckdb-vector-guard" ;
5258import i18n from "../../i18n" ;
5359import {
@@ -63,6 +69,7 @@ import {
6369 createAppAPI ,
6470 getPluginManager ,
6571 useExternalPluginsReady ,
72+ useSwipeSplitViewExclusivity ,
6673} from "../../hooks/usePlugins" ;
6774import { registerMbtilesProtocol } from "../../lib/mbtiles" ;
6875import { hasReverseGeocodeConsent } from "../../lib/reverse-geocode-consent" ;
@@ -81,6 +88,7 @@ import { CollaborateDialog } from "./CollaborateDialog";
8188import { useCollaboration } from "../../hooks/useCollaboration" ;
8289import { MapModeBanner } from "./MapModeBanner" ;
8390import { PixelTimeSeriesControl } from "./PixelTimeSeriesControl" ;
91+ import { MapContextMenu } from "./MapContextMenu" ;
8492import { MapGrid } from "./MapGrid" ;
8593import { RemoteCursorsOverlay } from "./RemoteCursorsOverlay" ;
8694import { useCommandBridge } from "../../hooks/useCommandBridge" ;
@@ -529,6 +537,9 @@ export function DesktopShell({
529537 const [ diagnosticsOpen , setDiagnosticsOpen ] = useState ( false ) ;
530538 const diagnostics = useDiagnosticsSnapshot ( ) ;
531539 const externalPluginsReady = useExternalPluginsReady ( mapControllerRef ) ;
540+ // Keep Layer Swipe and split view mutually exclusive (#844): entering a
541+ // multi-pane grid turns the swipe slider off.
542+ useSwipeSplitViewExclusivity ( mapControllerRef ) ;
532543 // Live-collaboration session. Owned here (rather than in TopToolbar) so both
533544 // the Collaborate dialog and the on-canvas status badge share one socket, and
534545 // so the dialog stays mounted in toolbar-hidden layouts.
@@ -903,6 +914,32 @@ export function DesktopShell({
903914 [ addGeoJsonLayer ] ,
904915 ) ;
905916
917+ const addDroppedPhotos = useCallback (
918+ ( result : GeotaggedPhotoResult | null ) : number => {
919+ if ( ! result || result . located === 0 ) return 0 ;
920+ const layerId = addGeoJsonLayer (
921+ t ( "addData.photos.defaultName" ) ,
922+ result . featureCollection ,
923+ ) ;
924+ const layer = useAppStore
925+ . getState ( )
926+ . layers . find ( ( existing ) => existing . id === layerId ) ;
927+ if ( layer ) mapControllerRef . current ?. fitLayer ( layer ) ;
928+ // Report skipped (no-GPS) photos too, mirroring the Add Data dialog's
929+ // summary, so a partially-skipped drop isn't silent.
930+ const summary = t ( "addData.photos.addedSummary" , {
931+ count : result . located ,
932+ } ) ;
933+ const skippedNote =
934+ result . skipped > 0
935+ ? ` ${ t ( "addData.photos.skippedNote" , { count : result . skipped } ) } `
936+ : "" ;
937+ setDropMessage ( summary + skippedNote ) ;
938+ return result . located ;
939+ } ,
940+ [ addGeoJsonLayer , t ] ,
941+ ) ;
942+
906943 const addDroppedRasters = useCallback (
907944 async ( rasters : DroppedRaster [ ] ) : Promise < number > => {
908945 if ( ! rasters . length ) return 0 ;
@@ -1054,19 +1091,37 @@ export function DesktopShell({
10541091 }
10551092 }
10561093
1057- if ( otherPaths . length > 0 ) {
1094+ // Geotagged photos become their own point layer; TIFF stays on the
1095+ // raster path. Handle them before the vector/raster pipeline so a
1096+ // dropped .jpg isn't routed to the DuckDB vector loader.
1097+ const photoResult = await loadDroppedPhotoPaths ( otherPaths ) ;
1098+ const photoCount = addDroppedPhotos ( photoResult ) ;
1099+ // Surface a clear message when every dropped photo lacked GPS, so
1100+ // the drop doesn't complete silently.
1101+ if ( photoResult && photoCount === 0 && photoResult . total > 0 ) {
1102+ setDropError (
1103+ t ( "addData.photos.errorNoGps" , { count : photoResult . total } ) ,
1104+ ) ;
1105+ }
1106+ const restPaths = otherPaths . filter (
1107+ ( path ) => ! isPhotoDropFileName ( path ) ,
1108+ ) ;
1109+
1110+ if ( restPaths . length > 0 ) {
10581111 const rasterCount = await addDroppedRasters (
1059- await loadDroppedRasterPaths ( otherPaths ) ,
1112+ await loadDroppedRasterPaths ( restPaths ) ,
10601113 ) ;
1061- const importedLayers = await loadDroppedVectorPaths ( otherPaths , {
1114+ const importedLayers = await loadDroppedVectorPaths ( restPaths , {
10621115 onLargeDataset : confirmLargeVectorDataset ,
10631116 } ) ;
10641117 // See the browser handler: skip finishDrop's empty-input error
1065- // when PBF files were present (even if rejected/failed).
1118+ // when PBF or photo files were present (even if rejected/failed).
1119+ // See the browser handler: suppress the empty-input error when
1120+ // photos were present so it can't clobber the GPS error above.
10661121 if (
10671122 importedLayers . length > 0 ||
10681123 rasterCount > 0 ||
1069- pbfPaths . length === 0
1124+ ( pbfPaths . length === 0 && photoResult === null )
10701125 ) {
10711126 finishDrop ( importedLayers , rasterCount ) ;
10721127 }
@@ -1098,7 +1153,11 @@ export function DesktopShell({
10981153 disposed = true ;
10991154 unlisten ?.( ) ;
11001155 } ;
1101- } , [ clearDropMessageLater , finishDrop , addDroppedRasters , addGeoJsonLayer ] ) ;
1156+ } , [ clearDropMessageLater ,
1157+ finishDrop ,
1158+ addDroppedRasters ,
1159+ addDroppedPhotos ,
1160+ addGeoJsonLayer ] ) ;
11021161
11031162 const handleDragEnter = useCallback ( ( event : DragEvent < HTMLDivElement > ) => {
11041163 if ( ! hasDroppedFiles ( event ) ) return ;
@@ -1185,22 +1244,42 @@ export function DesktopShell({
11851244 ) ;
11861245 }
11871246
1188- if ( otherFiles . length > 0 ) {
1247+ // Geotagged photos (JPEG/PNG/WebP/HEIC) become a single point layer of
1248+ // their own; TIFF is left to the raster path. Handle them before the
1249+ // vector/raster pipeline so a .jpg isn't sent to the DuckDB vector
1250+ // loader (which would fail).
1251+ const photoResult = await loadDroppedPhotoFiles ( otherFiles ) ;
1252+ const photoCount = addDroppedPhotos ( photoResult ) ;
1253+ // Surface a clear message when every dropped photo lacked GPS, so the
1254+ // drop doesn't complete silently.
1255+ if ( photoResult && photoCount === 0 && photoResult . total > 0 ) {
1256+ setDropError (
1257+ t ( "addData.photos.errorNoGps" , { count : photoResult . total } ) ,
1258+ ) ;
1259+ }
1260+ const restFiles = otherFiles . filter (
1261+ ( file ) => ! isPhotoDropFileName ( file . name ) ,
1262+ ) ;
1263+
1264+ if ( restFiles . length > 0 ) {
11891265 const rasterCount = await addDroppedRasters (
1190- loadDroppedRasterFiles ( otherFiles ) ,
1266+ loadDroppedRasterFiles ( restFiles ) ,
11911267 ) ;
1192- const importedLayers = await loadDroppedVectorFiles ( otherFiles , {
1268+ const importedLayers = await loadDroppedVectorFiles ( restFiles , {
11931269 onLargeDataset : confirmLargeVectorDataset ,
11941270 } ) ;
11951271 // Call finishDrop (which reports success or throws the empty-input
11961272 // error) only when the other files produced something, or when the
1197- // drop contained no PBF files at all. If PBF files were present —
1273+ // drop contained no PBF/photo files at all. If those were present —
11981274 // even if they were all rejected or failed — its empty-input error
1199- // would wrongly clobber the PBF outcome.
1275+ // would wrongly clobber their outcome.
1276+ // Suppress finishDrop's empty-input error whenever photos were
1277+ // present (photoResult !== null) — even if all lacked GPS — so its
1278+ // generic message can't clobber the specific GPS error set above.
12001279 if (
12011280 importedLayers . length > 0 ||
12021281 rasterCount > 0 ||
1203- pbfFiles . length === 0
1282+ ( pbfFiles . length === 0 && photoResult === null )
12041283 ) {
12051284 finishDrop ( importedLayers , rasterCount ) ;
12061285 }
@@ -1214,7 +1293,11 @@ export function DesktopShell({
12141293 clearDropMessageLater ( ) ;
12151294 }
12161295 } ,
1217- [ clearDropMessageLater , finishDrop , addDroppedRasters , addGeoJsonLayer ] ,
1296+ [ clearDropMessageLater ,
1297+ finishDrop ,
1298+ addDroppedRasters ,
1299+ addDroppedPhotos ,
1300+ addGeoJsonLayer ] ,
12181301 ) ;
12191302
12201303 const startLayerPanelResize = useCallback (
@@ -1565,6 +1648,10 @@ export function DesktopShell({
15651648 onControllerReady = { handleMapControllerReady }
15661649 />
15671650 < RemoteCursorsOverlay mapControllerRef = { mapControllerRef } />
1651+ < MapContextMenu
1652+ mapControllerRef = { mapControllerRef }
1653+ mapReadyGeneration = { mapReadyGeneration }
1654+ />
15681655 < BoundsRestrictionIndicator />
15691656 { /* Isolate the collaboration badge in its own boundary: it renders
15701657 over the map, so a fault here must never take down the map
0 commit comments