mirror of https://github.com/usememos/memos.git
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
import { LatLng } from "leaflet";
|
|
import { uniqBy } from "lodash-es";
|
|
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
|
import { observer } from "mobx-react-lite";
|
|
import { useContext, useState } from "react";
|
|
import type { LocalFile } from "@/components/memo-metadata";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
|
import { useTranslate } from "@/utils/i18n";
|
|
import { LinkMemoDialog, LocationDialog } from "../components";
|
|
import { GEOCODING } from "../constants";
|
|
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
|
import { useAbortController } from "../hooks/useAbortController";
|
|
import { MemoEditorContext } from "../types";
|
|
|
|
interface Props {
|
|
isUploading?: boolean;
|
|
location?: Location;
|
|
onLocationChange: (location?: Location) => void;
|
|
onToggleFocusMode?: () => void;
|
|
}
|
|
|
|
const InsertMenu = observer((props: Props) => {
|
|
const t = useTranslate();
|
|
const context = useContext(MemoEditorContext);
|
|
|
|
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
|
|
|
// Abort controller for canceling geocoding requests
|
|
const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController();
|
|
|
|
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
|
|
if (context.addLocalFiles) {
|
|
context.addLocalFiles(newFiles);
|
|
}
|
|
});
|
|
|
|
const linkMemo = useLinkMemo({
|
|
isOpen: linkDialogOpen,
|
|
currentMemoName: context.memoName,
|
|
existingRelations: context.relationList,
|
|
onAddRelation: (relation: MemoRelation) => {
|
|
context.setRelationList(uniqBy([...context.relationList, relation], (r) => r.relatedMemo?.name));
|
|
setLinkDialogOpen(false);
|
|
},
|
|
});
|
|
|
|
const location = useLocation(props.location);
|
|
|
|
const isUploading = selectingFlag || props.isUploading;
|
|
|
|
const handleLocationClick = () => {
|
|
setLocationDialogOpen(true);
|
|
if (!props.location && !location.locationInitialized) {
|
|
if (navigator.geolocation) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
|
|
},
|
|
(error) => {
|
|
console.error("Geolocation error:", error);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLocationConfirm = () => {
|
|
const newLocation = location.getLocation();
|
|
if (newLocation) {
|
|
props.onLocationChange(newLocation);
|
|
setLocationDialogOpen(false);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
|
|
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem onClick={handleUploadClick}>
|
|
<FileIcon className="w-4 h-4" />
|
|
{t("common.upload")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setLinkDialogOpen(true)}>
|
|
<LinkIcon className="w-4 h-4" />
|
|
{t("tooltip.link-memo")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleLocationClick}>
|
|
<MapPinIcon className="w-4 h-4" />
|
|
{t("tooltip.select-location")}
|
|
</DropdownMenuItem>
|
|
{/* View submenu with Focus Mode */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<MoreHorizontalIcon className="w-4 h-4" />
|
|
{t("common.more")}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem onClick={props.onToggleFocusMode}>
|
|
<Maximize2Icon className="w-4 h-4" />
|
|
{t("editor.focus-mode")}
|
|
<span className="ml-auto text-xs text-muted-foreground opacity-60">⌘⇧F</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Hidden file input */}
|
|
<input
|
|
className="hidden"
|
|
ref={fileInputRef}
|
|
disabled={isUploading}
|
|
onChange={handleFileInputChange}
|
|
type="file"
|
|
multiple={true}
|
|
accept="*"
|
|
/>
|
|
|
|
<LinkMemoDialog
|
|
open={linkDialogOpen}
|
|
onOpenChange={setLinkDialogOpen}
|
|
searchText={linkMemo.searchText}
|
|
onSearchChange={linkMemo.setSearchText}
|
|
filteredMemos={linkMemo.filteredMemos}
|
|
isFetching={linkMemo.isFetching}
|
|
onSelectMemo={linkMemo.addMemoRelation}
|
|
/>
|
|
|
|
<LocationDialog
|
|
open={locationDialogOpen}
|
|
onOpenChange={setLocationDialogOpen}
|
|
state={location.state}
|
|
locationInitialized={location.locationInitialized}
|
|
onPositionChange={handlePositionChange}
|
|
onLatChange={location.handleLatChange}
|
|
onLngChange={location.handleLngChange}
|
|
onPlaceholderChange={location.setPlaceholder}
|
|
onCancel={handleLocationCancel}
|
|
onConfirm={handleLocationConfirm}
|
|
/>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export default InsertMenu;
|