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 (
+
+
+
+
+ );
+}
+
+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;