diff --git a/.gitignore b/.gitignore index 1b68e4d..2fc9aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ yarn-error.log* tsconfig.tsbuildinfo # env files (copy .env.example) -.env +.env \ No newline at end of file diff --git a/externals/api/fetcher.ts b/externals/api/fetcher.ts index 4fa1f95..d435dac 100644 --- a/externals/api/fetcher.ts +++ b/externals/api/fetcher.ts @@ -4,4 +4,4 @@ export const fetchData = ( _options?: unknown, ): (() => Promise) => { return null as any -} +} \ No newline at end of file diff --git a/externals/components/Set/GuidanceWidget/ClockIcon.module.css b/externals/components/Set/GuidanceWidget/ClockIcon.module.css new file mode 100644 index 0000000..e69de29 diff --git a/externals/styles/fonts.tsx b/externals/styles/fonts.tsx index a50408e..0d9b40a 100644 --- a/externals/styles/fonts.tsx +++ b/externals/styles/fonts.tsx @@ -1,80 +1,48 @@ -import { Fira_Sans, Fira_Mono, Lato, Roboto } from 'next/font/google' +// Vite-compatible font loader (replaces next/font/google) +// Since this is a Vite build, we can't use next/font/google which requires Next.js compiler +// Instead, we create compatible objects and inject Google Fonts via CSS -export const roboto = Roboto({ - subsets: ['latin'], - weight: ['400', '700'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-roboto', - preload: false, -}) +// Helper function to create a font object compatible with next/font/google structure +function createFontObject(fontFamily: string, fallback: string[] = []) { + const fallbackStr = fallback.length > 0 ? `, ${fallback.join(', ')}` : '' + return { + style: { + fontFamily: `"${fontFamily}"${fallbackStr}`, + }, + variable: `--font-${fontFamily.toLowerCase().replace(/\s+/g, '-')}`, + } +} -export const firaSans = Fira_Sans({ - subsets: ['latin'], - weight: ['300', '400', '500', '600', '700'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-fira-sans', - preload: false, -}) +// Inject Google Fonts CSS if not already injected +if (typeof document !== 'undefined') { + const fontLinkId = 'google-fonts-vite-loader' + if (!document.getElementById(fontLinkId)) { + const link = document.createElement('link') + link.id = fontLinkId + link.rel = 'stylesheet' + link.href = + 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Mono:wght@400&family=Lato:wght@400&display=swap' + document.head.appendChild(link) + } +} -export const firaMono = Fira_Mono({ - subsets: ['latin'], - weight: ['400'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-fira-mono', - preload: false, -}) +const robotoFallback = [ + '-apple-system', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', +] -export const lato = Lato({ - subsets: ['latin'], - weight: ['400'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-lato', - preload: false, -}) +export const roboto = createFontObject('Roboto', robotoFallback) + +export const firaSans = createFontObject('Fira Sans', robotoFallback) + +export const firaMono = createFontObject('Fira Mono', robotoFallback) + +export const lato = createFontObject('Lato', robotoFallback) diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index ab01af5..51df134 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -1,8 +1,26 @@ +import { QueryClient, QueryClientProvider } from 'react-query' +import { SnackbarProvider } from 'notistack' import { ThemeProvider } from '@styles/minimal/theme-provider' import { SandboxResponseAreaTub } from './types/Sandbox/index' +// Create a QueryClient instance +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}) + function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { - return {children} + return ( + + + {children} + + + ) } // wrap the components with the necessary providers; only in the sandbox @@ -31,4 +49,4 @@ class WrappedSandboxResponseAreaTub extends SandboxResponseAreaTub { } } -export default WrappedSandboxResponseAreaTub +export default WrappedSandboxResponseAreaTub \ No newline at end of file diff --git a/src/types/PropositionalLogic/PropositionalLogic.component.tsx b/src/types/PropositionalLogic/PropositionalLogic.component.tsx new file mode 100644 index 0000000..2f463be --- /dev/null +++ b/src/types/PropositionalLogic/PropositionalLogic.component.tsx @@ -0,0 +1,204 @@ +import Stack from '@mui/material/Stack' +import Box from '@mui/system/Box' +import React, { useCallback, useEffect, useRef, useState } from 'react' + +import { + OmniInputResponsArea, + OmniInputResponsAreaProps, +} from '@components/OmniInput/OmniInputResponseArea.component' +import { ResponseAreaOmniInputContainer } from '@modules/shared/components/ResponseArea/ResponseAreaOmniInputContainer.component' +import { BaseResponseAreaProps } from '../base-props.type' +import { PropositionalLogicAnswerSchema } from './PropositionalLogic.schema' +import { PropositionalLogicSymbolKeyboard } from './PropositionalLogicSymbolKeyboard.component' +import { TruthTableSection } from './TruthTableSection.component' + +type PropositionalLogicProps = Omit< + BaseResponseAreaProps, + 'handleChange' | 'answer' +> & { + handleChange: (answer: PropositionalLogicAnswerSchema) => void + answer: PropositionalLogicAnswerSchema | undefined + allowDraw: boolean + allowScan: boolean + enableRefinement: boolean + allowTruthTable?: boolean +} + +export const PropositionalLogic: React.FC = ({ + handleChange, + handleSubmit, + answer, + allowDraw, + allowScan, + allowTruthTable = false, + hasPreview, + enableRefinement, + feedback, + typesafeErrorMessage, + checkIsLoading, + preResponseText, + postResponseText, + responsePreviewParams, + displayMode, +}) => { + // Normalize answer to object shape { formula, truthTable } + const answerObject = answer ?? { formula: '', truthTable: undefined } + const currentFormula = answerObject.formula ?? '' + + // Remount OmniInput when symbol button is clicked so it shows updated value (it only reads defaultValue on mount) + const [formulaKey, setFormulaKey] = useState(0) + const [displayAnswer, setDisplayAnswer] = useState(currentFormula) + + // Sync displayAnswer with answer prop when it changes + useEffect(() => { + setDisplayAnswer(currentFormula) + }, [currentFormula]) + + const omniInputContainerRef = useRef(null) + const cursorRef = useRef({ start: 0, end: 0 }) + const pendingCursorRef = useRef(null) + + const submitAnswer = useCallback( + (formula: string, truthTable: PropositionalLogicAnswerSchema['truthTable']) => { + handleChange({ + formula, + truthTable: allowTruthTable ? truthTable : undefined, + }) + }, + [handleChange, allowTruthTable], + ) + + const onFormulaChange = useCallback( + (newFormula) => { + setDisplayAnswer(newFormula) + submitAnswer(newFormula, answerObject.truthTable) + }, + [answerObject.truthTable, submitAnswer], + ) + + const insertSymbol = useCallback( + (symbol: string) => { + const { start, end } = cursorRef.current + const newValue = + displayAnswer.slice(0, start) + symbol + displayAnswer.slice(end) + setDisplayAnswer(newValue) + submitAnswer(newValue, answerObject.truthTable) + pendingCursorRef.current = start + symbol.length + setFormulaKey(k => k + 1) + }, + [displayAnswer, answerObject.truthTable, submitAnswer], + ) + + // Attach cursor-tracking listeners to OmniInput's textarea (found via DOM) + useEffect(() => { + const container = omniInputContainerRef.current + if (!container) return + + let timeoutId: ReturnType + let cleanup: (() => void) | undefined + + const tryAttach = () => { + const textarea = container.querySelector('textarea') + if (textarea) { + const updateCursor = () => { + cursorRef.current = { + start: textarea.selectionStart ?? 0, + end: textarea.selectionEnd ?? 0, + } + } + const events = ['select', 'keyup', 'mouseup', 'blur', 'focus', 'input'] as const + events.forEach(ev => textarea.addEventListener(ev, updateCursor)) + cleanup = () => { + events.forEach(ev => textarea.removeEventListener(ev, updateCursor)) + } + return + } + timeoutId = setTimeout(tryAttach, 50) + } + + tryAttach() + + return () => { + clearTimeout(timeoutId) + cleanup?.() + } + }, [formulaKey]) + + // Restore cursor position after symbol insert (OmniInput remounts) + useEffect(() => { + const pos = pendingCursorRef.current + if (pos === null) return + + const container = omniInputContainerRef.current + if (!container) return + + const tryRestore = () => { + const textarea = container.querySelector('textarea') + if (textarea) { + pendingCursorRef.current = null + textarea.focus() + textarea.setSelectionRange(pos, pos) + return true + } + return false + } + + if (!tryRestore()) { + const id = setTimeout(() => tryRestore(), 0) + return () => clearTimeout(id) + } + }, [displayAnswer, formulaKey]) + + const onTruthTableChange = useCallback( + (truthTable: PropositionalLogicAnswerSchema['truthTable']) => { + if (!truthTable) return + submitAnswer(displayAnswer, truthTable) + }, + [displayAnswer, submitAnswer], + ) + + const onRemoveTruthTable = useCallback(() => { + submitAnswer(displayAnswer, undefined) + }, [displayAnswer, submitAnswer]) + + return ( + + + + + + + {allowTruthTable && ( + + )} + + + ) +} + +export const HMR = true diff --git a/src/types/PropositionalLogic/PropositionalLogic.schema.ts b/src/types/PropositionalLogic/PropositionalLogic.schema.ts new file mode 100644 index 0000000..b08c8f1 --- /dev/null +++ b/src/types/PropositionalLogic/PropositionalLogic.schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +export const truthTableSchema = z.object({ + variables: z.array(z.string()), + cells: z.array(z.array(z.string())), +}) + +export type TruthTableSchema = z.infer + +/** Student answer (main component): formula + optional truth table. */ +export const propositionalLogicAnswerSchema = z.object({ + formula: z.string(), + truthTable: truthTableSchema.nullish().optional(), +}) + +export type PropositionalLogicAnswerSchema = z.infer< + typeof propositionalLogicAnswerSchema +> + +/** + * Wizard-only: expected answer payload (teacher chooses one of four). + * Stored in config.expectedAnswer, not in answer. + * validTruthTable: true means "answer should be a valid truth table" (no table entry). + */ +export const propositionalLogicExpectedAnswerSchema = z.object({ + satisfiability: z.boolean(), + tautology: z.boolean(), + equivalent: z.string().nullable(), + validTruthTable: z.boolean(), +}) + +export type PropositionalLogicExpectedAnswerSchema = z.infer< + typeof propositionalLogicExpectedAnswerSchema +> + +export const propositionalLogicConfigSchema = z.object({ + allowHandwrite: z.boolean(), + allowPhoto: z.boolean(), + enableRefinement: z.boolean().default(true), + expectedAnswer: propositionalLogicExpectedAnswerSchema.optional(), +}) + +export type PropositionalLogicConfigSchema = z.infer< + typeof propositionalLogicConfigSchema +> + +/** Parse unique single-letter variable names (A–Z or a–z) from a formula, sorted. */ +export function parseVariablesFromFormula(formula: string): string[] { + const letters = formula.match(/\b[A-Za-z]\b/g) ?? [] + return [...new Set(letters)].sort() +} + +/** Build empty cells grid: 2^n rows × (variables.length + 1) columns (last = result). */ +export function buildEmptyTruthTableCells( + variables: string[], +): string[][] { + const rows = 2 ** variables.length + const cols = variables.length + 1 + return Array.from({ length: rows }, () => Array(cols).fill('')) +} \ No newline at end of file diff --git a/src/types/PropositionalLogic/PropositionalLogicSymbolKeyboard.component.tsx b/src/types/PropositionalLogic/PropositionalLogicSymbolKeyboard.component.tsx new file mode 100644 index 0000000..a2550f2 --- /dev/null +++ b/src/types/PropositionalLogic/PropositionalLogicSymbolKeyboard.component.tsx @@ -0,0 +1,29 @@ +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import React from 'react' + +import { PROPOSITIONAL_LOGIC_SYMBOLS } from './symbols' + +type PropositionalLogicSymbolKeyboardProps = { + onInsert: (symbol: string) => void +} + +export const PropositionalLogicSymbolKeyboard: React.FC< + PropositionalLogicSymbolKeyboardProps +> = ({ onInsert }) => ( + + {PROPOSITIONAL_LOGIC_SYMBOLS.map(symbol => ( + + ))} + +) + diff --git a/src/types/PropositionalLogic/PropositionalLogicWizard.component.tsx b/src/types/PropositionalLogic/PropositionalLogicWizard.component.tsx new file mode 100644 index 0000000..67bb10c --- /dev/null +++ b/src/types/PropositionalLogic/PropositionalLogicWizard.component.tsx @@ -0,0 +1,189 @@ +import FormControl from '@mui/material/FormControl' +import FormControlLabel from '@mui/material/FormControlLabel' +import Radio from '@mui/material/Radio' +import RadioGroup from '@mui/material/RadioGroup' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import React, { useCallback, useEffect, useRef } from 'react' + +import { PropositionalLogicExpectedAnswerSchema } from './PropositionalLogic.schema' +import { PropositionalLogicSymbolKeyboard } from './PropositionalLogicSymbolKeyboard.component' + +type AnswerKind = 'satisfiability' | 'tautology' | 'equivalent' | 'validTruthTable' + +const EMPTY_EXPECTED: PropositionalLogicExpectedAnswerSchema = { + satisfiability: false, + tautology: false, + equivalent: null, + validTruthTable: false, +} + +function getSelectedKind( + expected: PropositionalLogicExpectedAnswerSchema, +): AnswerKind { + if (expected.satisfiability) return 'satisfiability' + if (expected.tautology) return 'tautology' + if (expected.equivalent !== null) return 'equivalent' + if (expected.validTruthTable) return 'validTruthTable' + return 'satisfiability' +} + +interface PropositionalLogicWizardProps { + expectedAnswer: PropositionalLogicExpectedAnswerSchema + allowHandwrite: boolean + allowPhoto: boolean + onChange: (args: { + expectedAnswer: PropositionalLogicExpectedAnswerSchema + allowHandwrite: boolean + allowPhoto: boolean + }) => void + setAllowSave?: React.Dispatch> +} + +export const PropositionalLogicWizard: React.FC< + PropositionalLogicWizardProps +> = props => { + const { + expectedAnswer, + allowHandwrite, + allowPhoto, + onChange, + setAllowSave, + } = props + const kind = getSelectedKind(expectedAnswer) + + useEffect(() => { + setAllowSave?.(true) + }, [setAllowSave]) + + const setKind = useCallback( + (newKind: AnswerKind) => { + const next: PropositionalLogicExpectedAnswerSchema = { + ...EMPTY_EXPECTED, + ...(newKind === 'satisfiability' && { satisfiability: true }), + ...(newKind === 'tautology' && { tautology: true }), + ...(newKind === 'equivalent' && { + equivalent: expectedAnswer.equivalent ?? '', + }), + ...(newKind === 'validTruthTable' && { validTruthTable: true }), + } + onChange({ expectedAnswer: next, allowHandwrite, allowPhoto }) + }, + [ + expectedAnswer.equivalent, + allowHandwrite, + allowPhoto, + onChange, + ], + ) + + const equivalentInputRef = useRef(null) + const cursorRef = useRef({ start: 0, end: 0 }) + const pendingCursorRef = useRef(null) + + const setEquivalentFormula = useCallback( + (formula: string) => { + onChange({ + expectedAnswer: { ...EMPTY_EXPECTED, equivalent: formula }, + allowHandwrite, + allowPhoto, + }) + }, + [allowHandwrite, allowPhoto, onChange], + ) + + const insertSymbol = useCallback( + (symbol: string) => { + const current = expectedAnswer.equivalent ?? '' + const { start, end } = cursorRef.current + const newValue = + current.slice(0, start) + symbol + current.slice(end) + setEquivalentFormula(newValue) + pendingCursorRef.current = start + symbol.length + }, + [expectedAnswer.equivalent, setEquivalentFormula], + ) + + const updateCursor = useCallback( + (e: React.SyntheticEvent) => { + const target = e.target as HTMLInputElement | HTMLTextAreaElement + if ('selectionStart' in target && 'selectionEnd' in target) { + cursorRef.current = { + start: target.selectionStart ?? 0, + end: target.selectionEnd ?? 0, + } + } + }, + [], + ) + + useEffect(() => { + if (pendingCursorRef.current !== null && equivalentInputRef.current) { + const pos = pendingCursorRef.current + pendingCursorRef.current = null + equivalentInputRef.current.focus() + equivalentInputRef.current.setSelectionRange(pos, pos) + } + }, [expectedAnswer.equivalent]) + + return ( +
+ + Expected answer (choose one) + + + setKind(e.target.value as AnswerKind)} + > + } + label="Should be satisfiable" + /> + } + label="Should be a tautology" + /> + } + label="Should be equivalent to (enter formula below)" + /> + } + label="Should be a valid truth table" + /> + + + + {kind === 'equivalent' && ( + + + { + updateCursor(e) + setEquivalentFormula(e.target.value) + }} + onSelect={updateCursor} + onKeyUp={updateCursor} + onMouseUp={updateCursor} + onBlur={updateCursor} + onFocus={updateCursor} + inputRef={equivalentInputRef} + variant="outlined" + /> + + )} +
+ ) +} + +export const HMR = true diff --git a/src/types/PropositionalLogic/README.md b/src/types/PropositionalLogic/README.md new file mode 100644 index 0000000..6d7bab3 --- /dev/null +++ b/src/types/PropositionalLogic/README.md @@ -0,0 +1,24 @@ +## Propositional Logic Input + +This response area accepts user answers through: + +- The `OmniInput` component for answer entry +- A specialised propositional logic keyboard to assist inserting symbols (e.g. ¬, ∧, ∨, →, ↔) + +### Optional Truth Table Section + +Users may optionally construct a truth table as part of their answer. + +- The table initialises with a single cell. +- Users can dynamically add/remove rows and columns as required. +- The final column is expected to contain the truth values of the entered formula. + +## Wizard + +Wizard contains the options on how the answer should be marked + +- Answer formula should be a tautology +- Answer formula should be satisfiable +- Answer formual should be equivalent to a given formula (comes with an input for the teacher to enter this formula) +- Answer should be a valid truth table + diff --git a/src/types/PropositionalLogic/TruthTableSection.component.tsx b/src/types/PropositionalLogic/TruthTableSection.component.tsx new file mode 100644 index 0000000..53c597a --- /dev/null +++ b/src/types/PropositionalLogic/TruthTableSection.component.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useRef, useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import TextField from '@mui/material/TextField' +import Stack from '@mui/material/Stack' +import AddIcon from '@mui/icons-material/Add' +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import { TruthTableSchema } from './PropositionalLogic.schema' +import { PROPOSITIONAL_LOGIC_SYMBOLS } from './symbols' + +const TRUE_FALSE_OPTIONS = [ + { value: '', label: '—' }, + { value: '⊤', label: '⊤ (true)' }, + { value: '⊥', label: '⊥ (false)' }, +] + +export type TruthTableSectionProps = { + formula: string + truthTable: TruthTableSchema | undefined + onTruthTableChange: (truthTable: TruthTableSchema) => void + onRemoveTruthTable?: () => void + allowDraw: boolean + allowScan: boolean + processingMode?: string +} + +export const TruthTableSection: React.FC = ({ + formula, + truthTable, + onTruthTableChange, + onRemoveTruthTable, +}) => { + + const handleAddTruthTable = useCallback(() => { + // Start with 1x1 table with formula as column name + const initialColumnName = formula || 'Expression' + const cells = [['']] // 1 row, 1 column + onTruthTableChange({ variables: [initialColumnName], cells }) + }, [formula, onTruthTableChange]) + + const handleCellChange = useCallback( + (rowIndex: number, colIndex: number, value: string) => { + if (!truthTable) return + const { cells } = truthTable + const next = cells.map((row, r) => + r === rowIndex + ? row.map((val, c) => (c === colIndex ? value : val)) + : row, + ) + onTruthTableChange({ ...truthTable, cells: next }) + }, + [truthTable, onTruthTableChange], + ) + + const handleColumnNameChange = useCallback( + (colIndex: number, newName: string) => { + if (!truthTable) return + const { variables } = truthTable + const next = variables.map((v, i) => (i === colIndex ? newName : v)) + onTruthTableChange({ ...truthTable, variables: next }) + }, + [truthTable, onTruthTableChange], + ) + + const handleAddRow = useCallback(() => { + if (!truthTable) return + const { cells, variables } = truthTable + const numCols = variables.length + const newRow = Array(numCols).fill('') + const nextCells = [...cells, newRow] + onTruthTableChange({ ...truthTable, cells: nextCells }) + }, [truthTable, onTruthTableChange]) + + const handleRemoveRow = useCallback( + (rowIndex: number) => { + if (!truthTable) return + const { cells } = truthTable + const nextCells = cells.filter((_, idx) => idx !== rowIndex) + onTruthTableChange({ ...truthTable, cells: nextCells }) + }, + [truthTable, onTruthTableChange], + ) + + const handleAddColumn = useCallback(() => { + if (!truthTable) return + const { cells, variables } = truthTable + // Add empty column name before the last column (which is the formula) + // Ensure the last column is always the formula + const formulaColumn = formula || 'Expression' + const nextVariables = [...variables.slice(0, -1), '', formulaColumn] + // Add empty cell to each row before the last column + const nextCells = cells.map(row => [...row.slice(0, -1), '', row[row.length - 1] || '']) + onTruthTableChange({ variables: nextVariables, cells: nextCells }) + }, [truthTable, formula, onTruthTableChange]) + + const handleRemoveColumn = useCallback( + (colIndex: number) => { + if (!truthTable) return + const { variables, cells } = truthTable + // Don't remove the last column (formula) and don't allow empty variable list + if (variables.length <= 1 || colIndex === variables.length - 1) return + + const nextVariables = variables.filter((_, idx) => idx !== colIndex) + const nextCells = cells.map(row => + row.filter((_, idx) => idx !== colIndex), + ) + onTruthTableChange({ variables: nextVariables, cells: nextCells }) + }, + [truthTable, onTruthTableChange], + ) + + const columnNames = truthTable?.variables ?? [] + const cells = truthTable?.cells ?? [] + const canAddTable = !truthTable + + // Track which column input has focus for symbol insertion + const [focusedColumnIndex, setFocusedColumnIndex] = useState(null) + const columnInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}) + + const handleInsertSymbol = useCallback( + (symbol: string, colIndex: number) => { + if (!truthTable) return + const { variables } = truthTable + const currentName = variables[colIndex] || '' + const newName = currentName + symbol + handleColumnNameChange(colIndex, newName) + + // Focus back on the input after inserting symbol + setTimeout(() => { + const input = columnInputRefs.current[colIndex] + if (input) { + input.focus() + // Move cursor to end + const len = input.value.length + input.setSelectionRange(len, len) + } + }, 0) + }, + [truthTable, handleColumnNameChange], + ) + + return ( + + {!truthTable ? ( + + ) : ( + <> + + + Truth table for formula + + {onRemoveTruthTable && ( + + )} + + + + + + + + + + {columnNames.map((name, idx) => { + const isLastColumn = idx === columnNames.length - 1 + const displayName = isLastColumn ? (formula || 'Expression') : name + return ( + + + { + if (!isLastColumn) { + columnInputRefs.current[idx] = el + } + }} + value={displayName} + onChange={isLastColumn ? undefined : (e => handleColumnNameChange(idx, e.target.value))} + onFocus={isLastColumn ? undefined : () => setFocusedColumnIndex(idx)} + onBlur={isLastColumn ? undefined : () => setFocusedColumnIndex(null)} + placeholder={isLastColumn ? (formula || 'Expression') : 'Name column here'} + InputProps={{ + readOnly: isLastColumn, + }} + size="small" + inputProps={{ + style: { textAlign: 'center', fontWeight: 600 }, + }} + sx={{ + '& .MuiInputBase-root': { + fontSize: '0.875rem', + }, + '& .MuiInputBase-input': { + padding: '4px 8px', + }, + ...(isLastColumn && { + '& .MuiInputBase-input': { + cursor: 'default', + }, + }), + }} + /> + {focusedColumnIndex === idx && !isLastColumn && ( + + {PROPOSITIONAL_LOGIC_SYMBOLS.map(sym => ( + + ))} + + )} + {!isLastColumn && ( + + handleRemoveColumn(idx)} + > + + + + )} + + + ) + })} + + + + + {cells.map((row, rowIndex) => ( + + {row.map((cellValue, colIndex) => ( + + + + ))} + + handleRemoveRow(rowIndex)}> + + + + + ))} + +
+
+ + )} +
+ ) +} diff --git a/src/types/PropositionalLogic/index.ts b/src/types/PropositionalLogic/index.ts new file mode 100644 index 0000000..733b107 --- /dev/null +++ b/src/types/PropositionalLogic/index.ts @@ -0,0 +1,118 @@ +import z from 'zod' + +import { + BaseResponseAreaProps, + BaseResponseAreaWizardProps, +} from '../base-props.type' +import { ResponseAreaTub } from '../response-area-tub' + +import { PropositionalLogic } from './PropositionalLogic.component' +import { + PropositionalLogicAnswerSchema, + PropositionalLogicConfigSchema, + PropositionalLogicExpectedAnswerSchema, + propositionalLogicConfigSchema, +} from './PropositionalLogic.schema' +import { PropositionalLogicWizard } from './PropositionalLogicWizard.component' + +const EMPTY_ANSWER: PropositionalLogicAnswerSchema = { + formula: '', + truthTable: undefined, +} + +const EMPTY_EXPECTED: PropositionalLogicExpectedAnswerSchema = { + satisfiability: false, + tautology: false, + equivalent: null, + validTruthTable: false, +} + +export class PropositionalLogicResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'PROPOSITIONAL_LOGIC' + + public readonly canToggleLatexInStats = true + + public readonly delegatePreResponseText = false + + public readonly delegatePostResponseText = false + + public readonly delegateLivePreview = false + + public readonly delegateFeedback = true + + public readonly delegateCheck = false + + public readonly delegateErrorMessage = false + + public readonly displayInFlexContainer = false + + protected answerSchema = z.unknown() + protected answer: string = JSON.stringify(EMPTY_ANSWER) + + protected configSchema = propositionalLogicConfigSchema + protected config?: PropositionalLogicConfigSchema + + initWithDefault = () => { + this.config = { + allowHandwrite: true, + allowPhoto: true, + enableRefinement: false, + } + this.answer = JSON.stringify(EMPTY_ANSWER) + } + + InputComponent = (props: BaseResponseAreaProps) => { + if (!this.config) throw new Error('Config missing') + let parsedAnswer: PropositionalLogicAnswerSchema + try { + const answerStr = + typeof props.answer === 'string' ? props.answer : JSON.stringify(props.answer ?? {}) + const raw = JSON.parse(answerStr) + parsedAnswer = { + formula: raw.formula ?? '', + truthTable: raw.truthTable ?? undefined, + } + } catch { + parsedAnswer = EMPTY_ANSWER + } + const handleChange = (answer: PropositionalLogicAnswerSchema) => { + props.handleChange(JSON.stringify(answer)) + } + + const allowTruthTable = + this.config.expectedAnswer?.validTruthTable == true + + return PropositionalLogic({ + ...props, + hasPreview: false, + handleChange, + answer: parsedAnswer, + allowDraw: this.config.allowHandwrite, + allowScan: this.config.allowPhoto, + enableRefinement: this.config.enableRefinement, + allowTruthTable, + }) + } + + WizardComponent = (props: BaseResponseAreaWizardProps) => { + if (!this.config) throw new Error('Config missing') + + const expectedAnswer: PropositionalLogicExpectedAnswerSchema = + this.config.expectedAnswer ?? EMPTY_EXPECTED + + return PropositionalLogicWizard({ + expectedAnswer, + allowHandwrite: this.config.allowHandwrite, + allowPhoto: this.config.allowPhoto, + setAllowSave: props.setAllowSave, + onChange: args => { + this.config = { ...this.config!, expectedAnswer: args.expectedAnswer } + props.handleChange({ + responseType: this.responseType, + config: this.config, + answer: this.config.expectedAnswer, + } as unknown as Parameters[0]) + }, + }) + } +} diff --git a/src/types/PropositionalLogic/symbols.ts b/src/types/PropositionalLogic/symbols.ts new file mode 100644 index 0000000..bbef3cd --- /dev/null +++ b/src/types/PropositionalLogic/symbols.ts @@ -0,0 +1,19 @@ +export type PropositionalLogicSymbol = { + label: string + value: string + title: string +} + +export const PROPOSITIONAL_LOGIC_SYMBOLS: PropositionalLogicSymbol[] = [ + { label: '¬', value: '¬', title: 'Not' }, + { label: '∧', value: '∧', title: 'And' }, + { label: '∨', value: '∨', title: 'Or' }, + { label: '⊕', value: '⊕', title: 'XOR' }, + { label: '→', value: '→', title: 'Implies' }, + { label: '↔', value: '↔', title: 'If and only if' }, + { label: '⊥', value: '⊥', title: 'False' }, + { label: '⊤', value: '⊤', title: 'True' }, + { label: '(', value: '(', title: 'Left parenthesis' }, + { label: ')', value: ')', title: 'Right parenthesis' }, +] + diff --git a/src/types/PropositionalLogic/utils/serialize.ts b/src/types/PropositionalLogic/utils/serialize.ts new file mode 100644 index 0000000..1f66532 --- /dev/null +++ b/src/types/PropositionalLogic/utils/serialize.ts @@ -0,0 +1,44 @@ +import { propositionalLogicAnswerSchema, PropositionalLogicAnswerSchema } from "../PropositionalLogic.schema" + +export type PersistedAnswer = { + formula: string + truthTable: string | null +} + +export function serializeAnswer( + answer: PropositionalLogicAnswerSchema +): PersistedAnswer { + return { + formula: answer.formula, + truthTable: answer.truthTable + ? JSON.stringify(answer.truthTable) + : null, + } +} + +export function deserializeAnswer(raw: string): PropositionalLogicAnswerSchema | undefined { + if (!raw || typeof raw !== 'object') return undefined + + const stored = raw as Partial + + if (typeof stored.truthTable === 'string') { + try { + const json = JSON.parse(stored.truthTable) + const validated = propositionalLogicAnswerSchema.safeParse({ + formula: stored.formula ?? '', + truthTable: json, + }) + + if (validated.success) { + return validated.data + } + } catch { + // corrupted JSON -> ignore + } + } + + return { + formula: stored.formula ?? '', + truthTable: undefined, + } +} \ No newline at end of file diff --git a/src/types/Sandbox/index.ts b/src/types/Sandbox/index.ts index 553fe5b..e010067 100644 --- a/src/types/Sandbox/index.ts +++ b/src/types/Sandbox/index.ts @@ -1,38 +1,3 @@ -import z from 'zod' +import { PropositionalLogicResponseAreaTub } from '../PropositionalLogic' -import { - BaseResponseAreaProps, - BaseResponseAreaWizardProps, -} from '../base-props.type' -import { ResponseAreaTub } from '../response-area-tub' - -import { SandboxInput } from './SandboxInput.component' - -export class SandboxResponseAreaTub extends ResponseAreaTub { - public readonly responseType = 'SANDBOX' - - protected answerSchema = z.string() - - protected answer?: string - - InputComponent = (props: BaseResponseAreaProps) => { - const parsedAnswer = this.answerSchema.safeParse(props.answer) - return SandboxInput({ - ...props, - answer: parsedAnswer.success ? parsedAnswer.data : undefined, - }) - } - - WizardComponent = (props: BaseResponseAreaWizardProps) => { - return SandboxInput({ - ...props, - answer: this.answer, - handleChange: answer => { - props.handleChange({ - responseType: this.responseType, - answer, - }) - }, - }) - } -} +export class SandboxResponseAreaTub extends PropositionalLogicResponseAreaTub {} diff --git a/src/types/index.ts b/src/types/index.ts index 0869168..5a68585 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ import { MatrixResponseAreaTub } from './Matrix' import { MultipleChoiceResponseAreaTub } from './MultipleChoice' import { NumberResponseAreaTub } from './NumberInput' import { NumericUnitsResponseAreaTub } from './NumericUnits' +import { PropositionalLogicResponseAreaTub } from './PropositionalLogic' import { ResponseAreaTub } from './response-area-tub' import { isResponseAreaSandboxType } from './sandbox' import { TableResponseAreaTub } from './Table' @@ -32,6 +33,7 @@ export const supportedResponseTypes = [ 'MATH_SINGLE_LINE', 'MATH_MULTI_LINES', 'IMAGES', + 'PROPOSITIONAL_LOGIC' ] const createReponseAreaTub = (type: string): ResponseAreaTub => { @@ -40,6 +42,8 @@ const createReponseAreaTub = (type: string): ResponseAreaTub => { } switch (type) { + case 'PROPOSITIONAL_LOGIC': + return new PropositionalLogicResponseAreaTub() case 'BOOLEAN': return new TrueFalseResponseAreaTub() case 'TEXT': diff --git a/yarn.lock b/yarn.lock index 2f15a67..028de3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7602,4 +7602,4 @@ zip-stream@^4.1.0: zod@^3.14.4: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" - integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== \ No newline at end of file