diff --git a/web/src/components/Editor/Editor.tsx b/web/src/components/Editor/Editor.tsx index 48f09615..d43c59b6 100644 --- a/web/src/components/Editor/Editor.tsx +++ b/web/src/components/Editor/Editor.tsx @@ -120,6 +120,7 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef { if (editorRef.current) { editorRef.current.value = text; + handleContentChangeCallback(editorRef.current.value); refresh(); } }, diff --git a/web/src/components/MemoEditor.tsx b/web/src/components/MemoEditor.tsx index 2a3265af..ece2430f 100644 --- a/web/src/components/MemoEditor.tsx +++ b/web/src/components/MemoEditor.tsx @@ -1,18 +1,57 @@ -import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import appContext from "../stores/appContext"; import { globalStateService, locationService, memoService, resourceService } from "../services"; import utils from "../helpers/utils"; import { storage } from "../helpers/storage"; +import useToggle from "../hooks/useToggle"; import toastHelper from "./Toast"; import Editor, { EditorProps, EditorRefActions } from "./Editor/Editor"; import "../less/memo-editor.less"; +const getCursorPostion = (input: HTMLTextAreaElement) => { + const { offsetLeft: inputX, offsetTop: inputY, selectionEnd: selectionPoint } = input; + const div = document.createElement("div"); + + const copyStyle = window.getComputedStyle(input); + for (const item of copyStyle) { + div.style.setProperty(item, copyStyle.getPropertyValue(item)); + } + div.style.position = "fixed"; + div.style.visibility = "hidden"; + div.style.whiteSpace = "pre-wrap"; + + // we need a character that will replace whitespace when filling our dummy element if it's a single line + const swap = "."; + const inputValue = input.tagName === "INPUT" ? input.value.replace(/ /g, swap) : input.value; + const textContent = inputValue.substring(0, selectionPoint || 0); + div.textContent = textContent; + if (input.tagName === "TEXTAREA") { + div.style.height = "auto"; + } + + const span = document.createElement("span"); + span.textContent = inputValue.substring(selectionPoint || 0) || "."; + div.appendChild(span); + document.body.appendChild(div); + const { offsetLeft: spanX, offsetTop: spanY } = span; + document.body.removeChild(div); + return { + x: inputX + spanX, + y: inputY + spanY, + }; +}; + interface Props {} const MemoEditor: React.FC = () => { - const { globalState } = useContext(appContext); + const { + globalState, + memoState: { tags }, + } = useContext(appContext); + const [isTagSeletorShown, toggleTagSeletor] = useToggle(false); const editorRef = useRef(null); const prevGlobalStateRef = useRef(globalState); + const tagSeletorRef = useRef(null); useEffect(() => { if (globalState.markMemoId) { @@ -60,8 +99,22 @@ const MemoEditor: React.FC = () => { } }; + const handleClickEvent = () => { + setTimeout(() => { + 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); @@ -94,8 +147,6 @@ const MemoEditor: React.FC = () => { const { editMemoId } = globalStateService.getState(); - content = content.replaceAll(" ", " "); - try { if (editMemoId) { const prevMemo = memoService.getMemoById(editMemoId); @@ -131,6 +182,23 @@ const MemoEditor: React.FC = () => { content = ""; } setEditorContentCache(content); + + if (editorRef.current) { + const currentValue = editorRef.current.getContent(); + const selectionStart = editorRef.current.element.selectionStart; + const prevString = currentValue.slice(0, selectionStart); + + if (prevString.endsWith("#")) { + toggleTagSeletor(true); + updateTagSelectorPopupPosition(); + } else { + toggleTagSeletor(false); + } + + setTimeout(() => { + editorRef.current?.focus(); + }); + } }, []); const handleTagTextBtnClick = useCallback(() => { @@ -142,22 +210,41 @@ const MemoEditor: React.FC = () => { const selectionStart = editorRef.current.element.selectionStart; const prevString = currentValue.slice(0, selectionStart); const nextString = currentValue.slice(selectionStart); - let cursorIndex = prevString.length; - if (prevString.endsWith("# ") && nextString.startsWith(" ")) { - editorRef.current.setContent(prevString.slice(0, prevString.length - 2) + nextString.slice(1)); - cursorIndex = prevString.length - 2; - } else { - editorRef.current.element.value = prevString + "# " + nextString; - cursorIndex = prevString.length + 2; + let nextValue = prevString + "# " + nextString; + let cursorIndex = prevString.length + 1; + + if (prevString.endsWith("#") && nextString.startsWith(" ")) { + nextValue = prevString.slice(0, prevString.length - 1) + nextString.slice(1); + cursorIndex = prevString.length - 1; } setTimeout(() => { - editorRef.current?.element.setSelectionRange(cursorIndex, cursorIndex); - editorRef.current?.focus(); + if (!editorRef.current) { + return; + } + + editorRef.current.element.value = nextValue; + editorRef.current.element.setSelectionRange(cursorIndex, cursorIndex); + editorRef.current.focus(); + handleContentChange(editorRef.current.element.value); }); }, []); + const updateTagSelectorPopupPosition = useCallback(() => { + if (!editorRef.current || !tagSeletorRef.current) { + return; + } + + const { x, y } = getCursorPostion(editorRef.current.element); + if (x + 128 + 16 > editorRef.current.element.clientWidth) { + tagSeletorRef.current.style.left = `${editorRef.current.element.clientWidth + 20 - 128}px`; + } else { + tagSeletorRef.current.style.left = `${x + 2}px`; + } + tagSeletorRef.current.style.top = `${y + 32 + 6}px`; + }, []); + const handleUploadFileBtnClick = useCallback(() => { const inputEl = document.createElement("input"); inputEl.type = "file"; @@ -177,6 +264,12 @@ const MemoEditor: React.FC = () => { inputEl.click(); }, []); + const handleTagSeletorClick = useCallback((event: React.MouseEvent) => { + if (tagSeletorRef.current !== event.target && tagSeletorRef.current?.contains(event.target as Node)) { + editorRef.current?.insertText((event.target as HTMLElement).textContent ?? ""); + } + }, []); + const showEditStatus = Boolean(globalState.editMemoId); const editorConfig: EditorProps = useMemo( @@ -200,6 +293,15 @@ const MemoEditor: React.FC = () => {

正在修改中...

+
0 ? "" : "hidden"}`} + onClick={handleTagSeletorClick} + > + {tags.map((t) => { + return {t}; + })} +
); }; diff --git a/web/src/components/MemoList.tsx b/web/src/components/MemoList.tsx index 4e2d636e..20bb2361 100644 --- a/web/src/components/MemoList.tsx +++ b/web/src/components/MemoList.tsx @@ -34,7 +34,7 @@ const MemoList: React.FC = () => { } } - if (tagQuery && !memo.content.includes(`# ${tagQuery}`)) { + if (tagQuery && !memo.content.includes(`#${tagQuery} `) && !memo.content.includes(`# ${tagQuery} `)) { shouldShow = false; } if ( diff --git a/web/src/helpers/consts.ts b/web/src/helpers/consts.ts index d83735c9..8873428f 100644 --- a/web/src/helpers/consts.ts +++ b/web/src/helpers/consts.ts @@ -11,7 +11,7 @@ export const TOAST_ANIMATION_DURATION = 400; export const DAILY_TIMESTAMP = 3600 * 24 * 1000; // 标签 正则 -export const TAG_REG = /#\s(.+?)\s/g; +export const TAG_REG = /#\s?(.+?)\s/g; // URL 正则 export const LINK_REG = /(https?:\/\/[^\s<\\*>']+)/g; diff --git a/web/src/helpers/filter.ts b/web/src/helpers/filter.ts index f193b3af..68d1ca7c 100644 --- a/web/src/helpers/filter.ts +++ b/web/src/helpers/filter.ts @@ -119,7 +119,7 @@ export const checkShouldShowMemo = (memo: Model.Memo, filter: Filter) => { let shouldShow = true; if (type === "TAG") { - let contained = memo.content.includes(`# ${value}`); + let contained = memo.content.includes(`#${value} `) || memo.content.includes(`# ${value} `); if (operator === "NOT_CONTAIN") { contained = !contained; } diff --git a/web/src/less/memo-editor.less b/web/src/less/memo-editor.less index a3074c9f..41ad0f5f 100644 --- a/web/src/less/memo-editor.less +++ b/web/src/less/memo-editor.less @@ -27,6 +27,31 @@ height: auto; background-color: white; } + + > .tag-list { + .flex(column, flex-start, flex-start); + position: absolute; + z-index: 10; + background-color: rgba(30, 30, 30, 0.9); + padding: 2px; + border-radius: 4px; + width: 128px; + max-height: 200px; + overflow: auto; + + > span { + width: 100%; + padding: 2px 6px; + color: white; + cursor: pointer; + border-radius: 4px; + font-size: 13px; + + &:hover { + background-color: #505050; + } + } + } } @media only screen and (max-width: 875px) { diff --git a/web/src/services/memoService.ts b/web/src/services/memoService.ts index dbe1a54c..682c1589 100644 --- a/web/src/services/memoService.ts +++ b/web/src/services/memoService.ts @@ -105,7 +105,7 @@ class MemoService { appStore.dispatch({ type: "SET_TAGS", payload: { - tags: Array.from(tagsSet), + tags: Array.from(tagsSet).filter((t) => Boolean(t)), }, }); }