import { Select, Option, Button, IconButton, Divider } from "@mui/joy"; import { isNumber, last, uniq, uniqBy } from "lodash-es"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import useLocalStorage from "react-use/lib/useLocalStorage"; import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "@/helpers/consts"; import { clearContentQueryParam } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; import { getMatchedNodes } from "@/labs/marked"; import { useFilterStore, useGlobalStore, useMemoStore, useResourceStore, useTagStore } from "@/store/module"; import { useUserV1Store } from "@/store/v1"; import { Resource } from "@/types/proto/api/v2/resource_service"; import { UserSetting, User_Role } from "@/types/proto/api/v2/user_service"; import { useTranslate } from "@/utils/i18n"; import showCreateMemoRelationDialog from "../CreateMemoRelationDialog"; import showCreateResourceDialog from "../CreateResourceDialog"; import Icon from "../Icon"; import VisibilityIcon from "../VisibilityIcon"; import TagSelector from "./ActionButton/TagSelector"; import Editor, { EditorRefActions } from "./Editor"; import RelationListView from "./RelationListView"; import ResourceListView from "./ResourceListView"; const listItemSymbolList = ["- [ ] ", "- [x] ", "- [X] ", "* ", "- "]; const emptyOlReg = /^(\d+)\. $/; interface Props { className?: string; editorClassName?: string; cacheKey?: string; memoId?: MemoId; relationList?: MemoRelation[]; onConfirm?: () => void; } interface State { memoVisibility: Visibility; resourceList: Resource[]; relationList: MemoRelation[]; isUploadingResource: boolean; isRequesting: boolean; } const MemoEditor = (props: Props) => { const { className, editorClassName, cacheKey, memoId, onConfirm } = props; const { i18n } = useTranslation(); const t = useTranslate(); const contentCacheKey = `memo-editor-${cacheKey}`; const [contentCache, setContentCache] = useLocalStorage(contentCacheKey, ""); const { state: { systemStatus }, } = useGlobalStore(); const userV1Store = useUserV1Store(); const filterStore = useFilterStore(); const memoStore = useMemoStore(); const tagStore = useTagStore(); const resourceStore = useResourceStore(); const currentUser = useCurrentUser(); const [state, setState] = useState({ memoVisibility: "PRIVATE", resourceList: [], relationList: props.relationList ?? [], isUploadingResource: false, isRequesting: false, }); const [hasContent, setHasContent] = useState(false); const [isInIME, setIsInIME] = useState(false); const editorRef = useRef(null); const userSetting = userV1Store.userSetting as UserSetting; const referenceRelations = memoId ? state.relationList.filter( (relation) => relation.memoId === memoId && relation.relatedMemoId !== memoId && relation.type === "REFERENCE" ) : state.relationList.filter((relation) => relation.type === "REFERENCE"); useEffect(() => { editorRef.current?.setContent(contentCache || ""); handleEditorFocus(); }, []); useEffect(() => { let visibility = userSetting.memoVisibility; if (systemStatus.disablePublicMemos && visibility === "PUBLIC") { visibility = "PRIVATE"; } setState((prevState) => ({ ...prevState, memoVisibility: visibility as Visibility, })); }, [userSetting.memoVisibility, systemStatus.disablePublicMemos]); useEffect(() => { if (memoId) { memoStore.getMemoById(memoId ?? UNKNOWN_ID).then((memo) => { if (memo) { handleEditorFocus(); setState((prevState) => ({ ...prevState, memoVisibility: memo.visibility, resourceList: memo.resourceList, relationList: memo.relationList, })); if (!contentCache) { editorRef.current?.setContent(memo.content ?? ""); } } }); } }, [memoId]); const handleKeyDown = (event: React.KeyboardEvent) => { if (!editorRef.current) { return; } const isMetaKey = event.ctrlKey || event.metaKey; if (isMetaKey) { if (event.key === "Enter") { handleSaveBtnClick(); return; } } if (event.key === "Enter" && !isInIME) { const cursorPosition = editorRef.current.getCursorPosition(); const contentBeforeCursor = editorRef.current.getContent().slice(0, cursorPosition); const rowValue = last(contentBeforeCursor.split("\n")); if (rowValue) { if (listItemSymbolList.includes(rowValue) || emptyOlReg.test(rowValue)) { event.preventDefault(); editorRef.current.removeText(cursorPosition - rowValue.length, rowValue.length); } else { // unordered/todo list let matched = false; for (const listItemSymbol of listItemSymbolList) { if (rowValue.startsWith(listItemSymbol)) { event.preventDefault(); editorRef.current.insertText("", `\n${listItemSymbol}`); matched = true; break; } } if (!matched) { // ordered list const olMatchRes = /^(\d+)\. /.exec(rowValue); if (olMatchRes) { const order = parseInt(olMatchRes[1]); if (isNumber(order)) { event.preventDefault(); editorRef.current.insertText("", `\n${order + 1}. `); } } } editorRef.current?.scrollToCursor(); } } return; } if (event.key === "Tab") { event.preventDefault(); const tabSpace = " ".repeat(TAB_SPACE_WIDTH); const cursorPosition = editorRef.current.getCursorPosition(); const selectedContent = editorRef.current.getSelectedContent(); editorRef.current.insertText(tabSpace); if (selectedContent) { editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH); } return; } }; const handleMemoVisibilityChange = (visibility: Visibility) => { setState((prevState) => ({ ...prevState, memoVisibility: visibility, })); }; const handleUploadFileBtnClick = () => { showCreateResourceDialog({ onConfirm: (resourceList) => { setState((prevState) => ({ ...prevState, resourceList: [...prevState.resourceList, ...resourceList], })); }, }); }; const handleAddMemoRelationBtnClick = () => { showCreateMemoRelationDialog({ onConfirm: (memoIdList) => { setState((prevState) => ({ ...prevState, relationList: uniqBy( [ ...memoIdList.map((id) => ({ memoId: memoId || UNKNOWN_ID, relatedMemoId: id, type: "REFERENCE" as MemoRelationType })), ...state.relationList, ].filter((relation) => relation.relatedMemoId !== (memoId || UNKNOWN_ID)), "relatedMemoId" ), })); }, }); }; const handleSetResourceList = (resourceList: Resource[]) => { setState((prevState) => ({ ...prevState, resourceList, })); }; const handleSetRelationList = (relationList: MemoRelation[]) => { setState((prevState) => ({ ...prevState, relationList, })); }; const handleUploadResource = async (file: File) => { setState((state) => { return { ...state, isUploadingResource: true, }; }); let resource = undefined; try { resource = await resourceStore.createResourceWithBlob(file); } catch (error: any) { console.error(error); toast.error(typeof error === "string" ? error : error.response.data.message); } setState((state) => { return { ...state, isUploadingResource: false, }; }); return resource; }; const uploadMultiFiles = async (files: FileList) => { const uploadedResourceList: Resource[] = []; for (const file of files) { const resource = await handleUploadResource(file); if (resource) { uploadedResourceList.push(resource); if (memoId) { await resourceStore.updateResource({ resource: Resource.fromPartial({ id: resource.id, memoId, }), updateMask: ["memo_id"], }); } } } if (uploadedResourceList.length > 0) { setState((prevState) => ({ ...prevState, resourceList: [...prevState.resourceList, ...uploadedResourceList], })); } }; const handleDropEvent = async (event: React.DragEvent) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { event.preventDefault(); await uploadMultiFiles(event.dataTransfer.files); } }; const handlePasteEvent = async (event: React.ClipboardEvent) => { if (event.clipboardData && event.clipboardData.files.length > 0) { event.preventDefault(); await uploadMultiFiles(event.clipboardData.files); } }; const handleContentChange = (content: string) => { setHasContent(content !== ""); if (content !== "") { setContentCache(content); } else { localStorage.removeItem(contentCacheKey); } }; const handleSaveBtnClick = async () => { if (state.isRequesting) { return; } setState((state) => { return { ...state, isRequesting: true, }; }); const content = editorRef.current?.getContent() ?? ""; try { if (memoId && memoId !== UNKNOWN_ID) { const prevMemo = await memoStore.getMemoById(memoId ?? UNKNOWN_ID); if (prevMemo) { await memoStore.patchMemo({ id: prevMemo.id, content, visibility: state.memoVisibility, resourceIdList: state.resourceList.map((resource) => resource.id), relationList: state.relationList, }); } } else { await memoStore.createMemo({ content, visibility: state.memoVisibility, resourceIdList: state.resourceList.map((resource) => resource.id), relationList: state.relationList, }); filterStore.clearFilter(); } editorRef.current?.setContent(""); clearContentQueryParam(); } catch (error: any) { console.error(error); toast.error(error.response.data.message); } setState((state) => { return { ...state, isRequesting: false, }; }); // Upsert tag with the content. const matchedNodes = getMatchedNodes(content); const tagNameList = uniq(matchedNodes.filter((node) => node.parserName === "tag").map((node) => node.matchedContent.slice(1))); for (const tagName of tagNameList) { await tagStore.upsertTag(tagName); } setState((prevState) => ({ ...prevState, resourceList: [], })); if (onConfirm) { onConfirm(); } }; const handleCheckBoxBtnClick = () => { 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); editorRef.current?.scrollToCursor(); }; const handleCodeBlockBtnClick = () => { 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```"); } editorRef.current?.scrollToCursor(); }; const handleTagSelectorClick = useCallback((tag: string) => { editorRef.current?.insertText(`#${tag} `); }, []); const handleEditorFocus = () => { editorRef.current?.focus(); }; const editorConfig = useMemo( () => ({ className: editorClassName ?? "", initialContent: "", placeholder: t("editor.placeholder"), onContentChange: handleContentChange, onPaste: handlePasteEvent, }), [i18n.language] ); const allowSave = (hasContent || state.resourceList.length > 0) && !state.isUploadingResource && !state.isRequesting; const disableOption = (v: string) => { const isAdminOrHost = currentUser.role === User_Role.ADMIN || currentUser.role === User_Role.HOST; if (v === "PUBLIC" && !isAdminOrHost) { return systemStatus.disablePublicMemos; } return false; }; return (
setIsInIME(true)} onCompositionEnd={() => setIsInIME(false)} >
handleTagSelectorClick(tag)} />
e.stopPropagation()}>
); }; export default MemoEditor;