From 02f39c2a59d8ad80da7e32e3d372c15e1e398c70 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 2 Jan 2026 09:36:30 +0800 Subject: [PATCH] refactor: replace generic LeafletMap with dedicated LocationPicker --- .../MemoEditor/Toolbar/InsertMenu.tsx | 72 ++++++------------- .../MemoEditor/components/LocationDialog.tsx | 4 +- web/src/components/MemoEditor/constants.ts | 6 -- web/src/components/MemoEditor/hooks/index.ts | 1 - .../MemoEditor/hooks/useAbortController.ts | 20 ------ .../components/UserMemoMap/UserMemoMap.tsx | 23 ++---- .../LocationPicker.tsx} | 42 +++-------- web/src/components/map/index.ts | 3 + web/src/components/map/map-utils.tsx | 36 ++++++++++ web/src/components/map/useReverseGeocoding.ts | 39 ++++++++++ .../memo-metadata/LocationDisplay.tsx | 4 +- 11 files changed, 119 insertions(+), 131 deletions(-) delete mode 100644 web/src/components/MemoEditor/hooks/useAbortController.ts rename web/src/components/{LeafletMap.tsx => map/LocationPicker.tsx} (81%) create mode 100644 web/src/components/map/index.ts create mode 100644 web/src/components/map/map-utils.tsx create mode 100644 web/src/components/map/useReverseGeocoding.ts diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 4f9f8ecd1..f36aa5640 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -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(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 => { - 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 ( diff --git a/web/src/components/MemoEditor/components/LocationDialog.tsx b/web/src/components/MemoEditor/components/LocationDialog.tsx index 99f39d05c..33944ed88 100644 --- a/web/src/components/MemoEditor/components/LocationDialog.tsx +++ b/web/src/components/MemoEditor/components/LocationDialog.tsx @@ -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 = ({
- +
diff --git a/web/src/components/MemoEditor/constants.ts b/web/src/components/MemoEditor/constants.ts index 0ac56dd83..1a8ef8300 100644 --- a/web/src/components/MemoEditor/constants.ts +++ b/web/src/components/MemoEditor/constants.ts @@ -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; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index 269c39d14..abd93e71e 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -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"; diff --git a/web/src/components/MemoEditor/hooks/useAbortController.ts b/web/src/components/MemoEditor/hooks/useAbortController.ts deleted file mode 100644 index 4e0b1c886..000000000 --- a/web/src/components/MemoEditor/hooks/useAbortController.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect, useRef } from "react"; - -export function useAbortController() { - const controllerRef = useRef(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 }; -} diff --git a/web/src/components/UserMemoMap/UserMemoMap.tsx b/web/src/components/UserMemoMap/UserMemoMap.tsx index 117909b98..05f3a4d44 100644 --- a/web/src/components/UserMemoMap/UserMemoMap.tsx +++ b/web/src/components/UserMemoMap/UserMemoMap.tsx @@ -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( - , - ), -}); - 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) => { )} - + { showCoverageOnHover={false} > {memosWithLocation.map((memo) => ( - +
diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/map/LocationPicker.tsx similarity index 81% rename from web/src/components/LeafletMap.tsx rename to web/src/components/map/LocationPicker.tsx index 648b42ed5..f5005e4ff 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/map/LocationPicker.tsx @@ -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(), -}); +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 : ; + return position === undefined ? null : ; }; // 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 ( - - - {}} /> + + + { }} /> - + ); }; diff --git a/web/src/components/map/index.ts b/web/src/components/map/index.ts new file mode 100644 index 000000000..86e14dbde --- /dev/null +++ b/web/src/components/map/index.ts @@ -0,0 +1,3 @@ +export { default as LocationPicker } from "./LocationPicker"; +export { createMarkerIcon, defaultMarkerIcon, ThemedTileLayer } from "./map-utils"; +export { useReverseGeocoding } from "./useReverseGeocoding"; diff --git a/web/src/components/map/map-utils.tsx b/web/src/components/map/map-utils.tsx new file mode 100644 index 000000000..984cf2e36 --- /dev/null +++ b/web/src/components/map/map-utils.tsx @@ -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 ; +}; + +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( + , + ), + }); +}; + +export const defaultMarkerIcon = createMarkerIcon(); diff --git a/web/src/components/map/useReverseGeocoding.ts b/web/src/components/map/useReverseGeocoding.ts new file mode 100644 index 000000000..e74fb33c0 --- /dev/null +++ b/web/src/components/map/useReverseGeocoding.ts @@ -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, + }); +}; diff --git a/web/src/components/memo-metadata/LocationDisplay.tsx b/web/src/components/memo-metadata/LocationDisplay.tsx index 8ef48cbc8..242f18066 100644 --- a/web/src/components/memo-metadata/LocationDisplay.tsx +++ b/web/src/components/memo-metadata/LocationDisplay.tsx @@ -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) => {
- +