From ba3d54d82278973d0bc3270806459ee4e3a3eb90 Mon Sep 17 00:00:00 2001 From: choidabom <48302257+choidabom@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:44:59 +0900 Subject: [PATCH] Add hyperlink creation feature (#332) * Add useToolBar hook * Add hyperlink creation feature * Add text selection check before inserting hyperlink * Refactor `useToolBar` & extract `checkAndAddFormat` functon to a separate utility file * Change valid URL check regular expression to `validator` library * Fix missing validator package installation --- frontend/package-lock.json | 16 ++++ frontend/package.json | 2 + frontend/src/components/editor/Editor.tsx | 98 ++++------------------ frontend/src/hooks/useFormatUtils.ts | 16 ++++ frontend/src/hooks/useToolBar.ts | 60 +++++++++++++ frontend/src/utils/urlHyperlinkInserter.ts | 45 ++++++++++ 6 files changed, 156 insertions(+), 81 deletions(-) create mode 100644 frontend/src/hooks/useToolBar.ts create mode 100644 frontend/src/utils/urlHyperlinkInserter.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10bf09b9..864bf7a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -60,6 +60,7 @@ "rehype-rewrite": "^4.0.2", "rehype-sanitize": "^6.0.0", "remark-math": "^6.0.0", + "validator": "^13.12.0", "vite-plugin-package-version": "^1.1.0", "yorkie-js-sdk": "0.4.31" }, @@ -74,6 +75,7 @@ "@types/react-dom": "^18.2.17", "@types/react-infinite-scroller": "^1.2.5", "@types/react-scroll-sync": "^0.9.0", + "@types/validator": "^13.12.1", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -2804,6 +2806,12 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.12.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.1.tgz", + "integrity": "sha512-w0URwf7BQb0rD/EuiG12KP0bailHKHP5YVviJG9zw3ykAokL0TuxU2TUqMB7EwZ59bDHYdeTIvjI5m0S7qHfOA==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -8662,6 +8670,14 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index ba84ecd4..bd8acfab 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,6 +73,7 @@ "rehype-rewrite": "^4.0.2", "rehype-sanitize": "^6.0.0", "remark-math": "^6.0.0", + "validator": "^13.12.0", "vite-plugin-package-version": "^1.1.0", "yorkie-js-sdk": "0.4.31" }, @@ -87,6 +88,7 @@ "@types/react-dom": "^18.2.17", "@types/react-infinite-scroller": "^1.2.5", "@types/react-scroll-sync": "^0.9.0", + "@types/validator": "^13.12.1", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/src/components/editor/Editor.tsx b/frontend/src/components/editor/Editor.tsx index 2682c81e..31f9cae4 100644 --- a/frontend/src/components/editor/Editor.tsx +++ b/frontend/src/components/editor/Editor.tsx @@ -1,6 +1,6 @@ import { markdown } from "@codemirror/lang-markdown"; import { EditorState } from "@codemirror/state"; -import { keymap, ViewUpdate } from "@codemirror/view"; +import { keymap } from "@codemirror/view"; import { xcodeDark, xcodeLight } from "@uiw/codemirror-theme-xcode"; import { basicSetup, EditorView } from "codemirror"; import { useCallback, useEffect, useState } from "react"; @@ -8,12 +8,14 @@ import { useDispatch, useSelector } from "react-redux"; import { ScrollSyncPane } from "react-scroll-sync"; import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; -import { FormatType, ToolBarState, useFormatUtils } from "../../hooks/useFormatUtils"; +import { useFormatUtils } from "../../hooks/useFormatUtils"; +import { useToolBar } from "../../hooks/useToolBar"; import { selectEditor, setCmView } from "../../store/editorSlice"; import { selectSetting } from "../../store/settingSlice"; import { selectWorkspace } from "../../store/workspaceSlice"; import { imageUploader } from "../../utils/imageUploader"; import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; +import { urlHyperlinkInserter } from "../../utils/urlHyperlinkInserter"; import { yorkieCodeMirror } from "../../utils/yorkie"; import ToolBar from "./ToolBar"; @@ -26,77 +28,15 @@ function Editor() { const workspaceStore = useSelector(selectWorkspace); const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation(); const { mutateAsync: uploadFile } = useUploadFileMutation(); - - const [toolBarState, setToolBarState] = useState({ - show: false, - position: { top: 0, left: 0 }, - selectedFormats: new Set(), - }); - - const { getFormatMarkerLength, applyFormat, setKeymapConfig } = useFormatUtils(); + const { applyFormat, setKeymapConfig } = useFormatUtils(); + const { toolBarState, setToolBarState, updateFormatBar } = useToolBar(); const ref = useCallback((node: HTMLElement | null) => { if (!node) return; setElement(node); }, []); - const updateFormatBar = useCallback( - (update: ViewUpdate) => { - const selection = update.state.selection.main; - if (!selection.empty) { - const coords = update.view.coordsAtPos(selection.from); - if (coords) { - const maxLength = getFormatMarkerLength(update.view.state, selection.from); - - const selectedTextStart = update.state.sliceDoc( - selection.from - maxLength, - selection.from - ); - const selectedTextEnd = update.state.sliceDoc( - selection.to, - selection.to + maxLength - ); - const formats = new Set(); - - const checkAndAddFormat = (marker: string, format: FormatType) => { - if ( - selectedTextStart.includes(marker) && - selectedTextEnd.includes(marker) - ) { - formats.add(format); - } - }; - - checkAndAddFormat("**", FormatType.BOLD); - checkAndAddFormat("_", FormatType.ITALIC); - checkAndAddFormat("`", FormatType.CODE); - checkAndAddFormat("~~", FormatType.STRIKETHROUGH); - - // TODO: Modify the rendering method so that it is not affected by the size of the Toolbar - setToolBarState((prev) => ({ - ...prev, - show: true, - position: { - top: coords.top - 5, - left: coords.left, - }, - selectedFormats: formats, - })); - } - } else { - setToolBarState((prev) => ({ - ...prev, - show: false, - selectedFormats: new Set(), - })); - } - }, - [getFormatMarkerLength] - ); - useEffect(() => { - let view: EditorView | undefined = undefined; - if ( !element || !editorStore.doc || @@ -126,28 +66,24 @@ function Editor() { keymap.of(setKeymapConfig()), basicSetup, markdown(), - yorkieCodeMirror(editorStore.doc, editorStore.client), themeMode == "light" ? xcodeLight : xcodeDark, - EditorView.theme({ - "&": { width: "100%" }, - }), + EditorView.theme({ "&": { width: "100%" } }), EditorView.lineWrapping, - intelligencePivot, - ...(settingStore.fileUpload.enable - ? [imageUploader(handleUploadImage, editorStore.doc)] - : []), EditorView.updateListener.of((update) => { if (update.selectionSet) { updateFormatBar(update); } }), + yorkieCodeMirror(editorStore.doc, editorStore.client), + intelligencePivot, + ...(settingStore.fileUpload.enable + ? [imageUploader(handleUploadImage, editorStore.doc)] + : []), + urlHyperlinkInserter(editorStore.doc), ], }); - view = new EditorView({ - state, - parent: element, - }); + const view = new EditorView({ state, parent: element }); dispatch(setCmView(view)); @@ -155,15 +91,15 @@ function Editor() { view?.destroy(); }; }, [ - dispatch, + element, editorStore.client, editorStore.doc, - element, themeMode, workspaceStore.data, + settingStore.fileUpload?.enable, + dispatch, createUploadUrl, uploadFile, - settingStore.fileUpload?.enable, applyFormat, updateFormatBar, setKeymapConfig, diff --git a/frontend/src/hooks/useFormatUtils.ts b/frontend/src/hooks/useFormatUtils.ts index deedf1e0..8816cba5 100644 --- a/frontend/src/hooks/useFormatUtils.ts +++ b/frontend/src/hooks/useFormatUtils.ts @@ -143,10 +143,26 @@ export const useFormatUtils = () => { [cmView, applyFormat] ); + const checkAndAddFormat = useCallback( + ( + selectedTextStart: string, + selectedTextEnd: string, + marker: string, + format: FormatType, + formats: Set + ) => { + if (selectedTextStart.includes(marker) && selectedTextEnd.includes(marker)) { + formats.add(format); + } + }, + [] + ); + return { getFormatMarkerLength, applyFormat, setKeymapConfig, toggleButtonChangeHandler, + checkAndAddFormat, }; }; diff --git a/frontend/src/hooks/useToolBar.ts b/frontend/src/hooks/useToolBar.ts new file mode 100644 index 00000000..fcbffecc --- /dev/null +++ b/frontend/src/hooks/useToolBar.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from "react"; +import { FormatType, ToolBarState, useFormatUtils } from "./useFormatUtils"; +import { ViewUpdate } from "@codemirror/view"; + +export const useToolBar = () => { + const [toolBarState, setToolBarState] = useState({ + show: false, + position: { top: 0, left: 0 }, + selectedFormats: new Set(), + }); + const { getFormatMarkerLength, checkAndAddFormat } = useFormatUtils(); + + const updateFormatBar = useCallback( + (update: ViewUpdate) => { + const { state, view } = update; + const selection = state.selection.main; + + if (selection.empty) { + setToolBarState((prev) => ({ + ...prev, + show: false, + selectedFormats: new Set(), + })); + return; + } + + const coords = view.coordsAtPos(selection.from); + if (!coords) return; + + const maxLength = getFormatMarkerLength(view.state, selection.from); + const selectedTextStart = state.sliceDoc(selection.from - maxLength, selection.from); + const selectedTextEnd = state.sliceDoc(selection.to, selection.to + maxLength); + const formats = new Set(); + const formatChecks = [ + { marker: "**", format: FormatType.BOLD }, + { marker: "_", format: FormatType.ITALIC }, + { marker: "`", format: FormatType.CODE }, + { marker: "~~", format: FormatType.STRIKETHROUGH }, + ]; + + formatChecks.forEach(({ marker, format }) => { + checkAndAddFormat(selectedTextStart, selectedTextEnd, marker, format, formats); + }); + + // TODO: Modify the rendering method so that it is not affected by the size of the Toolbar + setToolBarState((prev) => ({ + ...prev, + show: true, + position: { + top: coords.top - 5, + left: coords.left, + }, + selectedFormats: formats, + })); + }, + [getFormatMarkerLength, checkAndAddFormat] + ); + + return { toolBarState, setToolBarState, updateFormatBar }; +}; diff --git a/frontend/src/utils/urlHyperlinkInserter.ts b/frontend/src/utils/urlHyperlinkInserter.ts new file mode 100644 index 00000000..961f29b6 --- /dev/null +++ b/frontend/src/utils/urlHyperlinkInserter.ts @@ -0,0 +1,45 @@ +import { EditorView } from "codemirror"; +import validator from "validator"; +import { CodePairDocType } from "../store/editorSlice"; + +const isValidUrl = (url: string) => { + return validator.isURL(url); +}; + +const insertLinkToEditor = (url: string, view: EditorView, doc: CodePairDocType) => { + const { from, to } = view.state.selection.main; + const selectedText = view.state.sliceDoc(from, to); + const insert = `[${selectedText}](${url})`; + + doc.update((root, presence) => { + root.content.edit(from, to, insert); + presence.set({ + selection: root.content.indexRangeToPosRange([ + from + insert.length, + from + insert.length, + ]), + }); + }); + + view.dispatch({ + changes: { from, to, insert }, + selection: { + anchor: from + insert.length, + }, + }); +}; + +export const urlHyperlinkInserter = (doc: CodePairDocType) => { + return EditorView.domEventHandlers({ + paste(event, view) { + const url = event.clipboardData?.getData("text/plain"); + if (!url || !isValidUrl(url)) return; + + const { from, to } = view.state.selection.main; + if (from === to) return; + + insertLinkToEditor(url, view, doc); + event.preventDefault(); + }, + }); +};