refactor: redesign memo editor action bar UI

- Replace multiple action buttons with unified InsertMenu dropdown
- Consolidate upload, link memo, and location into single + button
- Redesign VisibilitySelector with text-based dropdown UI
- Unify badge styling for location, attachments, and links
  - Consistent height (h-7), padding, gaps, and border styles
  - Secondary foreground text color with hover states
  - Max width with truncation for long content
- Add image thumbnails in attachment badges
- Simplify button hierarchy with ghost variant for save/cancel
- Remove obsolete components (TagSelector, MarkdownMenu, etc.)
- Extract LocationView to separate component for better organization

Fixes #5196
This commit is contained in:
Steven 2025-10-31 21:29:58 +08:00
parent 1ced0bcdbd
commit 93964827ad
12 changed files with 519 additions and 773 deletions

View File

@ -1,154 +0,0 @@
import { uniqBy } from "lodash-es";
import { LinkIcon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractUserIdFromName } from "@/store/common";
import { Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";
const AddMemoRelationPopover = () => {
const t = useTranslate();
const context = useContext(MemoEditorContext);
const user = useCurrentUser();
const [searchText, setSearchText] = useState<string>("");
const [isFetching, setIsFetching] = useState<boolean>(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
const filteredMemos = fetchedMemos.filter(
(memo) => memo.name !== context.memoName && !context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
);
useDebounce(
async () => {
if (!popoverOpen) return;
setIsFetching(true);
try {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
if (searchText) {
conditions.push(`content.contains("${searchText}")`);
}
const { memos } = await memoServiceClient.listMemos({
filter: conditions.join(" && "),
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
setFetchedMemos(memos);
} catch (error: any) {
toast.error(error.details);
console.error(error);
}
setIsFetching(false);
},
300,
[popoverOpen, searchText],
);
const getHighlightedContent = (content: string) => {
const index = content.toLowerCase().indexOf(searchText.toLowerCase());
if (index === -1) {
return content;
}
let before = content.slice(0, index);
if (before.length > 20) {
before = "..." + before.slice(before.length - 20);
}
const highlighted = content.slice(index, index + searchText.length);
let after = content.slice(index + searchText.length);
if (after.length > 20) {
after = after.slice(0, 20) + "...";
}
return (
<>
{before}
<mark className="font-medium">{highlighted}</mark>
{after}
</>
);
};
const addMemoRelations = async (memo: Memo) => {
context.setRelationList(
uniqBy(
[
{
memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
type: MemoRelation_Type.REFERENCE,
},
...context.relationList,
].filter((relation) => relation.relatedMemo !== context.memoName),
"relatedMemo",
),
);
setPopoverOpen(false);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<LinkIcon className="size-5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("tooltip.link-memo")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="center">
<div className="w-[16rem] p-1 flex flex-col justify-start items-start">
{/* Search and selection interface */}
<div className="w-full">
<Input
placeholder={t("reference.search-placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="mb-2 !text-sm"
/>
<div className="max-h-[200px] overflow-y-auto">
{filteredMemos.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{isFetching ? "Loading..." : t("reference.no-memos-found")}
</div>
) : (
filteredMemos.map((memo) => (
<div
key={memo.name}
className="relative flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
addMemoRelations(memo);
}}
>
<div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-muted-foreground select-none">{memo.displayTime?.toLocaleString()}</p>
<p className="mt-0.5 text-sm leading-5 line-clamp-2">
{searchText ? getHighlightedContent(memo.content) : memo.snippet}
</p>
</div>
</div>
))
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default AddMemoRelationPopover;

View File

@ -0,0 +1,420 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { LinkIcon, LoaderIcon, MapPinIcon, PaperclipIcon, PlusIcon } from "lucide-react";
import mime from "mime";
import { observer } from "mobx-react-lite";
import { useContext, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce";
import LeafletMap from "@/components/LeafletMap";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { attachmentStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { Location, Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";
interface Props {
isUploading?: boolean;
location?: Location;
onLocationChange: (location?: Location) => void;
}
const InsertMenu = observer((props: Props) => {
const t = useTranslate();
const context = useContext(MemoEditorContext);
const user = useCurrentUser();
const fileInputRef = useRef<HTMLInputElement>(null);
// Upload state
const [uploadingFlag, setUploadingFlag] = useState(false);
// Link memo state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const [isFetching, setIsFetching] = useState(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
// Location state
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [locationInitialized, setLocationInitialized] = useState(false);
const [locationPlaceholder, setLocationPlaceholder] = useState(props.location?.placeholder || "");
const [locationPosition, setLocationPosition] = useState<LatLng | undefined>(
props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined,
);
const [latInput, setLatInput] = useState(props.location ? String(props.location.latitude) : "");
const [lngInput, setLngInput] = useState(props.location ? String(props.location.longitude) : "");
const isUploading = uploadingFlag || props.isUploading;
// File upload handler
const handleFileInputChange = async () => {
if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) {
return;
}
if (uploadingFlag) {
return;
}
setUploadingFlag(true);
const createdAttachmentList: Attachment[] = [];
try {
if (!fileInputRef.current || !fileInputRef.current.files) {
return;
}
for (const file of fileInputRef.current.files) {
const { name: filename, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename,
size,
type: type || mime.getType(filename) || "text/plain",
content: buffer,
}),
attachmentId: "",
});
createdAttachmentList.push(attachment);
}
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
setUploadingFlag(false);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
// Link memo handlers
const filteredMemos = fetchedMemos.filter(
(memo) => memo.name !== context.memoName && !context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
);
useDebounce(
async () => {
if (!linkDialogOpen) return;
setIsFetching(true);
try {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
if (searchText) {
conditions.push(`content.contains("${searchText}")`);
}
const { memos } = await memoServiceClient.listMemos({
filter: conditions.join(" && "),
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
setFetchedMemos(memos);
} catch (error: any) {
toast.error(error.details);
console.error(error);
}
setIsFetching(false);
},
300,
[linkDialogOpen, searchText],
);
const getHighlightedContent = (content: string) => {
const index = content.toLowerCase().indexOf(searchText.toLowerCase());
if (index === -1) {
return content;
}
let before = content.slice(0, index);
if (before.length > 20) {
before = "..." + before.slice(before.length - 20);
}
const highlighted = content.slice(index, index + searchText.length);
let after = content.slice(index + searchText.length);
if (after.length > 20) {
after = after.slice(0, 20) + "...";
}
return (
<>
{before}
<mark className="font-medium">{highlighted}</mark>
{after}
</>
);
};
const addMemoRelation = (memo: Memo) => {
context.setRelationList(
uniqBy(
[
{
memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
type: MemoRelation_Type.REFERENCE,
},
...context.relationList,
].filter((relation) => relation.relatedMemo !== context.memoName),
"relatedMemo",
),
);
setLinkDialogOpen(false);
setSearchText("");
};
const handleLinkMemoClick = () => {
setLinkDialogOpen(true);
};
// Location handlers
const handleLocationClick = () => {
setLocationDialogOpen(true);
if (!props.location && !locationInitialized) {
const handleError = (error: any) => {
setLocationInitialized(true);
console.error("Geolocation error:", error);
};
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
setLocationPosition(new LatLng(lat, lng));
setLatInput(String(lat));
setLngInput(String(lng));
setLocationInitialized(true);
},
(error) => {
handleError(error);
},
);
} else {
handleError("Geolocation is not supported by this browser.");
}
}
};
const handleLocationConfirm = () => {
if (locationPosition && locationPlaceholder.trim().length > 0) {
props.onLocationChange(
Location.fromPartial({
placeholder: locationPlaceholder,
latitude: locationPosition.lat,
longitude: locationPosition.lng,
}),
);
setLocationDialogOpen(false);
}
};
const handleLocationCancel = () => {
setLocationDialogOpen(false);
// Reset to current location
if (props.location) {
setLocationPlaceholder(props.location.placeholder);
setLocationPosition(new LatLng(props.location.latitude, props.location.longitude));
setLatInput(String(props.location.latitude));
setLngInput(String(props.location.longitude));
}
};
// Update position when lat/lng inputs change
const handleLatChange = (value: string) => {
setLatInput(value);
const lat = parseFloat(value);
const lng = parseFloat(lngInput);
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
setLocationPosition(new LatLng(lat, lng));
}
};
const handleLngChange = (value: string) => {
setLngInput(value);
const lat = parseFloat(latInput);
const lng = parseFloat(value);
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
setLocationPosition(new LatLng(lat, lng));
}
};
// Reverse geocoding when position changes
const handlePositionChange = (position: LatLng) => {
setLocationPosition(position);
setLatInput(String(position.lat));
setLngInput(String(position.lng));
const lat = position.lat;
const lng = position.lng;
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
.then((response) => response.json())
.then((data) => {
if (data && data.display_name) {
setLocationPlaceholder(data.display_name);
} else {
setLocationPlaceholder(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
}
})
.catch((error) => {
console.error("Failed to fetch reverse geocoding data:", error);
setLocationPlaceholder(`${lat.toFixed(6)}, ${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}>
<PaperclipIcon className="w-4 h-4" />
{t("common.upload")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLinkMemoClick}>
<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>
</DropdownMenuContent>
</DropdownMenu>
{/* Hidden file input */}
<input
className="hidden"
ref={fileInputRef}
disabled={isUploading}
onChange={handleFileInputChange}
type="file"
multiple={true}
accept="*"
/>
{/* Link memo dialog */}
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("tooltip.link-memo")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder={t("reference.search-placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="!text-sm"
/>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredMemos.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{isFetching ? "Loading..." : t("reference.no-memos-found")}
</div>
) : (
filteredMemos.map((memo) => (
<div
key={memo.name}
className="relative flex cursor-pointer items-start gap-2 border-b last:border-b-0 px-3 py-2 hover:bg-accent hover:text-accent-foreground"
onClick={() => addMemoRelation(memo)}
>
<div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-muted-foreground select-none">{memo.displayTime?.toLocaleString()}</p>
<p className="mt-0.5 text-sm leading-5 line-clamp-2">
{searchText ? getHighlightedContent(memo.content) : memo.snippet}
</p>
</div>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Location dialog */}
<Dialog open={locationDialogOpen} onOpenChange={setLocationDialogOpen}>
<DialogContent className="max-w-[min(28rem,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>{t("tooltip.select-location")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="w-full h-64 overflow-hidden rounded-md bg-muted/30">
<LeafletMap key={JSON.stringify(locationInitialized)} latlng={locationPosition} onChange={handlePositionChange} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1">
<Label htmlFor="memo-location-lat" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Lat
</Label>
<Input
id="memo-location-lat"
placeholder="Lat"
type="number"
step="any"
min="-90"
max="90"
value={latInput}
onChange={(e) => handleLatChange(e.target.value)}
className="h-9"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="memo-location-lng" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Lng
</Label>
<Input
id="memo-location-lng"
placeholder="Lng"
type="number"
step="any"
min="-180"
max="180"
value={lngInput}
onChange={(e) => handleLngChange(e.target.value)}
className="h-9"
/>
</div>
</div>
<div className="grid gap-1">
<Label htmlFor="memo-location-placeholder" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("tooltip.select-location")}
</Label>
<Textarea
id="memo-location-placeholder"
placeholder="Choose a position first."
value={locationPlaceholder}
disabled={!locationPosition}
onChange={(e) => setLocationPlaceholder(e.target.value)}
className="min-h-16"
/>
</div>
<div className="w-full flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={handleLocationCancel}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={handleLocationConfirm} disabled={!locationPosition || locationPlaceholder.trim().length === 0}>
{t("common.confirm")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
});
export default InsertMenu;

View File

@ -1,249 +0,0 @@
import { LatLng } from "leaflet";
import { MapPinIcon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import LeafletMap from "@/components/LeafletMap";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Location } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
location?: Location;
onChange: (location?: Location) => void;
}
interface State {
initialized: boolean;
placeholder: string;
position?: LatLng;
latInput: string;
lngInput: string;
}
const LocationSelector = (props: Props) => {
const t = useTranslate();
const [state, setState] = useState<State>({
initialized: false,
placeholder: props.location?.placeholder || "",
position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined,
latInput: props.location ? String(props.location.latitude) : "",
lngInput: props.location ? String(props.location.longitude) : "",
});
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
useEffect(() => {
setState((state) => ({
...state,
placeholder: props.location?.placeholder || "",
position: new LatLng(props.location?.latitude || 0, props.location?.longitude || 0),
latInput: String(props.location?.latitude) || "",
lngInput: String(props.location?.longitude) || "",
}));
}, [props.location]);
useEffect(() => {
if (popoverOpen && !props.location) {
const handleError = (error: any) => {
setState((prev) => ({ ...prev, initialized: true }));
console.error("Geolocation error:", error);
};
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
setState((prev) => ({
...prev,
position: new LatLng(lat, lng),
latInput: String(lat),
lngInput: String(lng),
initialized: true,
}));
},
(error) => {
handleError(error);
},
);
} else {
handleError("Geolocation is not supported by this browser.");
}
}
}, [popoverOpen, props.location]);
useEffect(() => {
if (!state.position) {
setState((prev) => ({ ...prev, placeholder: "" }));
return;
}
// Sync lat/lng input values from position
const newLat = String(state.position.lat);
const newLng = String(state.position.lng);
if (state.latInput !== newLat || state.lngInput !== newLng) {
setState((prev) => ({ ...prev, latInput: newLat, lngInput: newLng }));
}
// Fetch reverse geocoding data.
const lat = state.position.lat;
const lng = state.position.lng;
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
.then((response) => response.json())
.then((data) => {
if (data && data.display_name) {
setState((prev) => ({ ...prev, placeholder: data.display_name }));
} else {
// Fallback to coordinates if no display name
setState((prev) => ({
...prev,
placeholder: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
}));
}
})
.catch((error) => {
// Silent fallback: use coordinates as placeholder when geocoding fails
console.error("Failed to fetch reverse geocoding data:", error);
setState((prev) => ({
...prev,
placeholder: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
}));
});
}, [state.position]);
// Update position when lat/lng inputs change (if valid numbers)
useEffect(() => {
const lat = parseFloat(state.latInput);
const lng = parseFloat(state.lngInput);
// Validate coordinate ranges: lat must be -90 to 90, lng must be -180 to 180
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
if (!state.position || state.position.lat !== lat || state.position.lng !== lng) {
setState((prev) => ({ ...prev, position: new LatLng(lat, lng) }));
}
}
}, [state.latInput, state.lngInput]);
const onPositionChanged = (position: LatLng) => {
setState((prev) => ({ ...prev, position }));
};
const removeLocation = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
props.onChange(undefined);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size={props.location ? undefined : "icon"}>
<MapPinIcon className="size-5 shrink-0" />
{props.location && (
<>
<span className="ml-0.5 text-sm text-ellipsis whitespace-nowrap overflow-hidden max-w-28">
{props.location.placeholder}
</span>
<span className="ml-1 cursor-pointer hover:text-primary" onClick={removeLocation}>
<XIcon className="size-4 shrink-0" />
</span>
</>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
{!props.location && (
<TooltipContent side="bottom">
<p>{t("tooltip.select-location")}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<PopoverContent align="center" className="w-[min(24rem,calc(100vw-2rem))] p-0">
<div className="flex flex-col gap-2 p-0">
<div className="w-full overflow-hidden bg-muted/30">
<LeafletMap key={JSON.stringify(state.initialized)} latlng={state.position} onChange={onPositionChanged} />
</div>
<div className="w-full space-y-3 px-2 pb-2">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1">
<Label htmlFor="memo-location-lat" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Lat
</Label>
<Input
id="memo-location-lat"
placeholder="Lat"
type="number"
step="any"
min="-90"
max="90"
value={state.latInput}
onChange={(e) => setState((prev) => ({ ...prev, latInput: e.target.value }))}
className="h-9"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="memo-location-lng" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Lng
</Label>
<Input
id="memo-location-lng"
placeholder="Lng"
type="number"
step="any"
min="-180"
max="180"
value={state.lngInput}
onChange={(e) => setState((prev) => ({ ...prev, lngInput: e.target.value }))}
className="h-9"
/>
</div>
</div>
<div className="grid gap-1">
<Label htmlFor="memo-location-placeholder" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("tooltip.select-location")}
</Label>
<Textarea
id="memo-location-placeholder"
placeholder="Choose a position first."
value={state.placeholder}
disabled={!state.position}
onChange={(e) => setState((prev) => ({ ...prev, placeholder: e.target.value }))}
className="min-h-16"
/>
</div>
<div className="w-full flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setPopoverOpen(false)}>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={() => {
props.onChange(
Location.fromPartial({
placeholder: state.placeholder,
latitude: state.position?.lat,
longitude: state.position?.lng,
}),
);
setPopoverOpen(false);
}}
disabled={!state.position || state.placeholder.trim().length === 0}
>
{t("common.confirm")}
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default LocationSelector;

View File

@ -1,103 +0,0 @@
import { CheckSquareIcon, Code2Icon, SquareSlashIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
import { useTranslate } from "@/utils/i18n";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../ui/dropdown-menu";
import { EditorRefActions } from "../Editor";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const MarkdownMenu = (props: Props) => {
const { editorRef } = props;
const t = useTranslate();
const handleCodeBlockClick = () => {
if (!editorRef.current) {
return;
}
const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue === "" || prevValue.endsWith("\n")) {
editorRef.current.insertText("", "```\n", "\n```");
} else {
editorRef.current.insertText("", "\n```\n", "\n```");
}
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
};
const handleCheckboxClick = () => {
if (!editorRef.current) {
return;
}
const currentPosition = editorRef.current.getCursorPosition();
const currentLineNumber = editorRef.current.getCursorLineNumber();
const currentLine = editorRef.current.getLine(currentLineNumber);
let newLine = "";
let cursorChange = 0;
if (/^- \[( |x|X)\] /.test(currentLine)) {
newLine = currentLine.replace(/^- \[( |x|X)\] /, "");
cursorChange = -6;
} else if (/^\d+\. |- /.test(currentLine)) {
const match = currentLine.match(/^\d+\. |- /) ?? [""];
newLine = currentLine.replace(/^\d+\. |- /, "- [ ] ");
cursorChange = -match[0].length + 6;
} else {
newLine = "- [ ] " + currentLine;
cursorChange = 6;
}
editorRef.current.setLine(currentLineNumber, newLine);
editorRef.current.setCursorPosition(currentPosition + cursorChange);
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
};
return (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<SquareSlashIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("tooltip.markdown-menu")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleCodeBlockClick}>
<Code2Icon className="w-4 h-auto text-muted-foreground" />
{t("markdown.code-block")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCheckboxClick}>
<CheckSquareIcon className="w-4 h-auto text-muted-foreground" />
{t("markdown.checkbox")}
</DropdownMenuItem>
<div className="px-2 -mt-1">
<a
className="text-xs text-primary hover:underline"
href="https://www.usememos.com/docs/guides/content-syntax"
target="_blank"
rel="noopener noreferrer"
>
{t("markdown.content-syntax")}
</a>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default MarkdownMenu;

View File

@ -1,77 +0,0 @@
import { HashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import OverflowTip from "@/components/kit/OverflowTip";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
import { userStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import { EditorRefActions } from "../Editor";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const TagSelector = observer((props: Props) => {
const t = useTranslate();
const { editorRef } = props;
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag);
const handleTagClick = (tag: string) => {
const current = editorRef.current;
if (current === null) return;
const line = current.getLine(current.getCursorLineNumber());
const lastCharOfLine = line.slice(-1);
if (lastCharOfLine !== " " && lastCharOfLine !== " " && line !== "") {
current.insertText("\n");
}
current.insertText(`#${tag} `);
};
return (
<Popover>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<HashIcon className="size-5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("tooltip.tags")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="start" sideOffset={2}>
{tags.length > 0 ? (
<div className="flex flex-row justify-start items-start flex-wrap px-2 max-w-48 h-auto max-h-48 overflow-y-auto gap-x-2">
{tags.map((tag) => {
return (
<div
key={tag}
className="inline-flex w-auto max-w-full cursor-pointer text-base leading-6 text-muted-foreground hover:opacity-80"
onClick={() => handleTagClick(tag)}
>
<OverflowTip>#{tag}</OverflowTip>
</div>
);
})}
</div>
) : (
<p className="italic mx-2" onClick={(e) => e.stopPropagation()}>
{t("tag.no-tag-found")}
</p>
)}
</PopoverContent>
</Popover>
);
});
export default TagSelector;

View File

@ -1,104 +0,0 @@
import { t } from "i18next";
import { LoaderIcon, PaperclipIcon } from "lucide-react";
import mime from "mime";
import { observer } from "mobx-react-lite";
import { useContext, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { MemoEditorContext } from "../types";
interface Props {
isUploading?: boolean;
}
interface State {
uploadingFlag: boolean;
}
const UploadAttachmentButton = observer((props: Props) => {
const context = useContext(MemoEditorContext);
const [state, setState] = useState<State>({
uploadingFlag: false,
});
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async () => {
if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) {
return;
}
if (state.uploadingFlag) {
return;
}
setState((state) => {
return {
...state,
uploadingFlag: true,
};
});
const createdAttachmentList: Attachment[] = [];
try {
if (!fileInputRef.current || !fileInputRef.current.files) {
return;
}
for (const file of fileInputRef.current.files) {
const { name: filename, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename,
size,
type: type || mime.getType(filename) || "text/plain",
content: buffer,
}),
attachmentId: "",
});
createdAttachmentList.push(attachment);
}
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
setState((state) => {
return {
...state,
uploadingFlag: false,
};
});
};
const isUploading = state.uploadingFlag || props.isUploading;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className="relative" variant="ghost" size="icon" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-5 animate-spin" /> : <PaperclipIcon className="size-5" />}
<input
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
ref={fileInputRef}
disabled={isUploading}
onChange={handleFileInputChange}
type="file"
id="files"
multiple={true}
accept="*"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("tooltip.upload-attachment")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
export default UploadAttachmentButton;

View File

@ -1,11 +1,9 @@
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import VisibilityIcon from "@/components/VisibilityIcon"; import VisibilityIcon from "@/components/VisibilityIcon";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Visibility } from "@/types/proto/api/v1/memo_service"; import { Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
// ⬅️ ADD this line
interface Props { interface Props {
value: Visibility; value: Visibility;
onChange: (visibility: Visibility) => void; onChange: (visibility: Visibility) => void;
@ -22,35 +20,27 @@ const VisibilitySelector = (props: Props) => {
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") }, { value: Visibility.PUBLIC, label: t("memo.visibility.public") },
]; ];
const handleOpenChange = (open: boolean) => { const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
if (props.onOpenChange) {
props.onOpenChange(open);
}
};
return ( return (
<TooltipProvider> <DropdownMenu onOpenChange={props.onOpenChange}>
<Tooltip> <DropdownMenuTrigger asChild>
<TooltipTrigger asChild> <button className="inline-flex items-center gap-1.5 px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
<Select value={value.toString()} onValueChange={onChange} onOpenChange={handleOpenChange}> <VisibilityIcon visibility={value} className="opacity-60" />
<SelectTrigger size="xs" className="!bg-background"> <span>{currentLabel}</span>
<SelectValue /> <ChevronDownIcon className="w-3 h-3 opacity-60" />
</SelectTrigger> </button>
<SelectContent align="end"> </DropdownMenuTrigger>
{visibilityOptions.map((option) => ( <DropdownMenuContent align="end">
<SelectItem key={option.value} value={option.value.toString()}> {visibilityOptions.map((option) => (
<VisibilityIcon className="size-3.5" visibility={option.value} /> <DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
{option.label} <VisibilityIcon visibility={option.value} />
</SelectItem> <span className="flex-1">{option.label}</span>
))} {value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
</SelectContent> </DropdownMenuItem>
</Select> ))}
</TooltipTrigger> </DropdownMenuContent>
<TooltipContent side="bottom"> </DropdownMenu>
<p>{t("tooltip.select-visibility")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
}; };

View File

@ -1,8 +1,8 @@
import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"; import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { XIcon } from "lucide-react"; import { FileIcon, XIcon } from "lucide-react";
import { Attachment } from "@/types/proto/api/v1/attachment_service"; import { Attachment } from "@/types/proto/api/v1/attachment_service";
import AttachmentIcon from "../AttachmentIcon"; import { getAttachmentThumbnailUrl, getAttachmentType } from "@/utils/attachment";
import SortableItem from "./SortableItem"; import SortableItem from "./SortableItem";
interface Props { interface Props {
@ -38,14 +38,25 @@ const AttachmentListView = (props: Props) => {
return ( return (
<div <div
key={attachment.name} key={attachment.name}
className="max-w-full w-auto flex flex-row justify-start items-center flex-nowrap gap-x-1 bg-muted px-2 py-1 rounded hover:shadow-sm text-muted-foreground" className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
> >
<SortableItem id={attachment.name} className="flex flex-row justify-start items-center gap-x-1"> <SortableItem id={attachment.name} className="flex items-center gap-1.5 min-w-0">
<AttachmentIcon attachment={attachment} className="w-4! h-4! opacity-100!" /> {getAttachmentType(attachment) === "image/*" ? (
<span className="text-sm max-w-32 truncate">{attachment.filename}</span> <img
src={getAttachmentThumbnailUrl(attachment)}
alt={attachment.filename}
className="w-5 h-5 shrink-0 object-cover rounded"
/>
) : (
<FileIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate max-w-[160px]">{attachment.filename}</span>
</SortableItem> </SortableItem>
<button className="shrink-0" onClick={() => handleDeleteAttachment(attachment.name)}> <button
<XIcon className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-100" /> className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick={() => handleDeleteAttachment(attachment.name)}
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button> </button>
</div> </div>
); );

View File

@ -0,0 +1,34 @@
import { MapPinIcon, XIcon } from "lucide-react";
import { Location } from "@/types/proto/api/v1/memo_service";
interface Props {
location?: Location;
onRemove: () => void;
}
const LocationView = (props: Props) => {
if (!props.location) {
return null;
}
return (
<div className="w-full flex flex-row flex-wrap gap-2 mt-2">
<div className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent">
<MapPinIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[160px]">{props.location.placeholder}</span>
<button
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onRemove();
}}
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
</div>
</div>
);
};
export default LocationView;

View File

@ -37,12 +37,12 @@ const RelationListView = observer((props: Props) => {
return ( return (
<div <div
key={memo.name} key={memo.name}
className="w-auto max-w-xs overflow-hidden flex flex-row justify-start items-center bg-muted hover:opacity-80 rounded-md text-sm p-1 px-2 text-muted-foreground cursor-pointer hover:line-through" className="group relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent cursor-pointer"
onClick={() => handleDeleteRelation(memo)} onClick={() => handleDeleteRelation(memo)}
> >
<LinkIcon className="w-4 h-auto shrink-0 opacity-80" /> <LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span className="mx-1 max-w-full text-ellipsis whitespace-nowrap overflow-hidden">{memo.snippet}</span> <span className="truncate max-w-[160px]">{memo.snippet}</span>
<XIcon className="w-4 h-auto cursor-pointer shrink-0 opacity-60 hover:opacity-100" /> <XIcon className="w-3 h-3 shrink-0 text-muted-foreground" />
</div> </div>
); );
})} })}

View File

@ -6,9 +6,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import { TAB_SPACE_WIDTH } from "@/helpers/consts"; import { TAB_SPACE_WIDTH } from "@/helpers/consts";
import { isValidUrl } from "@/helpers/utils"; import { isValidUrl } from "@/helpers/utils";
@ -22,13 +20,11 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo"; import { convertVisibilityFromString } from "@/utils/memo";
import DateTimeInput from "../DateTimeInput"; import DateTimeInput from "../DateTimeInput";
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover"; import InsertMenu from "./ActionButton/InsertMenu";
import LocationSelector from "./ActionButton/LocationSelector"; import VisibilitySelector from "./ActionButton/VisibilitySelector";
import MarkdownMenu from "./ActionButton/MarkdownMenu";
import TagSelector from "./ActionButton/TagSelector";
import UploadAttachmentButton from "./ActionButton/UploadAttachmentButton";
import AttachmentListView from "./AttachmentListView"; import AttachmentListView from "./AttachmentListView";
import Editor, { EditorRefActions } from "./Editor"; import Editor, { EditorRefActions } from "./Editor";
import LocationView from "./LocationView";
import RelationListView from "./RelationListView"; import RelationListView from "./RelationListView";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers"; import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
import { MemoEditorContext } from "./types"; import { MemoEditorContext } from "./types";
@ -494,17 +490,23 @@ const MemoEditor = observer((props: Props) => {
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
> >
<Editor ref={editorRef} {...editorConfig} /> <Editor ref={editorRef} {...editorConfig} />
<LocationView
location={state.location}
onRemove={() =>
setState((prevState) => ({
...prevState,
location: undefined,
}))
}
/>
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} /> <AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} /> <RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}> <div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center opacity-60 shrink-1"> <div className="flex flex-row justify-start items-center gap-1">
<TagSelector editorRef={editorRef} /> <InsertMenu
<MarkdownMenu editorRef={editorRef} /> isUploading={state.isUploadingAttachment}
<UploadAttachmentButton isUploading={state.isUploadingAttachment} />
<AddMemoRelationPopover />
<LocationSelector
location={state.location} location={state.location}
onChange={(location) => onLocationChange={(location) =>
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
location, location,
@ -512,39 +514,15 @@ const MemoEditor = observer((props: Props) => {
} }
/> />
</div> </div>
<div className="shrink-0 -mr-1 flex flex-row justify-end items-center gap-1"> <div className="shrink-0 flex flex-row justify-end items-center gap-1">
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
{props.onCancel && ( {props.onCancel && (
<Button variant="ghost" className="opacity-60" disabled={state.isRequesting} onClick={handleCancelBtnClick}> <Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
)} )}
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}> <Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
{t("editor.save")} {state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
{!state.isRequesting ? (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<span className="pointer-events-auto">
<VisibilityIcon visibility={state.memoVisibility} className="w-4 h-auto ml-1 text-primary-foreground opacity-80" />
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12} sideOffset={12} onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PRIVATE)}>
<VisibilityIcon visibility={Visibility.PRIVATE} className="w-4 h-4" />
{t("memo.visibility.private")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PROTECTED)}>
<VisibilityIcon visibility={Visibility.PROTECTED} className="w-4 h-4" />
{t("memo.visibility.protected")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleMemoVisibilityChange(Visibility.PUBLIC)}>
<VisibilityIcon visibility={Visibility.PUBLIC} className="w-4 h-4" />
{t("memo.visibility.public")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<LoaderIcon className="w-4 h-auto ml-1 animate-spin" />
)}
</Button> </Button>
</div> </div>
</div> </div>