mirror of
https://github.com/usememos/memos.git
synced 2025-02-21 05:40:57 +01:00
refactor: use popover instead of dialog for memo relations
This commit is contained in:
parent
a5978e7657
commit
4ad6028681
@ -1,158 +0,0 @@
|
||||
import { Autocomplete, AutocompleteOption, Button, Checkbox, Chip, IconButton } from "@mui/joy";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useDebounce from "react-use/lib/useDebounce";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
onConfirm: (memos: Memo[], embedded?: boolean) => void;
|
||||
// Custom filter function for filtering memos.
|
||||
filter?: (memo: Memo) => boolean;
|
||||
}
|
||||
|
||||
const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, onConfirm } = props;
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const [searchText, setSearchText] = useState<string>("");
|
||||
const [isFetching, setIsFetching] = useState<boolean>(true);
|
||||
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
|
||||
const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]);
|
||||
const [embedded, setEmbedded] = useState<boolean>(true);
|
||||
const filteredMemos = fetchedMemos.filter((memo) => !selectedMemos.includes(memo) && (!props.filter || props.filter(memo)));
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const filters = [`creator == "${user.name}"`, `row_status == "NORMAL"`, `include_comments == true`];
|
||||
if (searchText) {
|
||||
filters.push(`content_search == [${JSON.stringify(searchText)}]`);
|
||||
}
|
||||
const { memos } = await memoServiceClient.listMemos({
|
||||
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||
filter: filters.length > 0 ? filters.join(" && ") : undefined,
|
||||
});
|
||||
setFetchedMemos(memos);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setIsFetching(false);
|
||||
},
|
||||
300,
|
||||
[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 handleCloseDialog = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
onConfirm(selectedMemos, embedded);
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container !w-96">
|
||||
<p className="title-text">{t("reference.add-references")}</p>
|
||||
<IconButton size="sm" onClick={() => destroy()}>
|
||||
<Icon.X className="w-5 h-auto" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="dialog-content-container max-w-[24rem]">
|
||||
<Autocomplete
|
||||
className="w-full"
|
||||
size="md"
|
||||
clearOnBlur
|
||||
disableClearable
|
||||
placeholder={t("reference.search-placeholder")}
|
||||
noOptionsText={t("reference.no-memos-found")}
|
||||
options={filteredMemos}
|
||||
loading={isFetching}
|
||||
inputValue={searchText}
|
||||
value={selectedMemos}
|
||||
multiple
|
||||
onInputChange={(_, value) => setSearchText(value.trim())}
|
||||
getOptionKey={(memo) => memo.name}
|
||||
getOptionLabel={(memo) => memo.content}
|
||||
isOptionEqualToValue={(memo, value) => memo.name === value.name}
|
||||
renderOption={(props, memo) => (
|
||||
<AutocompleteOption {...props}>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="text-xs text-gray-400 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>
|
||||
</AutocompleteOption>
|
||||
)}
|
||||
renderTags={(memos) =>
|
||||
memos.map((memo) => (
|
||||
<Chip key={memo.name} className="!max-w-full !rounded" variant="outlined" color="neutral">
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p>
|
||||
<span className="w-full text-sm leading-5 truncate">{memo.content}</span>
|
||||
</div>
|
||||
</Chip>
|
||||
))
|
||||
}
|
||||
onChange={(_, value) => setSelectedMemos(value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Checkbox label={t("reference.embedded-usage")} checked={embedded} onChange={(e) => setEmbedded(e.target.checked)} />
|
||||
</div>
|
||||
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-1">
|
||||
<Button variant="plain" color="neutral" onClick={handleCloseDialog}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} disabled={selectedMemos.length === 0}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showCreateMemoRelationDialog(props: Omit<Props, "destroy">) {
|
||||
generateDialog(
|
||||
{
|
||||
className: "create-memo-relation-dialog",
|
||||
dialogName: "create-memo-relation-dialog",
|
||||
},
|
||||
CreateMemoRelationDialog,
|
||||
props,
|
||||
);
|
||||
}
|
||||
|
||||
export default showCreateMemoRelationDialog;
|
@ -1,71 +0,0 @@
|
||||
import { IconButton } from "@mui/joy";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { useContext } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import showCreateMemoRelationDialog from "@/components/CreateMemoRelationDialog";
|
||||
import Icon from "@/components/Icon";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { EditorRefActions } from "../Editor";
|
||||
import { MemoEditorContext } from "../types";
|
||||
|
||||
interface Props {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
}
|
||||
|
||||
const AddMemoRelationButton = (props: Props) => {
|
||||
const { editorRef } = props;
|
||||
const context = useContext(MemoEditorContext);
|
||||
|
||||
const handleAddMemoRelationBtnClick = () => {
|
||||
showCreateMemoRelationDialog({
|
||||
onConfirm: (memos, embedded) => {
|
||||
// If embedded mode is enabled, embed the memo instead of creating a relation.
|
||||
if (embedded) {
|
||||
if (!editorRef.current) {
|
||||
toast.error("Failed to embed memo");
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
if (prevValue !== "" && !prevValue.endsWith("\n")) {
|
||||
editorRef.current.insertText("\n");
|
||||
}
|
||||
for (const memo of memos) {
|
||||
editorRef.current.insertText(`![[memos/${memo.uid}]]\n`);
|
||||
}
|
||||
setTimeout(() => {
|
||||
editorRef.current?.scrollToCursor();
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
context.setRelationList(
|
||||
uniqBy(
|
||||
[
|
||||
...memos.map((memo) => ({
|
||||
memo: context.memoName || "",
|
||||
relatedMemo: memo.name,
|
||||
type: MemoRelation_Type.REFERENCE,
|
||||
})),
|
||||
...context.relationList,
|
||||
].filter((relation) => relation.relatedMemo !== context.memoName),
|
||||
"relatedMemo",
|
||||
),
|
||||
);
|
||||
},
|
||||
filter: (memo: Memo) =>
|
||||
memo.name !== context.memoName && !context.relationList.some((relation) => relation.relatedMemo === memo.name),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton size="sm" onClick={handleAddMemoRelationBtnClick}>
|
||||
<Icon.Link className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddMemoRelationButton;
|
@ -0,0 +1,185 @@
|
||||
import { Autocomplete, AutocompleteOption, Button, Checkbox, Chip, IconButton } from "@mui/joy";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useDebounce from "react-use/lib/useDebounce";
|
||||
import Icon from "@/components/Icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { EditorRefActions } from "../Editor";
|
||||
import { MemoEditorContext } from "../types";
|
||||
|
||||
interface Props {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
}
|
||||
|
||||
const AddMemoRelationPopover = (props: Props) => {
|
||||
const { editorRef } = props;
|
||||
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 [selectedMemos, setSelectedMemos] = useState<Memo[]>([]);
|
||||
const [embedded, setEmbedded] = useState<boolean>(true);
|
||||
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const filteredMemos = fetchedMemos.filter(
|
||||
(memo) =>
|
||||
!selectedMemos.includes(memo) &&
|
||||
memo.name !== context.memoName &&
|
||||
!context.relationList.some((relation) => relation.relatedMemo === memo.name),
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const filters = [`creator == "${user.name}"`, `row_status == "NORMAL"`];
|
||||
if (searchText) {
|
||||
filters.push(`content_search == [${JSON.stringify(searchText)}]`);
|
||||
}
|
||||
const { memos } = await memoServiceClient.listMemos({
|
||||
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||
filter: filters.length > 0 ? filters.join(" && ") : undefined,
|
||||
});
|
||||
setFetchedMemos(memos);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setIsFetching(false);
|
||||
},
|
||||
300,
|
||||
[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 () => {
|
||||
// If embedded mode is enabled, embed the memo instead of creating a relation.
|
||||
if (embedded) {
|
||||
if (!editorRef.current) {
|
||||
toast.error("Failed to embed memo");
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPosition = editorRef.current.getCursorPosition();
|
||||
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
|
||||
if (prevValue !== "" && !prevValue.endsWith("\n")) {
|
||||
editorRef.current.insertText("\n");
|
||||
}
|
||||
for (const memo of selectedMemos) {
|
||||
editorRef.current.insertText(`![[memos/${memo.uid}]]\n`);
|
||||
}
|
||||
setTimeout(() => {
|
||||
editorRef.current?.scrollToCursor();
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
} else {
|
||||
context.setRelationList(
|
||||
uniqBy(
|
||||
[
|
||||
...selectedMemos.map((memo) => ({
|
||||
memo: context.memoName || "",
|
||||
relatedMemo: memo.name,
|
||||
type: MemoRelation_Type.REFERENCE,
|
||||
})),
|
||||
...context.relationList,
|
||||
].filter((relation) => relation.relatedMemo !== context.memoName),
|
||||
"relatedMemo",
|
||||
),
|
||||
);
|
||||
}
|
||||
setSelectedMemos([]);
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm">
|
||||
<Icon.Link className="w-5 h-5 mx-auto" />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center">
|
||||
<div className="w-[16rem] flex flex-col justify-start items-start">
|
||||
<Autocomplete
|
||||
className="w-full"
|
||||
size="md"
|
||||
clearOnBlur
|
||||
disableClearable
|
||||
placeholder={t("reference.search-placeholder")}
|
||||
noOptionsText={t("reference.no-memos-found")}
|
||||
options={filteredMemos}
|
||||
loading={isFetching}
|
||||
inputValue={searchText}
|
||||
value={selectedMemos}
|
||||
multiple
|
||||
onInputChange={(_, value) => setSearchText(value.trim())}
|
||||
getOptionKey={(memo) => memo.name}
|
||||
getOptionLabel={(memo) => memo.content}
|
||||
isOptionEqualToValue={(memo, value) => memo.name === value.name}
|
||||
renderOption={(props, memo) => (
|
||||
<AutocompleteOption {...props}>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="text-xs text-gray-400 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>
|
||||
</AutocompleteOption>
|
||||
)}
|
||||
renderTags={(memos) =>
|
||||
memos.map((memo) => (
|
||||
<Chip key={memo.name} className="!max-w-full !rounded" variant="outlined" color="neutral">
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="text-xs text-gray-400 select-none">{memo.displayTime?.toLocaleString()}</p>
|
||||
<span className="w-full text-sm leading-5 truncate">{memo.content}</span>
|
||||
</div>
|
||||
</Chip>
|
||||
))
|
||||
}
|
||||
onChange={(_, value) => setSelectedMemos(value)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Checkbox size="sm" label={t("reference.embedded-usage")} checked={embedded} onChange={(e) => setEmbedded(e.target.checked)} />
|
||||
</div>
|
||||
<div className="mt-2 w-full flex flex-row justify-start items-center space-x-1">
|
||||
<Button size="sm" onClick={addMemoRelations} disabled={selectedMemos.length === 0}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddMemoRelationPopover;
|
@ -20,7 +20,7 @@ import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
||||
import Icon from "../Icon";
|
||||
import VisibilityIcon from "../VisibilityIcon";
|
||||
import AddMemoRelationButton from "./ActionButton/AddMemoRelationButton";
|
||||
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
|
||||
import MarkdownMenu from "./ActionButton/MarkdownMenu";
|
||||
import TagSelector from "./ActionButton/TagSelector";
|
||||
import UploadResourceButton from "./ActionButton/UploadResourceButton";
|
||||
@ -432,7 +432,7 @@ const MemoEditor = (props: Props) => {
|
||||
<TagSelector editorRef={editorRef} />
|
||||
<MarkdownMenu editorRef={editorRef} />
|
||||
<UploadResourceButton />
|
||||
<AddMemoRelationButton editorRef={editorRef} />
|
||||
<AddMemoRelationPopover editorRef={editorRef} />
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="!mt-2" />
|
||||
|
@ -22,6 +22,13 @@ const theme = extendTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
JoyAutocomplete: {
|
||||
styleOverrides: {
|
||||
listbox: {
|
||||
zIndex: 9999,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user