refactor: replace generic LeafletMap with dedicated LocationPicker

This commit is contained in:
Johnny 2026-01-02 09:36:30 +08:00
parent f66c750075
commit 02f39c2a59
11 changed files with 119 additions and 131 deletions

View File

@ -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 (

View File

@ -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">

View File

@ -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;

View File

@ -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";

View File

@ -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 };
}

View File

@ -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">

View File

@ -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 >
);
};

View File

@ -0,0 +1,3 @@
export { default as LocationPicker } from "./LocationPicker";
export { createMarkerIcon, defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
export { useReverseGeocoding } from "./useReverseGeocoding";

View File

@ -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();

View File

@ -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,
});
};

View File

@ -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>