mirror of https://github.com/usememos/memos.git
refactor: replace generic LeafletMap with dedicated LocationPicker
This commit is contained in:
parent
f66c750075
commit
02f39c2a59
|
|
@ -1,7 +1,9 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { useReverseGeocoding } from "@/components/map";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -17,8 +19,7 @@ import {
|
|||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { LinkMemoDialog, LocationDialog } from "../components";
|
||||
import { GEOCODING } from "../constants";
|
||||
import { useAbortController, useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { InsertMenuProps } from "../types";
|
||||
|
||||
|
|
@ -30,9 +31,6 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
|
||||
|
||||
// Abort controller for canceling geocoding requests
|
||||
const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController();
|
||||
|
||||
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
|
||||
150,
|
||||
setMoreSubmenuOpen,
|
||||
|
|
@ -54,6 +52,24 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
|
||||
const location = useLocation(props.location);
|
||||
|
||||
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setDebouncedPosition(location.state.position);
|
||||
},
|
||||
1000,
|
||||
[location.state.position],
|
||||
);
|
||||
|
||||
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName) {
|
||||
location.setPlaceholder(displayName);
|
||||
}
|
||||
}, [displayName]);
|
||||
|
||||
const isUploading = selectingFlag || props.isUploading;
|
||||
|
||||
const handleLocationClick = () => {
|
||||
|
|
@ -81,56 +97,12 @@ const InsertMenu = (props: InsertMenuProps) => {
|
|||
};
|
||||
|
||||
const handleLocationCancel = () => {
|
||||
abortGeocoding();
|
||||
location.reset();
|
||||
setLocationDialogOpen(false);
|
||||
};
|
||||
|
||||
const fetchReverseGeocode = async (position: LatLng, signal: AbortSignal): Promise<string> => {
|
||||
const coordString = `${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`;
|
||||
try {
|
||||
const url = `${GEOCODING.endpoint}?lat=${position.lat}&lon=${position.lng}&format=${GEOCODING.format}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": GEOCODING.userAgent,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data?.display_name || coordString;
|
||||
} catch (error) {
|
||||
// Silently return coordinates for abort errors
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw error; // Re-throw to handle in caller
|
||||
}
|
||||
console.error("Failed to fetch reverse geocoding data:", error);
|
||||
return coordString;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionChange = (position: LatLng) => {
|
||||
location.handlePositionChange(position);
|
||||
|
||||
// Abort previous and create new signal for this request
|
||||
const signal = createGeocodingSignal();
|
||||
|
||||
fetchReverseGeocode(position, signal)
|
||||
.then((displayName) => {
|
||||
location.setPlaceholder(displayName);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Ignore abort errors (user canceled the request)
|
||||
if (error.name !== "AbortError") {
|
||||
// Set coordinate fallback for other errors
|
||||
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import LeafletMap from "@/components/LeafletMap";
|
||||
import { LocationPicker } from "@/components/map";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -36,7 +36,7 @@ export const LocationDialog = ({
|
|||
</VisuallyHidden>
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full h-64 overflow-hidden rounded-t-md bg-muted/30">
|
||||
<LeafletMap latlng={position} onChange={onPositionChange} />
|
||||
<LocationPicker latlng={position} onChange={onPositionChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col p-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
|
|
|||
|
|
@ -17,9 +17,3 @@ export const EDITOR_HEIGHT = {
|
|||
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
|
||||
normal: "max-h-[50vh]",
|
||||
} as const;
|
||||
|
||||
export const GEOCODING = {
|
||||
endpoint: "https://nominatim.openstreetmap.org/reverse",
|
||||
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",
|
||||
format: "json",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// Custom hooks for MemoEditor (internal use only)
|
||||
export { useAbortController } from "./useAbortController";
|
||||
export { useAutoSave } from "./useAutoSave";
|
||||
export { useBlobUrls } from "./useBlobUrls";
|
||||
export { useDragAndDrop } from "./useDragAndDrop";
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const abort = () => {
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
};
|
||||
|
||||
const abortAndCreate = (): AbortSignal => {
|
||||
abort();
|
||||
controllerRef.current = new AbortController();
|
||||
return controllerRef.current.signal;
|
||||
};
|
||||
|
||||
return { abort, abortAndCreate };
|
||||
}
|
||||
|
|
@ -4,30 +4,21 @@ import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
|||
import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||
import { MapContainer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||
import { Link } from "react-router-dom";
|
||||
import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils";
|
||||
import Spinner from "@/components/Spinner";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { resolveTheme } from "@/utils/theme";
|
||||
|
||||
interface Props {
|
||||
creator: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const markerIcon = new DivIcon({
|
||||
className: "relative border-none",
|
||||
html: ReactDOMServer.renderToString(
|
||||
<MapPinIcon className="absolute bottom-1/2 -left-1/2 text-red-500 drop-shadow-md" fill="currentColor" size={32} />,
|
||||
),
|
||||
});
|
||||
|
||||
interface ClusterGroup {
|
||||
getChildCount(): number;
|
||||
}
|
||||
|
|
@ -62,9 +53,7 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
|
|||
};
|
||||
|
||||
const UserMemoMap = ({ creator, className }: Props) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const creatorId = useMemo(() => extractUserIdFromName(creator), [creator]);
|
||||
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
|
||||
|
||||
const { data, isLoading } = useInfiniteMemos({
|
||||
state: State.NORMAL,
|
||||
|
|
@ -97,11 +86,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
|
|||
)}
|
||||
|
||||
<MapContainer center={defaultCenter} zoom={2} className="h-full w-full z-0" scrollWheelZoom attributionControl={false}>
|
||||
<TileLayer
|
||||
url={
|
||||
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
}
|
||||
/>
|
||||
<ThemedTileLayer />
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
|
|
@ -110,7 +95,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
|
|||
showCoverageOnHover={false}
|
||||
>
|
||||
{memosWithLocation.map((memo) => (
|
||||
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={markerIcon}>
|
||||
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={defaultMarkerIcon}>
|
||||
<Popup closeButton={false} className="w-48!">
|
||||
<div className="flex flex-col p-0.5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-1 mb-1">
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import L, { DivIcon, LatLng } from "leaflet";
|
||||
import { ExternalLinkIcon, MapPinIcon, MinusIcon, PlusIcon } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import L, { LatLng } from "leaflet";
|
||||
import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveTheme } from "@/utils/theme";
|
||||
|
||||
const markerIcon = new DivIcon({
|
||||
className: "relative border-none",
|
||||
html: ReactDOMServer.renderToString(<MapPinIcon className="absolute bottom-1/2 -left-1/2" fill="pink" size={24} />),
|
||||
});
|
||||
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
|
||||
|
||||
interface MarkerProps {
|
||||
position: LatLng | undefined;
|
||||
|
|
@ -34,7 +27,7 @@ const LocationMarker = (props: MarkerProps) => {
|
|||
// Call the parent onChange function.
|
||||
props.onChange(e.latlng);
|
||||
},
|
||||
locationfound() {},
|
||||
locationfound() { },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,7 +47,7 @@ const LocationMarker = (props: MarkerProps) => {
|
|||
}
|
||||
}, [props.position, map]);
|
||||
|
||||
return position === undefined ? null : <Marker position={position} icon={markerIcon}></Marker>;
|
||||
return position === undefined ? null : <Marker position={position} icon={defaultMarkerIcon}></Marker>;
|
||||
};
|
||||
|
||||
// Reusable glass-style button component
|
||||
|
|
@ -228,28 +221,15 @@ interface MapProps {
|
|||
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
|
||||
|
||||
const LeafletMap = (props: MapProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
|
||||
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
className="w-full h-72"
|
||||
center={position}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url={
|
||||
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
}
|
||||
/>
|
||||
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
|
||||
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false} zoomControl={false} attributionControl={false}>
|
||||
<ThemedTileLayer />
|
||||
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => { }} />
|
||||
<MapControls position={props.latlng} />
|
||||
<MapCleanup />
|
||||
</MapContainer>
|
||||
</MapContainer >
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as LocationPicker } from "./LocationPicker";
|
||||
export { createMarkerIcon, defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
|
||||
export { useReverseGeocoding } from "./useReverseGeocoding";
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { DivIcon } from "leaflet";
|
||||
import { MapPinIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { TileLayer } from "react-leaflet";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { resolveTheme } from "@/utils/theme";
|
||||
|
||||
const TILE_URLS = {
|
||||
light: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
} as const;
|
||||
|
||||
export const ThemedTileLayer = () => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
|
||||
return <TileLayer url={isDark ? TILE_URLS.dark : TILE_URLS.light} />;
|
||||
};
|
||||
|
||||
interface MarkerIconOptions {
|
||||
fill?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => {
|
||||
const { fill = "orange", size = 28, className = "" } = options || {};
|
||||
return new DivIcon({
|
||||
className: "relative border-none",
|
||||
html: ReactDOMServer.renderToString(
|
||||
<MapPinIcon className={`absolute bottom-1/2 -left-1/2 ${className}`.trim()} fill={fill} size={size} />,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const defaultMarkerIcon = createMarkerIcon();
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
const GEOCODING = {
|
||||
endpoint: "https://nominatim.openstreetmap.org/reverse",
|
||||
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",
|
||||
format: "json",
|
||||
} as const;
|
||||
|
||||
export const useReverseGeocoding = (lat: number | undefined, lng: number | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ["geocoding", lat, lng],
|
||||
queryFn: async () => {
|
||||
const coordString = `${lat?.toFixed(6)}, ${lng?.toFixed(6)}`;
|
||||
if (lat === undefined || lng === undefined) return "";
|
||||
|
||||
try {
|
||||
const url = `${GEOCODING.endpoint}?lat=${lat}&lon=${lng}&format=${GEOCODING.format}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": GEOCODING.userAgent,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data?.display_name as string) || coordString;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reverse geocoding data:", error);
|
||||
return coordString;
|
||||
}
|
||||
},
|
||||
enabled: lat !== undefined && lng !== undefined,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { MapPinIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { LocationPicker } from "@/components/map";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import LeafletMap from "../LeafletMap";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
interface LocationDisplayProps {
|
||||
|
|
@ -42,7 +42,7 @@ const LocationDisplay = ({ location, className }: LocationDisplayProps) => {
|
|||
</PopoverTrigger>
|
||||
<PopoverContent align="start">
|
||||
<div className="min-w-80 sm:w-lg flex flex-col justify-start items-start">
|
||||
<LeafletMap latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
|
||||
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
Loading…
Reference in New Issue