mirror of https://github.com/usememos/memos.git
feat(web): add glassmorphism map controls with Google Maps integration
- Add custom zoom controls with modern glassmorphism styling - Add "Open in Google Maps" button for location markers - Refactor to React component architecture with proper Leaflet integration - Create reusable GlassButton component for maintainability - Use React 18 createRoot for portal rendering - Replace imperative DOM manipulation with declarative React patterns - Add coordinate display to location metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d0c3908168
commit
d236ef1611
|
|
@ -1,8 +1,10 @@
|
|||
import { DivIcon, LatLng } from "leaflet";
|
||||
import { MapPinIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import L, { DivIcon, LatLng } from "leaflet";
|
||||
import { ExternalLinkIcon, MapPinIcon, 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 { cn } from "@/lib/utils";
|
||||
|
||||
const markerIcon = new DivIcon({
|
||||
className: "relative border-none",
|
||||
|
|
@ -54,6 +56,146 @@ const LocationMarker = (props: MarkerProps) => {
|
|||
return position === undefined ? null : <Marker position={position} icon={markerIcon}></Marker>;
|
||||
};
|
||||
|
||||
// Reusable glass-style button component
|
||||
interface GlassButtonProps {
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-lg",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
"bg-white/80 backdrop-blur-md border border-white/30 shadow-lg",
|
||||
"hover:bg-white/90 hover:scale-105 active:scale-95",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Container for all map control buttons
|
||||
interface ControlButtonsProps {
|
||||
position: LatLng | undefined;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onOpenGoogleMaps: () => void;
|
||||
}
|
||||
|
||||
const ControlButtons = ({ position, onZoomIn, onZoomOut, onOpenGoogleMaps }: ControlButtonsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{position && (
|
||||
<GlassButton
|
||||
icon={<ExternalLinkIcon size={16} className="text-foreground" />}
|
||||
onClick={onOpenGoogleMaps}
|
||||
ariaLabel="Open location in Google Maps"
|
||||
title="Open in Google Maps"
|
||||
/>
|
||||
)}
|
||||
<GlassButton icon={<PlusIcon size={16} className="text-foreground" />} onClick={onZoomIn} ariaLabel="Zoom in" title="Zoom in" />
|
||||
<GlassButton icon={<MinusIcon size={16} className="text-foreground" />} onClick={onZoomOut} ariaLabel="Zoom out" title="Zoom out" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Leaflet Control class
|
||||
class MapControlsContainer extends L.Control {
|
||||
private container: HTMLDivElement | null = null;
|
||||
|
||||
onAdd() {
|
||||
this.container = L.DomUtil.create("div", "");
|
||||
this.container.style.pointerEvents = "auto";
|
||||
|
||||
// Prevent map interactions when clicking controls
|
||||
L.DomEvent.disableClickPropagation(this.container);
|
||||
L.DomEvent.disableScrollPropagation(this.container);
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
getContainer() {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
interface MapControlsProps {
|
||||
position: LatLng | undefined;
|
||||
}
|
||||
|
||||
const MapControls = ({ position }: MapControlsProps) => {
|
||||
const map = useMap();
|
||||
const controlRef = useRef<MapControlsContainer | null>(null);
|
||||
const rootRef = useRef<ReturnType<typeof createRoot> | null>(null);
|
||||
|
||||
const handleOpenInGoogleMaps = () => {
|
||||
if (!position) return;
|
||||
const url = `https://www.google.com/maps?q=${position.lat},${position.lng}`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
map.zoomIn();
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
map.zoomOut();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Create custom Leaflet control
|
||||
const control = new MapControlsContainer({ position: "topright" });
|
||||
controlRef.current = control;
|
||||
control.addTo(map);
|
||||
|
||||
// Get container and render React component into it
|
||||
const container = control.getContainer();
|
||||
if (container) {
|
||||
rootRef.current = createRoot(container);
|
||||
rootRef.current.render(
|
||||
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: unmount React component and remove control
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
if (controlRef.current) {
|
||||
controlRef.current.remove();
|
||||
controlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Update rendered content when position changes
|
||||
useEffect(() => {
|
||||
if (rootRef.current) {
|
||||
rootRef.current.render(
|
||||
<ControlButtons position={position} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onOpenGoogleMaps={handleOpenInGoogleMaps} />,
|
||||
);
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapCleanup = () => {
|
||||
const map = useMap();
|
||||
|
||||
|
|
@ -86,9 +228,10 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
|
|||
const LeafletMap = (props: MapProps) => {
|
||||
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
|
||||
return (
|
||||
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false}>
|
||||
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false} zoomControl={false}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
|
||||
<MapControls position={props.latlng} />
|
||||
<MapCleanup />
|
||||
</MapContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
|
|||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-full flex flex-row gap-2",
|
||||
"w-auto max-w-full flex flex-row gap-2",
|
||||
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background hover:bg-accent text-secondary-foreground text-xs transition-colors",
|
||||
mode === "view" && "cursor-pointer",
|
||||
className,
|
||||
|
|
@ -36,6 +36,9 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
|
|||
<span className="shrink-0 text-muted-foreground">
|
||||
<MapPinIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="text-nowrap opacity-80">
|
||||
[{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°]
|
||||
</span>
|
||||
<span className="text-nowrap truncate">{displayText}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in New Issue