From dce9e6b35e7d90d25a28b35e6ae641da9fa557b0 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Mon, 2 Feb 2026 03:29:01 +0000 Subject: [PATCH 1/6] feat: basic codeMirror setup --- package.json | 13 +- src/sandbox-component.tsx | 4 +- src/types/Pseudocode/Pseudocode.component.tsx | 102 ++++++++ src/types/Pseudocode/index.ts | 40 +++ src/types/Pseudocode/utils/autoIndent.ts | 40 +++ src/types/Pseudocode/utils/highlight.ts | 32 +++ src/types/Pseudocode/utils/language.ts | 24 ++ .../Pseudocode/utils/pseudocode.theme.ts | 48 ++++ src/types/index.ts | 4 + yarn.lock | 231 +++++++++++++++++- 10 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 src/types/Pseudocode/Pseudocode.component.tsx create mode 100644 src/types/Pseudocode/index.ts create mode 100644 src/types/Pseudocode/utils/autoIndent.ts create mode 100644 src/types/Pseudocode/utils/highlight.ts create mode 100644 src/types/Pseudocode/utils/language.ts create mode 100644 src/types/Pseudocode/utils/pseudocode.theme.ts diff --git a/package.json b/package.json index 66e29ee..2a6ed21 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,16 @@ "dev": "run-p build:watch preview" }, "dependencies": { + "@codemirror/basic-setup": "^0.20.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.12", "@date-io/date-fns": "^2.13.2", "@emotion/react": "^11.4.1", "@emotion/server": "^11.4.0", "@emotion/styled": "^11.3.0", + "@lezer/highlight": "^1.2.3", "@monaco-editor/react": "^4.6.0", "@mui/icons-material": "^5.15.20", "@mui/lab": "^5.0.0-alpha.100", @@ -35,6 +41,7 @@ "@nivo/core": "^0.88.0", "@nivo/line": "^0.88.0", "@nivo/pie": "^0.88.0", + "codemirror": "^6.0.2", "date-fns": "^2.28.0", "framer-motion": "^11.2.10", "katex": "^0.16.2", @@ -43,8 +50,8 @@ "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-katex": "^3.0.1", "react-hook-form": "^7.31.2", + "react-katex": "^3.0.1", "react-query": "^3.39.0", "tldraw": "^3.13.1", "tss-react": "^3.7.0", @@ -66,11 +73,11 @@ "@types/shuffle-seed": "^1.1.0", "@vitejs/plugin-react": "^4.5.2", "eslint": "^9.3.0", - "eslint-plugin-react": "^7.34.2", - "eslint-plugin-react-hooks": "^4.6.2", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.34.2", + "eslint-plugin-react-hooks": "^4.6.2", "globals": "^16.2.0", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index ec553ef..f5ca886 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -1,13 +1,13 @@ import { ThemeProvider } from '@styles/minimal/theme-provider' -import { SandboxResponseAreaTub } from './types/Sandbox' +import { PseudocodeResponseAreaTub } from './types/Pseudocode' function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { return {children} } // wrap the components with the necessary providers; only in the sandbox -class WrappedSandboxResponseAreaTub extends SandboxResponseAreaTub { +class WrappedSandboxResponseAreaTub extends PseudocodeResponseAreaTub { constructor() { super() diff --git a/src/types/Pseudocode/Pseudocode.component.tsx b/src/types/Pseudocode/Pseudocode.component.tsx new file mode 100644 index 0000000..59332eb --- /dev/null +++ b/src/types/Pseudocode/Pseudocode.component.tsx @@ -0,0 +1,102 @@ +// PseudocodeInput.tsx +import { StreamLanguage, syntaxHighlighting } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { placeholder, keymap } from '@codemirror/view'; +import { makeStyles } from '@styles'; // adjust to your project +import { EditorView, basicSetup } from 'codemirror'; +import { useEffect, useRef } from 'react'; + +import { BaseResponseAreaProps } from '../base-props.type'; + +import { autoIndentAfterColon } from './utils/autoIndent'; +import { pseudocodeHighlightStyle } from './utils/highlight'; +import { pseudocodeLanguage } from './utils/language'; +import { pseudocodeTheme } from './utils/pseudocode.theme'; + +type PseudocodeInputProps = Omit & { + handleChange: (val: string) => void; + answer?: string; +}; + + +export const PseudocodeInput: React.FC = ({ handleChange, answer }) => { + const { classes } = useStyles(); + const editorRef = useRef(null); + const viewRef = useRef(null); + + // Initialize editor + useEffect(() => { + if (!editorRef.current) return; + + const state = EditorState.create({ + doc: answer ?? '', + extensions: [ + autoIndentAfterColon, + basicSetup, + pseudocodeLanguage, + syntaxHighlighting(pseudocodeHighlightStyle), + pseudocodeTheme, + placeholder('Write your pseudocode here...'), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + handleChange(update.state.doc.toString()); + } + }), + EditorView.theme({ + "&": { height: "100%" }, + ".cm-scroller": { overflow: "auto" }, + }), + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + // Update editor if answer prop changes externally + useEffect(() => { + if (viewRef.current && answer !== undefined) { + const view = viewRef.current; + if (view.state.doc.toString() !== answer) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: answer }, + }); + } + } + }, [answer]); + + return
; +}; + +const useStyles = makeStyles()((theme) => ({ + editor: { + width: '100%', + minHeight: '200px', + border: `1px solid ${theme.palette.divider}`, + borderRadius: '4px', + overflow: 'hidden', + backgroundColor: '#fff', + + '& .cm-editor': { + outline: 'none', + }, + '& .cm-content': { + fontFamily: '"Fira Code", "Courier New", monospace', + fontSize: '14px', + padding: '10px 0', + }, + '& .cm-gutters': { + backgroundColor: '#f5f5f5', + borderRight: `1px solid ${theme.palette.divider}`, + }, + }, +})); diff --git a/src/types/Pseudocode/index.ts b/src/types/Pseudocode/index.ts new file mode 100644 index 0000000..31f76cc --- /dev/null +++ b/src/types/Pseudocode/index.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' + +import { + BaseResponseAreaProps, + BaseResponseAreaWizardProps, +} from '../base-props.type' +import { ResponseAreaTub } from '../response-area-tub' + +import { PseudocodeInput } from './Pseudocode.component' + +export class PseudocodeResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'PSEUDOCODE' + + public readonly displayWideInput = true + + protected answerSchema = z.string() + + protected answer?: string + + InputComponent = (props: BaseResponseAreaProps) => { + const parsedAnswer = this.answerSchema.safeParse(props.answer) + return PseudocodeInput({ + ...props, + answer: parsedAnswer.success ? parsedAnswer.data : undefined, + }) + } + + WizardComponent = (props: BaseResponseAreaWizardProps) => { + return PseudocodeInput({ + ...props, + answer: this.answer, + handleChange: (answer: string) => { + props.handleChange({ + responseType: this.responseType, + answer, + }) + }, + }) + } +} diff --git a/src/types/Pseudocode/utils/autoIndent.ts b/src/types/Pseudocode/utils/autoIndent.ts new file mode 100644 index 0000000..72233d4 --- /dev/null +++ b/src/types/Pseudocode/utils/autoIndent.ts @@ -0,0 +1,40 @@ +import { keymap } from "@codemirror/view"; + +export const autoIndentAfterColon = keymap.of([ + { + key: 'Enter', + run(view) { + const { state } = view; + const { from } = state.selection.main; + const line = state.doc.lineAt(from); + + // Text before cursor on the current line + const beforeCursor = line.text.slice(0, from - line.from); + + // Only trigger if line ends with : + if (!beforeCursor.trimEnd().endsWith(':')) { + return false; // let default Enter behavior happen + } + + // Get current indentation + const indentMatch = beforeCursor.match(/^\s*/); + const baseIndent = indentMatch ? indentMatch[0] : ''; + + // Python-style indent (4 spaces) + const newIndent = baseIndent + ' '; + + view.dispatch({ + changes: { + from, + to: from, + insert: '\n' + newIndent, + }, + selection: { + anchor: from + 1 + newIndent.length, + }, + }); + + return true; + }, + }, +]); diff --git a/src/types/Pseudocode/utils/highlight.ts b/src/types/Pseudocode/utils/highlight.ts new file mode 100644 index 0000000..3d2ca4a --- /dev/null +++ b/src/types/Pseudocode/utils/highlight.ts @@ -0,0 +1,32 @@ +import { HighlightStyle } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; + +export const pseudocodeHighlightStyle = HighlightStyle.define([ + { + tag: t.keyword, + color: '#6f42c1', + fontWeight: '600', + }, + { + tag: t.bool, + color: '#d73a49', + fontWeight: '500', + }, + { + tag: t.number, + color: '#005cc5', + }, + { + tag: t.string, + color: '#22863a', + }, + { + tag: t.lineComment, + color: '#6a737d', + fontStyle: 'italic', + }, + { + tag: t.operator, + color: '#e36209', + }, +]); diff --git a/src/types/Pseudocode/utils/language.ts b/src/types/Pseudocode/utils/language.ts new file mode 100644 index 0000000..4cf3952 --- /dev/null +++ b/src/types/Pseudocode/utils/language.ts @@ -0,0 +1,24 @@ +import { StreamLanguage } from "@codemirror/language"; + +export const pseudocodeLanguage = StreamLanguage.define({ + token(stream) { + if (stream.match(/IF|THEN|ELSE|ELSEIF|ENDIF|WHILE|FOR|TO|STEP|NEXT|REPEAT|UNTIL|FUNCTION|PROCEDURE|BEGIN|END|RETURN|PRINT|INPUT/i)) { + return 'keyword'; + } + if (stream.match(/TRUE|FALSE|NULL/i)) { + return 'bool'; + } + if (stream.match(/-?\d+(\.\d+)?/)) { + return 'number'; + } + if (stream.match(/(["'])(?:\\.|(?!\1).)*?\1/)) { + return 'string'; + } + if (stream.match(/\/\/.*/)) { + return 'comment'; + } + + stream.next(); + return null; + }, +}); \ No newline at end of file diff --git a/src/types/Pseudocode/utils/pseudocode.theme.ts b/src/types/Pseudocode/utils/pseudocode.theme.ts new file mode 100644 index 0000000..cc47746 --- /dev/null +++ b/src/types/Pseudocode/utils/pseudocode.theme.ts @@ -0,0 +1,48 @@ +import { EditorView } from '@codemirror/view'; + +export const pseudocodeTheme = EditorView.theme( + { + '&': { + backgroundColor: '#fafafa', + color: '#24292f', + fontSize: '14px', + }, + + '.cm-content': { + fontFamily: '"Fira Code", "JetBrains Mono", monospace', + lineHeight: '1.6', + padding: '12px 8px', + caretColor: '#6f42c1', + }, + + '.cm-line': { + padding: '0 4px', + }, + + '.cm-cursor': { + borderLeftColor: '#6f42c1', + borderLeftWidth: '2px', + }, + + '.cm-activeLine': { + backgroundColor: 'rgba(110, 118, 129, 0.08)', + borderRadius: '4px', + }, + + '.cm-selectionBackground': { + backgroundColor: 'rgba(111, 66, 193, 0.25)', + }, + + '.cm-gutters': { + backgroundColor: '#f3f4f6', + color: '#6e7781', + borderRight: '1px solid #e5e7eb', + }, + + '.cm-activeLineGutter': { + backgroundColor: 'rgba(111, 66, 193, 0.15)', + fontWeight: '600', + }, + }, + { dark: false } +); diff --git a/src/types/index.ts b/src/types/index.ts index daeacc0..c6b7330 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ import { MatrixResponseAreaTub } from './Matrix' import { MultipleChoiceResponseAreaTub } from './MultipleChoice' import { NumberResponseAreaTub } from './NumberInput' import { NumericUnitsResponseAreaTub } from './NumericUnits' +import { PseudocodeResponseAreaTub } from './Pseudocode' import { ResponseAreaTub } from './response-area-tub' import { TableResponseAreaTub } from './Table' import { TextResponseAreaTub } from './TextInput' @@ -25,6 +26,7 @@ export const supportedResponseTypes = [ 'ESSAY', 'CODE', 'MILKDOWN', + 'PESUDOCODE' ] if (typeof window !== 'undefined') { @@ -98,6 +100,8 @@ const createReponseAreaTub = (type: string): ResponseAreaTub => { return new EssayResponseAreaTub() case 'CODE': return new CodeResponseAreaTub() + case 'PSEUDOCODE': + return new PseudocodeResponseAreaTub() case 'VOID': return new VoidResponseAreaTub() default: diff --git a/yarn.lock b/yarn.lock index 8ca7d57..f2d400d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -173,6 +173,163 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@codemirror/autocomplete@^0.20.0": + version "0.20.3" + resolved "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz#affe2d7e2b2e0be42ee1ac5fb74a1c84a6f1bfd7" + integrity sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw== + dependencies: + "@codemirror/language" "^0.20.0" + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.0" + "@lezer/common" "^0.16.0" + +"@codemirror/autocomplete@^6.0.0": + version "6.20.0" + resolved "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz#db818c12dce892a93fb8abadc2426febb002f8c1" + integrity sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + +"@codemirror/basic-setup@^0.20.0": + version "0.20.0" + resolved "https://registry.npmmirror.com/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz#ed331e0b2d29efc0a09317de9e10467b992b0c7b" + integrity sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg== + dependencies: + "@codemirror/autocomplete" "^0.20.0" + "@codemirror/commands" "^0.20.0" + "@codemirror/language" "^0.20.0" + "@codemirror/lint" "^0.20.0" + "@codemirror/search" "^0.20.0" + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.0" + +"@codemirror/commands@^0.20.0": + version "0.20.0" + resolved "https://registry.npmmirror.com/@codemirror/commands/-/commands-0.20.0.tgz#51405d442e6b8687b63e8fa27effc28179917c88" + integrity sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q== + dependencies: + "@codemirror/language" "^0.20.0" + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.0" + "@lezer/common" "^0.16.0" + +"@codemirror/commands@^6.0.0": + version "6.10.1" + resolved "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.1.tgz#a17a48f846947f48150b9670a3de8c4352b69256" + integrity sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.4.0" + "@codemirror/view" "^6.27.0" + "@lezer/common" "^1.1.0" + +"@codemirror/lang-javascript@^6.2.4": + version "6.2.4" + resolved "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz#eef2227d1892aae762f3a0f212f72bec868a02c5" + integrity sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/language" "^6.6.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + "@lezer/javascript" "^1.0.0" + +"@codemirror/language@^0.20.0": + version "0.20.2" + resolved "https://registry.npmmirror.com/@codemirror/language/-/language-0.20.2.tgz#31c3712eac2251810986272dcd6a50510e0c1529" + integrity sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw== + dependencies: + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.0" + "@lezer/common" "^0.16.0" + "@lezer/highlight" "^0.16.0" + "@lezer/lr" "^0.16.0" + style-mod "^4.0.0" + +"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.1", "@codemirror/language@^6.6.0": + version "6.12.1" + resolved "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.1.tgz#d615f7b099a39248312feaaf0bfafce4418aac1b" + integrity sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.23.0" + "@lezer/common" "^1.5.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + style-mod "^4.0.0" + +"@codemirror/lint@^0.20.0": + version "0.20.3" + resolved "https://registry.npmmirror.com/@codemirror/lint/-/lint-0.20.3.tgz#34c0fd45c5acd522637f68602e3a416162e03a15" + integrity sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA== + dependencies: + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.2" + crelt "^1.0.5" + +"@codemirror/lint@^6.0.0": + version "6.9.3" + resolved "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.3.tgz#eee48c9d60ea63582eee1ebd6b4ae65102eb8782" + integrity sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.35.0" + crelt "^1.0.5" + +"@codemirror/search@^0.20.0": + version "0.20.1" + resolved "https://registry.npmmirror.com/@codemirror/search/-/search-0.20.1.tgz#9eba0514218a673e29501a889a4fcb7da7ce24ad" + integrity sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q== + dependencies: + "@codemirror/state" "^0.20.0" + "@codemirror/view" "^0.20.0" + crelt "^1.0.5" + +"@codemirror/search@^6.0.0": + version "6.6.0" + resolved "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz#3b83a1e35391e1575a83a3b485e3f95263ddaa0b" + integrity sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.37.0" + crelt "^1.0.5" + +"@codemirror/state@^0.20.0": + version "0.20.1" + resolved "https://registry.npmmirror.com/@codemirror/state/-/state-0.20.1.tgz#de5c6dc0de3e216eaa3a9ee9391c926b766f6b46" + integrity sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ== + +"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0", "@codemirror/state@^6.5.4": + version "6.5.4" + resolved "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19" + integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== + dependencies: + "@marijn/find-cluster-break" "^1.0.0" + +"@codemirror/view@^0.20.0", "@codemirror/view@^0.20.2": + version "0.20.7" + resolved "https://registry.npmmirror.com/@codemirror/view/-/view-0.20.7.tgz#1d0acc740f71f92abef4b437c030d4e6c39ab6dc" + integrity sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ== + dependencies: + "@codemirror/state" "^0.20.0" + style-mod "^4.0.0" + w3c-keyname "^2.2.4" + +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.39.12": + version "6.39.12" + resolved "https://registry.npmmirror.com/@codemirror/view/-/view-6.39.12.tgz#c61961d7107b44bd233647fc9e33d96309d627c9" + integrity sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ== + dependencies: + "@codemirror/state" "^6.5.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@date-io/core@^2.17.0": version "2.17.0" resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.17.0.tgz#360a4d0641f069776ed22e457876e8a8a58c205e" @@ -650,6 +807,58 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@lezer/common@^0.16.0": + version "0.16.1" + resolved "https://registry.npmmirror.com/@lezer/common/-/common-0.16.1.tgz#3b98b42fdb11454b89e8a340da10bee1b0f94071" + integrity sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA== + +"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0": + version "1.5.0" + resolved "https://registry.npmmirror.com/@lezer/common/-/common-1.5.0.tgz#db227b596260189b67ba286387d9dc81fb07c70b" + integrity sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA== + +"@lezer/highlight@^0.16.0": + version "0.16.0" + resolved "https://registry.npmmirror.com/@lezer/highlight/-/highlight-0.16.0.tgz#95f7b7ee3c32c8a0f6ce499c085e8b1f927ffbdc" + integrity sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ== + dependencies: + "@lezer/common" "^0.16.0" + +"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3", "@lezer/highlight@^1.2.3": + version "1.2.3" + resolved "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857" + integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== + dependencies: + "@lezer/common" "^1.3.0" + +"@lezer/javascript@^1.0.0": + version "1.5.4" + resolved "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz#11746955f957d33c0933f17d7594db54a8b4beea" + integrity sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.1.3" + "@lezer/lr" "^1.3.0" + +"@lezer/lr@^0.16.0": + version "0.16.3" + resolved "https://registry.npmmirror.com/@lezer/lr/-/lr-0.16.3.tgz#1e4cc581d2725c498e6a731fc83c379114ba3a70" + integrity sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw== + dependencies: + "@lezer/common" "^0.16.0" + +"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0": + version "1.4.8" + resolved "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498" + integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA== + dependencies: + "@lezer/common" "^1.0.0" + +"@marijn/find-cluster-break@^1.0.0": + version "1.0.2" + resolved "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" + integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== + "@monaco-editor/loader@^1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.5.0.tgz#dcdbc7fe7e905690fb449bed1c251769f325c55d" @@ -3135,6 +3344,19 @@ clsx@^2.1.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +codemirror@^6.0.2: + version "6.0.2" + resolved "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz#4d3fea1ad60b6753f97ca835f2f48c6936a8946e" + integrity sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -3223,7 +3445,7 @@ crc32-stream@^4.0.2: crc-32 "^1.2.0" readable-stream "^3.4.0" -crelt@^1.0.0: +crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== @@ -6213,6 +6435,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-mod@^4.0.0, style-mod@^4.1.0: + version "4.1.3" + resolved "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" + integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== + styled-jsx@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" @@ -6548,7 +6775,7 @@ vite@^7.0.0: optionalDependencies: fsevents "~2.3.3" -w3c-keyname@^2.2.0: +w3c-keyname@^2.2.0, w3c-keyname@^2.2.4: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== From 7afeda187c151dfdf76f3deaba8f1f48956d8fb8 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Mon, 2 Feb 2026 17:57:26 +0000 Subject: [PATCH 2/6] feat: fixed types and folding --- src/sandbox-component.tsx | 2 +- src/types/Matrix/index.ts | 188 ++++++--- src/types/Pseudocode/Pseudocode.component.tsx | 136 +++--- src/types/Pseudocode/index.ts | 40 -- src/types/Pseudocode/index.tsx | 123 ++++++ .../{utils => plugins}/autoIndent.ts | 2 +- src/types/Pseudocode/plugins/fold.ts | 30 ++ .../{utils => plugins}/highlight.ts | 0 src/types/Pseudocode/plugins/language.ts | 54 +++ .../{utils => plugins}/pseudocode.theme.ts | 0 src/types/Pseudocode/types/input.ts | 202 +++++++++ src/types/Pseudocode/types/output.ts | 389 ++++++++++++++++++ src/types/Pseudocode/utils/consts.ts | 9 + src/types/Pseudocode/utils/language.ts | 24 -- src/types/Pseudocode/utils/styles.ts | 49 +++ src/types/index.ts | 2 +- tsconfig.json | 2 +- 17 files changed, 1064 insertions(+), 188 deletions(-) delete mode 100644 src/types/Pseudocode/index.ts create mode 100644 src/types/Pseudocode/index.tsx rename src/types/Pseudocode/{utils => plugins}/autoIndent.ts (94%) create mode 100644 src/types/Pseudocode/plugins/fold.ts rename src/types/Pseudocode/{utils => plugins}/highlight.ts (100%) create mode 100644 src/types/Pseudocode/plugins/language.ts rename src/types/Pseudocode/{utils => plugins}/pseudocode.theme.ts (100%) create mode 100644 src/types/Pseudocode/types/input.ts create mode 100644 src/types/Pseudocode/types/output.ts create mode 100644 src/types/Pseudocode/utils/consts.ts delete mode 100644 src/types/Pseudocode/utils/language.ts create mode 100644 src/types/Pseudocode/utils/styles.ts diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index f5ca886..63a5052 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -1,6 +1,6 @@ import { ThemeProvider } from '@styles/minimal/theme-provider' -import { PseudocodeResponseAreaTub } from './types/Pseudocode' +import { PseudocodeResponseAreaTub } from './types/Pseudocode/index.tsx' function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { return {children} diff --git a/src/types/Matrix/index.ts b/src/types/Matrix/index.ts index 6dae48e..196dd2f 100644 --- a/src/types/Matrix/index.ts +++ b/src/types/Matrix/index.ts @@ -1,92 +1,152 @@ -import { - DEFAULT_COLS, - DEFAULT_ROWS, -} from '@modules/shared/schemas/question-form.schema' -import _ from 'lodash' -import { z } from 'zod' +import { z } from 'zod'; import { BaseResponseAreaProps, BaseResponseAreaWizardProps, -} from '../base-props.type' -import { ResponseAreaTub } from '../response-area-tub' - -import { padMatrixFromRowsAndCols } from './helpers' -import { Matrix } from './Matrix.component' -import { matrixConfigSchema, matrixResponseAnswerSchema } from './Matrix.schema' -import { MatrixWizard } from './MatrixWizard.component' - -export const defaultMatrixAnswer = { - rows: DEFAULT_ROWS, - cols: DEFAULT_COLS, - type: 'MATRIX' as const, - answers: padMatrixFromRowsAndCols({ - rows: DEFAULT_ROWS, - cols: DEFAULT_COLS, - }), +} from '../base-props.type'; +import { PseudocodeInput } from '../Pseudocode/Pseudocode.component'; +import { EvaluationResult, EvaluationResultSchema } from '../Pseudocode/types/output'; +import { ResponseAreaTub } from '../response-area-tub'; + +/* ============================================================ + * Helpers + * ============================================================ + */ + +/** + * Safely parse + validate EvaluationResult from a JSON string. + * Returns undefined if anything fails. + */ +function safeParseEvaluationResult( + value: unknown +): EvaluationResult | undefined { + if (typeof value !== 'string') return undefined; + + try { + const json = JSON.parse(value); + const parsed = EvaluationResultSchema.safeParse(json); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +/** + * Minimal safe default. + * This ensures legacy systems always receive valid JSON. + */ +function createDefaultEvaluationResult(): EvaluationResult { + return { + is_correct: false, + score: 0, + feedback: '', + feedback_items: [], + warnings: [], + errors: [], + metadata: {}, + }; } -export class MatrixResponseAreaTub extends ResponseAreaTub { - public readonly responseType = 'MATRIX' +/* ============================================================ + * PseudocodeResponseAreaTub + * ============================================================ + */ - protected configSchema = matrixConfigSchema +export class PseudocodeResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'PSEUDOCODE'; - protected config?: z.infer + public readonly displayWideInput = true; - protected answerSchema = matrixResponseAnswerSchema + /** + * Legacy constraint: + * Stored answer must be a string (JSON-encoded) + */ + protected answerSchema = z.string(); - protected answer?: z.infer + /** + * Stored answer (JSON string) + */ + protected answer?: string; - public readonly displayWideInput = true + /* ---------------------------------------------------------- + * Lifecycle: Initialization + * ---------------------------------------------------------- */ + /** + * Ensure we always start with a valid JSON payload. + * Called when the response area is first created. + */ initWithDefault = () => { - this.config = { - rows: DEFAULT_ROWS, - cols: DEFAULT_COLS, + if (!this.answer) { + this.answer = JSON.stringify(createDefaultEvaluationResult()); + return; + } + + // If existing answer is invalid, reset safely + const parsed = safeParseEvaluationResult(this.answer); + if (!parsed) { + this.answer = JSON.stringify(createDefaultEvaluationResult()); } - this.answer = padMatrixFromRowsAndCols({ - rows: DEFAULT_ROWS, - cols: DEFAULT_COLS, - }) } - protected extractAnswer = (provided: any): void => { - if (!this.config) throw new Error('Config missing') - if (!Array.isArray(provided)) throw new Error('Answer is not an array') - - // legacy handling: answer used to be stored as a one-dimensional array. This - // checks which format the answer is in and converts it to a two-dimensional - // array if necessary - const isChuncked = Array.isArray(provided[0]) - let answerToParse: z.infer - if (isChuncked) { - answerToParse = provided - } else { - answerToParse = _.chunk(provided, this.config.cols) + /* ---------------------------------------------------------- + * Lifecycle: Pre-submission Validation + * ---------------------------------------------------------- */ + + /** + * Runs before every submission. + * Throwing here blocks submission. + */ + customCheck = () => { + const parsed = safeParseEvaluationResult(this.answer); + + if (!parsed) { + throw new Error( + 'Invalid pseudocode evaluation payload. Please re-enter your response.' + ); } - const parsedAnswer = this.answerSchema.safeParse(answerToParse) - if (!parsedAnswer.success) throw new Error('Could not extract answer') - this.answer = parsedAnswer.data + // Optional extra safety checks (cheap, but valuable) + if (parsed.score < 0 || parsed.score > 1) { + throw new Error('Evaluation score must be between 0 and 1.'); + } + + if (!Array.isArray(parsed.feedback_items)) { + throw new Error('Feedback items must be an array.'); + } } + /* ---------------------------------------------------------- + * Student / Read-only View + * ---------------------------------------------------------- */ + InputComponent = (props: BaseResponseAreaProps) => { - if (!this.config) throw new Error('Config missing') + const evaluationResult = safeParseEvaluationResult(props.answer); - return Matrix({ + return PseudocodeInput({ ...props, - config: this.config, - }) - } + answer: evaluationResult, + isTeacherMode: false, + }); + }; + + /* ---------------------------------------------------------- + * Wizard / Editable View + * ---------------------------------------------------------- */ WizardComponent = (props: BaseResponseAreaWizardProps) => { - if (!this.config) throw new Error('Config missing') - if (this.answer === undefined) throw new Error('Answer missing') + const evaluationResult = safeParseEvaluationResult(this.answer); - return MatrixWizard({ + return PseudocodeInput({ ...props, - config: this.config, - answer: this.answer, - }) - } + answer: evaluationResult, + isTeacherMode: true, + handleChange: (result: EvaluationResult) => { + props.handleChange({ + responseType: this.responseType, + answer: JSON.stringify(result), + }); + }, + }); + }; } diff --git a/src/types/Pseudocode/Pseudocode.component.tsx b/src/types/Pseudocode/Pseudocode.component.tsx index 59332eb..3bae9b3 100644 --- a/src/types/Pseudocode/Pseudocode.component.tsx +++ b/src/types/Pseudocode/Pseudocode.component.tsx @@ -1,36 +1,57 @@ // PseudocodeInput.tsx -import { StreamLanguage, syntaxHighlighting } from '@codemirror/language'; +import { foldGutter, foldService, StreamLanguage, syntaxHighlighting } from '@codemirror/language'; import { EditorState } from '@codemirror/state'; -import { placeholder, keymap } from '@codemirror/view'; -import { makeStyles } from '@styles'; // adjust to your project +import { placeholder } from '@codemirror/view'; import { EditorView, basicSetup } from 'codemirror'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { BaseResponseAreaProps } from '../base-props.type'; -import { autoIndentAfterColon } from './utils/autoIndent'; -import { pseudocodeHighlightStyle } from './utils/highlight'; -import { pseudocodeLanguage } from './utils/language'; -import { pseudocodeTheme } from './utils/pseudocode.theme'; +import { autoIndentAfterColon } from './plugins/autoIndent'; +import { pseudocodeFoldFunc } from './plugins/fold'; +import { pseudocodeHighlightStyle } from './plugins/highlight'; +import { pseudocodeLanguage } from './plugins/language'; +import { pseudocodeTheme } from './plugins/pseudocode.theme'; +import { StudentResponse } from './types/input'; +import { usePseudocodeInputStyles } from './utils/styles'; type PseudocodeInputProps = Omit & { - handleChange: (val: string) => void; - answer?: string; + handleChange: (val: StudentResponse) => void; + answer?: StudentResponse; + isTeacherMode?: boolean; }; +export const PseudocodeInput: React.FC = ({ + handleChange, + answer, + isTeacherMode = false, +}) => { + const { classes } = usePseudocodeInputStyles(); + const [internalAnswer, setInternalAnswer] = useState(answer ?? { + pseudocode: '', + time_complexity: null, + space_complexity: null, + explanation: null, + }); -export const PseudocodeInput: React.FC = ({ handleChange, answer }) => { - const { classes } = useStyles(); const editorRef = useRef(null); const viewRef = useRef(null); - // Initialize editor + // Sync external answer only when it changes + useEffect(() => { + if (!answer) return; + setInternalAnswer(answer); + }, [answer]); + + // Initialize CodeMirror useEffect(() => { if (!editorRef.current) return; const state = EditorState.create({ - doc: answer ?? '', + doc: internalAnswer.pseudocode ?? '', extensions: [ + foldGutter(), + foldService.of(pseudocodeFoldFunc), autoIndentAfterColon, basicSetup, pseudocodeLanguage, @@ -38,13 +59,13 @@ export const PseudocodeInput: React.FC = ({ handleChange, pseudocodeTheme, placeholder('Write your pseudocode here...'), EditorView.updateListener.of((update) => { - if (update.docChanged) { - handleChange(update.state.doc.toString()); - } + if (!update.docChanged) return; + const newCode = update.state.doc.toString(); + setInternalAnswer((prev) => ({ ...prev, pseudocode: newCode })); }), EditorView.theme({ - "&": { height: "100%" }, - ".cm-scroller": { overflow: "auto" }, + '&': { height: '100%' }, + '.cm-scroller': { overflow: 'auto' }, }), ], }); @@ -55,48 +76,51 @@ export const PseudocodeInput: React.FC = ({ handleChange, }); viewRef.current = view; - return () => { view.destroy(); viewRef.current = null; }; }, []); - // Update editor if answer prop changes externally - useEffect(() => { - if (viewRef.current && answer !== undefined) { - const view = viewRef.current; - if (view.state.doc.toString() !== answer) { - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: answer }, - }); - } - } - }, [answer]); + // Debounced / onBlur submit + const reportChange = () => { + handleChange({ + pseudocode: internalAnswer.pseudocode ?? '', + time_complexity: internalAnswer.time_complexity ?? null, + space_complexity: internalAnswer.space_complexity ?? null, + explanation: internalAnswer.explanation ?? null, + }); + }; - return
; -}; + return ( +
+
+ + setInternalAnswer((prev) => ({ ...prev, time_complexity: e.target.value }))} + onBlur={reportChange} + /> + + setInternalAnswer((prev) => ({ ...prev, space_complexity: e.target.value }))} + onBlur={reportChange} + /> + +