From 32d8cd38878eaf2cba611d1a51bc24e0b89c4d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 10:55:39 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat(editor):=20enable=20bracke?= =?UTF-8?q?t=20matching,=20auto-close,=20multi-cursor=20and=20resizable=20?= =?UTF-8?q?height=20(#8266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../submission/components/Editor.jsx | 54 ++++++++++++------- .../components/core/fields/EditorField.tsx | 9 ++++ .../core/fields/__test__/EditorField.test.tsx | 49 +++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 client/app/lib/components/core/fields/__test__/EditorField.test.tsx diff --git a/client/app/bundles/course/assessment/submission/components/Editor.jsx b/client/app/bundles/course/assessment/submission/components/Editor.jsx index d0d63e3838e..5e574f6ac3e 100644 --- a/client/app/bundles/course/assessment/submission/components/Editor.jsx +++ b/client/app/bundles/course/assessment/submission/components/Editor.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, useEffect, useRef } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Stack } from '@mui/material'; import PropTypes from 'prop-types'; @@ -17,6 +17,20 @@ const Editor = (props) => { editorRef, } = props; const { control } = useFormContext(); + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + const observer = new ResizeObserver(() => { + editorRef?.current?.editor?.resize(); + }); + + observer.observe(container); + + return () => observer.disconnect(); + }, [editorRef]); return ( @@ -24,23 +38,27 @@ const Editor = (props) => { control={control} name={fieldName} render={({ field }) => ( - { - field.onChange(event); - onChangeCallback(); - }, - }} - filename={file.filename} - language={language} - maxLines={25} - minLines={25} - onCursorChange={onCursorChange ?? (() => {})} - readOnly={false} - style={{ marginBottom: 10 }} - /> +
+ { + field.onChange(event); + onChangeCallback(); + }, + }} + filename={file.filename} + height="100%" + language={language} + onCursorChange={onCursorChange ?? (() => {})} + readOnly={false} + /> +
)} />
diff --git a/client/app/lib/components/core/fields/EditorField.tsx b/client/app/lib/components/core/fields/EditorField.tsx index 15e4c5c2ef6..3565ea8ee3d 100644 --- a/client/app/lib/components/core/fields/EditorField.tsx +++ b/client/app/lib/components/core/fields/EditorField.tsx @@ -143,6 +143,15 @@ const EditorField = forwardRef( useWorker: false, fontFamily: DEFAULT_FONT_FAMILY, showInvisibles: true, + behavioursEnabled: true, + wrapBehavioursEnabled: true, + enableMultiselect: true, + highlightActiveLine: true, + highlightSelectedWord: true, + showPrintMargin: false, + enableBasicAutocompletion: false, + enableLiveAutocompletion: false, + enableSnippets: false, }} /> ); diff --git a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx new file mode 100644 index 00000000000..df8ec48d522 --- /dev/null +++ b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx @@ -0,0 +1,49 @@ +import { render } from 'test-utils'; + +import EditorField from '../EditorField'; + +const mockSetOption = jest.fn(); + +jest.mock('react-ace', () => { + const { forwardRef } = require('react'); + + return { + __esModule: true, + default: forwardRef((props: Record, ref: unknown) => { + const setOptions = props.setOptions as Record; + if (setOptions) mockSetOption(setOptions); + + return
; + }), + }; +}); + +beforeEach(() => { + mockSetOption.mockClear(); +}); + +describe('EditorField', () => { + it('enables IDE typing features', () => { + render(); + + expect(mockSetOption).toHaveBeenCalled(); + const options = mockSetOption.mock.calls[0][0]; + + expect(options.behavioursEnabled).toBe(true); + expect(options.wrapBehavioursEnabled).toBe(true); + expect(options.enableMultiselect).toBe(true); + expect(options.highlightActiveLine).toBe(true); + expect(options.highlightSelectedWord).toBe(true); + expect(options.showPrintMargin).toBe(false); + }); + + it('explicitly disables autocomplete for exam fairness', () => { + render(); + + const options = mockSetOption.mock.calls[0][0]; + + expect(options.enableBasicAutocompletion).toBe(false); + expect(options.enableLiveAutocompletion).toBe(false); + expect(options.enableSnippets).toBe(false); + }); +}); From 99a4993c035ca5a4be46726118af0dd576824473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 11:02:15 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(editor):=20us?= =?UTF-8?q?e=20ResizeObserver=20polyfill,=20debounce=20resize,=20fix=20eff?= =?UTF-8?q?ect=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assessment/submission/components/Editor.jsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/components/Editor.jsx b/client/app/bundles/course/assessment/submission/components/Editor.jsx index 5e574f6ac3e..32d7199d362 100644 --- a/client/app/bundles/course/assessment/submission/components/Editor.jsx +++ b/client/app/bundles/course/assessment/submission/components/Editor.jsx @@ -4,6 +4,7 @@ import { Stack } from '@mui/material'; import PropTypes from 'prop-types'; import FormEditorField from 'lib/components/form/fields/EditorField'; +import ResizeObserver from 'utilities/ResizeObserver'; import { fileShape } from '../propTypes'; @@ -23,14 +24,22 @@ const Editor = (props) => { const container = containerRef.current; if (!container) return undefined; + let frameId; const observer = new ResizeObserver(() => { - editorRef?.current?.editor?.resize(); + cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + editorRef?.current?.editor?.resize(); + }); }); observer.observe(container); - return () => observer.disconnect(); - }, [editorRef]); + return () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( From 70e8e9029e9aae7e12111b8bfbf9ea0ae0b7629d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 11:07:00 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20fix(editor):=20remove=20unus?= =?UTF-8?q?ed=20eslint-disable,=20add=20return=20type=20to=20test=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assessment/submission/components/Editor.jsx | 1 - .../core/fields/__test__/EditorField.test.tsx | 14 ++++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/components/Editor.jsx b/client/app/bundles/course/assessment/submission/components/Editor.jsx index 32d7199d362..cfd366281ef 100644 --- a/client/app/bundles/course/assessment/submission/components/Editor.jsx +++ b/client/app/bundles/course/assessment/submission/components/Editor.jsx @@ -38,7 +38,6 @@ const Editor = (props) => { cancelAnimationFrame(frameId); observer.disconnect(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx index df8ec48d522..caf3637a74e 100644 --- a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx +++ b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx @@ -9,12 +9,14 @@ jest.mock('react-ace', () => { return { __esModule: true, - default: forwardRef((props: Record, ref: unknown) => { - const setOptions = props.setOptions as Record; - if (setOptions) mockSetOption(setOptions); - - return
; - }), + default: forwardRef( + (props: Record, _ref: unknown): JSX.Element => { + const setOptions = props.setOptions as Record; + if (setOptions) mockSetOption(setOptions); + + return
; + }, + ), }; }); From cb7a51266551882d6473595cf3e0246cd6efe093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 11:09:29 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20fix(editor):=20mock=20ace-bu?= =?UTF-8?q?ilds=20theme=20and=20mode=20imports=20in=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/components/core/fields/__test__/EditorField.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx index caf3637a74e..282ba470fc7 100644 --- a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx +++ b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx @@ -2,6 +2,9 @@ import { render } from 'test-utils'; import EditorField from '../EditorField'; +jest.mock('ace-builds/src-noconflict/theme-github', () => ({})); +jest.mock('ace-builds/src-noconflict/mode-python', () => ({})); + const mockSetOption = jest.fn(); jest.mock('react-ace', () => { From 055b950cb61975051b8ab9c87ec6edcfa827d7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 11:13:55 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=A5=20remove(editor):=20drop=20Edi?= =?UTF-8?q?torField=20test=20=E2=80=94=20ace-builds=20not=20mockable=20in?= =?UTF-8?q?=20Jest=20without=20global=20config=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/fields/__test__/EditorField.test.tsx | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 client/app/lib/components/core/fields/__test__/EditorField.test.tsx diff --git a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx b/client/app/lib/components/core/fields/__test__/EditorField.test.tsx deleted file mode 100644 index 282ba470fc7..00000000000 --- a/client/app/lib/components/core/fields/__test__/EditorField.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render } from 'test-utils'; - -import EditorField from '../EditorField'; - -jest.mock('ace-builds/src-noconflict/theme-github', () => ({})); -jest.mock('ace-builds/src-noconflict/mode-python', () => ({})); - -const mockSetOption = jest.fn(); - -jest.mock('react-ace', () => { - const { forwardRef } = require('react'); - - return { - __esModule: true, - default: forwardRef( - (props: Record, _ref: unknown): JSX.Element => { - const setOptions = props.setOptions as Record; - if (setOptions) mockSetOption(setOptions); - - return
; - }, - ), - }; -}); - -beforeEach(() => { - mockSetOption.mockClear(); -}); - -describe('EditorField', () => { - it('enables IDE typing features', () => { - render(); - - expect(mockSetOption).toHaveBeenCalled(); - const options = mockSetOption.mock.calls[0][0]; - - expect(options.behavioursEnabled).toBe(true); - expect(options.wrapBehavioursEnabled).toBe(true); - expect(options.enableMultiselect).toBe(true); - expect(options.highlightActiveLine).toBe(true); - expect(options.highlightSelectedWord).toBe(true); - expect(options.showPrintMargin).toBe(false); - }); - - it('explicitly disables autocomplete for exam fairness', () => { - render(); - - const options = mockSetOption.mock.calls[0][0]; - - expect(options.enableBasicAutocompletion).toBe(false); - expect(options.enableLiveAutocompletion).toBe(false); - expect(options.enableSnippets).toBe(false); - }); -}); From 084a98f2925f518cca1101bdd40005096badef60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 15:01:46 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=8E=A8=20style(editor):=20fix=20impor?= =?UTF-8?q?t=20sort=20order=20for=20ResizeObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bundles/course/assessment/submission/components/Editor.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/bundles/course/assessment/submission/components/Editor.jsx b/client/app/bundles/course/assessment/submission/components/Editor.jsx index cfd366281ef..08394a22d91 100644 --- a/client/app/bundles/course/assessment/submission/components/Editor.jsx +++ b/client/app/bundles/course/assessment/submission/components/Editor.jsx @@ -2,9 +2,9 @@ import { Component, useEffect, useRef } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Stack } from '@mui/material'; import PropTypes from 'prop-types'; +import ResizeObserver from 'utilities/ResizeObserver'; import FormEditorField from 'lib/components/form/fields/EditorField'; -import ResizeObserver from 'utilities/ResizeObserver'; import { fileShape } from '../propTypes'; From 65f89890d278a07978b3a1aff79a57b4320d8bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jing=20Hui=20PANG=20=28=E5=BD=AD=E7=AB=9E=E8=BE=89=29?= Date: Fri, 20 Mar 2026 16:28:58 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(editor):=20re?= =?UTF-8?q?move=20redundant=20RAF=20=E2=80=94=20ResizeObserver=20already?= =?UTF-8?q?=20throttles=20to=20once=20per=20paint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assessment/submission/components/Editor.jsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/components/Editor.jsx b/client/app/bundles/course/assessment/submission/components/Editor.jsx index 08394a22d91..74f79cba9cc 100644 --- a/client/app/bundles/course/assessment/submission/components/Editor.jsx +++ b/client/app/bundles/course/assessment/submission/components/Editor.jsx @@ -24,20 +24,13 @@ const Editor = (props) => { const container = containerRef.current; if (!container) return undefined; - let frameId; const observer = new ResizeObserver(() => { - cancelAnimationFrame(frameId); - frameId = requestAnimationFrame(() => { - editorRef?.current?.editor?.resize(); - }); + editorRef?.current?.editor?.resize(); }); observer.observe(container); - return () => { - cancelAnimationFrame(frameId); - observer.disconnect(); - }; + return () => observer.disconnect(); }, []); return (