Skip to content

Instantly share code, notes, and snippets.

@gerryfletch
Last active August 23, 2023 09:12
Show Gist options
  • Save gerryfletch/09d9995725027fcbf060d0149441c3a8 to your computer and use it in GitHub Desktop.
Save gerryfletch/09d9995725027fcbf060d0149441c3a8 to your computer and use it in GitHub Desktop.
import * as React from "react";
import { CSSProperties, useEffect, useMemo } from "react";
import { MathfieldElement, MathfieldOptions } from "mathlive";
import { create } from "jss";
import camelCase from "jss-plugin-camel-case";
export type MathEditorProps = {
options?: Partial<MathfieldOptions>;
value: string;
onChange: (latex: string) => void;
onPlaceholderChange?: (placeholderId: string, latex: string) => void;
className?: string;
containerStyle?: CSSProperties;
placeholderStyle?: CSSProperties;
};
/**
* @returns a styled math-editor as a non-controlled React component with placeholder support.
*/
export const MathEditor = (props: MathEditorProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const mfe = useMemo(() => new MathfieldElement(props.options), []);
useEffect(() => {
const container = containerRef.current!!;
container.innerHTML = "";
container.appendChild(mfe);
mfe.className = props.className || "";
// Listen to changes to main mathfield
mfe.addEventListener("input", ({ target }) =>
props.onChange((target as HTMLInputElement).value || "")
);
// Listen to placeholders, firing if onPlaceholderChange present
mfe.addEventListener("placeholder-change", ({ detail }) => {
const { placeholderId } = detail;
const value = mfe.getPlaceholderField(placeholderId)?.getValue() || "";
if (props.onPlaceholderChange) {
props.onPlaceholderChange(placeholderId, value);
}
});
// Add custom styles to placeholder mathfield elements
Object.values(mfe.placeholders).forEach((placeholder) =>
addStyleEl(placeholder, props.placeholderStyle ?? {})
);
}, []);
useEffect(() => {
mfe.value = props.value;
}, [props.value]);
return <div ref={containerRef} style={props.containerStyle} />;
};
/**
* Mathlive uses shadow DOMs which don't inherit global styles.
* We patch this by creating <style> tags for nested mathlive
* elements. `jss` is used to translate React CSSProperties into
* a stylesheet string which is inserted in a new style node.
* @param el Mathfield element to create styles for
* @param st CSS Properties
*/
const addStyleEl = (el: MathfieldElement, css: CSSProperties) => {
const node = document.createElement("style");
node.innerHTML = stylesheet("placeholder-mathfield", css);
el.appendChild(node);
el.classList.add("placeholder-mathfield");
};
const jss = create({ plugins: [camelCase()] });
const stylesheet = (className: string, styles?: CSSProperties): string =>
jss
.createStyleSheet({ [className]: styles }, { generateId: ({ key }) => key })
.toString();
@gerryfletch
Copy link
Author

gerryfletch commented Jun 1, 2023

@enzomarin Hey. Mathlive completely revamped placeholders to be “prompts”. I’m away on holiday for the next two weeks, but this is a version that may work for you (it’s likely also slightly outdated):

import * as React from 'react';

import { useEffect, useMemo } from 'react';
import { MathfieldElement } from 'mathlive';

export type MathEditorProps = {
  readOnly?: boolean;

  value: string;
  onChange: (latex: string, prompts: Record<string, string>) => void;

  className?: string;
};

/**
 * @returns a styled math-editor as a non-controlled React component with placeholder support.
 */
export const MathEditor = (props: MathEditorProps) => {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const mfe = useMemo(() => new MathfieldElement(), []);
  mfe.readOnly = props.readOnly ?? false;

  const currentValue = React.useRef<string>('');

  useEffect(() => {
    const container = containerRef.current!!;
    container.innerHTML = '';
    container.appendChild(mfe);
    mfe.className = props.className || '';

    mfe.addEventListener('input', ({ target }) => {
      const value = (target as HTMLInputElement).value || '';
      const promptValues: Record<string, string> = mfe
        .getPrompts()
        .reduce((acc, id) => ({ ...acc, [id]: mfe.getPromptValue(id) }), {});

      if (currentValue.current !== value) {
        currentValue.current = value;
        props.onChange(value, promptValues);
      }
    });
  }, []);

  useEffect(() => {
    if (currentValue.current !== props.value) {
      const position = mfe.position;
      mfe.setValue(props.value, { focus: true, feedback: false });
      mfe.position = position;
      currentValue.current = props.value;
    }
  }, [props.value]);

  return <div ref={containerRef} style={{ width: '100%' }} />;
};

I will update the gist when I’m back. Thank you for the reminder!

also note that the placeholder rework removed the shadow-Dom’s, so the CSS style sheet injection is no longer necessary

@enzomarin
Copy link

enzomarin commented Jun 1, 2023

Thank you very much for your help!! I had made it work with the official mathlive documentation, creating the customElement but the documentation for react is very poor and I had some problems.
Just one question, why do you return a 'div' instead of 'math-field'?

Thank you very much, I will be attentive to the changes you make when you return

@gerryfletch
Copy link
Author

@enzomarin At the time of creating this gist, the simplest way to create the MathLive element and make it a controlled component (value, onChange), was to write the instantiated class as a child node. There may be a nicer way to do things now but I haven’t paid much attention in the last few months.

@enzomarin
Copy link

enzomarin commented Jun 26, 2023

@gerryfletch Sorry for the inconvenience, but I have a problem and I wanted to see if you could help me.
I want to use the component you gave me in another "MathComponent" component. In this I want to get the reference to the mfe element to get its functions like for example "getPrompts" and "setPromptState", but no matter what I do I get "undefined" in "mfeInstance" and "promts" (line 27 and 28 of MathComponent) .
Basically I want to do what I do in the button of the "Mathfield" component (line 83) but from the "MathComponent" component.

MathComponent :

import { Box, Button } from "@chakra-ui/react"
import type { MathComponentMeta } from "../types"
import dynamic from "next/dynamic"
import { useRef, useState } from "react"
import type { MathEditorRef } from "./tools/mathLive"


const MathField = dynamic(() => import("./tools/mathLive"),{
  ssr:false
})

interface Props {
  meta: MathComponentMeta
}

const MathComponent = ({meta}: Props) => {
  const mfeRef = useRef<MathEditorRef>(null)
  const {expression, readonly, answers, correctAnswer} = meta
  const [answer,setAnswer] = useState([])

  const checkAnswer = () => {
    // Acceder a la instancia completa de mfe utilizando la referencia
    const mfeInstance = mfeRef.current.mfe;
    const prompts = mfeRef.current.getPrompts;

    console.log("answer ---->", answer);
    console.log("mfeInstance--->", mfeInstance);
    console.log("prompts--->", prompts);

    // Utilizar mfeInstance para realizar acciones en la instancia de MathfieldElement
    /*
    if (mfeInstance && prompts) {
      mfeInstance.setPromptState(prompts[0], "correct", true);
    }
    */
  };
  const handleMathEditorChange = (latex, promptsValues) => {

    // ... hacer algo con mfeInstance
    const entries = Object.entries(promptsValues)
    console.log("entries ------>",entries)
    setAnswer(entries)
  };
  return(
    <Box display='flex' flexDirection='row' justifyContent='' >
      <MathField 
        ref={mfeRef} 
        readOnly={readonly} 
        value={expression} 
        onChange={handleMathEditorChange}>
      </MathField>

      <Box display='flex' flexDirection='column'>
        <Button onClick={checkAnswer}>Aceptar</Button>
        <Button>Hint</Button>

      </Box>
    </Box>
  )
}

export default MathComponent

Mathfield component:

import * as React from 'react';

import { useEffect, useMemo, useImperativeHandle, forwardRef, useRef} from 'react';
import { MathfieldElement} from 'mathlive';
import { Button } from '@chakra-ui/react';

export type MathEditorProps = {
  readOnly?: boolean;
  value: string;
  onChange: (latex: string, prompts: Record<string, string>) => void;

  className?: string;
};
export interface MathEditorRef {
  getPrompts: () => string[];
  mfe: MathfieldElement | null;
}

/**
 * @returns a styled math-editor as a non-controlled React component with placeholder support.
 */
const Mathfield = forwardRef<MathEditorRef,MathEditorProps> ((props, ref ) => {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const mfe = useMemo(() => new MathfieldElement(), []);
  
  mfe.readOnly = props.readOnly ?? false;
  mfe.disabled = false
  const currentValue = React.useRef<string>(''); // Esta variable se utilizará para realizar un seguimiento del valor actual del editor de matemáticas.
  
  

  /*
  mfe.applyStyle(
    {backgroundColor: 'yellow' },
    {range: [0, -1]}
    )
    */
  useEffect(() => { // ejecuta un efecto secundario cuando el componente se monta por primera vez
    const container = containerRef.current!!;
    container.innerHTML = '';
    container.appendChild(mfe);
    mfe.className = props.className || '';

    mfe.addEventListener('input', ({ target }) => {
      const value = (target as HTMLInputElement).value || '';
      const promptValues: Record<string, string> = mfe
        .getPrompts()
        .reduce((acc, id) => ({ ...acc, [id]: mfe.getPromptValue(id) }), {});

      if (currentValue.current !== value) {
        currentValue.current = value;
        props.onChange(value, promptValues);
      }
    });

  }, []);

  useEffect(() => { // Este efecto se encarga de actualizar el valor del editor de matemáticas cuando props.value cambia.
    
    if (currentValue.current !== props.value) {
      const position = mfe.position;
      mfe.setValue(props.value, { focus: true, feedback: false });
      mfe.position = position;
      currentValue.current = props.value;
    }
  }, [props.value]);//se ejecutará cada vez que el valor de props.value
 
   
  const getPrompts = () => {
    return mfe.getPrompts();
  };


  useImperativeHandle(ref, () => ({
    getPrompts,
    mfe,
  }));
  return (
    <>

      <div onFocus={()=>{console.log("FOCUS!!!!!")}} ref={containerRef} style={{ maxWidth: '100%'}} />
      
      <Button onClick={()=>{
        console.log("mfe from mathlive-->",mfe)
        console.log("prompts from mathlive-->",mfe.getPrompts())
        mfe.setPromptState('a',"correct",true)
        }}>Aceptar</Button>
     
    </>
  )
})

export default Mathfield

image of the outputs:
https://i.postimg.cc/KzyVgXKh/Captura-de-pantalla-2023-06-26-a-la-s-02-31-43.png

@gerryfletch
Copy link
Author

Hey @enzomarin I'm not 100% sure what you're trying to accomplish, but calling getPrompts seems unnecessary when the button is clicked, can't you just rely on the output of prompts in the onChange function?

I would avoid editing the MathEditor component I pasted to you earlier. It should be generic enough to cover whatever your use case is.

If you could clarify what the goal of checkAnswer should be, and what data it is using, I may be able to help more. Sorry I couldn't be of more assistance.

@enzomarin
Copy link

enzomarin commented Jun 26, 2023

@gerryfletch thank you very much for replying, indeed getPrompts is unnecessary, I was just testing since I always got undefined for the mfe instance.

The purpose of checkAnswer is to compare the answer entered by a student (which is stored in the answer state) with an answer object which is stored in answers and obtained from meta. If the answer is correct what I want is to do mfe.setPrompts(placeholderId, state, locked) so that the 'placeholder' (textbox) is locked and colored green (this is done with mfe.setPrompts, this is what I do on the button from the Mathfield component).

The onChange function gives me value and promptValues which are the complete latex and a key object with its value (placeholderId: value) respectively of each 'placeholder'. But from onChange I can't access the setPromts function of mfe, is there any way to access or pass the properties and functions of mfe from onChange?

(sorry for my english)

@gerryfletch
Copy link
Author

gerryfletch commented Jun 26, 2023

I see what you're saying. Yeah, getting ahold of the ref makes sense then. Forwarding the ref might be a little tricky because the mathlive element isn't actually a react component, so it could be worth you adding a new optional prop, mfe, that lets you pass the instance into the component? That way the parent can instantiate it.

const mfe = useMemo(() => new MathfieldElement(), []);

...

<MathEditor readOnly={...} mfe={mfe} ... />

...

export const MathEditor = (props: MathEditorProps) => {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const mfe = useMemo(() => props.mfe ?? new MathfieldElement(), []);

Not ideal, I know, but until I get some time to sit down and figure that out, passing the mfe is probably the easiest way to unblock you.

@enzomarin
Copy link

great, it's a solution that had not occurred to me, I'll try it.

Thank you very much for your time!

@enzomarin
Copy link

Hello again, sorry to bother you but I need your help again. It turns out that when I use the virtual keyboard, that floating element appears on the screen that, in addition to being annoying, allows you to modify the mathematical expression (it shouldn't since it is defined as static). I've tried looking in the documentation but I can't find what it is or how to disable it.
Captura de pantalla 2023-08-23 a la(s) 00 04 56

@gerryfletch
Copy link
Author

Hey @enzomarin I haven't used or updated mathlive in months, but I don't recall seeing that floating window before so it may be something new. I'd advise raising a discussion on the main repository or on Gitter if the community is still active on there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment