Merge pull request #331 from c-bata/improve-md-editor

Fix bug of markdown editor and add mathjax support.
This commit is contained in:
Masashi Shibata
2023-01-04 11:05:55 +09:00
committed by GitHub
15 changed files with 1375 additions and 374 deletions
+2 -3
View File
@@ -22,9 +22,8 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: '16'
- run: |
npm install
npm run lint
- run: npm install
- run: npm run lint
build:
name: JS build check
+1 -2
View File
@@ -219,7 +219,6 @@ export const actionCreator = () => {
})
})
.catch((err) => {
console.dir(err)
if (err.response.status === 409) {
const index = studyDetails[studyId].trials.findIndex(
(t) => t.trial_id === trialId
@@ -233,7 +232,7 @@ export const actionCreator = () => {
)
return
}
setTrialNote(studyId, index, note)
setTrialNote(studyId, index, err.response.data.note)
}
const reason = err.response?.data.reason
if (reason !== undefined) {
-1
View File
@@ -47,7 +47,6 @@ export const App: FC = () => {
backgroundColor: colorMode === "dark" ? "#121212" : "#ffffff",
width: "100%",
minHeight: "100vh",
paddingBottom: theme.spacing(2),
}}
>
<SnackbarProvider maxSnack={3}>
@@ -0,0 +1,133 @@
import React, { FC } from "react"
import {
Box,
Button,
Card,
CardContent,
Divider,
List,
ListItem,
ListItemButton,
ListItemText,
Typography,
useTheme,
} from "@mui/material"
import { Link } from "react-router-dom"
import LinkIcon from "@mui/icons-material/Link"
export const BestTrialsCard: FC<{
studyDetail: StudyDetail | null
}> = ({ studyDetail }) => {
const theme = useTheme()
let header = "Best Trials"
let content: React.ReactNode = null
if (studyDetail !== null && studyDetail.best_trials.length === 1) {
const bestTrial = studyDetail.best_trials[0]
header = `Best Trial (number=${bestTrial.number})`
content = (
<>
<Typography
variant="h3"
sx={{ fontWeight: 600, marginBottom: theme.spacing(2) }}
color="secondary"
>
{bestTrial.values}
</Typography>
<Typography>
Params = [
{bestTrial.params.map((p) => `${p.name}: ${p.value}`).join(", ")}]
</Typography>
<Typography>
Intermediate Values = [
{studyDetail.best_trials[0].intermediate_values
.map((p) => `${p.step}: ${p.value}`)
.join(", ")}
]
</Typography>
<Typography>
User Attributes = [
{studyDetail.best_trials[0].user_attrs
.map((p) => `${p.key}: ${p.value}`)
.join(", ")}
]
</Typography>
<Button
variant="outlined"
startIcon={<LinkIcon />}
component={Link}
to={`${URL_PREFIX}/studies/${bestTrial.study_id}/trials/${bestTrial.number}/`}
sx={{ margin: theme.spacing(1) }}
>
Details
</Button>
</>
)
} else if (studyDetail !== null && studyDetail.best_trials.length > 1) {
const bestTrials = studyDetail.best_trials
content = (
<>
<Divider
sx={{ paddingBottom: theme.spacing(1) }}
orientation="horizontal"
/>
<Box
sx={{
overflow: "auto",
height: "450px",
width: "100%",
}}
>
<List>
{bestTrials.map((trial) => (
<ListItem key={trial.number} disablePadding>
<ListItemButton
component={Link}
to={
URL_PREFIX +
`/studies/${trial.study_id}/trials/${trial.number}`
}
>
<ListItemText
primary={
<Typography variant="h5">Trial {trial.number}</Typography>
}
secondary={
<>
<Typography>
Objective Values = [{trial.values?.join(", ")}]
</Typography>
<Typography>
Params = [
{trial.params
.map((p) => `${p.name}: ${p.value}`)
.join(", ")}
]
</Typography>
</>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</>
)
}
return (
<Card>
<CardContent
sx={{
display: "inline-content",
flexDirection: "column",
}}
>
<Typography variant="h6" sx={{ margin: "1em 0", fontWeight: 600 }}>
{header}
</Typography>
{content}
</CardContent>
</Card>
)
}
@@ -214,7 +214,9 @@ const plotContour = (
const filteredTrials = trials.filter((t) => filterFunc(t, objectiveId))
if (filteredTrials.length === 0) {
plotly.react(plotDomId, [])
plotly.react(plotDomId, [], {
template: mode === "dark" ? plotlyDarkTemplate : {},
})
return
}
+3 -1
View File
@@ -80,7 +80,9 @@ const plotEdf = (study: StudyDetail, objectiveId: number, mode: string) => {
const filteredTrials = trials.filter((t) => filterFunc(t, objectiveId))
if (filteredTrials.length === 0) {
plotly.react(plotDomId, [])
plotly.react(plotDomId, [], {
template: mode === "dark" ? plotlyDarkTemplate : {},
})
return
}
@@ -224,7 +224,7 @@ const plotHistory = (
filteredTrials = filteredTrials.filter((t) => t.state !== "Pruned")
}
if (filteredTrials.length === 0) {
plotly.react(plotDomId, [])
plotly.react(plotDomId, [], layout)
return
}
@@ -56,7 +56,7 @@ export const GraphHyperparameterImportanceBeta: FC<{
<CardContent>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: 600, textAlign: "center" }}
sx={{ margin: "1em 0", fontWeight: 600 }}
>
{title}
</Typography>
+255 -106
View File
@@ -4,6 +4,11 @@ import {
Card,
CardContent,
CardHeader,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
SxProps,
TextField,
@@ -13,21 +18,46 @@ import {
import React, { FC, createRef, useState, useEffect } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import remarkMath from "remark-math"
import rehypeMathjax from "rehype-mathjax"
import LoadingButton from "@mui/lab/LoadingButton"
import SaveIcon from "@mui/icons-material/Save"
import EditIcon from "@mui/icons-material/Edit"
import CloseIcon from "@mui/icons-material/Close"
import EditIcon from "@mui/icons-material/Edit"
import Divider from "@mui/material/Divider"
import { Theme } from "@mui/material/styles"
import {
CodeComponent,
ReactMarkdownNames,
} from "react-markdown/lib/ast-to-react"
import HtmlIcon from "@mui/icons-material/Html"
import ModeEditIcon from "@mui/icons-material/ModeEdit"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { darcula } from "react-syntax-highlighter/dist/esm/styles/prism"
import { actionCreator } from "../action"
const placeholder = `## What is this feature for?
Here you can freely take a note in *(GitHub flavored) Markdown format*.
In addition, **code blocks with syntax highlights** and **formula** are also supported here, as shown below.
### Code-block with Syntax Highlights
\`\`\`python
def objective(trial):
x = trial.suggest_float("x", -10, 10)
y = trial.suggest_float("y", -10, 10)
return (x - 5) ** 2 + (y + 5) ** 2
\`\`\`
### Formula
$$
L = \\frac{1}{2} \\rho v^2 S C_L
$$
`
const CodeBlock: CodeComponent | ReactMarkdownNames = ({
inline,
className,
@@ -62,7 +92,6 @@ export const TrialNote: FC<{
studyId={studyId}
trialId={trialId}
latestNote={latestNote}
minRows={10}
cardSx={cardSx}
/>
)
@@ -71,35 +100,86 @@ export const TrialNote: FC<{
export const StudyNote: FC<{
studyId: number
latestNote: Note
minRows: number
cardSx?: SxProps<Theme>
}> = ({ studyId, latestNote, minRows, cardSx }) => {
return (
<NoteBase
studyId={studyId}
latestNote={latestNote}
minRows={minRows}
cardSx={cardSx}
/>
)
}> = ({ studyId, latestNote, cardSx }) => {
return <NoteBase studyId={studyId} latestNote={latestNote} cardSx={cardSx} />
}
const NoteBase: FC<{
const useConfirmCloseDialog = (
handleClose: () => void
): [() => void, () => React.ReactNode] => {
const theme = useTheme()
const [open, setOpen] = useState(false)
const zIndex = theme.zIndex.snackbar - 1
const openDialog = () => {
setOpen(true)
}
const renderDialog = () => {
return (
<Dialog
open={open}
sx={{
zIndex: zIndex,
}}
>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogContent>
<DialogContentText>
Do you want to save or discard your changes?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Discard
</Button>
<Button
onClick={() => {
setOpen(false)
}}
color="primary"
>
Stay
</Button>
</DialogActions>
</Dialog>
)
}
return [openDialog, renderDialog]
}
const MarkdownRenderer: FC<{ body: string }> = ({ body }) => (
<ReactMarkdown
children={body}
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax]}
components={{ code: CodeBlock }}
/>
)
const MarkdownEditorModal: FC<{
studyId: number
trialId?: number
latestNote: Note
minRows: number
cardSx?: SxProps<Theme>
}> = ({ studyId, trialId, latestNote, minRows, cardSx }) => {
setEditorUnmount: () => void
}> = ({ studyId, trialId, latestNote, setEditorUnmount }) => {
const theme = useTheme()
const [renderMarkdown, setRenderMarkdown] = useState(true)
const action = actionCreator()
const [openConfirmCloseDialog, renderConfirmCloseDialog] =
useConfirmCloseDialog(() => {
setEditorUnmount()
window.onbeforeunload = null
})
const [saving, setSaving] = useState(false)
const [edited, setEdited] = useState(false)
const [curNote, setCurNote] = useState({ version: 0, body: "" })
const textAreaRef = createRef<HTMLTextAreaElement>()
const action = actionCreator()
const notLatest = latestNote.version > curNote.version
const [previewMarkdown, setPreviewMarkdown] = useState<string>("")
const [preview, setPreview] = useState<boolean>(false)
useEffect(() => {
setCurNote(latestNote)
}, [])
@@ -119,7 +199,6 @@ const NoteBase: FC<{
body: textAreaRef.current ? textAreaRef.current.value : "",
}
setSaving(true)
let actionResponse: Promise<void>
if (trialId === undefined) {
actionResponse = action.saveStudyNote(studyId, newNote)
@@ -129,8 +208,8 @@ const NoteBase: FC<{
actionResponse
.then(() => {
setCurNote(newNote)
setRenderMarkdown(true)
window.onbeforeunload = null
setEditorUnmount()
})
.finally(() => {
setSaving(false)
@@ -146,103 +225,173 @@ const NoteBase: FC<{
window.onbeforeunload = null
}
let content
if (renderMarkdown) {
const defaultBody =
"*A markdown editor for taking a memo, related to the study. Click the 'Edit' button in the upper right corner to access the editor.*"
content = (
<ReactMarkdown
children={latestNote.body || defaultBody}
remarkPlugins={[remarkGfm]}
components={{ code: CodeBlock }}
/>
)
} else {
content = (
<>
<TextField
disabled={saving}
minRows={minRows}
multiline={true}
placeholder={`Description about the ${
trialId === undefined ? "study" : "trial"
}...`}
sx={{ width: "100%", margin: `${theme.spacing(1)} 0` }}
inputProps={{ style: { resize: "vertical" } }}
inputRef={textAreaRef}
defaultValue={curNote.body}
onChange={() => {
const cur = textAreaRef.current ? textAreaRef.current.value : ""
setEdited(cur !== curNote.body)
}}
/>
<Box
sx={{ display: "flex", flexDirection: "row", alignItems: "center" }}
>
{notLatest && !saving && (
<>
<Typography
sx={{
color: theme.palette.error.main,
fontSize: "0.8rem",
display: "inline",
}}
>
The text you are editing has updated. Do you want to discard
your changes and refresh the textarea?
</Typography>
<Button
variant="text"
onClick={handleRefresh}
color="error"
size="small"
sx={{ textDecoration: "underline" }}
>
Yes
</Button>
</>
)}
<Box sx={{ flexGrow: 1 }} />
<LoadingButton
onClick={handleSave}
loading={saving}
loadingPosition="start"
startIcon={<SaveIcon />}
variant="contained"
disabled={!edited}
>
Save
</LoadingButton>
</Box>
</>
)
}
// See https://github.com/iamhosseindhv/notistack/issues/231#issuecomment-825924840
const zIndex = theme.zIndex.snackbar - 2
return (
<Card sx={{ margin: theme.spacing(2), ...cardSx }}>
<Card
sx={{
bottom: 0,
height: "100%",
left: 0,
overflow: "hidden",
position: "fixed",
right: 0,
top: 0,
zIndex: zIndex,
p: theme.spacing(2),
display: "flex",
flexDirection: "column",
}}
>
<CardHeader
action={
<IconButton
onClick={() => {
setPreview(!preview)
setPreviewMarkdown(
textAreaRef.current ? textAreaRef.current.value : ""
)
}}
>
{preview ? (
<ModeEditIcon color="primary" />
) : (
<HtmlIcon color="primary" />
)}
</IconButton>
}
title="Markdown Editor"
/>
<Box
sx={{
height: "100%",
padding: theme.spacing(2),
display: preview ? "default" : "none",
overflow: "scroll",
}}
>
<MarkdownRenderer body={previewMarkdown} />
</Box>
<TextField
disabled={saving}
multiline={true}
placeholder={placeholder}
sx={{
position: "relative",
resize: "none",
width: "100%",
height: "100%",
margin: theme.spacing(1, 0),
display: preview ? "none" : "default",
"& .MuiInputBase-root": { height: "100%" },
}}
inputProps={{
style: { resize: "none", overflow: "scroll", height: "100%" },
}}
inputRef={textAreaRef}
defaultValue={latestNote.body}
onChange={() => {
const cur = textAreaRef.current ? textAreaRef.current.value : ""
if (edited !== (cur !== curNote.body)) {
setEdited(cur !== curNote.body)
}
}}
/>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
{notLatest && !saving && (
<>
<Typography
sx={{
color: theme.palette.error.main,
fontSize: "0.8rem",
display: "inline",
}}
>
The text you are editing has updated. Do you want to discard your
changes and refresh the textarea?
</Typography>
<Button
variant="text"
onClick={handleRefresh}
color="error"
size="small"
sx={{ textDecoration: "underline" }}
>
Yes
</Button>
</>
)}
<Box sx={{ flexGrow: 1 }} />
<Button
variant="outlined"
onClick={() => {
if (edited) {
openConfirmCloseDialog()
} else {
setEditorUnmount()
}
}}
startIcon={<CloseIcon />}
>
Close
</Button>
<LoadingButton
onClick={handleSave}
loading={saving}
loadingPosition="start"
startIcon={<SaveIcon />}
variant="contained"
disabled={!edited || notLatest}
sx={{ marginLeft: theme.spacing(1) }}
>
Save
</LoadingButton>
</Box>
{renderConfirmCloseDialog()}
</Card>
)
}
const NoteBase: FC<{
studyId: number
trialId?: number
latestNote: Note
cardSx?: SxProps<Theme>
}> = ({ studyId, trialId, latestNote, cardSx }) => {
const theme = useTheme()
const [editorMounted, setEditorMounted] = useState<boolean>(false)
const defaultBody = ""
return (
<Card sx={{ margin: theme.spacing(2), overflow: "scroll", ...cardSx }}>
<CardHeader
title="Note"
action={
!renderMarkdown ? (
<IconButton
onClick={() => {
setRenderMarkdown(true)
}}
>
<CloseIcon />
</IconButton>
) : (
<IconButton onClick={() => setRenderMarkdown(false)}>
<EditIcon />
</IconButton>
)
<IconButton
onClick={() => {
setEditorMounted(true)
}}
>
<EditIcon />
</IconButton>
}
sx={{ paddingBottom: 0 }}
/>
<CardContent sx={{ paddingTop: theme.spacing(1) }}>
<Divider />
{content}
<MarkdownRenderer body={latestNote.body || defaultBody} />
</CardContent>
{editorMounted && (
<MarkdownEditorModal
studyId={studyId}
trialId={trialId}
latestNote={latestNote}
setEditorUnmount={() => {
setEditorMounted(false)
}}
/>
)}
</Card>
)
}
@@ -235,11 +235,7 @@ export const StudyDetail: FC<{
<TrialTable studyDetail={studyDetail} />
</Card>
{studyDetail !== null ? (
<StudyNote
studyId={studyIdNumber}
latestNote={studyDetail.note}
minRows={5}
/>
<StudyNote studyId={studyIdNumber} latestNote={studyDetail.note} />
) : null}
</div>
</Container>
@@ -34,6 +34,7 @@ import { DataGrid, DataGridColumn } from "./DataGrid"
import { GraphIntermediateValues } from "./GraphIntermediateValues"
import { Edf } from "./GraphEdf"
import { TrialList } from "./TrialList"
import { BestTrialsCard } from "./BestTrialsCard"
interface ParamTypes {
studyId: string
@@ -123,103 +124,12 @@ export const StudyDetailBeta: FC<{
graphHeight="450px"
/>
<Grid2 xs={6} spacing={2}>
<Card>
<CardContent
sx={{
alignItems: "center",
display: "flex",
flexDirection: "column",
}}
>
{studyDetail !== null &&
studyDetail.best_trials.length === 1 && (
<>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: 600 }}
>
Best Trial
</Typography>
<Typography
variant="h3"
sx={{ fontWeight: 600, marginBottom: theme.spacing(2) }}
color="secondary"
>
{studyDetail.best_trials[0].values}
</Typography>
<Typography>
number={studyDetail.best_trials[0].number}
</Typography>
<Typography>
trial_id={studyDetail.best_trials[0].trial_id}
</Typography>
<Typography>
Params = [
{studyDetail.best_trials[0].params
.map((p) => `${p.name}: ${p.value}`)
.join(", ")}
]
</Typography>
<Typography>
Intermediate Values = [
{studyDetail.best_trials[0].intermediate_values
.map((p) => `${p.step}: ${p.value}`)
.join(", ")}
]
</Typography>
<Typography>
User Attributes = [
{studyDetail.best_trials[0].user_attrs
.map((p) => `${p.key}: ${p.value}`)
.join(", ")}
]
</Typography>
</>
)}
{studyDetail !== null && studyDetail.directions.length > 1 && (
<>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: 600 }}
>
Best Trials ({studyDetail.best_trials.length} trials)
</Typography>
{studyDetail.best_trials.map((trial, i) => (
<Card
key={i}
sx={{
border: "1px solid rgba(128,128,128,0.5)",
margin: theme.spacing(1, 0),
}}
>
<CardContent>
<Typography variant="h6">
Trial number={trial.number} (trial_id=
{trial.trial_id})
</Typography>
<Typography>
Objective Values = [{trial.values?.join(", ")}]
</Typography>
<Typography>
Params = [
{trial.params
.map((p) => `${p.name}: ${p.value}`)
.join(", ")}
]
</Typography>
</CardContent>
</Card>
))}
</>
)}
</CardContent>
</Card>
<BestTrialsCard studyDetail={studyDetail} />
</Grid2>
<Grid2 xs={6}>
<Card>
<CardContent
sx={{
alignItems: "center",
display: "flex",
flexDirection: "column",
}}
@@ -290,7 +200,6 @@ export const StudyDetailBeta: FC<{
<StudyNote
studyId={studyId}
latestNote={studyDetail.note}
minRows={30}
cardSx={{ height: "90vh" }}
/>
)
@@ -144,7 +144,6 @@ export const StudyListBeta: FC<{
<Button
variant="outlined"
startIcon={<Refresh />}
aria-haspopup="true"
onClick={(e) => {
action.updateStudySummaries("Success to reload")
}}
@@ -155,7 +154,6 @@ export const StudyListBeta: FC<{
<Button
variant="outlined"
startIcon={<AddBoxIcon />}
aria-haspopup="true"
onClick={(e) => {
openCreateStudyDialog()
}}
+55 -42
View File
@@ -20,6 +20,30 @@ import { TrialNote } from "./Note"
import { DataGrid, DataGridColumn } from "./DataGrid"
import { Link } from "react-router-dom"
type Color =
| "default"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning"
const getChipColor = (state: TrialState): Color => {
if (state === "Complete") {
return "success"
} else if (state === "Running") {
return "secondary"
} else if (state === "Waiting") {
return "secondary"
} else if (state === "Pruned") {
return "warning"
} else if (state === "Fail") {
return "error"
}
return "default"
}
export const TrialList: FC<{
studyDetail: StudyDetail | null
trialNumber: number | null
@@ -75,15 +99,17 @@ export const TrialList: FC<{
<CardHeader
title={`Trial ${trial.number} (trial_id=${trial.trial_id})`}
/>
<CardContent>
{isBestTrial(trial.trial_id) ? (
<CardContent sx={{ paddingTop: 0 }}>
<Box sx={{ marginBottom: theme.spacing(1) }}>
<Chip
label={"Best Trial"}
color="primary"
sx={{ marginBottom: theme.spacing(1) }}
size="small"
color={getChipColor(trial.state)}
label={trial.state}
sx={{ marginRight: theme.spacing(1) }}
/>
) : null}
{isBestTrial(trial.trial_id) ? (
<Chip label={"Best Trial"} color="primary" />
) : null}
</Box>
<Typography>
Values:{" "}
{trial.values?.map((v) => v.toString()).join(" ") || "None"}
@@ -152,25 +178,6 @@ export const TrialList: FC<{
studyDetail?.trials.length || 0
} Trials`}</ListSubheader>
{trials.map((trial, i) => {
let color:
| "default"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning" = "default"
if (trial.state === "Complete") {
color = "success"
} else if (trial.state === "Running") {
color = "secondary"
} else if (trial.state === "Waiting") {
color = "secondary"
} else if (trial.state === "Pruned") {
color = "warning"
} else if (trial.state === "Fail") {
color = "error"
}
return (
<ListItem key={trial.trial_id} disablePadding>
<ListItemButton
@@ -183,23 +190,29 @@ export const TrialList: FC<{
setSelected(trial.number)
}}
selected={i === selected}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<ListItemText
primary={`Trial ${trial.number}`}
secondary={
<Box sx={{ padding: theme.spacing(1, 0) }}>
<Chip color={color} label={trial.state} size="small" />
{isBestTrial(trial.trial_id) ? (
<Chip
label={"Best Trial"}
color="primary"
sx={{ marginLeft: theme.spacing(1) }}
size="small"
/>
) : null}
</Box>
}
/>
<ListItemText primary={`Trial ${trial.number}`} />
<Box>
<Chip
color={getChipColor(trial.state)}
label={trial.state}
sx={{ margin: theme.spacing(1, 0) }}
size="small"
/>
{isBestTrial(trial.trial_id) ? (
<Chip
label={"Best Trial"}
color="primary"
sx={{ marginLeft: theme.spacing(1) }}
size="small"
/>
) : null}
</Box>
</ListItemButton>
</ListItem>
)
+915 -115
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -30,7 +30,9 @@
"react-router-dom": "^5.3.4",
"react-syntax-highlighter": "^15.5.0",
"recoil": "^0.7.6",
"remark-gfm": "^3.0.1"
"rehype-mathjax": "^4.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1"
},
"devDependencies": {
"@babel/core": "^7.14.3",