Skip to content

Commit 02f39c2

Browse files
committed
refactor: replace generic LeafletMap with dedicated LocationPicker
1 parent f66c750 commit 02f39c2

File tree

11 files changed

+119
-131
lines changed

11 files changed

+119
-131
lines changed

web/src/components/MemoEditor/Toolbar/InsertMenu.tsx

Lines changed: 22 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { LatLng } from "leaflet";
22
import { uniqBy } from "lodash-es";
33
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
4-
import { useState } from "react";
4+
import { useEffect, useState } from "react";
5+
import { useDebounce } from "react-use";
6+
import { useReverseGeocoding } from "@/components/map";
57
import type { LocalFile } from "@/components/memo-metadata";
68
import { Button } from "@/components/ui/button";
79
import {
@@ -17,8 +19,7 @@ import {
1719
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
1820
import { useTranslate } from "@/utils/i18n";
1921
import { LinkMemoDialog, LocationDialog } from "../components";
20-
import { GEOCODING } from "../constants";
21-
import { useAbortController, useFileUpload, useLinkMemo, useLocation } from "../hooks";
22+
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
2223
import { useEditorContext } from "../state";
2324
import type { InsertMenuProps } from "../types";
2425

@@ -30,9 +31,6 @@ const InsertMenu = (props: InsertMenuProps) => {
3031
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
3132
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
3233

33-
// Abort controller for canceling geocoding requests
34-
const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController();
35-
3634
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
3735
150,
3836
setMoreSubmenuOpen,
@@ -54,6 +52,24 @@ const InsertMenu = (props: InsertMenuProps) => {
5452

5553
const location = useLocation(props.location);
5654

55+
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
56+
57+
useDebounce(
58+
() => {
59+
setDebouncedPosition(location.state.position);
60+
},
61+
1000,
62+
[location.state.position],
63+
);
64+
65+
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
66+
67+
useEffect(() => {
68+
if (displayName) {
69+
location.setPlaceholder(displayName);
70+
}
71+
}, [displayName]);
72+
5773
const isUploading = selectingFlag || props.isUploading;
5874

5975
const handleLocationClick = () => {
@@ -81,56 +97,12 @@ const InsertMenu = (props: InsertMenuProps) => {
8197
};
8298

8399
const handleLocationCancel = () => {
84-
abortGeocoding();
85100
location.reset();
86101
setLocationDialogOpen(false);
87102
};
88103

89-
const fetchReverseGeocode = async (position: LatLng, signal: AbortSignal): Promise<string> => {
90-
const coordString = `${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`;
91-
try {
92-
const url = `${GEOCODING.endpoint}?lat=${position.lat}&lon=${position.lng}&format=${GEOCODING.format}`;
93-
const response = await fetch(url, {
94-
headers: {
95-
"User-Agent": GEOCODING.userAgent,
96-
Accept: "application/json",
97-
},
98-
signal,
99-
});
100-
101-
if (!response.ok) {
102-
throw new Error(`HTTP error! status: ${response.status}`);
103-
}
104-
105-
const data = await response.json();
106-
return data?.display_name || coordString;
107-
} catch (error) {
108-
// Silently return coordinates for abort errors
109-
if (error instanceof Error && error.name === "AbortError") {
110-
throw error; // Re-throw to handle in caller
111-
}
112-
console.error("Failed to fetch reverse geocoding data:", error);
113-
return coordString;
114-
}
115-
};
116-
117104
const handlePositionChange = (position: LatLng) => {
118105
location.handlePositionChange(position);
119-
120-
// Abort previous and create new signal for this request
121-
const signal = createGeocodingSignal();
122-
123-
fetchReverseGeocode(position, signal)
124-
.then((displayName) => {
125-
location.setPlaceholder(displayName);
126-
})
127-
.catch((error) => {
128-
// Ignore abort errors (user canceled the request)
129-
if (error.name !== "AbortError") {
130-
// Set coordinate fallback for other errors
131-
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
132-
}
133-
});
134106
};
135107

136108
return (

web/src/components/MemoEditor/components/LocationDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import LeafletMap from "@/components/LeafletMap";
1+
import { LocationPicker } from "@/components/map";
22
import { Button } from "@/components/ui/button";
33
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
44
import { Input } from "@/components/ui/input";
@@ -36,7 +36,7 @@ export const LocationDialog = ({
3636
</VisuallyHidden>
3737
<div className="flex flex-col">
3838
<div className="w-full h-64 overflow-hidden rounded-t-md bg-muted/30">
39-
<LeafletMap latlng={position} onChange={onPositionChange} />
39+
<LocationPicker latlng={position} onChange={onPositionChange} />
4040
</div>
4141
<div className="w-full flex flex-col p-3 gap-3">
4242
<div className="grid grid-cols-2 gap-3">

web/src/components/MemoEditor/constants.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,3 @@ export const EDITOR_HEIGHT = {
1717
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
1818
normal: "max-h-[50vh]",
1919
} as const;
20-
21-
export const GEOCODING = {
22-
endpoint: "https://nominatim.openstreetmap.org/reverse",
23-
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",
24-
format: "json",
25-
} as const;

web/src/components/MemoEditor/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// Custom hooks for MemoEditor (internal use only)
2-
export { useAbortController } from "./useAbortController";
32
export { useAutoSave } from "./useAutoSave";
43
export { useBlobUrls } from "./useBlobUrls";
54
export { useDragAndDrop } from "./useDragAndDrop";

web/src/components/MemoEditor/hooks/useAbortController.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

web/src/components/UserMemoMap/UserMemoMap.tsx

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,21 @@ import "leaflet.markercluster/dist/MarkerCluster.Default.css";
44
import "leaflet.markercluster/dist/MarkerCluster.css";
55
import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
66
import { useEffect, useMemo } from "react";
7-
import ReactDOMServer from "react-dom/server";
8-
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
7+
import { MapContainer, Marker, Popup, useMap } from "react-leaflet";
98
import MarkerClusterGroup from "react-leaflet-cluster";
109
import { Link } from "react-router-dom";
10+
import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils";
1111
import Spinner from "@/components/Spinner";
12-
import { useAuth } from "@/contexts/AuthContext";
1312
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
1413
import { cn } from "@/lib/utils";
1514
import { State } from "@/types/proto/api/v1/common_pb";
1615
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
17-
import { resolveTheme } from "@/utils/theme";
1816

1917
interface Props {
2018
creator: string;
2119
className?: string;
2220
}
2321

24-
const markerIcon = new DivIcon({
25-
className: "relative border-none",
26-
html: ReactDOMServer.renderToString(
27-
<MapPinIcon className="absolute bottom-1/2 -left-1/2 text-red-500 drop-shadow-md" fill="currentColor" size={32} />,
28-
),
29-
});
30-
3122
interface ClusterGroup {
3223
getChildCount(): number;
3324
}
@@ -62,9 +53,7 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
6253
};
6354

6455
const UserMemoMap = ({ creator, className }: Props) => {
65-
const { userGeneralSetting } = useAuth();
6656
const creatorId = useMemo(() => extractUserIdFromName(creator), [creator]);
67-
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
6857

6958
const { data, isLoading } = useInfiniteMemos({
7059
state: State.NORMAL,
@@ -97,11 +86,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
9786
)}
9887

9988
<MapContainer center={defaultCenter} zoom={2} className="h-full w-full z-0" scrollWheelZoom attributionControl={false}>
100-
<TileLayer
101-
url={
102-
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
103-
}
104-
/>
89+
<ThemedTileLayer />
10590
<MarkerClusterGroup
10691
chunkedLoading
10792
iconCreateFunction={createClusterCustomIcon}
@@ -110,7 +95,7 @@ const UserMemoMap = ({ creator, className }: Props) => {
11095
showCoverageOnHover={false}
11196
>
11297
{memosWithLocation.map((memo) => (
113-
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={markerIcon}>
98+
<Marker key={memo.name} position={[memo.location!.latitude, memo.location!.longitude]} icon={defaultMarkerIcon}>
11499
<Popup closeButton={false} className="w-48!">
115100
<div className="flex flex-col p-0.5">
116101
<div className="flex items-center justify-between border-b border-border pb-1 mb-1">
Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
1-
import L, { DivIcon, LatLng } from "leaflet";
2-
import { ExternalLinkIcon, MapPinIcon, MinusIcon, PlusIcon } from "lucide-react";
3-
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
1+
import L, { LatLng } from "leaflet";
2+
import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react";
3+
import { type ReactNode, useEffect, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
5-
import ReactDOMServer from "react-dom/server";
6-
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
7-
import { useAuth } from "@/contexts/AuthContext";
5+
import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
86
import { cn } from "@/lib/utils";
9-
import { resolveTheme } from "@/utils/theme";
10-
11-
const markerIcon = new DivIcon({
12-
className: "relative border-none",
13-
html: ReactDOMServer.renderToString(<MapPinIcon className="absolute bottom-1/2 -left-1/2" fill="pink" size={24} />),
14-
});
7+
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
158

169
interface MarkerProps {
1710
position: LatLng | undefined;
@@ -34,7 +27,7 @@ const LocationMarker = (props: MarkerProps) => {
3427
// Call the parent onChange function.
3528
props.onChange(e.latlng);
3629
},
37-
locationfound() {},
30+
locationfound() { },
3831
});
3932

4033
useEffect(() => {
@@ -54,7 +47,7 @@ const LocationMarker = (props: MarkerProps) => {
5447
}
5548
}, [props.position, map]);
5649

57-
return position === undefined ? null : <Marker position={position} icon={markerIcon}></Marker>;
50+
return position === undefined ? null : <Marker position={position} icon={defaultMarkerIcon}></Marker>;
5851
};
5952

6053
// Reusable glass-style button component
@@ -228,28 +221,15 @@ interface MapProps {
228221
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
229222

230223
const LeafletMap = (props: MapProps) => {
231-
const { userGeneralSetting } = useAuth();
232224
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
233-
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
234225

235226
return (
236-
<MapContainer
237-
className="w-full h-72"
238-
center={position}
239-
zoom={13}
240-
scrollWheelZoom={false}
241-
zoomControl={false}
242-
attributionControl={false}
243-
>
244-
<TileLayer
245-
url={
246-
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
247-
}
248-
/>
249-
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
227+
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false} zoomControl={false} attributionControl={false}>
228+
<ThemedTileLayer />
229+
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => { }} />
250230
<MapControls position={props.latlng} />
251231
<MapCleanup />
252-
</MapContainer>
232+
</MapContainer >
253233
);
254234
};
255235

web/src/components/map/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as LocationPicker } from "./LocationPicker";
2+
export { createMarkerIcon, defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
3+
export { useReverseGeocoding } from "./useReverseGeocoding";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { DivIcon } from "leaflet";
2+
import { MapPinIcon } from "lucide-react";
3+
import { useMemo } from "react";
4+
import ReactDOMServer from "react-dom/server";
5+
import { TileLayer } from "react-leaflet";
6+
import { useAuth } from "@/contexts/AuthContext";
7+
import { resolveTheme } from "@/utils/theme";
8+
9+
const TILE_URLS = {
10+
light: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
11+
dark: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
12+
} as const;
13+
14+
export const ThemedTileLayer = () => {
15+
const { userGeneralSetting } = useAuth();
16+
const isDark = useMemo(() => resolveTheme(userGeneralSetting?.theme || "system").includes("dark"), [userGeneralSetting?.theme]);
17+
return <TileLayer url={isDark ? TILE_URLS.dark : TILE_URLS.light} />;
18+
};
19+
20+
interface MarkerIconOptions {
21+
fill?: string;
22+
size?: number;
23+
className?: string;
24+
}
25+
26+
export const createMarkerIcon = (options?: MarkerIconOptions): DivIcon => {
27+
const { fill = "orange", size = 28, className = "" } = options || {};
28+
return new DivIcon({
29+
className: "relative border-none",
30+
html: ReactDOMServer.renderToString(
31+
<MapPinIcon className={`absolute bottom-1/2 -left-1/2 ${className}`.trim()} fill={fill} size={size} />,
32+
),
33+
});
34+
};
35+
36+
export const defaultMarkerIcon = createMarkerIcon();
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
const GEOCODING = {
4+
endpoint: "https://nominatim.openstreetmap.org/reverse",
5+
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",
6+
format: "json",
7+
} as const;
8+
9+
export const useReverseGeocoding = (lat: number | undefined, lng: number | undefined) => {
10+
return useQuery({
11+
queryKey: ["geocoding", lat, lng],
12+
queryFn: async () => {
13+
const coordString = `${lat?.toFixed(6)}, ${lng?.toFixed(6)}`;
14+
if (lat === undefined || lng === undefined) return "";
15+
16+
try {
17+
const url = `${GEOCODING.endpoint}?lat=${lat}&lon=${lng}&format=${GEOCODING.format}`;
18+
const response = await fetch(url, {
19+
headers: {
20+
"User-Agent": GEOCODING.userAgent,
21+
Accept: "application/json",
22+
},
23+
});
24+
25+
if (!response.ok) {
26+
throw new Error(`HTTP error! status: ${response.status}`);
27+
}
28+
29+
const data = await response.json();
30+
return (data?.display_name as string) || coordString;
31+
} catch (error) {
32+
console.error("Failed to fetch reverse geocoding data:", error);
33+
return coordString;
34+
}
35+
},
36+
enabled: lat !== undefined && lng !== undefined,
37+
staleTime: Infinity,
38+
});
39+
};

0 commit comments

Comments
 (0)