memos/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx

272 lines
9.3 KiB
TypeScript

import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import {
FileIcon,
LinkIcon,
LoaderIcon,
MapPinIcon,
Maximize2Icon,
MicIcon,
MoreHorizontalIcon,
PauseIcon,
PlayIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
import { LocationDialog } from "./InsertMenu/LocationDialog";
import { useAudioRecorder } from "./InsertMenu/useAudioRecorder";
import { useFileUpload } from "./InsertMenu/useFileUpload";
import { useLinkMemo } from "./InsertMenu/useLinkMemo";
import { useLocation } from "./InsertMenu/useLocation";
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);
const { fileInputRef, uploadingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((attachments: Attachment[]) => {
context.setAttachmentList([...context.attachmentList, ...attachments]);
});
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 audioRecorder = useAudioRecorder();
const isUploading = uploadingFlag || 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 = () => {
location.reset();
setLocationDialogOpen(false);
};
const handlePositionChange = (position: LatLng) => {
location.handlePositionChange(position);
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${position.lat}&lon=${position.lng}&format=json`, {
headers: {
"User-Agent": "Memos/1.0 (https://github.com/usememos/memos)",
Accept: "application/json",
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => {
if (data?.display_name) {
location.setPlaceholder(data.display_name);
} else {
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
}
})
.catch((error) => {
console.error("Failed to fetch reverse geocoding data:", error);
location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`);
});
};
const handleStopRecording = async () => {
try {
const blob = await audioRecorder.stopRecording();
const filename = `recording-${Date.now()}.webm`;
const file = new File([blob], filename, { type: "audio/webm" });
const { name, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename: name,
size,
type,
content: buffer,
}),
attachmentId: "",
});
context.setAttachmentList([...context.attachmentList, attachment]);
} catch (error: any) {
console.error("Failed to upload audio recording:", error);
toast.error(error.message || "Failed to upload audio recording");
}
};
return (
<>
{audioRecorder.isRecording ? (
<div className="flex flex-row items-center gap-2 mr-2">
<div className="flex flex-row items-center px-2 py-1 rounded-md bg-red-50 text-red-600 border border-red-200">
<div className={`w-2 h-2 rounded-full bg-red-500 mr-2 ${!audioRecorder.isPaused ? "animate-pulse" : ""}`} />
<span className="font-mono text-sm">{new Date(audioRecorder.recordingTime * 1000).toISOString().substring(14, 19)}</span>
</div>
<Button
variant="outline"
size="icon"
onClick={audioRecorder.togglePause}
className="shrink-0"
aria-label={audioRecorder.isPaused ? "Resume recording" : "Pause recording"}
>
{audioRecorder.isPaused ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleStopRecording}
className="shrink-0 text-red-600 hover:text-red-700"
aria-label="Stop and save recording"
>
<div className="w-3 h-3 bg-current rounded-sm" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={audioRecorder.cancelRecording}
className="shrink-0 text-red-600 hover:text-red-700"
aria-label="Cancel recording"
>
<XIcon className="w-4 h-4" />
</Button>
</div>
) : (
<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>
<DropdownMenuItem onClick={audioRecorder.startRecording}>
<MicIcon className="w-4 h-4" />
{t("tooltip.record-audio")}
</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}
getHighlightedContent={linkMemo.getHighlightedContent}
/>
<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;