import { isNumber, last, toLower, uniq } from "lodash-es"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getMatchedNodes } from "@/labs/marked"; import { deleteMemoResource, upsertMemoResource } from "@/helpers/api"; import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "@/helpers/consts"; import { useEditorStore, useGlobalStore, useFilterStore, useMemoStore, useResourceStore, useTagStore, useUserStore } from "@/store/module"; import * as storage from "@/helpers/storage"; import Icon from "./Icon"; import Selector from "./base/Selector"; import Editor, { EditorRefActions } from "./Editor/Editor"; import ResourceIcon from "./ResourceIcon"; import showResourcesSelectorDialog from "./ResourcesSelectorDialog"; import showCreateResourceDialog from "./CreateResourceDialog"; import "@/less/memo-editor.less"; const listItemSymbolList = ["- [ ] ", "- [x] ", "- [X] ", "* ", "- "]; const emptyOlReg = /^(\d+)\. $/; const getEditorContentCache = (): string => { return storage.get(["editorContentCache"]).editorContentCache ?? ""; }; const setEditorContentCache = (content: string) => { storage.set({ editorContentCache: content, }); }; interface State { fullscreen: boolean; isUploadingResource: boolean; isRequesting: boolean; } const MemoEditor = () => { const { t, i18n } = useTranslation(); const userStore = useUserStore(); const editorStore = useEditorStore(); const filterStore = useFilterStore(); const memoStore = useMemoStore(); const tagStore = useTagStore(); const resourceStore = useResourceStore(); const { state: { systemStatus }, } = useGlobalStore(); const [state, setState] = useState({ fullscreen: false, isUploadingResource: false, isRequesting: false, }); const [allowSave, setAllowSave] = useState(false); const [isInIME, setIsInIME] = useState(false); const editorState = editorStore.state; const prevEditorStateRef = useRef(editorState); const editorRef = useRef(null); const tagSelectorRef = useRef(null); const user = userStore.state.user as User; const setting = user.setting; const tags = tagStore.state.tags; const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => { return { value: item.value, text: t(`memo.visibility.${toLower(item.value)}`), }; }); useEffect(() => { if (systemStatus.disablePublicMemos) { editorStore.setMemoVisibility("PRIVATE"); } }, [systemStatus.disablePublicMemos]); useEffect(() => { const { editingMemoIdCache } = storage.get(["editingMemoIdCache"]); if (editingMemoIdCache) { editorStore.setEditMemoWithId(editingMemoIdCache); } else { editorStore.setMemoVisibility(setting.memoVisibility); } }, []); useEffect(() => { if (editorState.editMemoId) { memoStore.getMemoById(editorState.editMemoId ?? UNKNOWN_ID).then((memo) => { if (memo) { handleEditorFocus(); editorStore.setMemoVisibility(memo.visibility); editorStore.setResourceList(memo.resourceList); editorRef.current?.setContent(memo.content ?? ""); } }); storage.set({ editingMemoIdCache: editorState.editMemoId, }); } else { storage.remove(["editingMemoIdCache"]); } prevEditorStateRef.current = editorState; }, [editorState.editMemoId]); 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 === "Escape") { if (state.fullscreen) { handleFullscreenBtnClick(); } 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 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(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 (editorState.editMemoId) { await upsertMemoResource(editorState.editMemoId, resource.id); } } } if (uploadedResourceList.length > 0) { const resourceList = editorStore.getState().resourceList; editorStore.setResourceList([...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 handleSaveBtnClick = async () => { if (state.isRequesting) { return; } setState((state) => { return { ...state, isRequesting: true, }; }); const content = editorRef.current?.getContent() ?? ""; try { const { editMemoId } = editorStore.getState(); if (editMemoId && editMemoId !== UNKNOWN_ID) { const prevMemo = await memoStore.getMemoById(editMemoId ?? UNKNOWN_ID); if (prevMemo) { await memoStore.patchMemo({ id: prevMemo.id, content, visibility: editorState.memoVisibility, resourceIdList: editorState.resourceList.map((resource) => resource.id), }); } editorStore.clearEditMemo(); } else { await memoStore.createMemo({ content, visibility: editorState.memoVisibility, resourceIdList: editorState.resourceList.map((resource) => resource.id), }); filterStore.clearFilter(); } } 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((state) => { return { ...state, fullscreen: false, }; }); editorStore.clearResourceList(); setEditorContentCache(""); editorRef.current?.setContent(""); }; const handleCancelEdit = () => { if (editorState.editMemoId) { editorStore.clearEditMemo(); editorStore.clearResourceList(); editorRef.current?.setContent(""); setEditorContentCache(""); } }; const handleContentChange = (content: string) => { setAllowSave(content !== ""); setEditorContentCache(content); }; const handleCheckBoxBtnClick = () => { 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("", "- [ ] "); } else { editorRef.current?.insertText("", "\n- [ ] "); } 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 handleUploadFileBtnClick = () => { showCreateResourceDialog({ onConfirm: (resourceList) => { editorStore.setResourceList([...editorState.resourceList, ...resourceList]); }, }); }; const handleFullscreenBtnClick = () => { setState((state) => { return { ...state, fullscreen: !state.fullscreen, }; }); }; const handleTagSelectorClick = useCallback((tag: string) => { editorRef.current?.insertText(`#${tag} `); }, []); const handleDeleteResource = async (resourceId: ResourceId) => { editorStore.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId)); if (editorState.editMemoId) { await deleteMemoResource(editorState.editMemoId, resourceId); } }; const handleMemoVisibilityOptionChanged = async (value: string) => { const visibilityValue = value as Visibility; editorStore.setMemoVisibility(visibilityValue); }; const handleEditorFocus = () => { editorRef.current?.focus(); }; const handleEditorBlur = () => { // do nothing }; const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID); const editorConfig = useMemo( () => ({ className: `memo-editor`, initialContent: getEditorContentCache(), placeholder: t("editor.placeholder"), fullscreen: state.fullscreen, onContentChange: handleContentChange, onPaste: handlePasteEvent, }), [state.fullscreen, i18n.language] ); return (
setIsInIME(true)} onCompositionEnd={() => setIsInIME(false)} >
{tags.length > 0 ? ( tags.map((tag) => { return ( handleTagSelectorClick(tag)} key={tag}> #{tag} ); }) ) : (

e.stopPropagation()}> No tags found

)}
{t("common.create")}
{t("editor.resources")}
{editorState.resourceList && editorState.resourceList.length > 0 && (
{editorState.resourceList.map((resource) => { return (
{resource.filename} handleDeleteResource(resource.id)} />
); })}
)}
); }; export default MemoEditor;