Skip to content

Commit

Permalink
Add Vim binding support using CodeMirror 6 (#340)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
choidabom authored and minai621 committed Nov 5, 2024
1 parent b5c46c5 commit 04c374c
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 62 deletions.
13 changes: 13 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/common/ThemeButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<IconButton onClick={handleChangeTheme} color="inherit">
{themeMode === "light" ? <LightModeIcon /> : <DarkModeIcon />}
{themeMode === ThemeType.LIGHT ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
);
}
Expand Down
34 changes: 23 additions & 11 deletions frontend/src/components/editor/DocumentView.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -20,9 +20,7 @@ function DocumentView() {

return (
<>
{/* For Markdown Preview Theme */}
<div className="wmde-markdown-var" />
{editorStore.mode === "both" && (
{editorStore.mode === EditorModeType.BOTH && (
<Resizable axis={"x"} initial={windowWidth / 2} min={400}>
{({ position: width, separatorProps }) => (
<ScrollSync>
Expand All @@ -32,10 +30,18 @@ function DocumentView() {
display: "flex",
height: "100%",
overflow: "hidden",
position: "relative",
}}
>
<div id="left-block" style={{ width }}>
<Editor />
<div
id="left-block"
style={{
width,
position: "relative",
height: "100%",
}}
>
<Editor width={width} />
</div>
<Paper
id="splitter"
Expand Down Expand Up @@ -66,12 +72,18 @@ function DocumentView() {
)}
</Resizable>
)}
{editorStore.mode === "read" && (

{editorStore.mode === EditorModeType.EDIT && (
<div style={{ position: "relative", height: "100%" }}>
<Editor width={"100%"} />
</div>
)}

{editorStore.mode === EditorModeType.READ && (
<Box sx={{ p: 4, overflow: "auto" }} height="100%">
<Preview />
</Box>
)}
{editorStore.mode === "edit" && <Editor />}
</>
);
}
Expand Down
63 changes: 42 additions & 21 deletions frontend/src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,20 +11,28 @@ 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";
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<HTMLElement>();
const editorStore = useSelector(selectEditor);
const configStore = useSelector(selectConfig);
const settingStore = useSelector(selectSetting);
const workspaceStore = useSelector(selectWorkspace);
const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation();
Expand Down Expand Up @@ -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,
Expand All @@ -83,8 +93,10 @@ function Editor() {
],
});

const view = new EditorView({ state, parent: element });
// Vim key mapping: Map 'jj' to '<Esc>' in insert mode
Vim.map("jj", "<Esc>", "insert");

const view = new EditorView({ state, parent: element });
dispatch(setCmView(view));

return () => {
Expand All @@ -94,6 +106,7 @@ function Editor() {
element,
editorStore.client,
editorStore.doc,
configStore.codeKey,
themeMode,
workspaceStore.data,
settingStore.fileUpload?.enable,
Expand All @@ -106,26 +119,34 @@ function Editor() {
]);

return (
<ScrollSyncPane>
<div
style={{
height: "100%",
overflow: "auto",
}}
>
<div
ref={ref}
style={{
display: "flex",
alignItems: "stretch",
minHeight: "100%",
}}
/>
{Boolean(toolBarState.show) && (
<ToolBar toolBarState={toolBarState} onChangeToolBarState={setToolBarState} />
)}
<>
<div style={{ height: `calc(100% - ${BOTTOM_BAR_HEIGHT}px)` }}>
<ScrollSyncPane>
<div
style={{
height: "100%",
overflow: "auto",
}}
>
<div
ref={ref}
style={{
display: "flex",
alignItems: "stretch",
minHeight: "100%",
}}
/>
{Boolean(toolBarState.show) && (
<ToolBar
toolBarState={toolBarState}
onChangeToolBarState={setToolBarState}
/>
)}
</div>
</ScrollSyncPane>
</div>
</ScrollSyncPane>
<EditorBottomBar width={width} />
</>
);
}

Expand Down
74 changes: 74 additions & 0 deletions frontend/src/components/editor/EditorBottomBar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(null);
const open = Boolean(anchorEl);

const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

const handleChangeCodeKey = (newKeyCode: CodeKeyType) => {
dispatch(setCodeKeyType(newKeyCode));
handleClose();
};

return (
<Paper
variant="outlined"
sx={{
position: "absolute",
bottom: 0,
left: 0,
width,
borderTop: 1,
borderColor: "divider",
height: BOTTOM_BAR_HEIGHT,
display: "flex",
backgroundColor: "background.paper",
}}
>
<Button variant="text" onClick={handleOpen}>
{configStore.codeKey}
</Button>
<Menu
id="codekey-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
{Object.values(CodeKeyType).map((keyType) => (
<MenuItem key={keyType} onClick={() => handleChangeCodeKey(keyType)}>
{keyType}
</MenuItem>
))}
</Menu>
</Paper>
);
}

export default EditorBottomBar;
2 changes: 1 addition & 1 deletion frontend/src/components/editor/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/headers/DocumentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]);

Expand All @@ -58,7 +59,7 @@ function DocumentHeader() {
</Tooltip>
)}
<Paper>
{editorState.shareRole !== "READ" && (
{editorState.shareRole !== ShareRole.READ && (
<ToggleButtonGroup
value={editorState.mode}
exclusive
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/components/popovers/ProfilePopover.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import LogoutIcon from "@mui/icons-material/Logout";
import ManageAccountsIcon from "@mui/icons-material/ManageAccounts";
import {
ListItemIcon,
ListItemText,
Expand All @@ -6,16 +10,12 @@ import {
Popover,
PopoverProps,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import ManageAccountsIcon from "@mui/icons-material/ManageAccounts";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { setAccessToken } from "../../store/authSlice";
import { setTheme, ThemeType } from "../../store/configSlice";
import { setUserData } from "../../store/userSlice";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { setTheme } from "../../store/configSlice";
import { useNavigate } from "react-router-dom";

function ProfilePopover(props: PopoverProps) {
const dispatch = useDispatch();
Expand All @@ -32,7 +32,7 @@ function ProfilePopover(props: PopoverProps) {
};

const handleChangeTheme = () => {
dispatch(setTheme(themeMode == "light" ? "dark" : "light"));
dispatch(setTheme(themeMode == ThemeType.LIGHT ? ThemeType.DARK : ThemeType.LIGHT));
};

return (
Expand Down
Loading

0 comments on commit 04c374c

Please sign in to comment.