Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to write multiple lines in quizzes if using [markdown] #1361

Merged
merged 5 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Communication between the parent page and the IFrame is restricted to specific m
| `current-state` | IFrame | Parent | Informs the parent that the IFrame's state has changed. Includes data and validity status. |
| `height-changed` | IFrame | Parent | Notifies the parent that the content height has changed, allowing the parent to resize the IFrame. |
| `set-language` | Parent | IFrame | Informs the IFrame of the user's preferred language using IETF BCP 47 language tags. |
| `open-link` | Iframe | Parent | The IFrame requests a link to be opened in the browser's main browsing context. |

### Views

Expand Down
75 changes: 0 additions & 75 deletions services/quizzes/src/components/MarkdownEditor.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion services/quizzes/src/components/ParsedText/tagParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const htmlWriter = new HtmlRenderer()

const KATEX_OUTPUT_FORMAT = "htmlAndMathml"
const LATEX_REGEX = /\[latex\](.*?)\[\/latex\]/g
const MARKDOWN_REGEX = /\[markdown\](.*?)\[\/markdown\]/g
const MARKDOWN_REGEX = /\[markdown\]([\s\S]*?)\[\/markdown\]/g
type PairArray<T, K> = [T, K][]

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next"

import { PrivateSpecQuiz } from "../../../../types/quizTypes/privateSpec"
import useQuizzesExerciseServiceOutputState from "../../../hooks/useQuizzesExerciseServiceOutputState"
import MarkdownEditor from "../../MarkdownEditor"

import ParsedTextField from "./QuizComponents/common/ParsedTextField"

import Accordion from "@/shared-module/common/components/Accordion"
import RadioButton from "@/shared-module/common/components/InputFields/RadioButton"
Expand Down Expand Up @@ -65,9 +66,9 @@ const QuizCommonInfo: React.FC = () => {
<details>
<summary>{t("advanced-options")}</summary>
<AdvancedOptionsContainer>
<MarkdownEditor
text={selected.submitMessage ?? ""}
<ParsedTextField
label={t("submit-message")}
value={selected.submitMessage ?? ""}
onChange={(value) => {
updateState((draft) => {
if (!draft) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { css } from "@emotion/css"
import styled from "@emotion/styled"
import { Eye, Pencil } from "@vectopus/atlas-icons-react"
import React, { useState } from "react"
import { Eye, InfoCircle, Pencil } from "@vectopus/atlas-icons-react"
import React, { Ref, useContext, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"

import ParsedText from "../../../../ParsedText"

import MessagePortContext from "@/contexts/MessagePortContext"
import Button from "@/shared-module/common/components/Button"
import TextAreaField from "@/shared-module/common/components/InputFields/TextAreaField"
import TextField from "@/shared-module/common/components/InputFields/TextField"
import { OpenLinkMessage } from "@/shared-module/common/exercise-service-protocol-types"

const DisplayContainer = styled.div`
display: flex;
Expand All @@ -27,16 +29,33 @@ const ParsedTextContainer = styled.div`
height: 68px;
`

const LATEX_TAGS = /\[\/?latex\]/g
const MARKDOWN_TAGS = /\[\/?markdown\]/g
const StyledButton = styled(Button)`
display: flex !important;
align-items: center;
border: 1px solid #dae6e5 !important;
margin: 2px 0px 4px 0px !important;
height: 24px;
cursor: pointer;
:hover {
border: 1px solid #bcd1d0;
}
`

/**
* Checks if there exists tags such as [latex][/latex] or [markdown][/markdown]
* @param text Text to parse from
*/
const containsTags = (text: string) => {
return [...text.matchAll(LATEX_TAGS), ...text.matchAll(MARKDOWN_TAGS)].length > 0
}
const TextfieldWrapper = styled.div`
display: flex;
flex-direction: row;
gap: 7px;
align-items: center;
`

const Grow = styled.div`
flex-grow: 1;
`

const InfoLink = styled.a`
position: relative;
top: 13px;
`

interface ParsedTextFieldProps {
label: string
Expand All @@ -46,81 +65,105 @@ interface ParsedTextFieldProps {

const ParsedTextField: React.FC<ParsedTextFieldProps> = ({ label, value, onChange }) => {
const [preview, setPreview] = useState(false)
const [text, setText] = useState("")

const hasTags = containsTags(text)
const [text, setText] = useState(value ?? "")
const cursorPosition = useRef<number | null>(null)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
const messagePort = useContext(MessagePortContext)

const { t } = useTranslation()

const PreviewButton = preview ? (
<>
<Button
className={css`
display: flex !important;
align-items: center;
border: 1px solid #dae6e5 !important;
margin: 2px 0px 4px 0px !important;
height: 24px;
cursor: pointer;
:hover {
border: 1px solid #bcd1d0;
}
`}
variant="icon"
size="small"
onClick={() => {
setPreview(!preview)
}}
>
<Pencil size={16} />
</Button>

<p> {t("edit-text")} </p>
</>
) : (
const containsMarkdown = useMemo(
() => text.includes("[markdown]") && text.includes("[/markdown]"),
[text],
)

const prevContainsMarkdown = useRef<boolean>(containsMarkdown)

const containsLatex = useMemo(() => text.includes("[latex]") && text.includes("[/latex]"), [text])

const hasTags = useMemo(
() => containsMarkdown || containsLatex,
[containsMarkdown, containsLatex],
)

/**
* Handles focus and cursor position restoration when the `containsMarkdown` state changes.
*/
useEffect(() => {
if (
(!prevContainsMarkdown.current && containsMarkdown) ||
(prevContainsMarkdown.current && !containsMarkdown)
) {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.setSelectionRange(cursorPosition.current, cursorPosition.current)
}
}

prevContainsMarkdown.current = containsMarkdown
}, [containsMarkdown])

const PreviewButton = (
<>
<Button
className={css`
display: flex !important;
align-items: center;
border: 1px solid #dae6e5 !important;
margin: 2px 0px 4px 0px !important;
height: 24px;
cursor: pointer;
:hover {
border: 1px solid #bcd1d0;
}
`}
variant="icon"
size="small"
onClick={() => {
setPreview(!preview)
}}
>
<Eye size={18} />
</Button>
<p> {t("preview-rendered-text")} </p>
<StyledButton variant="icon" size="small" onClick={() => setPreview(!preview)}>
{preview ? <Pencil size={16} /> : <Eye size={18} />}
</StyledButton>
<p> {preview ? t("edit-text") : t("preview-rendered-text")} </p>
</>
)

const handleOnChange = (value: string) => {
cursorPosition.current = inputRef.current?.selectionStart ?? null
onChange(value)
setText(value)
}

return (
<TextfieldContainer>
{preview ? (
<ParsedTextContainer>
<ParsedText text={value} parseMarkdown parseLatex inline />
</ParsedTextContainer>
) : (
<TextField
value={value ?? undefined}
onChangeByValue={(value) => handleOnChange(value)}
label={label}
/>
)}
<TextfieldWrapper>
<Grow>
{preview ? (
<ParsedTextContainer>
<ParsedText text={value} parseMarkdown parseLatex inline />
</ParsedTextContainer>
) : containsMarkdown ? (
<TextAreaField
ref={inputRef as Ref<HTMLTextAreaElement>}
autoResize
value={value ?? ""}
onChangeByValue={(value) => handleOnChange(value)}
label={label}
/>
) : (
<TextField
ref={inputRef as Ref<HTMLInputElement>}
value={value ?? ""}
onChangeByValue={(value) => handleOnChange(value)}
label={label}
/>
)}
</Grow>
<InfoLink
href="https://github.com/rage/secret-project-331/wiki/Add-new-exercise#formatting-feedback-messages"
// eslint-disable-next-line i18next/no-literal-string
target="_blank"
// eslint-disable-next-line i18next/no-literal-string
rel="noopener noreferrer"
onClick={(e) => {
if (messagePort) {
const target = e.target as HTMLAnchorElement
messagePort.postMessage({
message: "open-link",
data:
(target as HTMLAnchorElement).href ||
(target.parentElement as HTMLAnchorElement)?.href,
} satisfies OpenLinkMessage)
}
}}
>
<InfoCircle />
</InfoLink>
</TextfieldWrapper>
<DisplayContainer>{hasTags && PreviewButton}</DisplayContainer>
Comment on lines +145 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential security risk for open-link messages.
Currently, any link clicked can emit an “open-link” message. If external URLs are user-provided, consider sanitizing or restricting them to trusted domains to prevent phishing or malicious link injection.

</TextfieldContainer>
)
Expand Down
5 changes: 5 additions & 0 deletions services/quizzes/src/contexts/MessagePortContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext } from "react"

const MessagePortContext = createContext<MessagePort | null>(null)

export default MessagePortContext
9 changes: 6 additions & 3 deletions services/quizzes/src/pages/iframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { migratePrivateSpecQuiz } from "../util/migration/privateSpecQuiz"
import migratePublicSpecQuiz from "../util/migration/publicSpecQuiz"
import migrateQuizAnswer from "../util/migration/userAnswerSpec"

import MessagePortContext from "@/contexts/MessagePortContext"
import { StudentExerciseTaskSubmissionResult } from "@/shared-module/common/bindings"
import HeightTrackingContainer from "@/shared-module/common/components/HeightTrackingContainer"
import {
Expand Down Expand Up @@ -209,9 +210,11 @@ const IFrame: React.FC<React.PropsWithChildren<unknown>> = () => {

return (
<HeightTrackingContainer port={port}>
<div>
<Renderer port={port} setState={setState} state={state} />
</div>
<MessagePortContext.Provider value={port}>
<div>
<Renderer port={port} setState={setState} state={state} />
</div>
</MessagePortContext.Provider>
</HeightTrackingContainer>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
onChangeByValue?: (value: string, name?: string) => void
}

const TextField: React.FC<TextFieldProps> = forwardRef<HTMLInputElement, TextFieldProps>(
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ onChange, onChangeByValue, className, disabled, error, ...rest }: TextFieldProps, ref) => {
const { t } = useTranslation()

Expand Down
Loading
Loading