UNPKG

@pie-lib/math-toolbar

Version:

Math toolbar for editing math equations

500 lines (440 loc) 14 kB
import React from 'react'; import debug from 'debug'; import PropTypes from 'prop-types'; import cx from 'classnames'; import Button from '@material-ui/core/Button'; import { withStyles } from '@material-ui/core/styles'; import MenuItem from '@material-ui/core/MenuItem'; import Select from '@material-ui/core/Select'; import isEqual from 'lodash/isEqual'; import { HorizontalKeypad, mq, updateSpans } from '@pie-lib/math-input'; import { color, InputContainer } from '@pie-lib/render-ui'; import { markFractionBaseSuperscripts } from './utils'; const { commonMqFontStyles, commonMqKeyboardStyles, longdivStyles, supsubStyles } = mq.CommonMqStyles; const log = debug('@pie-lib:math-toolbar:editor-and-pad'); const decimalRegex = /\.|,/g; const toNodeData = (data) => { if (!data) { return; } const { type, value } = data; if (type === 'command' || type === 'cursor') { return data; } else if (type === 'answer') { return { type: 'answer', ...data }; } else if (value === 'clear') { return { type: 'clear' }; } else { return { type: 'write', value }; } }; export class EditorAndPad extends React.Component { static propTypes = { classNames: PropTypes.object, keypadMode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), autoFocus: PropTypes.bool, allowAnswerBlock: PropTypes.bool, showKeypad: PropTypes.bool, controlledKeypad: PropTypes.bool, controlledKeypadMode: PropTypes.bool, error: PropTypes.string, noDecimal: PropTypes.bool, hideInput: PropTypes.bool, noLatexHandling: PropTypes.bool, layoutForKeyPad: PropTypes.object, maxResponseAreas: PropTypes.number, additionalKeys: PropTypes.array, latex: PropTypes.string.isRequired, onAnswerBlockAdd: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, onChange: PropTypes.func.isRequired, classes: PropTypes.object, setKeypadInteraction: PropTypes.func, }; constructor(props) { super(props); this.state = { equationEditor: 'item-authoring', addDisabled: false }; } componentDidMount() { if (this.input && this.props.autoFocus) { this.input.focus(); } } onClick = (data) => { const { noDecimal, noLatexHandling, onChange } = this.props; const c = toNodeData(data); log('mathChange: ', c); if (noLatexHandling) { onChange(c.value); return; } // if decimals are not allowed for this response, we discard the input if (noDecimal && (c.value === '.' || c.value === ',')) { return; } if (!c) { return; } if (c.type === 'clear') { log('call clear...'); this.input.clear(); } else if (c.type === 'command') { this.input.command(c.value); } else if (c.type === 'cursor') { this.input.keystroke(c.value); } else if (c.type === 'answer') { this.input.write('%response%'); } else { this.input.write(c.value); } }; updateDisable = (isEdit) => { const { maxResponseAreas } = this.props; if (maxResponseAreas) { const shouldDisable = this.checkResponseAreasNumber(maxResponseAreas, isEdit); this.setState({ addDisabled: shouldDisable }); } }; onAnswerBlockClick = () => { this.props.onAnswerBlockAdd(); this.onClick({ type: 'answer', }); this.updateDisable(true); }; onEditorChange = (latex) => { const { onChange, noDecimal } = this.props; updateSpans(); markFractionBaseSuperscripts(); this.updateDisable(true); // if no decimals are allowed and the last change is a decimal dot, discard the change if (noDecimal && (latex.indexOf('.') !== -1 || latex.indexOf(',') !== -1) && this.input) { this.input.clear(); this.input.write(latex.replace(decimalRegex, '')); return; } // eslint-disable-next-line no-useless-escape const regexMatch = latex.match(/[0-9]\\ \\frac\{[^\{]*\}\{ \}/); if (this.input && regexMatch && regexMatch?.length) { try { this.input.mathField.__controller.cursor.insLeftOf(this.input.mathField.__controller.cursor.parent[-1].parent); this.input.mathField.el().dispatchEvent(new KeyboardEvent('keydown', { keyCode: 8 })); } catch (e) { // eslint-disable-next-line no-console console.error(e.toString()); } return; } onChange(latex); }; /** Only render if the mathquill instance's latex is different * or the keypad state changed from one state to the other (shown / hidden) */ shouldComponentUpdate(nextProps, nextState) { const inputIsDifferent = this.input.mathField.latex() !== nextProps.latex; log('[shouldComponentUpdate] ', 'inputIsDifferent: ', inputIsDifferent); if (!isEqual(this.props.error, nextProps.error)) { return true; } if (!inputIsDifferent && this.props.keypadMode !== nextProps.keypadMode) { return true; } if (!inputIsDifferent && this.props.noDecimal !== nextProps.noDecimal) { return true; } if (!inputIsDifferent && this.state.equationEditor !== nextState.equationEditor) { return true; } if (!inputIsDifferent && this.props.controlledKeypad) { return this.props.showKeypad !== nextProps.showKeypad; } return inputIsDifferent; } onEditorTypeChange = (evt) => { this.setState({ equationEditor: evt.target.value }); }; checkResponseAreasNumber = (maxResponseAreas, isEdit) => { const { latex } = (this.input && this.input.props) || {}; if (latex) { const count = (latex.match(/answerBlock/g) || []).length; return isEdit ? count === maxResponseAreas - 1 : count === maxResponseAreas; } return false; }; render() { const { classNames, keypadMode, allowAnswerBlock, additionalKeys, controlledKeypad, controlledKeypadMode, showKeypad, setKeypadInteraction, noDecimal, hideInput, layoutForKeyPad, latex, onFocus, onBlur, classes, error, } = this.props; const shouldShowKeypad = !controlledKeypad || (controlledKeypad && showKeypad); const { addDisabled } = this.state; log('[render]', latex); return ( <div className={cx(classes.mathToolbar, classNames.mathToolbar)}> <div className={cx(classes.inputAndTypeContainer, { [classes.hide]: hideInput })}> {controlledKeypadMode && ( <InputContainer label="Equation Editor" className={classes.selectContainer}> <Select className={classes.select} onChange={this.onEditorTypeChange} value={this.state.equationEditor}> <MenuItem value="non-negative-integers">Numeric - Non-Negative Integers</MenuItem> <MenuItem value="integers">Numeric - Integers</MenuItem> <MenuItem value="decimals">Numeric - Decimals</MenuItem> <MenuItem value="fractions">Numeric - Fractions</MenuItem> <MenuItem value={1}>Grade 1 - 2</MenuItem> <MenuItem value={3}>Grade 3 - 5</MenuItem> <MenuItem value={6}>Grade 6 - 7</MenuItem> <MenuItem value={8}>Grade 8 - HS</MenuItem> <MenuItem value={'geometry'}>Geometry</MenuItem> <MenuItem value={'advanced-algebra'}>Advanced Algebra</MenuItem> <MenuItem value={'statistics'}>Statistics</MenuItem> <MenuItem value={'item-authoring'}>Item Authoring</MenuItem> </Select> </InputContainer> )} <div className={cx(classes.inputContainer, error ? classes.error : '')}> <mq.Input onFocus={() => { onFocus && onFocus(); this.updateDisable(false); }} onBlur={(event) => { this.updateDisable(false); onBlur && onBlur(event); }} className={cx(classes.mathEditor, classNames.editor, !controlledKeypadMode ? classes.longMathEditor : '')} innerRef={(r) => (this.input = r)} latex={latex} onChange={this.onEditorChange} /> </div> </div> {allowAnswerBlock && ( <Button className={classes.addAnswerBlockButton} type="primary" style={{ bottom: shouldShowKeypad ? '320px' : '20px' }} onClick={this.onAnswerBlockClick} disabled={addDisabled} > + Response Area </Button> )} <hr className={classes.hr} /> {shouldShowKeypad && ( <HorizontalKeypad className={cx(classes[keypadMode], classes.keyboard)} controlledKeypadMode={controlledKeypadMode} layoutForKeyPad={layoutForKeyPad} additionalKeys={additionalKeys} mode={controlledKeypadMode ? this.state.equationEditor : keypadMode} onClick={this.onClick} noDecimal={noDecimal} setKeypadInteraction={setKeypadInteraction} /> )} </div> ); } } const styles = (theme) => ({ inputAndTypeContainer: { display: 'flex', alignItems: 'center', '& .mq-editable-field .mq-cursor': { top: '-4px', }, '& .mq-math-mode .mq-selection, .mq-editable-field .mq-selection': { paddingTop: '18px', }, '& .mq-math-mode .mq-overarrow': { fontFamily: 'Roboto, Helvetica, Arial, sans-serif !important', }, '& .mq-math-mode .mq-overline .mq-overline-inner': { paddingTop: '0.4em !important', }, '& .mq-overarrow.mq-arrow-both': { minWidth: '1.23em', '& *': { lineHeight: '1 !important', }, '&:before': { top: '-0.45em', left: '-1px', }, '&:after': { position: 'absolute !important', top: '0px !important', right: '-2px', }, '&.mq-empty:after': { top: '-0.45em', }, }, '& .mq-overarrow.mq-arrow-right': { '&:before': { top: '-0.4em', right: '-1px', }, }, '& *': { ...commonMqFontStyles, ...supsubStyles, ...longdivStyles, '& .mq-math-mode .mq-sqrt-prefix': { verticalAlign: 'baseline !important', top: '1px !important', left: '-0.1em !important', }, '& .mq-math-mode .mq-overarc ': { paddingTop: '0.45em !important', }, '& .mq-math-mode .mq-empty': { padding: '9px 1px !important', }, '& .mq-math-mode .mq-root-block': { paddingTop: '10px', }, '& .mq-scaled .mq-sqrt-prefix': { top: '0 !important', }, '& .mq-math-mode .mq-longdiv .mq-longdiv-inner': { marginLeft: '4px !important', paddingTop: '6px !important', paddingLeft: '6px !important', }, '& .mq-math-mode .mq-paren': { verticalAlign: 'top !important', padding: '1px 0.1em !important', }, '& .mq-math-mode .mq-sqrt-stem': { borderTop: '0.07em solid', marginLeft: '-1.5px', marginTop: '-2px !important', paddingTop: '5px !important', }, '& .mq-math-mode .mq-denominator': { marginTop: '-5px !important', padding: '0.5em 0.1em 0.1em !important', }, '& .mq-math-mode .mq-numerator, .mq-math-mode .mq-over': { padding: '0 0.1em !important', paddingBottom: '0 !important', marginBottom: '-2px', }, }, '& span[data-prime="true"]': { fontFamily: 'Roboto, Helvetica, Arial, sans-serif !important', }, }, hide: { display: 'none', }, selectContainer: { flex: 'initial', width: '25%', minWidth: '100px', marginLeft: '15px', marginTop: '5px', marginBottom: '5px', marginRight: '5px', '& label': { fontFamily: 'Roboto, Helvetica, Arial, sans-serif !important', }, '& div': { fontFamily: 'Roboto, Helvetica, Arial, sans-serif !important', }, }, mathEditor: { maxWidth: '400px', color: color.text(), backgroundColor: color.background(), padding: '2px', }, longMathEditor: { maxWidth: '500px', }, addAnswerBlockButton: { position: 'absolute', right: '12px', border: '1px solid lightgrey', }, hr: { padding: 0, margin: 0, height: '1px', border: 'none', borderBottom: `solid 1px ${theme.palette.primary.main}`, }, mathToolbar: { zIndex: 9, position: 'relative', textAlign: 'center', width: 'auto', '& > .mq-math-mode': { border: 'solid 1px lightgrey', }, '& > .mq-focused': { outline: 'none', boxShadow: 'none', border: `dotted 1px ${theme.palette.primary.main}`, borderRadius: '0px', }, '& .mq-overarrow-inner': { border: 'none !important', paddingTop: '0 !important', }, '& .mq-overarrow-inner-right': { display: 'none !important', }, '& .mq-overarrow-inner-left': { display: 'none !important', }, '& .mq-longdiv-inner': { borderTop: '1px solid !important', paddingTop: '1.5px !important', }, '& .mq-overarrow.mq-arrow-both': { top: '7.8px', marginTop: '0px', minWidth: '1.23em', }, '& .mq-parallelogram': { lineHeight: 0.85, }, }, inputContainer: { minWidth: '500px', maxWidth: '900px', minHeight: '30px', width: '100%', display: 'flex', marginTop: theme.spacing.unit, marginBottom: theme.spacing.unit, '& .mq-sqrt-prefix .mq-scaled': { verticalAlign: 'middle !important', }, }, error: { border: '2px solid red', }, keyboard: commonMqKeyboardStyles, language: { '& *': { fontFamily: 'Roboto, Helvetica, Arial, sans-serif !important', }, }, }); export default withStyles(styles)(EditorAndPad);