From 04c374ccda174296089678b91109102e81ebadd8 Mon Sep 17 00:00:00 2001 From: choidabom <48302257+choidabom@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:14:50 +0900 Subject: [PATCH] Add Vim binding support using CodeMirror 6 (#340) * Add Vim binding for CodePair using CodeMirror 6 * Add vim key mapping 'jj' for insert mode * Fix argument type * Fix components to use `function` keyword * Add EditorBottomBar props interface * Fix enum members to use capital letters * Move `EditorBottomBar` into `Editor` components * Change to `Paper` component * Move codekey options to `configSlice` --- frontend/package-lock.json | 13 ++++ frontend/package.json | 1 + .../src/components/common/ThemeButton.tsx | 8 +- .../src/components/editor/DocumentView.tsx | 34 ++++++--- frontend/src/components/editor/Editor.tsx | 63 ++++++++++------ .../src/components/editor/EditorBottomBar.tsx | 74 +++++++++++++++++++ frontend/src/components/editor/Preview.tsx | 2 +- .../src/components/headers/DocumentHeader.tsx | 7 +- .../components/popovers/ProfilePopover.tsx | 16 ++-- .../pages/workspace/document/share/Index.tsx | 13 +++- frontend/src/store/configSlice.ts | 22 +++++- frontend/src/store/editorSlice.ts | 19 +++-- 12 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/editor/EditorBottomBar.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6ba95e8..d853b4fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@mui/x-date-pickers": "^7.13.0", "@react-hook/window-size": "^3.1.1", "@reduxjs/toolkit": "^2.0.1", + "@replit/codemirror-vim": "^6.2.1", "@sentry/react": "^7.99.0", "@swc/helpers": "^0.5.3", "@tanstack/react-query": "^5.17.15", @@ -1973,6 +1974,18 @@ "node": ">=14.0.0" } }, + "node_modules/@replit/codemirror-vim": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.2.1.tgz", + "integrity": "sha512-qDAcGSHBYU5RrdO//qCmD8K9t6vbP327iCj/iqrkVnjbrpFhrjOt92weGXGHmTNRh16cUtkUZ7Xq7rZf+8HVow==", + "peerDependencies": { + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.1.0", + "@codemirror/search": "^6.2.0", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.0.3" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6f83a6f1..cdcd38dd 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "@mui/x-date-pickers": "^7.13.0", "@react-hook/window-size": "^3.1.1", "@reduxjs/toolkit": "^2.0.1", + "@replit/codemirror-vim": "^6.2.1", "@sentry/react": "^7.99.0", "@swc/helpers": "^0.5.3", "@tanstack/react-query": "^5.17.15", diff --git a/frontend/src/components/common/ThemeButton.tsx b/frontend/src/components/common/ThemeButton.tsx index c29a036c..6d8afe69 100644 --- a/frontend/src/components/common/ThemeButton.tsx +++ b/frontend/src/components/common/ThemeButton.tsx @@ -1,21 +1,21 @@ -import { IconButton } from "@mui/material"; import DarkModeIcon from "@mui/icons-material/DarkMode"; import LightModeIcon from "@mui/icons-material/LightMode"; +import { IconButton } from "@mui/material"; import { useDispatch } from "react-redux"; -import { setTheme } from "../../store/configSlice"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; +import { setTheme, ThemeType } from "../../store/configSlice"; function ThemeButton() { const dispatch = useDispatch(); const themeMode = useCurrentTheme(); const handleChangeTheme = () => { - dispatch(setTheme(themeMode == "light" ? "dark" : "light")); + dispatch(setTheme(themeMode == ThemeType.LIGHT ? ThemeType.DARK : ThemeType.LIGHT)); }; return ( - {themeMode === "light" ? : } + {themeMode === ThemeType.LIGHT ? : } ); } diff --git a/frontend/src/components/editor/DocumentView.tsx b/frontend/src/components/editor/DocumentView.tsx index 7b8613e4..1209458c 100644 --- a/frontend/src/components/editor/DocumentView.tsx +++ b/frontend/src/components/editor/DocumentView.tsx @@ -1,11 +1,11 @@ +import { Backdrop, Box, CircularProgress, Paper } from "@mui/material"; +import { useWindowWidth } from "@react-hook/window-size"; import { useSelector } from "react-redux"; -import { selectEditor } from "../../store/editorSlice"; import Resizable from "react-resizable-layout"; -import { useWindowWidth } from "@react-hook/window-size"; +import { ScrollSync, ScrollSyncPane } from "react-scroll-sync"; +import { EditorModeType, selectEditor } from "../../store/editorSlice"; import Editor from "./Editor"; -import { Backdrop, Box, CircularProgress, Paper } from "@mui/material"; import Preview from "./Preview"; -import { ScrollSync, ScrollSyncPane } from "react-scroll-sync"; function DocumentView() { const editorStore = useSelector(selectEditor); @@ -20,9 +20,7 @@ function DocumentView() { return ( <> - {/* For Markdown Preview Theme */} -
- {editorStore.mode === "both" && ( + {editorStore.mode === EditorModeType.BOTH && ( {({ position: width, separatorProps }) => ( @@ -32,10 +30,18 @@ function DocumentView() { display: "flex", height: "100%", overflow: "hidden", + position: "relative", }} > -
- +
+
)} - {editorStore.mode === "read" && ( + + {editorStore.mode === EditorModeType.EDIT && ( +
+ +
+ )} + + {editorStore.mode === EditorModeType.READ && ( )} - {editorStore.mode === "edit" && } ); } diff --git a/frontend/src/components/editor/Editor.tsx b/frontend/src/components/editor/Editor.tsx index 31f9cae4..1c6dfe29 100644 --- a/frontend/src/components/editor/Editor.tsx +++ b/frontend/src/components/editor/Editor.tsx @@ -1,6 +1,7 @@ import { markdown } from "@codemirror/lang-markdown"; import { EditorState } from "@codemirror/state"; import { keymap } from "@codemirror/view"; +import { Vim, vim } from "@replit/codemirror-vim"; import { xcodeDark, xcodeLight } from "@uiw/codemirror-theme-xcode"; import { basicSetup, EditorView } from "codemirror"; import { useCallback, useEffect, useState } from "react"; @@ -10,6 +11,7 @@ import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/a import { useCurrentTheme } from "../../hooks/useCurrentTheme"; import { useFormatUtils } from "../../hooks/useFormatUtils"; import { useToolBar } from "../../hooks/useToolBar"; +import { CodeKeyType, selectConfig } from "../../store/configSlice"; import { selectEditor, setCmView } from "../../store/editorSlice"; import { selectSetting } from "../../store/settingSlice"; import { selectWorkspace } from "../../store/workspaceSlice"; @@ -17,13 +19,20 @@ import { imageUploader } from "../../utils/imageUploader"; import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; import { urlHyperlinkInserter } from "../../utils/urlHyperlinkInserter"; import { yorkieCodeMirror } from "../../utils/yorkie"; +import EditorBottomBar, { BOTTOM_BAR_HEIGHT } from "./EditorBottomBar"; import ToolBar from "./ToolBar"; -function Editor() { +interface EditorProps { + width: number | string; +} + +function Editor(props: EditorProps) { + const { width } = props; const dispatch = useDispatch(); const themeMode = useCurrentTheme(); const [element, setElement] = useState(); const editorStore = useSelector(selectEditor); + const configStore = useSelector(selectConfig); const settingStore = useSelector(selectSetting); const workspaceStore = useSelector(selectWorkspace); const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation(); @@ -66,6 +75,7 @@ function Editor() { keymap.of(setKeymapConfig()), basicSetup, markdown(), + configStore.codeKey === CodeKeyType.VIM ? vim() : [], themeMode == "light" ? xcodeLight : xcodeDark, EditorView.theme({ "&": { width: "100%" } }), EditorView.lineWrapping, @@ -83,8 +93,10 @@ function Editor() { ], }); - const view = new EditorView({ state, parent: element }); + // Vim key mapping: Map 'jj' to '' in insert mode + Vim.map("jj", "", "insert"); + const view = new EditorView({ state, parent: element }); dispatch(setCmView(view)); return () => { @@ -94,6 +106,7 @@ function Editor() { element, editorStore.client, editorStore.doc, + configStore.codeKey, themeMode, workspaceStore.data, settingStore.fileUpload?.enable, @@ -106,26 +119,34 @@ function Editor() { ]); return ( - -
-
- {Boolean(toolBarState.show) && ( - - )} + <> +
+ +
+
+ {Boolean(toolBarState.show) && ( + + )} +
+
-
+ + ); } diff --git a/frontend/src/components/editor/EditorBottomBar.tsx b/frontend/src/components/editor/EditorBottomBar.tsx new file mode 100644 index 00000000..b3e3d507 --- /dev/null +++ b/frontend/src/components/editor/EditorBottomBar.tsx @@ -0,0 +1,74 @@ +import { Button, Menu, MenuItem, Paper } from "@mui/material"; +import { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { CodeKeyType, selectConfig, setCodeKeyType } from "../../store/configSlice"; + +export const BOTTOM_BAR_HEIGHT = 25; + +interface EditorBottomBarProps { + width: number | string; +} + +function EditorBottomBar(props: EditorBottomBarProps) { + const { width } = props; + const dispatch = useDispatch(); + const configStore = useSelector(selectConfig); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleChangeCodeKey = (newKeyCode: CodeKeyType) => { + dispatch(setCodeKeyType(newKeyCode)); + handleClose(); + }; + + return ( + + + + {Object.values(CodeKeyType).map((keyType) => ( + handleChangeCodeKey(keyType)}> + {keyType} + + ))} + + + ); +} + +export default EditorBottomBar; diff --git a/frontend/src/components/editor/Preview.tsx b/frontend/src/components/editor/Preview.tsx index 6e1d493b..c9d7a3bc 100644 --- a/frontend/src/components/editor/Preview.tsx +++ b/frontend/src/components/editor/Preview.tsx @@ -6,8 +6,8 @@ import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; import rehypeExternalLinks from "rehype-external-links"; import rehypeKatex from "rehype-katex"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { getCodeString } from "rehype-rewrite"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkMath from "remark-math"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; import { selectEditor } from "../../store/editorSlice"; diff --git a/frontend/src/components/headers/DocumentHeader.tsx b/frontend/src/components/headers/DocumentHeader.tsx index 75f014b4..89e86769 100644 --- a/frontend/src/components/headers/DocumentHeader.tsx +++ b/frontend/src/components/headers/DocumentHeader.tsx @@ -18,6 +18,7 @@ import { useNavigate } from "react-router-dom"; import { useUserPresence } from "../../hooks/useUserPresence"; import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice"; import { selectWorkspace } from "../../store/workspaceSlice"; +import { ShareRole } from "../../utils/share"; import DownloadMenu from "../common/DownloadMenu"; import ShareButton from "../common/ShareButton"; import ThemeButton from "../common/ThemeButton"; @@ -31,8 +32,8 @@ function DocumentHeader() { const { presenceList } = useUserPresence(editorState.doc); useEffect(() => { - if (editorState.shareRole === "READ") { - dispatch(setMode("read")); + if (editorState.shareRole === ShareRole.READ) { + dispatch(setMode(EditorModeType.READ)); } }, [dispatch, editorState.shareRole]); @@ -58,7 +59,7 @@ function DocumentHeader() { )} - {editorState.shareRole !== "READ" && ( + {editorState.shareRole !== ShareRole.READ && ( { - dispatch(setTheme(themeMode == "light" ? "dark" : "light")); + dispatch(setTheme(themeMode == ThemeType.LIGHT ? ThemeType.DARK : ThemeType.LIGHT)); }; return ( diff --git a/frontend/src/pages/workspace/document/share/Index.tsx b/frontend/src/pages/workspace/document/share/Index.tsx index 365da099..17132416 100644 --- a/frontend/src/pages/workspace/document/share/Index.tsx +++ b/frontend/src/pages/workspace/document/share/Index.tsx @@ -5,8 +5,15 @@ import { Navigate, useLocation, useSearchParams } from "react-router-dom"; import DocumentView from "../../../../components/editor/DocumentView"; import { useGetDocumentBySharingTokenQuery } from "../../../../hooks/api/document"; import { useYorkieDocument } from "../../../../hooks/useYorkieDocument"; -import { setClient, setDoc, setMode, setShareRole } from "../../../../store/editorSlice"; +import { + EditorModeType, + setClient, + setDoc, + setMode, + setShareRole, +} from "../../../../store/editorSlice"; import { selectUser } from "../../../../store/userSlice"; +import { ShareRole } from "../../../../utils/share"; function DocumentShareIndex() { const dispatch = useDispatch(); @@ -25,8 +32,8 @@ function DocumentShareIndex() { dispatch(setShareRole(sharedDocument.role)); - if (sharedDocument.role === "READ") { - dispatch(setMode("read")); + if (sharedDocument.role === ShareRole.READ) { + dispatch(setMode(EditorModeType.READ)); } }, [dispatch, sharedDocument?.role]); diff --git a/frontend/src/store/configSlice.ts b/frontend/src/store/configSlice.ts index a7eca082..f4f40a09 100644 --- a/frontend/src/store/configSlice.ts +++ b/frontend/src/store/configSlice.ts @@ -2,20 +2,31 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; import { RootState } from "./store"; -type ThemeType = "default" | "dark" | "light"; +export enum ThemeType { + DEFAULT = "default", + DARK = "dark", + LIGHT = "light", +} + +export enum CodeKeyType { + SUBLIME = "sublime", + VIM = "vim", +} export interface ConfigState { theme: ThemeType; drawerOpen: boolean; + codeKey: CodeKeyType; } const initialState: ConfigState = { - theme: "default", + theme: ThemeType.DEFAULT, drawerOpen: true, + codeKey: CodeKeyType.SUBLIME, }; export const configSlice = createSlice({ - name: "editor", + name: "config", initialState, reducers: { setTheme: (state, action: PayloadAction) => { @@ -24,10 +35,13 @@ export const configSlice = createSlice({ setDrawerOpen: (state, action: PayloadAction) => { state.drawerOpen = action.payload; }, + setCodeKeyType: (state, action: PayloadAction) => { + state.codeKey = action.payload; + }, }, }); -export const { setTheme, setDrawerOpen } = configSlice.actions; +export const { setTheme, setDrawerOpen, setCodeKeyType } = configSlice.actions; export const selectConfig = (state: RootState) => state.config; diff --git a/frontend/src/store/editorSlice.ts b/frontend/src/store/editorSlice.ts index c6d7ee4f..b4652f32 100644 --- a/frontend/src/store/editorSlice.ts +++ b/frontend/src/store/editorSlice.ts @@ -1,12 +1,17 @@ -import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import { RootState } from "./store"; +import { createSlice } from "@reduxjs/toolkit"; +import { EditorView } from "codemirror"; import * as yorkie from "yorkie-js-sdk"; -import { YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync"; import { ShareRole } from "../utils/share"; -import { EditorView } from "codemirror"; +import { YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync"; +import { RootState } from "./store"; + +export enum EditorModeType { + EDIT = "edit", + BOTH = "both", + READ = "read", +} -export type EditorModeType = "edit" | "both" | "read"; export type CodePairDocType = yorkie.Document< YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType @@ -21,7 +26,7 @@ export interface EditorState { } const initialState: EditorState = { - mode: "both", + mode: EditorModeType.BOTH, shareRole: null, doc: null, client: null, @@ -50,7 +55,7 @@ export const editorSlice = createSlice({ }, }); -export const { setMode, setDoc, setClient, setShareRole, setCmView } = editorSlice.actions; +export const { setMode, setShareRole, setDoc, setClient, setCmView } = editorSlice.actions; export const selectEditor = (state: RootState) => state.editor;