feat: support set embedded content in UI

This commit is contained in:
Steven 2024-01-26 22:51:57 +08:00
parent e1977df14b
commit bc2d2d0cde
6 changed files with 182 additions and 84 deletions

View File

@ -1,4 +1,4 @@
import { Autocomplete, AutocompleteOption, Button, Chip, IconButton } from "@mui/joy"; import { Autocomplete, AutocompleteOption, Button, Checkbox, Chip, IconButton } from "@mui/joy";
import React, { useState } from "react"; import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce"; import useDebounce from "react-use/lib/useDebounce";
@ -11,18 +11,18 @@ import { generateDialog } from "./Dialog";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props extends DialogProps { interface Props extends DialogProps {
onCancel?: () => void; onConfirm: (memoIdList: number[], embedded?: boolean) => void;
onConfirm?: (memoIdList: number[]) => void;
} }
const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => { const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
const { destroy, onCancel, onConfirm } = props; const { destroy, onConfirm } = props;
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser(); const user = useCurrentUser();
const [searchText, setSearchText] = useState<string>(""); const [searchText, setSearchText] = useState<string>("");
const [isFetching, setIsFetching] = useState<boolean>(true); const [isFetching, setIsFetching] = useState<boolean>(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]); const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]); const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]);
const [embedded, setEmbedded] = useState<boolean>(false);
const filteredMemos = fetchedMemos.filter((memo) => !selectedMemos.includes(memo)); const filteredMemos = fetchedMemos.filter((memo) => !selectedMemos.includes(memo));
useDebounce( useDebounce(
@ -73,16 +73,14 @@ const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
}; };
const handleCloseDialog = () => { const handleCloseDialog = () => {
if (onCancel) {
onCancel();
}
destroy(); destroy();
}; };
const handleConfirmBtnClick = async () => { const handleConfirmBtnClick = async () => {
if (onConfirm) { onConfirm(
onConfirm(selectedMemos.map((memo) => memo.id)); selectedMemos.map((memo) => memo.id),
} embedded
);
destroy(); destroy();
}; };
@ -133,6 +131,9 @@ const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
} }
onChange={(_, value) => setSelectedMemos(value)} onChange={(_, value) => setSelectedMemos(value)}
/> />
<div className="mt-3">
<Checkbox label={"Use as Embedded Content"} 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"> <div className="mt-4 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseDialog}> <Button variant="plain" color="neutral" onClick={handleCloseDialog}>
{t("common.cancel")} {t("common.cancel")}

View File

@ -1,5 +1,8 @@
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import Icon from "@/components/Icon";
import MemoResourceListView from "@/components/MemoResourceListView"; import MemoResourceListView from "@/components/MemoResourceListView";
import { getDateTimeString } from "@/helpers/datetime";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useMemoStore } from "@/store/v1"; import { useMemoStore } from "@/store/v1";
import MemoContent from ".."; import MemoContent from "..";
@ -11,7 +14,7 @@ interface Props {
params: string; params: string;
} }
const EmbeddedMemo = ({ resourceId }: Props) => { const EmbeddedMemo = ({ resourceId, params: paramsStr }: Props) => {
const context = useContext(RendererContext); const context = useContext(RendererContext);
const loadingState = useLoading(); const loadingState = useLoading();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
@ -34,8 +37,25 @@ const EmbeddedMemo = ({ resourceId }: Props) => {
// Add the memo to the set of embedded memos. This is used to prevent infinite loops when a memo embeds itself. // Add the memo to the set of embedded memos. This is used to prevent infinite loops when a memo embeds itself.
context.embeddedMemos.add(resourceName); context.embeddedMemos.add(resourceName);
const params = new URLSearchParams(paramsStr);
const useInlineMode = params.has("inline");
if (useInlineMode) {
return (
<div className="w-full">
<MemoContent nodes={memo.nodes} memoId={memo.id} embeddedMemos={context.embeddedMemos} />
<MemoResourceListView resources={memo.resources} />
</div>
);
}
return ( return (
<div className="w-full"> <div className="relative flex flex-col justify-start items-start w-full p-4 pt-3 mb-2 bg-white dark:bg-zinc-800 rounded-lg border border-gray-200 dark:border-zinc-700">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm text-gray-400 select-none">{getDateTimeString(memo.displayTime)}</span>
<Link className="hover:opacity-80" to={`/m/${memo.name}`} unstable_viewTransition>
<Icon.ExternalLink className="w-4 h-auto opacity-80" />
</Link>
</div>
<MemoContent nodes={memo.nodes} memoId={memo.id} embeddedMemos={context.embeddedMemos} /> <MemoContent nodes={memo.nodes} memoId={memo.id} embeddedMemos={context.embeddedMemos} />
<MemoResourceListView resources={memo.resources} /> <MemoResourceListView resources={memo.resources} />
</div> </div>

View File

@ -0,0 +1,68 @@
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 { UNKNOWN_ID } from "@/helpers/consts";
import { useMemoStore } from "@/store/v1";
import { MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service";
import { EditorRefActions } from "../Editor";
import { MemoEditorContext } from "../types";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const AddMemoRelationButton = (props: Props) => {
const { editorRef } = props;
const memoStore = useMemoStore();
const context = useContext(MemoEditorContext);
const handleAddMemoRelationBtnClick = () => {
showCreateMemoRelationDialog({
onConfirm: (memoIdList, 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 memoId of memoIdList) {
const memo = memoStore.getMemoById(memoId);
editorRef.current.insertText(`![[memos/${memo.name}]]\n`);
}
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
return;
}
context.setRelationList(
uniqBy(
[
...memoIdList.map((id) => ({ memoId: context.memoId || UNKNOWN_ID, relatedMemoId: id, type: MemoRelation_Type.REFERENCE })),
...context.relationList,
].filter((relation) => relation.relatedMemoId !== (context.memoId || UNKNOWN_ID)),
"relatedMemoId"
)
);
},
});
};
return (
<IconButton size="sm" onClick={handleAddMemoRelationBtnClick}>
<Icon.Link className="w-5 h-5 mx-auto" />
</IconButton>
);
};
export default AddMemoRelationButton;

View File

@ -1,5 +1,4 @@
import { Select, Option, Button, IconButton, Divider } from "@mui/joy"; import { Select, Option, Button, IconButton, Divider } from "@mui/joy";
import { uniqBy } from "lodash-es";
import React, { useEffect, useMemo, useRef, useState } from "react"; 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";
@ -15,16 +14,17 @@ import { Resource } from "@/types/proto/api/v2/resource_service";
import { UserSetting } from "@/types/proto/api/v2/user_service"; import { UserSetting } from "@/types/proto/api/v2/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo"; import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import showCreateMemoRelationDialog from "../CreateMemoRelationDialog";
import showCreateResourceDialog from "../CreateResourceDialog"; import showCreateResourceDialog from "../CreateResourceDialog";
import Icon from "../Icon"; import Icon from "../Icon";
import VisibilityIcon from "../VisibilityIcon"; import VisibilityIcon from "../VisibilityIcon";
import AddMemoRelationButton from "./ActionButton/AddMemoRelationButton";
import MarkdownMenu from "./ActionButton/MarkdownMenu"; import MarkdownMenu from "./ActionButton/MarkdownMenu";
import TagSelector from "./ActionButton/TagSelector"; import TagSelector from "./ActionButton/TagSelector";
import Editor, { EditorRefActions } from "./Editor"; import Editor, { EditorRefActions } from "./Editor";
import RelationListView from "./RelationListView"; import RelationListView from "./RelationListView";
import ResourceListView from "./ResourceListView"; import ResourceListView from "./ResourceListView";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers"; import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
import { MemoEditorContext } from "./types";
interface Props { interface Props {
className?: string; className?: string;
@ -158,23 +158,6 @@ const MemoEditor = (props: Props) => {
}); });
}; };
const handleAddMemoRelationBtnClick = () => {
showCreateMemoRelationDialog({
onConfirm: (memoIdList) => {
setState((prevState) => ({
...prevState,
relationList: uniqBy(
[
...memoIdList.map((id) => ({ memoId: memoId || UNKNOWN_ID, relatedMemoId: id, type: MemoRelation_Type.REFERENCE })),
...state.relationList,
].filter((relation) => relation.relatedMemoId !== (memoId || UNKNOWN_ID)),
"relatedMemoId"
),
}));
},
});
};
const handleSetResourceList = (resourceList: Resource[]) => { const handleSetResourceList = (resourceList: Resource[]) => {
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
@ -371,62 +354,73 @@ const MemoEditor = (props: Props) => {
const allowSave = (hasContent || state.resourceList.length > 0) && !state.isUploadingResource && !state.isRequesting; const allowSave = (hasContent || state.resourceList.length > 0) && !state.isUploadingResource && !state.isRequesting;
return ( return (
<div <MemoEditorContext.Provider
className={`${ value={{
className ?? "" relationList: state.relationList,
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border border-gray-200 dark:border-zinc-700`} setRelationList: (relationList: MemoRelation[]) => {
tabIndex={0} setState((prevState) => ({
onKeyDown={handleKeyDown} ...prevState,
onDrop={handleDropEvent} relationList,
onFocus={handleEditorFocus} }));
},
memoId,
}}
> >
<Editor ref={editorRef} {...editorConfig} /> <div
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} /> className={`${
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} /> className ?? ""
<div className="relative w-full flex flex-row justify-between items-center pt-2" onFocus={(e) => e.stopPropagation()}> } relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border border-gray-200 dark:border-zinc-700`}
<div className="flex flex-row justify-start items-center opacity-80"> tabIndex={0}
<TagSelector editorRef={editorRef} /> onKeyDown={handleKeyDown}
<MarkdownMenu editorRef={editorRef} /> onDrop={handleDropEvent}
<IconButton size="sm" onClick={handleUploadFileBtnClick}> onFocus={handleEditorFocus}
<Icon.Image className="w-5 h-5 mx-auto" /> >
</IconButton> <Editor ref={editorRef} {...editorConfig} />
<IconButton size="sm" onClick={handleAddMemoRelationBtnClick}> <ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
<Icon.Link className="w-5 h-5 mx-auto" /> <RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
</IconButton> <div className="relative w-full flex flex-row justify-between items-center pt-2" onFocus={(e) => e.stopPropagation()}>
<div className="flex flex-row justify-start items-center opacity-80">
<TagSelector editorRef={editorRef} />
<MarkdownMenu editorRef={editorRef} />
<IconButton size="sm" onClick={handleUploadFileBtnClick}>
<Icon.Image className="w-5 h-5 mx-auto" />
</IconButton>
<AddMemoRelationButton editorRef={editorRef} />
</div>
</div>
<Divider className="!mt-2" />
<div className="w-full flex flex-row justify-between items-center py-3 dark:border-t-zinc-500">
<div className="relative flex flex-row justify-start items-center" onFocus={(e) => e.stopPropagation()}>
<Select
variant="plain"
value={state.memoVisibility}
startDecorator={<VisibilityIcon visibility={state.memoVisibility} />}
onChange={(_, visibility) => {
if (visibility) {
handleMemoVisibilityChange(visibility);
}
}}
>
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC].map((item) => (
<Option key={item} value={item} className="whitespace-nowrap">
{t(`memo.visibility.${convertVisibilityToString(item).toLowerCase()}` as any)}
</Option>
))}
</Select>
</div>
<div className="shrink-0 flex flex-row justify-end items-center">
<Button
disabled={!allowSave}
loading={state.isRequesting}
endDecorator={<Icon.Send className="w-4 h-auto" />}
onClick={handleSaveBtnClick}
>
{t("editor.save")}
</Button>
</div>
</div> </div>
</div> </div>
<Divider className="!mt-2" /> </MemoEditorContext.Provider>
<div className="w-full flex flex-row justify-between items-center py-3 dark:border-t-zinc-500">
<div className="relative flex flex-row justify-start items-center" onFocus={(e) => e.stopPropagation()}>
<Select
variant="plain"
value={state.memoVisibility}
startDecorator={<VisibilityIcon visibility={state.memoVisibility} />}
onChange={(_, visibility) => {
if (visibility) {
handleMemoVisibilityChange(visibility);
}
}}
>
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC].map((item) => (
<Option key={item} value={item} className="whitespace-nowrap">
{t(`memo.visibility.${convertVisibilityToString(item).toLowerCase()}` as any)}
</Option>
))}
</Select>
</div>
<div className="shrink-0 flex flex-row justify-end items-center">
<Button
disabled={!allowSave}
loading={state.isRequesting}
endDecorator={<Icon.Send className="w-4 h-auto" />}
onClick={handleSaveBtnClick}
>
{t("editor.save")}
</Button>
</div>
</div>
</div>
); );
}; };

View File

@ -0,0 +1,14 @@
import { createContext } from "react";
import { MemoRelation } from "@/types/proto/api/v2/memo_relation_service";
interface Context {
relationList: MemoRelation[];
setRelationList: (relationList: MemoRelation[]) => void;
// memoId is the id of the memo that is being edited.
memoId?: number;
}
export const MemoEditorContext = createContext<Context>({
relationList: [],
setRelationList: () => {},
});

View File

@ -0,0 +1 @@
export * from "./context";