From 56171b18618acfb7109dbcf90374dd885c25068b Mon Sep 17 00:00:00 2001 From: vtm Date: Mon, 24 Mar 2025 09:28:03 +0100 Subject: [PATCH 1/2] Add back vim mode --- .../assets/js/widgets/components/Editor.jsx | 68 +++++++-- .../assets/js/widgets/config/editorModes.js | 2 +- .../js/widgets/pages/game/EditorContainer.jsx | 134 ++++++++++++------ .../js/widgets/pages/game/EditorToolbar.jsx | 2 + .../js/widgets/pages/game/VimModeButton.jsx | 44 +++--- services/app/apps/codebattle/package.json | 1 + services/app/apps/codebattle/yarn.lock | 5 + 7 files changed, 184 insertions(+), 72 deletions(-) diff --git a/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx b/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx index 38e8ae44e..b3151297a 100644 --- a/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx +++ b/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx @@ -1,9 +1,10 @@ -import React, { memo } from 'react'; +import React, { memo, useRef, useEffect } from 'react'; -import '../initEditor'; import MonacoEditor from '@monaco-editor/react'; +import { initVimMode } from 'monaco-vim'; import PropTypes from 'prop-types'; +import '../initEditor'; import languages from '../config/languages'; import useEditor from '../utils/useEditor'; @@ -11,20 +12,57 @@ import EditorLoading from './EditorLoading'; function Editor(props) { const { - value, - syntax, - onChange, - theme, - loading = false, - } = props; + value, syntax, onChange, theme, loading = false, mode, +} = props; + + // Map your custom language key to an actual Monaco recognized language const mappedSyntax = languages[syntax]; + // Hooks from your custom editor config const { options, - handleEditorDidMount, + handleEditorDidMount: originalEditorDidMount, handleEditorWillMount, } = useEditor(props); + // Create a ref for the actual Monaco editor instance + const editorRef = useRef(null); + // Create a ref for the Vim status element + const vimStatusRef = useRef(null); + // Create a ref to hold the vimMode controller so we can dispose if needed + const vimModeRef = useRef(null); + + // Wrap your existing "didMount" to store editor and call original if needed + function handleEditorDidMount(editor, monaco) { + editorRef.current = editor; + + if (typeof originalEditorDidMount === 'function') { + originalEditorDidMount(editor, monaco); + } + } + + // Whenever `mode` changes, enable or disable vimMode + useEffect(() => { + // If we haven't mounted the editor yet, exit + if (!editorRef.current) return; + + if (mode === 'vim') { + // If not already in Vim mode, enable it + if (!vimModeRef.current) { + vimModeRef.current = initVimMode( + editorRef.current, + vimStatusRef.current, + ); + } + } else { + // If we're switching away from Vim mode, dispose it + if (vimModeRef.current) { + vimModeRef.current.dispose(); + vimModeRef.current = null; + } + } + }, [mode]); + return ( <> + + {/* This is for displaying normal/insert mode status in Vim */} +
+ ); @@ -49,6 +98,7 @@ Editor.propTypes = { syntax: PropTypes.string, onChange: PropTypes.func.isRequired, theme: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, loading: PropTypes.bool, wordWrap: PropTypes.string, lineNumbers: PropTypes.string, diff --git a/services/app/apps/codebattle/assets/js/widgets/config/editorModes.js b/services/app/apps/codebattle/assets/js/widgets/config/editorModes.js index 871635aa6..8463d20ba 100644 --- a/services/app/apps/codebattle/assets/js/widgets/config/editorModes.js +++ b/services/app/apps/codebattle/assets/js/widgets/config/editorModes.js @@ -1,4 +1,4 @@ export default { default: 'default', - // vim: 'vim', + vim: 'vim', }; diff --git a/services/app/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx b/services/app/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx index 4b9a5cd9d..b77747d5f 100644 --- a/services/app/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx +++ b/services/app/apps/codebattle/assets/js/widgets/pages/game/EditorContainer.jsx @@ -1,8 +1,5 @@ import React, { - useEffect, - useContext, - useCallback, - useRef, + useEffect, useContext, useCallback, useRef, } from 'react'; import { useInterpret } from '@xstate/react'; @@ -42,21 +39,27 @@ const restrictedText = '\n\n\n\t"Only for Premium subscribers"'; const useEditorChannelSubscription = (mainService, editorService, player) => { const dispatch = useDispatch(); - const inTestingRoom = useMachineStateSelector(mainService, inTestingRoomSelector); + const inTestingRoom = useMachineStateSelector( + mainService, + inTestingRoomSelector, + ); const isPreview = useMachineStateSelector(mainService, inPreviewRoomSelector); useEffect(() => { if (isPreview) { - return () => { }; + return () => {}; } if (inTestingRoom) { editorService.send('load_testing_editor'); - return () => { }; + return () => {}; } - const clearEditorListeners = GameActions.connectToEditor(editorService, player?.isBanned)(dispatch); + const clearEditorListeners = GameActions.connectToEditor( + editorService, + player?.isBanned, + )(dispatch); return clearEditorListeners; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -86,58 +89,89 @@ function EditorContainer({ const subscriptionType = useSelector(selectors.subscriptionTypeSelector); const currentUserId = useSelector(selectors.currentUserIdSelector); - const currentEditorLangSlug = useSelector(selectors.userLangSelector(currentUserId)); + const currentEditorLangSlug = useSelector( + selectors.userLangSelector(currentUserId), + ); - const updateEditorValue = useCallback(data => dispatch(GameActions.updateEditorText(data)), [dispatch]); - const updateAndSendEditorValue = useCallback(data => { - dispatch(GameActions.updateEditorText(data)); - dispatch(GameActions.sendEditorText(data)); - }, [dispatch]); + const updateEditorValue = useCallback( + data => dispatch(GameActions.updateEditorText(data)), + [dispatch], + ); + const updateAndSendEditorValue = useCallback( + data => { + dispatch(GameActions.updateEditorText(data)); + dispatch(GameActions.sendEditorText(data)); + }, + [dispatch], + ); const { mainService } = useContext(RoomContext); const isPreview = useMachineStateSelector(mainService, inPreviewRoomSelector); - const isRestricted = useMachineStateSelector(mainService, isRestrictedContentSelector); - const inTestingRoom = useMachineStateSelector(mainService, inTestingRoomSelector); - const inBuilderRoom = useMachineStateSelector(mainService, inBuilderRoomSelector); - const isActiveGame = useMachineStateSelector(mainService, isGameActiveSelector); + const isRestricted = useMachineStateSelector( + mainService, + isRestrictedContentSelector, + ); + const inTestingRoom = useMachineStateSelector( + mainService, + inTestingRoomSelector, + ); + const inBuilderRoom = useMachineStateSelector( + mainService, + inBuilderRoomSelector, + ); + const isActiveGame = useMachineStateSelector( + mainService, + isGameActiveSelector, + ); const isGameOver = useMachineStateSelector(mainService, isGameOverSelector); - const openedReplayer = useMachineStateSelector(mainService, openedReplayerSelector); + const openedReplayer = useMachineStateSelector( + mainService, + openedReplayerSelector, + ); const isTournamentGame = !!tournamentId; const context = { userId: id, type, subscriptionType }; - const editorService = useInterpret( - editorMachine, - { - context, - devTools: true, - id: `editor_${id}`, - actions: { - userSendSolution: ctx => { - if (ctx.editorState === 'active') { - dispatch(GameActions.checkGameSolution()); - } - }, - handleTimeoutFailureChecking: ctx => { - dispatch(actions.updateExecutionOutput({ + const editorService = useInterpret(editorMachine, { + context, + devTools: true, + id: `editor_${id}`, + actions: { + userSendSolution: ctx => { + if (ctx.editorState === 'active') { + dispatch(GameActions.checkGameSolution()); + } + }, + handleTimeoutFailureChecking: ctx => { + dispatch( + actions.updateExecutionOutput({ userId: ctx.userId, status: 'client_timeout', output: '', result: {}, asserts: [], - })); + }), + ); - dispatch(actions.updateCheckStatus({ [ctx.userId]: false })); - }, + dispatch(actions.updateCheckStatus({ [ctx.userId]: false })); }, }, - ); + }); - const editorCurrent = useMachineStateSelector(editorService, editorStateSelector); + const editorCurrent = useMachineStateSelector( + editorService, + editorStateSelector, + ); - const checkActiveTaskSolution = useCallback(() => editorService.send('user_check_solution'), [editorService]); - const checkTestTaskSolution = useCallback(() => dispatch(GameActions.checkTaskSolution(editorService)), [dispatch, editorService]); + const checkActiveTaskSolution = useCallback( + () => editorService.send('user_check_solution'), + [editorService], + ); + const checkTestTaskSolution = useCallback( + () => dispatch(GameActions.checkTaskSolution(editorService)), + [dispatch, editorService], + ); const checkResult = inTestingRoom ? checkTestTaskSolution @@ -164,7 +198,7 @@ function EditorContainer({ }; } - return () => { }; + return () => {}; // eslint-disable-next-line react-hooks/exhaustive-deps }, [inTestingRoom]); @@ -195,9 +229,13 @@ function EditorContainer({ }; const canChange = userSettings.type === editorUserTypes.currentUser && !openedReplayer; - const editable = !openedReplayer && userSettings.editable && userSettings.editorState !== 'banned'; + const editable = !openedReplayer + && userSettings.editable + && userSettings.editorState !== 'banned'; const canSendCursor = canChange && !inTestingRoom && !inBuilderRoom; - const updateEditor = editorCurrent.context.editorState === 'testing' ? updateEditorValue : updateAndSendEditorValue; + const updateEditor = editorCurrent.context.editorState === 'testing' + ? updateEditorValue + : updateAndSendEditorValue; const onChange = canChange ? updateEditor : noop; const editorParams = { @@ -228,13 +266,19 @@ function EditorContainer({ 'bg-winner': isGameOver && editorCurrent.matches('idle') && isWon, }); - const gameRoomEditorStylesVersion2 = { minHeight: `calc(100vh - 92px - ${toolbarRef.current?.clientHeight || 0}px)` }; + const gameRoomEditorStylesVersion2 = { + minHeight: `calc(100vh - 92px - ${toolbarRef.current?.clientHeight || 0}px)`, + }; return (
(
( role="group" aria-label="Editor mode" > +
); diff --git a/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx b/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx index 8b9f55456..0e0f50166 100644 --- a/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx +++ b/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx @@ -1,32 +1,42 @@ -import React from 'react'; +import React from "react"; -import cn from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; +import cn from "classnames"; +// import { Col } from 'react-bootstrap'; +import { useDispatch, useSelector } from "react-redux"; -import editorModes from '../../config/editorModes'; -import { editorsModeSelector } from '../../selectors'; -import { actions } from '../../slices'; +import editorModes from "../../config/editorModes"; +import { editorsModeSelector } from "../../selectors"; +import { actions } from "../../slices"; function VimModeButton() { const dispatch = useDispatch(); const currentMode = useSelector(editorsModeSelector); - const isVimMode = currentMode === editorModes.vim; - const mode = isVimMode ? editorModes.default : editorModes.vim; - - const classNames = cn('btn btn-sm rounded-left', { - 'btn-light': !isVimMode, - 'btn-secondary': isVimMode, - }); - const handleToggleVimMode = () => { - dispatch(actions.setEditorsMode(mode)); + dispatch( + actions.setEditorsMode(isVimMode ? editorModes.default : editorModes.vim), + ); }; + // Use meaningful text, not just color, to indicate state + const buttonText = isVimMode ? "Vim: ON" : "Vim: OFF"; + + // Keep styling if desired, but ensure text clarifies the mode + const classNames = cn("btn btn-sm rounded-left", { + "btn-light": !isVimMode, + "btn-secondary": isVimMode, + }); + return ( - ); } diff --git a/services/app/apps/codebattle/package.json b/services/app/apps/codebattle/package.json index be4927ea4..c2af9b1f7 100644 --- a/services/app/apps/codebattle/package.json +++ b/services/app/apps/codebattle/package.json @@ -75,6 +75,7 @@ "monaco-editor": "^0.52.2", "monaco-editor-webpack-plugin": "^7.1.0", "monaco-themes": "^0.4.4", + "monaco-vim": "^0.4.2", "nprogress": "^0.2.0", "path-browserify": "^1.0.1", "phoenix": "^1.6.6", diff --git a/services/app/apps/codebattle/yarn.lock b/services/app/apps/codebattle/yarn.lock index 2b4e693f1..53215f96c 100644 --- a/services/app/apps/codebattle/yarn.lock +++ b/services/app/apps/codebattle/yarn.lock @@ -9421,6 +9421,11 @@ monaco-themes@^0.4.4: dependencies: fast-plist "^0.1.3" +monaco-vim@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/monaco-vim/-/monaco-vim-0.4.2.tgz#b56a6bbe2332c987391b3d04000134e0c645da19" + integrity sha512-rdbQC3O2rmpwX2Orzig/6gZjZfH7q7TIeB+uEl49sa+QyNm3jCKJOw5mwxBdFzTqbrPD+URfg6A2lEkuL5kymw== + mozjpeg@^7.0.0: version "7.1.1" resolved "https://registry.yarnpkg.com/mozjpeg/-/mozjpeg-7.1.1.tgz#dfb61953536e66fcabd4ae795e7a312d42a51f18" From 5497283520a4ac503ec9e2a8491b984d40fbc7db Mon Sep 17 00:00:00 2001 From: vtm Date: Thu, 10 Apr 2025 19:21:56 +0200 Subject: [PATCH 2/2] Fix scrolling issue --- .../assets/js/widgets/components/Editor.jsx | 12 +++---- .../js/widgets/pages/game/VimModeButton.jsx | 22 ++++++------- .../assets/js/widgets/utils/useEditor.js | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx b/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx index b3151297a..221b55c69 100644 --- a/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx +++ b/services/app/apps/codebattle/assets/js/widgets/components/Editor.jsx @@ -40,7 +40,6 @@ function Editor(props) { originalEditorDidMount(editor, monaco); } } - // Whenever `mode` changes, enable or disable vimMode useEffect(() => { // If we haven't mounted the editor yet, exit @@ -54,14 +53,13 @@ function Editor(props) { vimStatusRef.current, ); } - } else { + } else if (vimModeRef.current) { // If we're switching away from Vim mode, dispose it - if (vimModeRef.current) { - vimModeRef.current.dispose(); - vimModeRef.current = null; - } + vimModeRef.current.dispose(); + vimModeRef.current = null; } - }, [mode]); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [mode, editorRef.current]); return ( <> diff --git a/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx b/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx index 0e0f50166..0b5d02b56 100644 --- a/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx +++ b/services/app/apps/codebattle/assets/js/widgets/pages/game/VimModeButton.jsx @@ -1,12 +1,12 @@ -import React from "react"; +import React from 'react'; -import cn from "classnames"; +import cn from 'classnames'; // import { Col } from 'react-bootstrap'; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useSelector } from 'react-redux'; -import editorModes from "../../config/editorModes"; -import { editorsModeSelector } from "../../selectors"; -import { actions } from "../../slices"; +import editorModes from '../../config/editorModes'; +import { editorsModeSelector } from '../../selectors'; +import { actions } from '../../slices'; function VimModeButton() { const dispatch = useDispatch(); @@ -20,12 +20,12 @@ function VimModeButton() { }; // Use meaningful text, not just color, to indicate state - const buttonText = isVimMode ? "Vim: ON" : "Vim: OFF"; + const buttonText = isVimMode ? 'Vim' : 'Vim'; // Keep styling if desired, but ensure text clarifies the mode - const classNames = cn("btn btn-sm rounded-left", { - "btn-light": !isVimMode, - "btn-secondary": isVimMode, + const classNames = cn('btn btn-sm rounded-left', { + 'btn-light': !isVimMode, + 'btn-secondary': isVimMode, }); return ( @@ -34,7 +34,7 @@ function VimModeButton() { className={classNames} onClick={handleToggleVimMode} aria-pressed={isVimMode} - title={isVimMode ? "Disable Vim mode" : "Enable Vim mode"} + title={isVimMode ? 'Disable Vim mode' : 'Enable Vim mode'} > {buttonText} diff --git a/services/app/apps/codebattle/assets/js/widgets/utils/useEditor.js b/services/app/apps/codebattle/assets/js/widgets/utils/useEditor.js index 0264536d9..801ef7445 100644 --- a/services/app/apps/codebattle/assets/js/widgets/utils/useEditor.js +++ b/services/app/apps/codebattle/assets/js/widgets/utils/useEditor.js @@ -285,6 +285,38 @@ const useEditor = props => { toggleMuteSound(); }, }); + + domNode.addEventListener( + 'wheel', + e => { + const scrollTop = currentEditor.getScrollTop(); + const scrollHeight = currentEditor.getScrollHeight(); + const clientHeight = currentEditor.getLayoutInfo().height; + + const { deltaY } = e; + + const atTop = scrollTop <= 0; + const atBottom = scrollTop + clientHeight >= scrollHeight - 1; + + const scrollingDown = deltaY > 0; + const scrollingUp = deltaY < 0; + + const shouldBubble = (scrollingUp && atTop) || (scrollingDown && atBottom); + + if (shouldBubble) { + // Prevent Monaco from swallowing the event + e.preventDefault(); + + // Forward the scroll to the window (including momentum scroll) + window.scrollBy({ + top: deltaY, + left: 0, + behavior: 'auto', // "smooth" breaks momentum feel from touchpad + }); + } + }, + { passive: false }, // Needed so we can call preventDefault() + ); }; return {