mirror of
https://github.com/usememos/memos.git
synced 2025-03-29 00:50:13 +01:00
feat: support set embedded content in UI
This commit is contained in:
parent
e1977df14b
commit
bc2d2d0cde
@ -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")}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
14
web/src/components/MemoEditor/types/context.ts
Normal file
14
web/src/components/MemoEditor/types/context.ts
Normal 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: () => {},
|
||||
});
|
1
web/src/components/MemoEditor/types/index.ts
Normal file
1
web/src/components/MemoEditor/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./context";
|
Loading…
x
Reference in New Issue
Block a user