diff --git a/web/src/components/Editor/Editor.tsx b/web/src/components/Editor/Editor.tsx index aa29e5004..a96e313a7 100644 --- a/web/src/components/Editor/Editor.tsx +++ b/web/src/components/Editor/Editor.tsx @@ -1,10 +1,8 @@ import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react"; -import { useTranslation } from "react-i18next"; import useRefresh from "../../hooks/useRefresh"; import "../../less/editor.less"; export interface EditorRefActions { - element: HTMLTextAreaElement; focus: FunctionType; insertText: (text: string) => void; setContent: (text: string) => void; @@ -12,29 +10,19 @@ export interface EditorRefActions { getCursorPosition: () => number; } -interface EditorProps { +interface Props { className: string; initialContent: string; placeholder: string; fullscreen: boolean; - showConfirmBtn: boolean; tools?: ReactNode; - onConfirmBtnClick: (content: string) => void; + onPaste: (event: React.ClipboardEvent) => void; onContentChange: (content: string) => void; } // eslint-disable-next-line react/display-name -const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef) => { - const { - className, - initialContent, - placeholder, - fullscreen, - showConfirmBtn, - onConfirmBtnClick: handleConfirmBtnClickCallback, - onContentChange: handleContentChangeCallback, - } = props; - const { t } = useTranslation(); +const Editor = forwardRef((props: Props, ref: React.ForwardedRef) => { + const { className, initialContent, placeholder, fullscreen, onPaste, onContentChange: handleContentChangeCallback } = props; const editorRef = useRef(null); const refresh = useRefresh(); @@ -54,7 +42,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef ({ - element: editorRef.current as HTMLTextAreaElement, focus: () => { editorRef.current?.focus(); }, @@ -94,25 +81,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef) => { - event.stopPropagation(); - - if (event.code === "Enter") { - if (event.metaKey || event.ctrlKey) { - handleCommonConfirmBtnClick(); - } - } - }, []); - - const handleCommonConfirmBtnClick = useCallback(() => { - if (!editorRef.current) { - return; - } - - handleConfirmBtnClickCallback(editorRef.current.value); - editorRef.current.value = ""; - }, []); - return (
-
-
{props.tools !== undefined && props.tools}
-
- {showConfirmBtn && ( - - )} -
-
); }); diff --git a/web/src/components/MemoEditor.tsx b/web/src/components/MemoEditor.tsx index 796c48c4f..50860dcd3 100644 --- a/web/src/components/MemoEditor.tsx +++ b/web/src/components/MemoEditor.tsx @@ -1,6 +1,7 @@ import { IEmojiData } from "emoji-picker-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { deleteMemoResource, getMemoResourceList, upsertMemoResource } from "../helpers/api"; import { UNKNOWN_ID } from "../helpers/consts"; import { editorStateService, locationService, memoService, resourceService } from "../services"; import { useAppSelector } from "../store"; @@ -25,6 +26,7 @@ interface State { fullscreen: boolean; isUploadingResource: boolean; shouldShowEmojiPicker: boolean; + resourceList: Resource[]; } const MemoEditor: React.FC = () => { @@ -36,9 +38,11 @@ const MemoEditor: React.FC = () => { isUploadingResource: false, fullscreen: false, shouldShowEmojiPicker: false, + resourceList: [], }); - const editorRef = useRef(null); + const [allowSave, setAllowSave] = useState(false); const prevGlobalStateRef = useRef(editorState); + const editorRef = useRef(null); const tagSeletorRef = useRef(null); const editorFontStyle = user?.setting.editorFontStyle || "normal"; const mobileEditorStyle = user?.setting.mobileEditorStyle || "normal"; @@ -50,104 +54,69 @@ const MemoEditor: React.FC = () => { editorRef.current?.insertText(memoLinkText); editorStateService.clearMarkMemo(); } + }, [editorState.markMemoId]); + useEffect(() => { if ( editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID && editorState.editMemoId !== prevGlobalStateRef.current.editMemoId ) { - const editMemo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID); - if (editMemo) { - editorRef.current?.setContent(editMemo.content ?? ""); + const memo = memoService.getMemoById(editorState.editMemoId ?? UNKNOWN_ID); + if (memo) { + getMemoResourceList(memo.id).then(({ data: { data } }) => { + setState({ + ...state, + resourceList: data, + }); + }); + editorRef.current?.setContent(memo.content ?? ""); editorRef.current?.focus(); } } prevGlobalStateRef.current = editorState; - }, [editorState.markMemoId, editorState.editMemoId]); - - useEffect(() => { - const handlePasteEvent = async (event: ClipboardEvent) => { - if (event.clipboardData && event.clipboardData.files.length > 0) { - event.preventDefault(); - const file = event.clipboardData.files[0]; - const url = await handleUploadFile(file); - if (url) { - editorRef.current?.insertText(`![](${url})`); - } - } - }; - - const handleDropEvent = async (event: DragEvent) => { - if (event.dataTransfer && event.dataTransfer.files.length > 0) { - event.preventDefault(); - const file = event.dataTransfer.files[0]; - const url = await handleUploadFile(file); - if (url) { - editorRef.current?.insertText(`![](${url})`); - } + }, [state, editorState.editMemoId]); + + 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) { + setState({ + ...state, + resourceList: [...state.resourceList, resource], + }); } - }; - - const handleClickEvent = () => { - handleContentChange(editorRef.current?.element.value ?? ""); - }; - - const handleKeyDownEvent = () => { - setTimeout(() => { - handleContentChange(editorRef.current?.element.value ?? ""); - }); - }; - - editorRef.current?.element.addEventListener("paste", handlePasteEvent); - editorRef.current?.element.addEventListener("drop", handleDropEvent); - editorRef.current?.element.addEventListener("click", handleClickEvent); - editorRef.current?.element.addEventListener("keydown", handleKeyDownEvent); - - return () => { - editorRef.current?.element.removeEventListener("paste", handlePasteEvent); - editorRef.current?.element.removeEventListener("drop", handleDropEvent); - editorRef.current?.element.removeEventListener("click", handleClickEvent); - editorRef.current?.element.removeEventListener("keydown", handleKeyDownEvent); - }; - }, []); + } + }; - const handleUploadFile = useCallback( - async (file: File) => { - if (state.isUploadingResource) { - return; - } + const handleUploadResource = async (file: File) => { + setState({ + ...state, + isUploadingResource: true, + }); - setState({ - ...state, - isUploadingResource: true, - }); - const { type } = file; + let resource = undefined; - if (!type.startsWith("image")) { - toastHelper.error(t("editor.only-image-supported")); - return; - } + try { + resource = await resourceService.upload(file); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } - try { - const image = await resourceService.upload(file); - const url = `/o/r/${image.id}/${image.filename}`; - return url; - } catch (error: any) { - console.error(error); - toastHelper.error(error.response.data.message); - } finally { - setState({ - ...state, - isUploadingResource: false, - }); - } - }, - [state] - ); + setState({ + ...state, + isUploadingResource: false, + }); + return resource; + }; - const handleSaveBtnClick = async (content: string) => { - if (content === "") { + const handleSaveBtnClick = async () => { + const content = editorRef.current?.getContent(); + if (!content) { toastHelper.error(t("editor.cant-empty")); return; } @@ -165,9 +134,12 @@ const MemoEditor: React.FC = () => { } editorStateService.clearEditMemo(); } else { - await memoService.createMemo({ + const memo = await memoService.createMemo({ content, }); + for (const resource of state.resourceList) { + await upsertMemoResource(memo.id, resource.id); + } locationService.clearQuery(); } } catch (error: any) { @@ -178,19 +150,26 @@ const MemoEditor: React.FC = () => { setState({ ...state, fullscreen: false, + resourceList: [], }); setEditorContentCache(""); + editorRef.current?.setContent(""); }; - const handleCancelEditingBtnClick = useCallback(() => { + const handleCancelEditing = () => { + setState({ + ...state, + resourceList: [], + }); editorStateService.clearEditMemo(); editorRef.current?.setContent(""); setEditorContentCache(""); - }, []); + }; - const handleContentChange = useCallback((content: string) => { + const handleContentChange = (content: string) => { + setAllowSave(content !== ""); setEditorContentCache(content); - }, []); + }; const handleEmojiPickerBtnClick = () => { handleChangeShouldShowEmojiPicker(!state.shouldShowEmojiPicker); @@ -224,7 +203,7 @@ const MemoEditor: React.FC = () => { } }; - const handleUploadFileBtnClick = useCallback(() => { + const handleUploadFileBtnClick = () => { const inputEl = document.createElement("input"); inputEl.style.position = "fixed"; inputEl.style.top = "-100vh"; @@ -232,22 +211,30 @@ const MemoEditor: React.FC = () => { document.body.appendChild(inputEl); inputEl.type = "file"; inputEl.multiple = true; - inputEl.accept = "image/*"; + inputEl.accept = "*"; inputEl.onchange = async () => { if (!inputEl.files || inputEl.files.length === 0) { return; } + const resourceList: Resource[] = []; for (const file of inputEl.files) { - const url = await handleUploadFile(file); - if (url) { - editorRef.current?.insertText(`![](${url})`); + const resource = await handleUploadResource(file); + if (resource) { + resourceList.push(resource); + if (editorState.editMemoId) { + await upsertMemoResource(editorState.editMemoId, resource.id); + } } } + setState({ + ...state, + resourceList: [...state.resourceList, ...resourceList], + }); document.body.removeChild(inputEl); }; inputEl.click(); - }, []); + }; const handleFullscreenBtnClick = () => { setState({ @@ -279,6 +266,17 @@ const MemoEditor: React.FC = () => { handleChangeShouldShowEmojiPicker(false); }; + const handleDeleteResource = async (resourceId: ResourceId) => { + setState({ + ...state, + resourceList: state.resourceList.filter((resource) => resource.id !== resourceId), + }); + + if (editorState.editMemoId) { + await deleteMemoResource(editorState.editMemoId, resourceId); + } + }; + const isEditing = Boolean(editorState.editMemoId && editorState.editMemoId !== UNKNOWN_ID); const editorConfig = useMemo( @@ -287,63 +285,80 @@ const MemoEditor: React.FC = () => { initialContent: getEditorContentCache(), placeholder: t("editor.placeholder"), fullscreen: state.fullscreen, - showConfirmBtn: true, - onConfirmBtnClick: handleSaveBtnClick, onContentChange: handleContentChange, }), - [isEditing, state.fullscreen, i18n.language, editorFontStyle] + [state.fullscreen, i18n.language, editorFontStyle] ); return (
{t("editor.editing")} -
- -
- -
- {tags.length > 0 ? ( - tags.map((tag) => { - return ( - - {tag} - - ); - }) + +
+
+
+ +
+ {tags.length > 0 ? ( + tags.map((tag) => { + return ( + + {tag} + + ); + }) + ) : ( +

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

+ )} +
+
+ + + + + +
+
+ +
+
+ {state.resourceList.length > 0 && ( +
+ {state.resourceList.map((resource) => { + return ( +
+ {resource.type.includes("image") ? ( + ) : ( -

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

+ )} + {resource.filename} + handleDeleteResource(resource.id)} />
-
- - - - - - - } - /> + ); + })} +
+ )} >(`/api/memo/${memoId}/resource`); +} + +export function upsertMemoResource(memoId: MemoId, resourceId: ResourceId) { + return axios.post>(`/api/memo/${memoId}/resource`, { + resourceId, + }); +} + +export function deleteMemoResource(memoId: MemoId, resourceId: ResourceId) { + return axios.delete(`/api/memo/${memoId}/resource/${resourceId}`); +} + export function getTagList(tagFind?: TagFind) { const queryList = []; if (tagFind?.creatorId) { diff --git a/web/src/less/editor.less b/web/src/less/editor.less index cd280a42f..c027f504b 100644 --- a/web/src/less/editor.less +++ b/web/src/less/editor.less @@ -25,44 +25,4 @@ } } } - - > .common-tools-wrapper { - @apply w-full flex flex-row justify-between items-center; - - > .common-tools-container { - @apply flex flex-row justify-start items-center; - - > .action-btn { - @apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer opacity-60 hover:opacity-90 hover:bg-gray-300 hover:shadow; - - > .icon-img { - @apply w-5 h-5 mx-auto flex flex-row justify-center items-center; - } - - > .tip-text { - @apply hidden ml-1 text-xs leading-5 text-gray-700 border border-gray-300 rounded-xl px-2; - } - } - } - - > .btns-container { - @apply grow-0 shrink-0 flex flex-row justify-end items-center; - - > .action-btn { - @apply border-none select-none cursor-pointer py-1 px-3 rounded text-sm hover:opacity-80; - } - - > .cancel-btn { - @apply text-gray-500 bg-transparent mr-2; - } - - > .confirm-btn { - @apply shadow cursor-pointer px-3 py-0 leading-8 bg-green-600 text-white text-sm hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60; - - > .icon-text { - @apply text-base ml-1; - } - } - } - } } diff --git a/web/src/less/memo-editor.less b/web/src/less/memo-editor.less index 2c3bae4ba..835672f4f 100644 --- a/web/src/less/memo-editor.less +++ b/web/src/less/memo-editor.less @@ -63,28 +63,88 @@ > .memo-editor { @apply flex flex-col justify-start items-start relative w-full h-auto bg-white; + } - .tag-action { - @apply relative; + > .common-tools-wrapper { + @apply w-full flex flex-row justify-between items-center; - &:hover { - > .tag-list { - @apply flex; - } - } + > .common-tools-container { + @apply flex flex-row justify-start items-center; + + > .action-btn { + @apply flex flex-row justify-center items-center p-1 w-auto h-auto mr-1 select-none rounded cursor-pointer opacity-60 hover:opacity-90 hover:bg-gray-300 hover:shadow; - > .tag-list { - @apply hidden flex-col justify-start items-start absolute top-6 left-0 mt-1 p-1 z-1 rounded w-32 max-h-52 overflow-auto font-mono bg-black; + &.tag-action { + @apply relative; - > .item-container { - @apply w-full text-white cursor-pointer rounded text-sm leading-6 px-2 hover:bg-gray-700; + &:hover { + > .tag-list { + @apply flex; + } + } + + > .tag-list { + @apply hidden flex-col justify-start items-start absolute top-6 left-0 mt-1 p-1 z-1 rounded w-32 max-h-52 overflow-auto font-mono bg-black; + + > .item-container { + @apply w-full text-white cursor-pointer rounded text-sm leading-6 px-2 hover:bg-gray-700; + } + + > .tip-text { + @apply w-full text-sm text-gray-200 leading-6 px-2 cursor-default; + } + } + } + + > .icon-img { + @apply w-5 h-5 mx-auto flex flex-row justify-center items-center; } > .tip-text { - @apply w-full text-sm text-gray-200 leading-6 px-2 cursor-default; + @apply hidden ml-1 text-xs leading-5 text-gray-700 border border-gray-300 rounded-xl px-2; } } } + + > .btns-container { + @apply grow-0 shrink-0 flex flex-row justify-end items-center; + + > .action-btn { + @apply border-none select-none cursor-pointer py-1 px-3 rounded text-sm hover:opacity-80; + } + + > .cancel-btn { + @apply text-gray-500 bg-transparent mr-2; + } + + > .confirm-btn { + @apply shadow cursor-pointer px-3 py-0 leading-8 bg-green-600 text-white text-sm hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60; + + > .icon-text { + @apply text-base ml-1; + } + } + } + } + + > .resource-list-wrapper { + @apply w-full flex flex-row justify-start flex-wrap; + + > .resource-container { + @apply mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-50 px-2 py-1 rounded cursor-pointer hover:bg-gray-100; + + > .icon-img { + @apply w-4 h-auto mr-1 text-gray-500; + } + + > .name-text { + @apply text-gray-500 text-sm max-w-xs truncate font-mono; + } + + > .close-icon { + @apply w-4 h-auto ml-1 text-gray-500 hover:text-gray-800; + } + } } .emoji-picker-react { diff --git a/web/src/services/memoService.ts b/web/src/services/memoService.ts index bc98dcb1e..332149d06 100644 --- a/web/src/services/memoService.ts +++ b/web/src/services/memoService.ts @@ -93,6 +93,7 @@ const memoService = { const { data } = (await api.createMemo(memoCreate)).data; const memo = convertResponseModelMemo(data); store.dispatch(createMemo(memo)); + return memo; }, patchMemo: async (memoPatch: MemoPatch): Promise => {