import { toLower } from "lodash"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { deleteMemoResource, upsertMemoResource } from "../helpers/api"; import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts"; import { editorStateService, locationService, memoService, resourceService } from "../services"; import { useAppSelector } from "../store"; import * as storage from "../helpers/storage"; import Icon from "./Icon"; import toastHelper from "./Toast"; import Selector from "./common/Selector"; import Editor, { EditorRefActions } from "./Editor/Editor"; import ResourceIcon from "./ResourceIcon"; import showResourcesSelectorDialog from "./ResourcesSelectorDialog"; import "../less/memo-editor.less"; const getEditorContentCache = (): string => { return storage.get(["editorContentCache"]).editorContentCache ?? ""; }; const setEditorContentCache = (content: string) => { storage.set({ editorContentCache: content, }); }; const setEditingMemoVisibilityCache = (visibility: Visibility) => { storage.set({ editingMemoVisibilityCache: visibility, }); }; interface State { fullscreen: boolean; isUploadingResource: boolean; shouldShowEmojiPicker: boolean; } const MemoEditor = () => { const { t, i18n } = useTranslation(); const user = useAppSelector((state) => state.user.user as User); const setting = user.setting; const editorState = useAppSelector((state) => state.editor); const tags = useAppSelector((state) => state.memo.tags); const [state, setState] = useState({ isUploadingResource: false, fullscreen: false, shouldShowEmojiPicker: false, }); const [allowSave, setAllowSave] = useState(false); const prevGlobalStateRef = useRef(editorState); const editorRef = useRef(null); const tagSelectorRef = useRef(null); const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => { return { value: item.value, text: t(`memo.visibility.${toLower(item.value)}`), }; }); useEffect(() => { const { editingMemoIdCache, editingMemoVisibilityCache } = storage.get(["editingMemoIdCache", "editingMemoVisibilityCache"]); if (editingMemoIdCache) { editorStateService.setEditMemoWithId(editingMemoIdCache); } if (editingMemoVisibilityCache) { editorStateService.setMemoVisibility(editingMemoVisibilityCache as "PUBLIC" | "PROTECTED" | "PRIVATE"); } else { editorStateService.setMemoVisibility(setting.memoVisibility); } }, []); useEffect(() => { if (editorState.editMemoId) { memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID).then((memo) => { if (memo) { handleEditorFocus(); editorStateService.setMemoVisibility(memo.visibility); editorStateService.setResourceList(memo.resourceList); editorRef.current?.setContent(memo.content ?? ""); } }); storage.set({ editingMemoIdCache: editorState.editMemoId, }); } else { storage.remove(["editingMemoIdCache"]); } prevGlobalStateRef.current = editorState; }, [editorState.editMemoId]); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.ctrlKey || event.metaKey) { if (event.key === "Enter") { handleSaveBtnClick(); return; } if (event.key === "b") { event.preventDefault(); editorRef.current?.insertText("", "**", "**"); return; } if (event.key === "i") { event.preventDefault(); editorRef.current?.insertText("", "*", "*"); return; } if (event.key === "e") { event.preventDefault(); editorRef.current?.insertText("", "`", "`"); return; } } if (event.key === "Enter") { if (!editorRef.current) { return; } const cursorPosition = editorRef.current.getCursorPosition(); const prevValue = editorRef.current.getContent().slice(0, cursorPosition); const prevRows = prevValue.split("\n"); const prevRowValue = prevRows[prevRows.length - 1]; if (prevRowValue === "- " || prevRowValue === "- [ ] " || prevRowValue === "- [x] " || prevRowValue === "- [X] ") { event.preventDefault(); prevRows[prevRows.length - 1] = ""; editorRef.current.setContent(prevRows.join("\n")); } else { if (prevRowValue.startsWith("- [ ] ") || prevRowValue.startsWith("- [x] ") || prevRowValue.startsWith("- [X] ")) { event.preventDefault(); editorRef.current.insertText("", "\n- [ ] "); } else if (prevRowValue.startsWith("- ")) { event.preventDefault(); editorRef.current.insertText("", "\n- "); } } return; } if (event.key === "Escape") { if (state.fullscreen) { handleFullscreenBtnClick(); } else { handleCancelEdit(); } return; } if (event.key === "Tab") { event.preventDefault(); editorRef.current?.insertText(" ".repeat(TAB_SPACE_WIDTH)); return; } }; const handleDropEvent = async (event: React.DragEvent) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { event.preventDefault(); const resourceList: Resource[] = []; for (const file of event.dataTransfer.files) { const resource = await handleUploadResource(file); if (resource) { resourceList.push(resource); if (editorState.editMemoId) { await upsertMemoResource(editorState.editMemoId, resource.id); } } } editorStateService.setResourceList([...editorState.resourceList, ...resourceList]); } }; const handlePasteEvent = async (event: React.ClipboardEvent) => { if (event.clipboardData && event.clipboardData.files.length > 0) { event.preventDefault(); const file = event.clipboardData.files[0]; const resource = await handleUploadResource(file); if (resource) { editorStateService.setResourceList([...editorState.resourceList, resource]); } } }; const handleUploadResource = async (file: File) => { setState((state) => { return { ...state, isUploadingResource: true, }; }); let resource = undefined; try { resource = await resourceService.upload(file); } catch (error: any) { console.error(error); toastHelper.error(error.response.data.message); } setState((state) => { return { ...state, isUploadingResource: false, }; }); return resource; }; const handleSaveBtnClick = async () => { const content = editorRef.current?.getContent(); if (!content) { toastHelper.error(t("editor.cant-empty")); return; } try { const { editMemoId } = editorStateService.getState(); if (editMemoId && editMemoId !== UNKNOWN_ID) { const prevMemo = await memoService.getMemoById(editMemoId ?? UNKNOWN_ID); if (prevMemo) { await memoService.patchMemo({ id: prevMemo.id, content, visibility: editorState.memoVisibility, resourceIdList: editorState.resourceList.map((resource) => resource.id), }); } editorStateService.clearEditMemo(); } else { await memoService.createMemo({ content, visibility: editorState.memoVisibility, resourceIdList: editorState.resourceList.map((resource) => resource.id), }); locationService.clearQuery(); } } catch (error: any) { console.error(error); toastHelper.error(error.response.data.message); } setState((state) => { return { ...state, fullscreen: false, }; }); editorStateService.clearResourceList(); setEditorContentCache(""); storage.remove(["editingMemoVisibilityCache"]); editorRef.current?.setContent(""); }; const handleCancelEdit = () => { editorStateService.clearEditMemo(); editorStateService.clearResourceList(); editorRef.current?.setContent(""); setEditorContentCache(""); storage.remove(["editingMemoVisibilityCache"]); }; 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- [ ] "); } }; 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```"); } }; const handleUploadFileBtnClick = () => { const inputEl = document.createElement("input"); inputEl.style.position = "fixed"; inputEl.style.top = "-100vh"; inputEl.style.left = "-100vw"; document.body.appendChild(inputEl); inputEl.type = "file"; inputEl.multiple = true; inputEl.accept = "*"; inputEl.onchange = async () => { if (!inputEl.files || inputEl.files.length === 0) { return; } const resourceList: Resource[] = []; for (const file of inputEl.files) { const resource = await handleUploadResource(file); if (resource) { resourceList.push(resource); if (editorState.editMemoId) { await upsertMemoResource(editorState.editMemoId, resource.id); } } } editorStateService.setResourceList([...editorState.resourceList, ...resourceList]); document.body.removeChild(inputEl); }; inputEl.click(); }; const handleFullscreenBtnClick = () => { setState((state) => { return { ...state, fullscreen: !state.fullscreen, }; }); }; const handleTagSelectorClick = useCallback((event: React.MouseEvent) => { if (tagSelectorRef.current !== event.target && tagSelectorRef.current?.contains(event.target as Node)) { editorRef.current?.insertText(`#${(event.target as HTMLElement).textContent} ` ?? ""); handleEditorFocus(); } }, []); const handleDeleteResource = async (resourceId: ResourceId) => { editorStateService.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; editorStateService.setMemoVisibility(visibilityValue); setEditingMemoVisibilityCache(visibilityValue); }; const handleEditorFocus = () => { editorRef.current?.focus(); }; const handleEditorBlur = () => { // do nth }; 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 (
{tags.length > 0 ? ( tags.map((tag) => { return ( {tag} ); }) ) : (

e.stopPropagation()}> {t("common.null")}

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