Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
02f39c2a
Commit
02f39c2a
authored
Jan 02, 2026
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: replace generic LeafletMap with dedicated LocationPicker
parent
f66c7500
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
119 additions
and
131 deletions
+119
-131
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+22
-50
LocationDialog.tsx
web/src/components/MemoEditor/components/LocationDialog.tsx
+2
-2
constants.ts
web/src/components/MemoEditor/constants.ts
+0
-6
index.ts
web/src/components/MemoEditor/hooks/index.ts
+0
-1
useAbortController.ts
web/src/components/MemoEditor/hooks/useAbortController.ts
+0
-20
UserMemoMap.tsx
web/src/components/UserMemoMap/UserMemoMap.tsx
+4
-19
LocationPicker.tsx
web/src/components/map/LocationPicker.tsx
+11
-31
index.ts
web/src/components/map/index.ts
+3
-0
map-utils.tsx
web/src/components/map/map-utils.tsx
+36
-0
useReverseGeocoding.ts
web/src/components/map/useReverseGeocoding.ts
+39
-0
LocationDisplay.tsx
web/src/components/memo-metadata/LocationDisplay.tsx
+2
-2
No files found.
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
02f39c2a
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
(
...
...
web/src/components/MemoEditor/components/LocationDialog.tsx
View file @
02f39c2a
import
LeafletMap
from
"@/components/LeafletM
ap"
;
import
{
LocationPicker
}
from
"@/components/m
ap"
;
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"
>
<
L
eafletMap
latlng=
{
position
}
onChange=
{
onPositionChange
}
/>
<
L
ocationPicker
latlng=
{
position
}
onChange=
{
onPositionChange
}
/>
</
div
>
<
div
className=
"w-full flex flex-col p-3 gap-3"
>
<
div
className=
"grid grid-cols-2 gap-3"
>
...
...
web/src/components/MemoEditor/constants.ts
View file @
02f39c2a
...
...
@@ -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
;
web/src/components/MemoEditor/hooks/index.ts
View file @
02f39c2a
// Custom hooks for MemoEditor (internal use only)
export
{
useAbortController
}
from
"./useAbortController"
;
export
{
useAutoSave
}
from
"./useAutoSave"
;
export
{
useBlobUrls
}
from
"./useBlobUrls"
;
export
{
useDragAndDrop
}
from
"./useDragAndDrop"
;
...
...
web/src/components/MemoEditor/hooks/useAbortController.ts
deleted
100644 → 0
View file @
f66c7500
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
};
}
web/src/components/UserMemoMap/UserMemoMap.tsx
View file @
02f39c2a
...
...
@@ -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=
{
m
arkerIcon
}
>
<
Marker
key=
{
memo
.
name
}
position=
{
[
memo
.
location
!
.
latitude
,
memo
.
location
!
.
longitude
]
}
icon=
{
defaultM
arkerIcon
}
>
<
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"
>
...
...
web/src/components/
LeafletMap
.tsx
→
web/src/components/
map/LocationPicker
.tsx
View file @
02f39c2a
import
L
,
{
DivIcon
,
LatLng
}
from
"leaflet"
;
import
{
ExternalLinkIcon
,
M
apPinIcon
,
M
inusIcon
,
PlusIcon
}
from
"lucide-react"
;
import
{
type
ReactNode
,
useEffect
,
use
Memo
,
use
Ref
,
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=
{
m
arkerIcon
}
></
Marker
>;
return
position
===
undefined
?
null
:
<
Marker
position=
{
position
}
icon=
{
defaultM
arkerIcon
}
></
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
>
);
};
...
...
web/src/components/map/index.ts
0 → 100644
View file @
02f39c2a
export
{
default
as
LocationPicker
}
from
"./LocationPicker"
;
export
{
createMarkerIcon
,
defaultMarkerIcon
,
ThemedTileLayer
}
from
"./map-utils"
;
export
{
useReverseGeocoding
}
from
"./useReverseGeocoding"
;
web/src/components/map/map-utils.tsx
0 → 100644
View file @
02f39c2a
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
();
web/src/components/map/useReverseGeocoding.ts
0 → 100644
View file @
02f39c2a
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,
});
};
web/src/components/memo-metadata/LocationDisplay.tsx
View file @
02f39c2a
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"
>
<
L
eafletMap
latlng=
{
new
LatLng
(
location
.
latitude
,
location
.
longitude
)
}
readonly=
{
true
}
/>
<
L
ocationPicker
latlng=
{
new
LatLng
(
location
.
latitude
,
location
.
longitude
)
}
readonly=
{
true
}
/>
</
div
>
</
PopoverContent
>
</
Popover
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment