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 => { 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 ( <> {t("common.upload")} setLinkDialogOpen(true)}> {t("tooltip.link-memo")} {t("tooltip.select-location")} {/* View submenu with Focus Mode */} {t("common.more")} {t("editor.focus-mode")} ⌘⇧F {/* Hidden file input */} ); }); export default InsertMenu;