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 { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce";
@ -11,18 +11,18 @@ import { generateDialog } from "./Dialog";
import Icon from "./Icon";
interface Props extends DialogProps {
onCancel?: () => void;
onConfirm?: (memoIdList: number[]) => void;
onConfirm: (memoIdList: number[], embedded?: boolean) => void;
}
const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
const { destroy, onCancel, onConfirm } = 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>(false);
const filteredMemos = fetchedMemos.filter((memo) => !selectedMemos.includes(memo));
useDebounce(
@ -73,16 +73,14 @@ const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
};
const handleCloseDialog = () => {
if (onCancel) {
onCancel();
}
destroy();
};
const handleConfirmBtnClick = async () => {
if (onConfirm) {
onConfirm(selectedMemos.map((memo) => memo.id));
}
onConfirm(
selectedMemos.map((memo) => memo.id),
embedded
);
destroy();
};
@ -133,6 +131,9 @@ const CreateMemoRelationDialog: React.FC<Props> = (props: Props) => {
}
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">
<Button variant="plain" color="neutral" onClick={handleCloseDialog}>
{t("common.cancel")}

View File

@ -1,5 +1,8 @@
import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import Icon from "@/components/Icon";
import MemoResourceListView from "@/components/MemoResourceListView";
import { getDateTimeString } from "@/helpers/datetime";
import useLoading from "@/hooks/useLoading";
import { useMemoStore } from "@/store/v1";
import MemoContent from "..";
@ -11,7 +14,7 @@ interface Props {
params: string;
}
const EmbeddedMemo = ({ resourceId }: Props) => {
const EmbeddedMemo = ({ resourceId, params: paramsStr }: Props) => {
const context = useContext(RendererContext);
const loadingState = useLoading();
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.
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 (
<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} />
<MemoResourceListView resources={memo.resources} />
</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 { uniqBy } from "lodash-es";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
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 { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import showCreateMemoRelationDialog from "../CreateMemoRelationDialog";
import showCreateResourceDialog from "../CreateResourceDialog";
import Icon from "../Icon";
import VisibilityIcon from "../VisibilityIcon";
import AddMemoRelationButton from "./ActionButton/AddMemoRelationButton";
import MarkdownMenu from "./ActionButton/MarkdownMenu";
import TagSelector from "./ActionButton/TagSelector";
import Editor, { EditorRefActions } from "./Editor";
import RelationListView from "./RelationListView";
import ResourceListView from "./ResourceListView";
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
import { MemoEditorContext } from "./types";
interface Props {
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[]) => {
setState((prevState) => ({
...prevState,
@ -371,62 +354,73 @@ const MemoEditor = (props: Props) => {
const allowSave = (hasContent || state.resourceList.length > 0) && !state.isUploadingResource && !state.isRequesting;
return (
<div
className={`${
className ?? ""
} 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`}
tabIndex={0}
onKeyDown={handleKeyDown}
onDrop={handleDropEvent}
onFocus={handleEditorFocus}
<MemoEditorContext.Provider
value={{
relationList: state.relationList,
setRelationList: (relationList: MemoRelation[]) => {
setState((prevState) => ({
...prevState,
relationList,
}));
},
memoId,
}}
>
<Editor ref={editorRef} {...editorConfig} />
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<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>
<IconButton size="sm" onClick={handleAddMemoRelationBtnClick}>
<Icon.Link className="w-5 h-5 mx-auto" />
</IconButton>
<div
className={`${
className ?? ""
} 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`}
tabIndex={0}
onKeyDown={handleKeyDown}
onDrop={handleDropEvent}
onFocus={handleEditorFocus}
>
<Editor ref={editorRef} {...editorConfig} />
<ResourceListView resourceList={state.resourceList} setResourceList={handleSetResourceList} />
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
<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>
<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>
</MemoEditorContext.Provider>
);
};

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";