Merge branch 'dev'
commit
f5eb185eb2
@ -1,26 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import './reset.css';
|
||||
import './app.css';
|
||||
|
||||
export const GameIdContext = React.createContext<string>(undefined);
|
||||
|
||||
function App() {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="app">
|
||||
<GameIdContext.Provider value={"g/" + params.owner + "/" + params.repo}>
|
||||
<Outlet />
|
||||
</GameIdContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
import App from './app';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
import './reset.css';
|
||||
import './app.css';
|
||||
import { MobileContext } from './components/infoview/context';
|
||||
import { useWindowDimensions } from './window_width';
|
||||
|
||||
export const GameIdContext = React.createContext<string>(undefined);
|
||||
|
||||
function App() {
|
||||
const params = useParams()
|
||||
const gameId = "g/" + params.owner + "/" + params.repo
|
||||
|
||||
// TODO: Make mobileLayout be changeable in settings
|
||||
// TODO: Handle resize Events
|
||||
const {width, height} = useWindowDimensions()
|
||||
const [mobile, setMobile] = React.useState(width < 800)
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<GameIdContext.Provider value={gameId}>
|
||||
<MobileContext.Provider value={{mobile, setMobile}}>
|
||||
<Outlet />
|
||||
</MobileContext.Provider>
|
||||
</GameIdContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@ -1,15 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
export interface ButtonProps extends LinkProps {
|
||||
disabled?: boolean
|
||||
inverted?: boolean
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
if (props.disabled) {
|
||||
return <span className={`btn btn-disabled ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
||||
} else {
|
||||
return <Link className={`btn ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Button } from './Button'
|
||||
import { GameIdContext } from '../App';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { deleteProgress, selectProgress, loadProgress, GameProgressState } from '../state/progress';
|
||||
|
||||
const downloadFile = ({ data, fileName, fileType }) => {
|
||||
const blob = new Blob([data], { type: fileType })
|
||||
const a = document.createElement('a')
|
||||
a.download = fileName
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
const clickEvt = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
a.dispatchEvent(clickEvt)
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function GameMenu() {
|
||||
|
||||
const [file, setFile] = React.useState<File>();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const store = useStore()
|
||||
|
||||
const [eraseMenu, setEraseMenu] = React.useState(false);
|
||||
const openEraseMenu = () => setEraseMenu(true);
|
||||
const closeEraseMenu = () => setEraseMenu(false);
|
||||
|
||||
const [uploadMenu, setUploadMenu] = React.useState(false);
|
||||
const openUploadMenu = () => setUploadMenu(true);
|
||||
const closeUploadMenu = () => setUploadMenu(false);
|
||||
|
||||
const gameProgress = useSelector(selectProgress(gameId))
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const downloadProgress = (e) => {
|
||||
e.preventDefault()
|
||||
downloadFile({
|
||||
data: JSON.stringify(gameProgress),
|
||||
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
||||
fileType: 'text/json',
|
||||
})
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const uploadProgress = (e) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsText(file, "UTF-8");
|
||||
fileReader.onload = (e) => {
|
||||
const data = JSON.parse(e.target.result.toString()) as GameProgressState;
|
||||
console.debug("Json Data", data);
|
||||
|
||||
dispatch(loadProgress({game: gameId, data: data}))
|
||||
}
|
||||
|
||||
closeUploadMenu()
|
||||
}
|
||||
|
||||
const eraseProgress = () => {
|
||||
dispatch(deleteProgress({game: gameId}))
|
||||
closeEraseMenu()
|
||||
}
|
||||
|
||||
const downloadAndErase = (e) => {
|
||||
downloadProgress(e)
|
||||
eraseProgress()
|
||||
}
|
||||
|
||||
return <nav className="game-menu">
|
||||
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
||||
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
||||
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
||||
|
||||
{eraseMenu?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
||||
<h2>Delete Progress?</h2>
|
||||
|
||||
<p>Do you want to delete your saved progress irreversibly?</p>
|
||||
<p>(This only affects your saved proofs, no levels are ever locked.
|
||||
Saves from other games are not deleted.)</p>
|
||||
|
||||
<Button onClick={eraseProgress} to="">Delete</Button>
|
||||
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
||||
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
{uploadMenu ?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
||||
<h2>Upload Saved Progress</h2>
|
||||
|
||||
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
||||
|
||||
<p><b>Warning:</b> This will delete your current game progress!
|
||||
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
||||
|
||||
<input type="file" onChange={handleFileChange}/>
|
||||
|
||||
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
</nav>
|
||||
}
|
||||
|
||||
export default GameMenu
|
||||
@ -1,111 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './inventory.css'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
|
||||
import Markdown from './Markdown';
|
||||
import { useLoadDocQuery, InventoryTile, LevelInfo } from '../state/api';
|
||||
import { GameIdContext } from '../App';
|
||||
|
||||
export function Inventory({levelInfo, setInventoryDoc } :
|
||||
{
|
||||
levelInfo: LevelInfo,
|
||||
setInventoryDoc: (inventoryDoc: {name: string, type: string}) => void,
|
||||
}) {
|
||||
|
||||
// TODO: This seems like a useless wrapper to me
|
||||
function openDoc(name, type) {
|
||||
setInventoryDoc({name, type})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inventory">
|
||||
{/* TODO: Click on Tactic: show info
|
||||
TODO: click on paste icon -> paste into command line */}
|
||||
<h2>Tactics</h2>
|
||||
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} />
|
||||
|
||||
<h2>Definitions</h2>
|
||||
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} />
|
||||
|
||||
<h2>Lemmas</h2>
|
||||
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc}
|
||||
defaultTab={levelInfo?.lemmaTab} level={levelInfo}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined} :
|
||||
{
|
||||
items: InventoryTile[],
|
||||
docType: string,
|
||||
openDoc(name: string, type: string): void,
|
||||
defaultTab? : string,
|
||||
level? : LevelInfo,
|
||||
}) {
|
||||
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
||||
// been loaded. Is there a better way to observe this?
|
||||
|
||||
const categorySet = new Set<string>()
|
||||
for (let item of items) {
|
||||
categorySet.add(item.category)
|
||||
}
|
||||
const categories = Array.from(categorySet).sort()
|
||||
|
||||
const [tab, setTab] = useState(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
||||
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
||||
if (defaultTab) {
|
||||
setTab(defaultTab)
|
||||
}}, [level])
|
||||
|
||||
return <>
|
||||
{categories.length > 1 &&
|
||||
<div className="tab-bar">
|
||||
{categories.map((cat) =>
|
||||
<div className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`} onClick={() => { setTab(cat) }}>{cat}</div>)}
|
||||
</div>}
|
||||
<div className="inventory-list">
|
||||
{ [...items].sort(
|
||||
// Sort entries `available > disabled > locked`.
|
||||
(x, y) => +x.locked - +y.locked || +x.disabled - +y.disabled
|
||||
).map(item => {
|
||||
if ((tab ?? categories[0]) == item.category) {
|
||||
return <InventoryItem key={item.name} showDoc={() => {openDoc(item.name, docType)}}
|
||||
name={item.name} displayName={item.displayName} locked={item.locked}
|
||||
disabled={item.disabled} newly={item.new}/>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function InventoryItem({name, displayName, locked, disabled, newly, showDoc}) {
|
||||
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
||||
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
||||
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
||||
const title = locked ? "Not unlocked yet" :
|
||||
disabled ? "Not available in this level" : ""
|
||||
|
||||
const handleClick = () => {
|
||||
if (!locked && !disabled) {
|
||||
showDoc()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={`item ${className}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
||||
}
|
||||
|
||||
export function Documentation({name, type}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
||||
|
||||
return <>
|
||||
<h2 className="doc">{doc.data?.displayName}</h2>
|
||||
<p><code>{doc.data?.statement}</code></p>
|
||||
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
||||
<Markdown>{doc.data?.content}</Markdown>
|
||||
</>
|
||||
}
|
||||
@ -1,429 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
import { InfoviewApi } from '@leanprover/infoview'
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress, FormControlLabel, FormGroup, Switch, IconButton } from '@mui/material';
|
||||
import MuiDrawer from '@mui/material/Drawer';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
import {Inventory, Documentation} from './Inventory';
|
||||
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
import 'lean4web/client/src/editor/vscode.css';
|
||||
import 'lean4web/client/src/editor/infoview.css';
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { InfoProvider } from 'lean4web/client/src/editor/infoview';
|
||||
import 'lean4web/client/src/editor/infoview.css'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import './level.css'
|
||||
import { Button } from './Button'
|
||||
import { ConnectionContext, useLeanClient } from '../connection';
|
||||
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api';
|
||||
import { changedSelection, codeEdited, selectCode, selectSelections, progressSlice, selectCompleted } from '../state/progress';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
||||
import { useStore } from 'react-redux';
|
||||
|
||||
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection';
|
||||
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event';
|
||||
import { Main } from './infoview/main'
|
||||
import type { Location } from 'vscode-languageserver-protocol';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { styled, useTheme, Theme, CSSObject } from '@mui/material/styles';
|
||||
import Markdown from './Markdown';
|
||||
|
||||
import Split from 'react-split'
|
||||
import { Alert } from '@mui/material';
|
||||
import { GameIdContext } from '../App';
|
||||
|
||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(null as any);
|
||||
|
||||
export const InputModeContext = React.createContext<{
|
||||
commandLineMode: boolean,
|
||||
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
commandLineInput: string,
|
||||
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
||||
}>({
|
||||
commandLineMode: true,
|
||||
setCommandLineMode: () => {},
|
||||
commandLineInput: "",
|
||||
setCommandLineInput: () => {},
|
||||
});
|
||||
|
||||
function Level() {
|
||||
|
||||
const params = useParams();
|
||||
const levelId = parseInt(params.levelId)
|
||||
const worldId = params.worldId
|
||||
|
||||
useLoadWorldFiles(worldId)
|
||||
|
||||
if (levelId == 0) {
|
||||
return <Introduction worldId={worldId} />
|
||||
} else {
|
||||
return <PlayableLevel worldId={worldId} levelId={levelId} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function PlayableLevel({worldId, levelId}) {
|
||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||
const introductionPanelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
||||
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
||||
|
||||
const [commandLineMode, setCommandLineMode] = useState(true)
|
||||
const [commandLineInput, setCommandLineInput] = useState("")
|
||||
const [canUndo, setCanUndo] = useState(initialCode.trim() !== "")
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to top when loading a new level
|
||||
introductionPanelRef.current!.scrollTo(0,0)
|
||||
// Reset command line input when loading a new level
|
||||
setCommandLineInput("")
|
||||
}, [levelId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commandLineMode) {
|
||||
// Delete last input attempt from command line
|
||||
editor.executeEdits("command-line", [{
|
||||
range: editor.getSelection(),
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.focus()
|
||||
}
|
||||
}, [commandLineMode])
|
||||
|
||||
const handleUndo = () => {
|
||||
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let range
|
||||
console.log(endPos.column)
|
||||
if (endPos.column === 1) {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber - 1, 1),
|
||||
endPos
|
||||
)
|
||||
} else {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber, 1),
|
||||
endPos
|
||||
)
|
||||
}
|
||||
editor.executeEdits("undo-button", [{
|
||||
range,
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
}
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const onDidChangeContent = (code) => {
|
||||
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
||||
|
||||
setCanUndo(code.trim() !== "")
|
||||
}
|
||||
|
||||
const onDidChangeSelection = (monacoSelections) => {
|
||||
const selections = monacoSelections.map(
|
||||
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
||||
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
||||
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
||||
}
|
||||
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
const {editor, infoProvider, editorConnection} =
|
||||
useLevelEditor(worldId, levelId, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
||||
|
||||
// Effect when command line mode gets enabled
|
||||
useEffect(() => {
|
||||
if (editor && commandLineMode) {
|
||||
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(endPos, endPos),
|
||||
text: "\n",
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
}
|
||||
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let currPos = editor.getPosition()
|
||||
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
||||
// This is not a position that would naturally occur from CommandLine, reset:
|
||||
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
||||
}
|
||||
}
|
||||
}, [editor, commandLineMode])
|
||||
|
||||
// if this is set to a pair `(name, type)` then the according doc will be open.
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
|
||||
const levelTitle = <>{levelId && `Level ${levelId}`}{level?.data?.title && `: ${level?.data?.title}`}</>
|
||||
|
||||
return <>
|
||||
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={level.isLoading} levelTitle={levelTitle} worldId={worldId} levelId={levelId} />
|
||||
<Split minSize={0} snapOffset={200} sizes={[50, 25, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<div className="exercise-panel">
|
||||
<div ref={introductionPanelRef} className="introduction-panel">
|
||||
{level?.data?.introduction &&
|
||||
<div className="message info">
|
||||
<Markdown>{level?.data?.introduction}</Markdown>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="exercise">
|
||||
<Markdown>
|
||||
{(level?.data?.statementName ?
|
||||
`**Theorem** \`${level?.data?.statementName}\`: `
|
||||
:
|
||||
level?.data?.descrText && "**Exercise**: ")
|
||||
+ `${level?.data?.descrText}`
|
||||
}
|
||||
</Markdown>
|
||||
<div className={`statement ${commandLineMode ? 'hidden' : ''}`}><code>{level?.data?.descrFormat}</code></div>
|
||||
<div ref={codeviewRef} className={`codeview ${commandLineMode ? 'hidden' : ''}`}></div>
|
||||
</div>
|
||||
<div className="input-mode-switch">
|
||||
{commandLineMode && <button className="btn" onClick={handleUndo} disabled={!canUndo}><FontAwesomeIcon icon={faRotateLeft} /> Undo</button>}
|
||||
<FormGroup>
|
||||
<FormControlLabel control={<Switch onChange={(ev) => { setCommandLineMode(!commandLineMode) }} />} label="Editor mode" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<EditorContext.Provider value={editorConnection}>
|
||||
<MonacoEditorContext.Provider value={editor}>
|
||||
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
||||
{editorConnection && <Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />}
|
||||
</InputModeContext.Provider>
|
||||
</MonacoEditorContext.Provider>
|
||||
</EditorContext.Provider>
|
||||
|
||||
{completed && <div className="conclusion">
|
||||
{level?.data?.conclusion?.trim() &&
|
||||
<div className="message info">
|
||||
<Markdown>{level?.data?.conclusion}</Markdown>
|
||||
</div>
|
||||
}
|
||||
{levelId >= gameInfo.data?.worldSize[worldId] ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
||||
Next <FontAwesomeIcon icon={faArrowRight} /></Button>}
|
||||
|
||||
</div>}
|
||||
</div>
|
||||
<div className="inventory-panel">
|
||||
{!level.isLoading &&
|
||||
<Inventory levelInfo={level?.data} setInventoryDoc={setInventoryDoc} />}
|
||||
</div>
|
||||
<div className="doc-panel">
|
||||
{inventoryDoc && <Documentation name={inventoryDoc.name} type={inventoryDoc.type} />}
|
||||
</div>
|
||||
</Split>
|
||||
</>
|
||||
}
|
||||
|
||||
export default Level
|
||||
|
||||
function Introduction({worldId}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
return <>
|
||||
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Einführung" worldId={worldId} levelId={0} />
|
||||
<div style={gameInfo.isLoading ? {display: "none"} : null} className="exercise-panel">
|
||||
<div className="introduction-panel">
|
||||
<div className="message info">
|
||||
<Markdown>
|
||||
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="conclusion">
|
||||
{0 == gameInfo.data?.worldSize[worldId] ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
||||
Start <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
||||
<div>
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button>
|
||||
<span className="app-bar-title">
|
||||
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
<Button disabled={levelId <= 0} inverted={true}
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
><FontAwesomeIcon icon={faArrowLeft} /> Previous</Button>
|
||||
<Button disabled={levelId >= gameInfo.data?.worldSize[worldId]} inverted={true}
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}
|
||||
>Next <FontAwesomeIcon icon={faArrowRight} /></Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
function useLevelEditor(worldId: string, levelId: number, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
||||
|
||||
const connection = React.useContext(ConnectionContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
||||
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
||||
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
||||
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
||||
|
||||
// Create Editor
|
||||
useEffect(() => {
|
||||
const editor = monaco.editor.create(codeviewRef.current!, {
|
||||
glyphMargin: true,
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbersMinChars: 3,
|
||||
'semanticHighlighting.enabled': true,
|
||||
theme: 'vs-code-theme-converted'
|
||||
})
|
||||
|
||||
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
||||
|
||||
const editorApi = infoProvider.getApi()
|
||||
|
||||
const editorEvents: EditorEvents = {
|
||||
initialize: new EventEmitter(),
|
||||
gotServerNotification: new EventEmitter(),
|
||||
sentClientNotification: new EventEmitter(),
|
||||
serverRestarted: new EventEmitter(),
|
||||
serverStopped: new EventEmitter(),
|
||||
changedCursorLocation: new EventEmitter(),
|
||||
changedInfoviewConfig: new EventEmitter(),
|
||||
runTestScript: new EventEmitter(),
|
||||
requestedAction: new EventEmitter(),
|
||||
};
|
||||
|
||||
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
||||
const infoviewApi: InfoviewApi = {
|
||||
initialize: async l => editorEvents.initialize.fire(l),
|
||||
gotServerNotification: async (method, params) => {
|
||||
editorEvents.gotServerNotification.fire([method, params]);
|
||||
},
|
||||
sentClientNotification: async (method, params) => {
|
||||
editorEvents.sentClientNotification.fire([method, params]);
|
||||
},
|
||||
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
||||
serverStopped: async serverStoppedReason => {
|
||||
editorEvents.serverStopped.fire(serverStoppedReason)
|
||||
},
|
||||
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
||||
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
||||
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
||||
// See https://rollupjs.org/guide/en/#avoiding-eval
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
runTestScript: async script => new Function(script)(),
|
||||
getInfoviewHtml: async () => document.body.innerHTML,
|
||||
};
|
||||
|
||||
const ec = new EditorConnection(editorApi, editorEvents);
|
||||
setEditorConnection(ec)
|
||||
|
||||
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
||||
|
||||
setEditor(editor)
|
||||
setInfoProvider(infoProvider)
|
||||
setInfoviewApi(infoviewApi)
|
||||
|
||||
return () => { infoProvider.dispose(); editor.dispose() }
|
||||
}, [])
|
||||
|
||||
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
||||
|
||||
// Create model when level changes
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (!model) {
|
||||
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
||||
}
|
||||
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
||||
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
||||
editor.setModel(model)
|
||||
if (initialSelections) {
|
||||
editor.setSelections(initialSelections)
|
||||
}
|
||||
|
||||
infoviewApi.serverRestarted(leanClient.initializeResult)
|
||||
infoProvider.openPreview(editor, infoviewApi)
|
||||
|
||||
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
||||
|
||||
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
||||
}
|
||||
}, [editor, levelId, connection, leanClientStarted])
|
||||
|
||||
return {editor, infoProvider, editorConnection}
|
||||
}
|
||||
|
||||
/** Open all files in this world on the server so that they will load faster when accessed */
|
||||
function useLoadWorldFiles(worldId) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const store = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data) {
|
||||
const models = []
|
||||
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (model) {
|
||||
models.push(model)
|
||||
} else {
|
||||
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
||||
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
||||
}
|
||||
}
|
||||
return () => { for (let model of models) { model.dispose() } }
|
||||
}
|
||||
}, [gameInfo.data, worldId])
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as React from 'react'
|
||||
|
||||
function PrivacyPolicyPopup ({handleClose}) {
|
||||
return <div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={handleClose} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
|
||||
<p>Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymized.</p>
|
||||
|
||||
<p>We do not use cookies, but your game progress is stored in the browser
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
|
||||
<p>Our server is located in Germany.</p>
|
||||
|
||||
<p><strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
||||
<FontAwesomeIcon icon={faShield} />
|
||||
<p className="p1">legal</p>
|
||||
<p className="p2">notes</p>
|
||||
</div>
|
||||
{open?
|
||||
<PrivacyPolicyPopup handleClose={handleClose} />: null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// export default PrivacyPolicy
|
||||
|
||||
export {
|
||||
PrivacyPolicy,
|
||||
PrivacyPolicyPopup,
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './welcome.css'
|
||||
import cytoscape, { LayoutOptions } from 'cytoscape'
|
||||
import klay from 'cytoscape-klay';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Split from 'react-split'
|
||||
|
||||
import GameMenu from './GameMenu';
|
||||
import {PrivacyPolicy} from './PrivacyPolicy';
|
||||
|
||||
cytoscape.use( klay );
|
||||
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { useGetGameInfoQuery } from '../state/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from './Markdown';
|
||||
import { selectCompleted } from '../state/progress';
|
||||
import { GameIdContext } from '../App';
|
||||
import { Button } from './Button';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
|
||||
const N = 24 // max number of levels per world
|
||||
const R = 800 // radius of a world
|
||||
const r = 110 // radius of a level
|
||||
const s = 100 // global scale
|
||||
const padding = 2000 // padding of the graphic (on a different scale)
|
||||
|
||||
function LevelIcon({ worldId, levelId, position }) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const completed = useSelector(selectCompleted(gameId, worldId,levelId))
|
||||
|
||||
const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
||||
const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
|
||||
|
||||
// TODO: relative positioning?
|
||||
return (
|
||||
<Link to={`/${gameId}/world/${worldId}/level/${levelId}`}>
|
||||
<circle fill={completed ? "green" :"#999"} cx={x} cy={y} r={r} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const { nodes, bounds }: any = gameInfo.data ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data?.title) {
|
||||
window.document.title = gameInfo.data.title
|
||||
}
|
||||
}, [gameInfo.data?.title])
|
||||
|
||||
const svgElements = []
|
||||
|
||||
if (gameInfo.data) {
|
||||
for (let i in gameInfo.data.worlds.edges) {
|
||||
const edge = gameInfo.data.worlds.edges[i]
|
||||
svgElements.push(
|
||||
<line key={`pathway${i}`} x1={s*nodes[edge[0]].position.x} y1={s*nodes[edge[0]].position.y}
|
||||
x2={s*nodes[edge[1]].position.x} y2={s*nodes[edge[1]].position.y} stroke="#1976d2" strokeWidth={s}/>
|
||||
)
|
||||
}
|
||||
|
||||
for (let id in nodes) {
|
||||
let position: cytoscape.Position = nodes[id].position
|
||||
|
||||
for (let i = 1; i <= gameInfo.data.worldSize[id]; i++) {
|
||||
svgElements.push(
|
||||
<LevelIcon
|
||||
key={`/${gameId}/world/${id}/level/${i}`}
|
||||
position={position} worldId={id} levelId={i} />
|
||||
)
|
||||
}
|
||||
|
||||
svgElements.push(
|
||||
<Link key={`world${id}`} to={`/${gameId}/world/${id}/level/0`}>
|
||||
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
|
||||
fill="#1976d2"/>
|
||||
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
|
||||
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
|
||||
<div>
|
||||
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
|
||||
{nodes[id].data.title ? nodes[id].data.title : id}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="app-content ">
|
||||
{ gameInfo.isLoading?
|
||||
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
:
|
||||
<Split className="welcome" minSize={200} sizes={[70, 30]}>
|
||||
<div className="column">
|
||||
<Typography variant="body1" component="div" className="welcome-text">
|
||||
<Markdown>{gameInfo.data?.introduction}</Markdown>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="column">
|
||||
<GameMenu />
|
||||
<Box textAlign='center' sx={{ m: 5 }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${s*bounds.x2 - s*bounds.x1 + 2 * padding} ${s*bounds.y2 - s*bounds.y1 + 2 * padding}` : ''}>
|
||||
{svgElements}
|
||||
</svg>
|
||||
</Box>
|
||||
</div>
|
||||
</Split>
|
||||
}
|
||||
<PrivacyPolicy />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Welcome
|
||||
|
||||
function computeWorldLayout(worlds) {
|
||||
|
||||
let elements = []
|
||||
for (let id in worlds.nodes) {
|
||||
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
|
||||
}
|
||||
for (let edge of worlds.edges) {
|
||||
elements.push({
|
||||
data: {
|
||||
id: edge[0] + " --edge-to--> " + edge[1],
|
||||
source: edge[0],
|
||||
target: edge[1]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cy = cytoscape({
|
||||
container: null,
|
||||
elements,
|
||||
headless: true,
|
||||
styleEnabled: false
|
||||
})
|
||||
|
||||
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
|
||||
let nodes = {}
|
||||
cy.nodes().forEach((node, id) => {
|
||||
nodes[node.id()] = {
|
||||
position: node.position(),
|
||||
data: node.data()
|
||||
}
|
||||
})
|
||||
const bounds = cy.nodes().boundingBox()
|
||||
return { nodes, bounds }
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
export interface ButtonProps extends LinkProps {
|
||||
disabled?: boolean
|
||||
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
if (props.disabled) {
|
||||
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
|
||||
} else {
|
||||
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { GameHint } from "./infoview/rpc_api";
|
||||
import * as React from 'react';
|
||||
import Markdown from './markdown';
|
||||
|
||||
export function Hint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function HiddenHint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
|
||||
return <div className={`message warning step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function Hints({hints, showHidden, step, selected, toggleSelection} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any}) {
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
// TODO: Should not use index as key.
|
||||
return <>
|
||||
{openHints.map((hint, j) => <Hint key={`hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||
{showHidden && hiddenHints.map((hint, j) => <HiddenHint key={`hidden-hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
|
||||
</>
|
||||
}
|
||||
|
||||
export function DeletedHint({hint} : {hint: GameHint}) {
|
||||
return <div className="message information deleted-hint">
|
||||
<Markdown>{hint.text}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function DeletedHints({hints} : {hints: GameHint[]}) {
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
// TODO: Should not use index as key.
|
||||
return <>
|
||||
{openHints.map((hint, i) => <DeletedHint key={`deleted-hint-${i}`} hint={hint} />)}
|
||||
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
|
||||
</>
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { LspDiagnosticsContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||
import { InputModeContext, MonacoEditorContext } from '../Level'
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
|
||||
import { Registry } from 'monaco-textmate' // peer dependency
|
||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||
import * as lightPlusTheme from 'lean4web/client/src/lightPlus.json'
|
||||
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
||||
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
||||
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
||||
import languageConfig from 'lean4/language-configuration.json';
|
||||
|
||||
|
||||
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
||||
|
||||
// register Monaco languages
|
||||
monaco.languages.register({
|
||||
id: 'lean4cmd',
|
||||
extensions: ['.leancmd']
|
||||
})
|
||||
|
||||
// map of monaco "language id's" to TextMate scopeNames
|
||||
const grammars = new Map()
|
||||
grammars.set('lean4', 'source.lean')
|
||||
grammars.set('lean4cmd', 'source.lean')
|
||||
|
||||
const registry = new Registry({
|
||||
getGrammarDefinition: async (scopeName) => {
|
||||
if (scopeName === 'source.lean') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanSyntax)
|
||||
}
|
||||
} else if (scopeName === 'source.lean.markdown') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanMarkdownSyntax)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(codeblockSyntax)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wireTmGrammars(monaco, registry, grammars)
|
||||
|
||||
let config: any = { ...languageConfig }
|
||||
config.autoClosingPairs = config.autoClosingPairs.map(
|
||||
pair => { return {'open': pair[0], 'close': pair[1]} }
|
||||
)
|
||||
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
||||
|
||||
export function CommandLine() {
|
||||
|
||||
/** Reference to the hidden multi-line editor */
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const { commandLineMode, commandLineInput, setCommandLineInput } = React.useContext(InputModeContext)
|
||||
|
||||
const inputRef = useRef()
|
||||
|
||||
// Run the command
|
||||
const runCommand = React.useCallback(() => {
|
||||
if (processing) return;
|
||||
const pos = editor.getPosition()
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(
|
||||
pos,
|
||||
editor.getModel().getFullModelRange().getEndPosition()
|
||||
),
|
||||
text: commandLineInput + "\n",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.setPosition(pos)
|
||||
setProcessing(true)
|
||||
}, [commandLineInput, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
||||
oneLineEditor.setValue(commandLineInput)
|
||||
}
|
||||
}, [commandLineInput])
|
||||
|
||||
// React when answer from the server comes back
|
||||
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
||||
if (params.uri == editor.getModel().uri.toString()) {
|
||||
setProcessing(false)
|
||||
if (!hasErrorsOrWarnings(params.diagnostics)) {
|
||||
setCommandLineInput("")
|
||||
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const myEditor = monaco.editor.create(inputRef.current!, {
|
||||
value: commandLineInput,
|
||||
language: "lean4cmd",
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
'semanticHighlighting.enabled': true,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontalScrollbarSize: 3
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
theme: 'vs-code-theme-converted',
|
||||
contextmenu: false
|
||||
})
|
||||
|
||||
setOneLineEditor(myEditor)
|
||||
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
||||
|
||||
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Ensure that our one-line editor can only have a single line
|
||||
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
||||
const value = oneLineEditor.getValue()
|
||||
setCommandLineInput(value)
|
||||
const newValue = value.replace(/[\n\r]/g, '')
|
||||
if (value != newValue) {
|
||||
oneLineEditor.setValue(newValue)
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, setCommandLineInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Run command when pressing enter
|
||||
const l = oneLineEditor.onKeyUp((ev) => {
|
||||
if (ev.code === "Enter") {
|
||||
runCommand()
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, runCommand])
|
||||
|
||||
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
||||
ev.preventDefault()
|
||||
runCommand()
|
||||
}
|
||||
|
||||
return <div className="command-line">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="command-line-input-wrapper">
|
||||
<div ref={inputRef} className="command-line-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={processing} className="btn btn-inverted"><FontAwesomeIcon icon={faWandMagicSparkles} /> Execute</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
||||
been completed.*/
|
||||
function hasErrorsOrWarnings(diags) {
|
||||
return diags.some(
|
||||
(d) =>
|
||||
!d.message.startsWith("unsolved goals") &&
|
||||
(d.severity == DiagnosticSeverity.Error || d.severity == DiagnosticSeverity.Warning)
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,341 @@
|
||||
import * as React from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { Registry } from 'monaco-textmate' // peer dependency
|
||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
||||
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
|
||||
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
|
||||
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
|
||||
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
|
||||
import languageConfig from 'lean4/language-configuration.json';
|
||||
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api';
|
||||
import { Diagnostic } from 'vscode-languageserver-types';
|
||||
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||
import { useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context'
|
||||
import { goalsToString } from './goals'
|
||||
import { GameHint, InteractiveGoals } from './rpc_api'
|
||||
|
||||
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
|
||||
|
||||
// register Monaco languages
|
||||
monaco.languages.register({
|
||||
id: 'lean4cmd',
|
||||
extensions: ['.leancmd']
|
||||
})
|
||||
|
||||
// map of monaco "language id's" to TextMate scopeNames
|
||||
const grammars = new Map()
|
||||
grammars.set('lean4', 'source.lean')
|
||||
grammars.set('lean4cmd', 'source.lean')
|
||||
|
||||
const registry = new Registry({
|
||||
getGrammarDefinition: async (scopeName) => {
|
||||
if (scopeName === 'source.lean') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanSyntax)
|
||||
}
|
||||
} else if (scopeName === 'source.lean.markdown') {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(leanMarkdownSyntax)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
format: 'json',
|
||||
content: JSON.stringify(codeblockSyntax)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wireTmGrammars(monaco, registry, grammars)
|
||||
|
||||
let config: any = { ...languageConfig }
|
||||
config.autoClosingPairs = config.autoClosingPairs.map(
|
||||
pair => { return {'open': pair[0], 'close': pair[1]} }
|
||||
)
|
||||
monaco.languages.setLanguageConfiguration('lean4cmd', config);
|
||||
|
||||
/** The input field */
|
||||
export function CommandLine({proofPanelRef}: {proofPanelRef: React.MutableRefObject<HTMLDivElement>}) {
|
||||
|
||||
/** Reference to the hidden multi-line editor */
|
||||
const editor = React.useContext(MonacoEditorContext)
|
||||
const model = editor.getModel()
|
||||
const uri = model.uri.toString()
|
||||
|
||||
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
const {commandLineInput, setCommandLineInput} = React.useContext(InputModeContext)
|
||||
|
||||
const inputRef = useRef()
|
||||
|
||||
// The context storing all information about the current proof
|
||||
const {proof, setProof} = React.useContext(ProofContext)
|
||||
|
||||
// state to store the last batch of deleted messages
|
||||
const {setDeletedChat} = React.useContext(DeletedChatContext)
|
||||
|
||||
// TODO: does the position matter at all?
|
||||
const rpcSess = useRpcSessionAtPos({uri: uri, line: 1, character: 1})
|
||||
|
||||
/** Load all goals an messages of the current proof (line-by-line) and save
|
||||
* the retrieved information into context (`ProofContext`)
|
||||
*/
|
||||
const loadAllGoals = React.useCallback(() => {
|
||||
|
||||
let goalCalls = []
|
||||
let msgCalls = []
|
||||
|
||||
// For each line of code ask the server for the goals and the messages on this line
|
||||
for (let i = 0; i < model.getLineCount(); i++) {
|
||||
goalCalls.push(
|
||||
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
|
||||
)
|
||||
msgCalls.push(
|
||||
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1})
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all these requests to be processed before saving the results
|
||||
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => {
|
||||
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
|
||||
let tmpProof : ProofStep[] = []
|
||||
|
||||
let goalCount = 0
|
||||
|
||||
steps.map((goals, i) => {
|
||||
// The first step has an empty command and therefore also no error messages
|
||||
// Usually there is a newline at the end of the editors content, so we need to
|
||||
// display diagnostics from potentally two lines in the last step.
|
||||
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
|
||||
|
||||
// Filter out the 'unsolved goals' message
|
||||
messages = messages.filter((msg) => {
|
||||
return !("append" in msg.message &&
|
||||
"text" in msg.message.append[0] &&
|
||||
msg.message.append[0].text === "unsolved goals")
|
||||
})
|
||||
|
||||
if (typeof goals == 'undefined') {
|
||||
tmpProof.push({
|
||||
command: i ? model.getLineContent(i) : '',
|
||||
goals: [],
|
||||
hints: [],
|
||||
errors: messages
|
||||
} as ProofStep)
|
||||
console.debug('goals is undefined')
|
||||
return
|
||||
}
|
||||
|
||||
// If the number of goals reduce, show a message
|
||||
if (goals.goals.length && goalCount > goals.goals.length) {
|
||||
messages.unshift({
|
||||
range: {
|
||||
start: {
|
||||
line: i-1,
|
||||
character: 0,
|
||||
},
|
||||
end: {
|
||||
line: i-1,
|
||||
character: 0,
|
||||
}},
|
||||
severity: DiagnosticSeverity.Information,
|
||||
message: {
|
||||
text: 'intermediate goal solved 🎉'
|
||||
}
|
||||
})
|
||||
}
|
||||
goalCount = goals.goals.length
|
||||
|
||||
// with no goals there will be no hints.
|
||||
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
|
||||
|
||||
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
|
||||
console.debug(`Goals: (${i}): `, goalsToString(goals)) //
|
||||
console.debug(`Hints: (${i}): `, hints)
|
||||
console.debug(`Errors: (${i}): `, messages)
|
||||
|
||||
tmpProof.push({
|
||||
// the command of the line above. Note that `getLineContent` starts counting
|
||||
// at `1` instead of `zero`. The first ProofStep will have an empty command.
|
||||
command: i ? model.getLineContent(i) : '',
|
||||
// TODO: store correct data
|
||||
goals: goals.goals,
|
||||
// only need the hints of the active goals in chat
|
||||
hints: hints,
|
||||
// errors and messages from the server
|
||||
errors: messages
|
||||
} as ProofStep)
|
||||
|
||||
})
|
||||
// Save the proof to the context
|
||||
setProof(tmpProof)
|
||||
console.debug('updated proof')
|
||||
proofPanelRef.current?.lastElementChild?.scrollIntoView()
|
||||
})
|
||||
})
|
||||
}, [editor, rpcSess, uri, model, proofPanelRef])
|
||||
|
||||
// Run the command
|
||||
const runCommand = React.useCallback(() => {
|
||||
if (processing) {return}
|
||||
|
||||
// TODO: Desired logic is to only reset this after a new *error-free* command has been entered
|
||||
setDeletedChat([])
|
||||
|
||||
const pos = editor.getPosition()
|
||||
if (commandLineInput) {
|
||||
setProcessing(true)
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(
|
||||
pos,
|
||||
editor.getModel().getFullModelRange().getEndPosition()
|
||||
),
|
||||
text: commandLineInput.trim() + "\n",
|
||||
forceMoveMarkers: false
|
||||
}])
|
||||
}
|
||||
|
||||
editor.setPosition(pos)
|
||||
}, [commandLineInput, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
|
||||
oneLineEditor.setValue(commandLineInput)
|
||||
}
|
||||
}, [commandLineInput])
|
||||
|
||||
// React when answer from the server comes back
|
||||
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
|
||||
if (params.uri == uri) {
|
||||
setProcessing(false)
|
||||
loadAllGoals()
|
||||
if (!hasErrors(params.diagnostics)) {
|
||||
setCommandLineInput("")
|
||||
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
|
||||
}
|
||||
} else {
|
||||
// console.debug(`expected uri: ${uri}, got: ${params.uri}`)
|
||||
// console.debug(params)
|
||||
}
|
||||
// TODO: This is the wrong place apparently. Where do wee need to load them?
|
||||
// TODO: instead of loading all goals every time, we could only load the last one
|
||||
// loadAllGoals()
|
||||
}, [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
const myEditor = monaco.editor.create(inputRef.current!, {
|
||||
value: commandLineInput,
|
||||
language: "lean4cmd",
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbers: 'off',
|
||||
tabSize: 2,
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
'semanticHighlighting.enabled': true,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontalScrollbarSize: 3
|
||||
},
|
||||
overviewRulerBorder: false,
|
||||
theme: 'vs-code-theme-converted',
|
||||
contextmenu: false
|
||||
})
|
||||
|
||||
setOneLineEditor(myEditor)
|
||||
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
|
||||
|
||||
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Ensure that our one-line editor can only have a single line
|
||||
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
|
||||
const value = oneLineEditor.getValue()
|
||||
setCommandLineInput(value)
|
||||
const newValue = value.replace(/[\n\r]/g, '')
|
||||
if (value != newValue) {
|
||||
oneLineEditor.setValue(newValue)
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, setCommandLineInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!oneLineEditor) return
|
||||
// Run command when pressing enter
|
||||
const l = oneLineEditor.onKeyUp((ev) => {
|
||||
if (ev.code === "Enter") {
|
||||
runCommand()
|
||||
}
|
||||
})
|
||||
return () => { l.dispose() }
|
||||
}, [oneLineEditor, runCommand])
|
||||
|
||||
// BUG: Causes `file closed` error
|
||||
//TODO: Intention is to run once when loading, does that work?
|
||||
useEffect(() => {
|
||||
console.debug(`time to update: ${uri} \n ${rpcSess}`)
|
||||
console.debug(rpcSess)
|
||||
loadAllGoals()
|
||||
}, [rpcSess])
|
||||
|
||||
/** Process the entered command */
|
||||
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
|
||||
ev.preventDefault()
|
||||
runCommand()
|
||||
}
|
||||
|
||||
return <div className="command-line">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="command-line-input-wrapper">
|
||||
<div ref={inputRef} className="command-line-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={processing} className="btn btn-inverted">
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} /> Execute
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
|
||||
been completed.*/
|
||||
export function hasErrors(diags: Diagnostic[]) {
|
||||
return diags.some(
|
||||
(d) =>
|
||||
!d.message.startsWith("unsolved goals") &&
|
||||
(d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Didn't manage to unify this with the one above
|
||||
export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
|
||||
return (typeof diags !== 'undefined') && diags.some(
|
||||
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileOverview This file contains the the react contexts used in the project.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
|
||||
|
||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
|
||||
null as any)
|
||||
|
||||
export type InfoStatus = 'updating' | 'error' | 'ready';
|
||||
|
||||
/** One step of the proof */
|
||||
export type ProofStep = {
|
||||
/** The command in this step */
|
||||
command : string
|
||||
/** List of goals *after* this command */
|
||||
goals: InteractiveGoal[] // TODO: Add correct type
|
||||
/** Story relevant messages */
|
||||
hints: GameHint[] // TODO: Add correct type
|
||||
/** Errors and warnings */
|
||||
errors: InteractiveDiagnostic[] // TODO: Add correct type
|
||||
}
|
||||
|
||||
/** The context storing the proof step-by-step for the command line mode */
|
||||
export const ProofContext = React.createContext<{
|
||||
/** The proof consists of multiple steps that are processed one after the other.
|
||||
* In particular multi-line terms like `match`-statements will not be supported.
|
||||
*
|
||||
* Note that the first step will always have `null` as command
|
||||
*/
|
||||
proof: ProofStep[],
|
||||
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
|
||||
}>({
|
||||
proof: [],
|
||||
setProof: () => {} // TODO: implement me
|
||||
})
|
||||
|
||||
export interface ProofStateProps {
|
||||
// pos: DocumentPosition;
|
||||
status: InfoStatus;
|
||||
messages: InteractiveDiagnostic[];
|
||||
goals?: InteractiveGoals;
|
||||
termGoal?: InteractiveTermGoal;
|
||||
error?: string;
|
||||
// userWidgets: UserWidgetInstance[];
|
||||
// rpcSess: RpcSessionAtPos;
|
||||
// triggerUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ProofStateContext = React.createContext<{
|
||||
proofState : ProofStateProps,
|
||||
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
|
||||
}>({
|
||||
proofState : {
|
||||
status: 'updating',
|
||||
messages: [],
|
||||
goals: undefined,
|
||||
termGoal: undefined,
|
||||
error: undefined},
|
||||
setProofState: () => {},
|
||||
})
|
||||
|
||||
export const MobileContext = React.createContext<{
|
||||
mobile : boolean,
|
||||
setMobile: React.Dispatch<React.SetStateAction<Boolean>>
|
||||
}>({
|
||||
mobile : false,
|
||||
setMobile: () => {},
|
||||
})
|
||||
|
||||
export const WorldLevelIdContext = React.createContext<{
|
||||
worldId : string,
|
||||
levelId: number
|
||||
}>({
|
||||
worldId : null,
|
||||
levelId: 0,
|
||||
})
|
||||
|
||||
/** Context to keep highlight selected proof step and corresponding chat messages. */
|
||||
export const SelectionContext = React.createContext<{
|
||||
selectedStep : number,
|
||||
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
|
||||
}>({
|
||||
selectedStep : undefined,
|
||||
setSelectedStep: () => {}
|
||||
})
|
||||
|
||||
/** Context for deleted Hints that are visible just a bit after they've been deleted */
|
||||
export const DeletedChatContext = React.createContext<{
|
||||
deletedChat : GameHint[],
|
||||
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
|
||||
showHelp : Set<number>,
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
|
||||
}>({
|
||||
deletedChat: undefined,
|
||||
setDeletedChat: () => {},
|
||||
showHelp: undefined,
|
||||
setShowHelp: () => {}
|
||||
})
|
||||
|
||||
export const InputModeContext = React.createContext<{
|
||||
commandLineMode: boolean,
|
||||
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
commandLineInput: string,
|
||||
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
|
||||
}>({
|
||||
commandLineMode: true,
|
||||
setCommandLineMode: () => {},
|
||||
commandLineInput: "",
|
||||
setCommandLineInput: () => {},
|
||||
});
|
||||
@ -1,27 +0,0 @@
|
||||
import { GameHint } from "./rpcApi";
|
||||
import * as React from 'react';
|
||||
import { Alert, FormControlLabel, Switch } from '@mui/material';
|
||||
import Markdown from '../Markdown';
|
||||
|
||||
function Hint({hint} : {hint: GameHint}) {
|
||||
return <div className="message info"><Markdown>{hint.text}</Markdown></div>
|
||||
}
|
||||
|
||||
export function Hints({hints} : {hints: GameHint[]}) {
|
||||
|
||||
|
||||
const [showHints, setShowHints] = React.useState(false);
|
||||
|
||||
const openHints = hints.filter(hint => !hint.hidden)
|
||||
const hiddenHints = hints.filter(hint => hint.hidden)
|
||||
|
||||
return <>
|
||||
{openHints.map(hint => <Hint hint={hint} />)}
|
||||
{hiddenHints.length > 0 &&
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showHints} onChange={() => setShowHints((prev) => !prev)} />}
|
||||
label="I need help!"
|
||||
/>}
|
||||
{showHints && hiddenHints.map(hint => <Hint hint={hint} />)}
|
||||
</>
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
/* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts ` */
|
||||
|
||||
/**
|
||||
* @fileOverview Defines the interface for the communication with the server.
|
||||
*
|
||||
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
|
||||
*/
|
||||
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
|
||||
|
||||
export interface GameHint {
|
||||
@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './inventory.css'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
|
||||
import { GameIdContext } from '../app';
|
||||
import Markdown from './markdown';
|
||||
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
|
||||
import { selectDifficulty, selectInventory } from '../state/progress';
|
||||
import { store } from '../state/store';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export function Inventory({levelInfo, openDoc, enableAll=false} :
|
||||
{
|
||||
levelInfo: LevelInfo|InventoryOverview,
|
||||
openDoc: (props: {name: string, type: string}) => void,
|
||||
enableAll?: boolean,
|
||||
}) {
|
||||
|
||||
return (
|
||||
<div className="inventory">
|
||||
{/* TODO: Click on Tactic: show info
|
||||
TODO: click on paste icon -> paste into command line */}
|
||||
<h2>Tactics</h2>
|
||||
{levelInfo?.tactics &&
|
||||
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
|
||||
}
|
||||
<h2>Definitions</h2>
|
||||
{levelInfo?.definitions &&
|
||||
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
|
||||
}
|
||||
<h2>Lemmas</h2>
|
||||
{levelInfo?.lemmas &&
|
||||
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} :
|
||||
{
|
||||
items: InventoryTile[],
|
||||
docType: string,
|
||||
openDoc(props: {name: string, type: string}): void,
|
||||
defaultTab? : string,
|
||||
level? : LevelInfo|InventoryOverview,
|
||||
enableAll?: boolean,
|
||||
}) {
|
||||
// TODO: `level` is only used in the `useEffect` below to check if a new level has
|
||||
// been loaded. Is there a better way to observe this?
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
|
||||
const categorySet = new Set<string>()
|
||||
for (let item of items) {
|
||||
categorySet.add(item.category)
|
||||
}
|
||||
const categories = Array.from(categorySet).sort()
|
||||
|
||||
const [tab, setTab] = useState(defaultTab)
|
||||
|
||||
// Add inventory items from local store as unlocked.
|
||||
// Items are unlocked if they are in the local store, or if the server says they should be
|
||||
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
|
||||
// modi)
|
||||
let inv: string[] = selectInventory(gameId)(store.getState())
|
||||
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
|
||||
|
||||
useEffect(() => {
|
||||
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
|
||||
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
|
||||
if (defaultTab) {
|
||||
setTab(defaultTab)
|
||||
}}, [level])
|
||||
|
||||
return <>
|
||||
{categories.length > 1 &&
|
||||
<div className="tab-bar">
|
||||
{categories.map((cat) =>
|
||||
<div key={`category-${cat}`} className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`}
|
||||
onClick={() => { setTab(cat) }}>{cat}</div>)}
|
||||
</div>}
|
||||
<div className="inventory-list">
|
||||
{[...modifiedItems].sort(
|
||||
// For lemas, sort entries `available > disabled > locked`
|
||||
// otherwise alphabetically
|
||||
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
|
||||
).filter(item => ((tab ?? categories[0]) == item.category)).map((item, i) => {
|
||||
return <InventoryItem key={`${item.category}-${item.name}`}
|
||||
showDoc={() => {openDoc({name: item.name, type: docType})}}
|
||||
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
|
||||
disabled={item.disabled} newly={item.new} enableAll={enableAll}/>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
|
||||
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
|
||||
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
|
||||
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
|
||||
const title = locked ? "Not unlocked yet" :
|
||||
disabled ? "Not available in this level" : ""
|
||||
|
||||
const handleClick = () => {
|
||||
if (enableAll || !locked) {
|
||||
showDoc()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
|
||||
}
|
||||
|
||||
export function Documentation({name, type, handleClose}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
|
||||
|
||||
return <div className="documentation">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h1 className="doc">{doc.data?.displayName}</h1>
|
||||
<p><code>{doc.data?.statement}</code></p>
|
||||
{/* <code>docstring: {doc.data?.docstring}</code> */}
|
||||
<Markdown>{doc.data?.content}</Markdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */
|
||||
export function InventoryPanel({visible = true}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
||||
|
||||
// The inventory is overlayed by the doc entry of a clicked item
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
// Set `inventoryDoc` to `null` to close the doc
|
||||
function closeInventoryDoc() {setInventoryDoc(null)}
|
||||
|
||||
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
|
||||
{inventoryDoc ?
|
||||
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
|
||||
:
|
||||
<Inventory levelInfo={inventory.data} openDoc={setInventoryDoc} enableAll={true}/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,685 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState, useRef, useContext } from 'react'
|
||||
import { useSelector, useStore } from 'react-redux'
|
||||
import Split from 'react-split'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBars, faBook, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import type { Location } from 'vscode-languageserver-protocol'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'
|
||||
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'
|
||||
import { InfoProvider } from 'lean4web/client/src/editor/infoview'
|
||||
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter'
|
||||
import { InfoviewApi } from '@leanprover/infoview'
|
||||
import { EditorContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts'
|
||||
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection'
|
||||
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { ConnectionContext, connection, useLeanClient } from '../connection'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
|
||||
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
|
||||
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
|
||||
import { store } from '../state/store'
|
||||
import { Button } from './button'
|
||||
import Markdown from './markdown'
|
||||
import {InventoryPanel} from './inventory'
|
||||
import { hasInteractiveErrors } from './infoview/command_line'
|
||||
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
|
||||
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
|
||||
import { DualEditor } from './infoview/main'
|
||||
import { GameHint } from './infoview/rpc_api'
|
||||
import { DeletedHints, Hints } from './hints'
|
||||
import { Impressum } from './privacy_policy'
|
||||
|
||||
import '@fontsource/roboto/300.css'
|
||||
import '@fontsource/roboto/400.css'
|
||||
import '@fontsource/roboto/500.css'
|
||||
import '@fontsource/roboto/700.css'
|
||||
import 'lean4web/client/src/editor/infoview.css'
|
||||
import 'lean4web/client/src/editor/vscode.css'
|
||||
import './level.css'
|
||||
|
||||
function Level() {
|
||||
const params = useParams()
|
||||
const levelId = parseInt(params.levelId)
|
||||
const worldId = params.worldId
|
||||
useLoadWorldFiles(worldId)
|
||||
|
||||
return <WorldLevelIdContext.Provider value={{worldId, levelId}}>
|
||||
{levelId == 0 ? <Introduction /> : <PlayableLevel />}
|
||||
</WorldLevelIdContext.Provider>
|
||||
}
|
||||
|
||||
function ChatPanel({lastLevel}) {
|
||||
const chatRef = useRef<HTMLDivElement>(null)
|
||||
const {mobile} = useContext(MobileContext)
|
||||
const gameId = useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
const {proof, setProof} = useContext(ProofContext)
|
||||
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = useContext(DeletedChatContext)
|
||||
const {selectedStep, setSelectedStep} = useContext(SelectionContext)
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
|
||||
let k = proof.length - 1
|
||||
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
|
||||
|
||||
function toggleSelection(line: number) {
|
||||
return (ev) => {
|
||||
console.debug('toggled selection')
|
||||
if (selectedStep == line) {
|
||||
setSelectedStep(undefined)
|
||||
} else {
|
||||
setSelectedStep(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasHiddenHints(i : number): boolean {
|
||||
let step = proof[i]
|
||||
// For example if the proof isn't loaded yet
|
||||
if(!step) {return false}
|
||||
return step.hints.some((hint) => hint.hidden)
|
||||
}
|
||||
|
||||
const activateHiddenHints = (ev) => {
|
||||
// If the last step (`k`) has errors, we want the hidden hints from the
|
||||
// second-to-last step to be affected
|
||||
if (!(proof.length)) {return}
|
||||
|
||||
// state must not be mutated, therefore we need to clone the set
|
||||
let tmp = new Set(showHelp)
|
||||
if (tmp.has(k - withErr)) {
|
||||
tmp.delete(k - withErr)
|
||||
} else {
|
||||
tmp.add(k - withErr)
|
||||
}
|
||||
setShowHelp(tmp)
|
||||
console.debug(`help: ${Array.from(tmp.values())}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: For some reason this is always called twice
|
||||
console.debug('scroll chat')
|
||||
if (!mobile) {
|
||||
chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0)
|
||||
}
|
||||
}, [proof, showHelp])
|
||||
|
||||
// Scroll to element if selection changes
|
||||
useEffect(() => {
|
||||
if (typeof selectedStep !== 'undefined') {
|
||||
Array.from(chatRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
|
||||
elem.scrollIntoView({block: "center"})
|
||||
})
|
||||
}
|
||||
}, [selectedStep])
|
||||
|
||||
// useEffect(() => {
|
||||
// // // Scroll to top when loading a new level
|
||||
// // // TODO: Thats the wrong behaviour probably
|
||||
// // chatRef.current!.scrollTo(0,0)
|
||||
// }, [gameId, worldId, levelId])
|
||||
|
||||
return <div className="chat-panel">
|
||||
<div ref={chatRef} className="chat">
|
||||
{level?.data?.introduction &&
|
||||
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelection(0)}>
|
||||
<Markdown>{level?.data?.introduction}</Markdown>
|
||||
</div>
|
||||
}
|
||||
{proof.map((step, i) => {
|
||||
// It the last step has errors, it will have the same hints
|
||||
// as the second-to-last step. Therefore we should not display them.
|
||||
if (!(i == proof.length - 1 && withErr)) {
|
||||
// TODO: Should not use index as key.
|
||||
return <Hints key={`hints-${i}`}
|
||||
hints={step.hints} showHidden={showHelp.has(i)} step={i}
|
||||
selected={selectedStep} toggleSelection={toggleSelection(i)}/>
|
||||
}
|
||||
})}
|
||||
<DeletedHints hints={deletedChat}/>
|
||||
{completed &&
|
||||
<>
|
||||
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||
Level completed! 🎉
|
||||
</div>
|
||||
{level?.data?.conclusion?.trim() &&
|
||||
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
|
||||
<Markdown>{level?.data?.conclusion}</Markdown>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{completed && (lastLevel ?
|
||||
<Button to={`/${gameId}`}>
|
||||
<FontAwesomeIcon icon={faHome} /> Leave World
|
||||
</Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
|
||||
Next <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>)
|
||||
}
|
||||
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
|
||||
<Button to="" onClick={activateHiddenHints}>
|
||||
Show more help!
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExercisePanel({codeviewRef, impressum, closeImpressum, visible=true}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
return <div className={`exercise-panel ${visible ? '' : 'hidden'}`}>
|
||||
<div className="exercise">
|
||||
<DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/>
|
||||
</div>
|
||||
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PlayableLevel() {
|
||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
|
||||
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
|
||||
const inventory: Array<String> = useSelector(selectInventory(gameId))
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||
|
||||
// The state variables for the `ProofContext`
|
||||
const [proof, setProof] = useState<Array<ProofStep>>([])
|
||||
// When deleting the proof, we want to keep to old messages around until
|
||||
// a new proof has been entered. e.g. to consult messages coming from dead ends
|
||||
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
|
||||
// A set of row numbers where help is displayed
|
||||
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
|
||||
// Only for mobile layout
|
||||
const [pageNumber, setPageNumber] = useState(0)
|
||||
const [commandLineMode, setCommandLineMode] = useState(true)
|
||||
const [commandLineInput, setCommandLineInput] = useState("")
|
||||
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// impressum pop-up
|
||||
const [impressum, setImpressum] = React.useState(false)
|
||||
function closeImpressum() {setImpressum(false)}
|
||||
function toggleImpressum() {setImpressum(!impressum)}
|
||||
|
||||
// When clicking on an inventory item, the inventory is overlayed by the item's doc.
|
||||
// If this state is set to a pair `(name, type)` then the according doc will be open.
|
||||
// Set `inventoryDoc` to `null` to close the doc
|
||||
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
|
||||
function closeInventoryDoc () {setInventoryDoc(null)}
|
||||
|
||||
|
||||
|
||||
const onDidChangeContent = (code) => {
|
||||
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
|
||||
}
|
||||
|
||||
const onDidChangeSelection = (monacoSelections) => {
|
||||
const selections = monacoSelections.map(
|
||||
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
|
||||
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
|
||||
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
|
||||
}
|
||||
|
||||
|
||||
const {editor, infoProvider, editorConnection} =
|
||||
useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
|
||||
|
||||
/** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside
|
||||
* `CommandLineInterface`.
|
||||
*/
|
||||
const handleUndo = () => {
|
||||
const endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let range
|
||||
console.log(endPos.column)
|
||||
if (endPos.column === 1) {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber - 1, 1),
|
||||
endPos
|
||||
)
|
||||
} else {
|
||||
range = monaco.Selection.fromPositions(
|
||||
new monaco.Position(endPos.lineNumber, 1),
|
||||
endPos
|
||||
)
|
||||
}
|
||||
editor.executeEdits("undo-button", [{
|
||||
range,
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
}
|
||||
|
||||
// Select and highlight proof steps and corresponding hints
|
||||
// TODO: with the new design, there is no difference between the introduction and
|
||||
// a hint at the beginning of the proof...
|
||||
const [selectedStep, setSelectedStep] = useState<number>()
|
||||
|
||||
// if the user inventory changes, notify the server
|
||||
useEffect(() => {
|
||||
let leanClient = connection.getLeanClient(gameId)
|
||||
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, checkEnabled: difficulty > 0})
|
||||
}, [inventory])
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: That's a problem if the saved proof contains an error
|
||||
// Reset command line input when loading a new level
|
||||
setCommandLineInput("")
|
||||
|
||||
// Load the selected help steps from the store
|
||||
setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState())))
|
||||
}, [gameId, worldId, levelId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandLineMode) {
|
||||
// Delete last input attempt from command line
|
||||
editor.executeEdits("command-line", [{
|
||||
range: editor.getSelection(),
|
||||
text: "",
|
||||
forceMoveMarkers: false
|
||||
}]);
|
||||
editor.focus()
|
||||
}
|
||||
}, [commandLineMode])
|
||||
|
||||
useEffect(() => {
|
||||
// Forget whether hidden hints are displayed for steps that don't exist yet
|
||||
if (proof.length) {
|
||||
console.debug(Array.from(showHelp))
|
||||
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length))))
|
||||
}
|
||||
}, [proof])
|
||||
|
||||
// save showed help in store
|
||||
useEffect(() => {
|
||||
if (proof.length) {
|
||||
console.debug(`showHelp:\n ${showHelp}`)
|
||||
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)}))
|
||||
}
|
||||
}, [showHelp])
|
||||
|
||||
// Effect when command line mode gets enabled
|
||||
useEffect(() => {
|
||||
if (editor && commandLineMode) {
|
||||
let endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
|
||||
editor.executeEdits("command-line", [{
|
||||
range: monaco.Selection.fromPositions(endPos, endPos),
|
||||
text: "\n",
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
}
|
||||
endPos = editor.getModel().getFullModelRange().getEndPosition()
|
||||
let currPos = editor.getPosition()
|
||||
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
|
||||
// This is not a position that would naturally occur from CommandLine, reset:
|
||||
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
|
||||
}
|
||||
}
|
||||
}, [editor, commandLineMode])
|
||||
|
||||
return <>
|
||||
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}>
|
||||
<SelectionContext.Provider value={{selectedStep, setSelectedStep}}>
|
||||
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
|
||||
<ProofContext.Provider value={{proof, setProof}}>
|
||||
<EditorContext.Provider value={editorConnection}>
|
||||
<MonacoEditorContext.Provider value={editor}>
|
||||
<LevelAppBar
|
||||
isLoading={level.isLoading}
|
||||
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
|
||||
(level?.data?.title && ` : ${level?.data?.title}`)}
|
||||
toggleImpressum={toggleImpressum}
|
||||
pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||
{mobile?
|
||||
// TODO: This is copied from the `Split` component below...
|
||||
<>
|
||||
<div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<ExercisePanel
|
||||
impressum={impressum}
|
||||
closeImpressum={closeImpressum}
|
||||
codeviewRef={codeviewRef}
|
||||
visible={pageNumber == 0} />
|
||||
<InventoryPanel visible={pageNumber == 1} />
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
|
||||
<ChatPanel lastLevel={lastLevel}/>
|
||||
<ExercisePanel
|
||||
impressum={impressum}
|
||||
closeImpressum={closeImpressum}
|
||||
codeviewRef={codeviewRef} />
|
||||
<InventoryPanel />
|
||||
</Split>
|
||||
}
|
||||
</MonacoEditorContext.Provider>
|
||||
</EditorContext.Provider>
|
||||
</ProofContext.Provider>
|
||||
</InputModeContext.Provider>
|
||||
</SelectionContext.Provider>
|
||||
</DeletedChatContext.Provider>
|
||||
</>
|
||||
}
|
||||
|
||||
export default Level
|
||||
|
||||
/** The site with the introduction text of a world */
|
||||
function Introduction() {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId} = useContext(WorldLevelIdContext)
|
||||
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const [impressum, setImpressum] = React.useState(false)
|
||||
|
||||
const closeImpressum = () => {
|
||||
setImpressum(false)
|
||||
}
|
||||
|
||||
const toggleImpressum = () => {
|
||||
setImpressum(!impressum)
|
||||
}
|
||||
|
||||
return <>
|
||||
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
|
||||
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/>
|
||||
<div style={gameInfo.isLoading ? {display: "none"} : null} className="introduction-panel">
|
||||
<div className="content-wrapper">
|
||||
<Markdown>
|
||||
{gameInfo.data?.worlds.nodes[worldId].introduction}
|
||||
</Markdown>
|
||||
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
|
||||
</div>
|
||||
{gameInfo.data?.worldSize[worldId] == 0 ?
|
||||
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
|
||||
<Button to={`/${gameId}/world/${worldId}/level/1`}>
|
||||
Start <FontAwesomeIcon icon={faArrowRight} />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
/** The top-navigation bar */
|
||||
function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber = undefined, setPageNumber = undefined}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||
|
||||
const { commandLineMode, setCommandLineMode } = React.useContext(InputModeContext)
|
||||
|
||||
const [navOpen, setNavOpen] = React.useState(false)
|
||||
|
||||
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
|
||||
{mobile ?
|
||||
<>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
{mobile && pageNumber == 0 ?
|
||||
<Button to=""
|
||||
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
|
||||
<FontAwesomeIcon icon={faBook}/>
|
||||
</Button>
|
||||
: pageNumber == 1 &&
|
||||
<Button to=""
|
||||
title="show inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
|
||||
<FontAwesomeIcon icon={faArrowLeft}/>
|
||||
</Button>
|
||||
}
|
||||
<Button to="" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
|
||||
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
|
||||
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||
<Button inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||
</Button>
|
||||
}
|
||||
{levelId > 0 && <>
|
||||
<Button disabled={levelId <= 0} inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
title="previous level"
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||
</Button>
|
||||
</>}
|
||||
<Button to={`/${gameId}`} inverted="true" title="back to world selection">
|
||||
<FontAwesomeIcon icon={faHome} /> Home
|
||||
</Button>
|
||||
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||
title="toggle Editor mode">
|
||||
<FontAwesomeIcon icon={faCode} /> Toggle Editor
|
||||
</Button>
|
||||
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} /> Info & Impressum
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<div>
|
||||
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
|
||||
<FontAwesomeIcon icon={faHome} />
|
||||
</Button>
|
||||
<span className="app-bar-title">
|
||||
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="app-bar-title">
|
||||
{levelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nav-btns">
|
||||
{levelId > 0 && <>
|
||||
<Button disabled={levelId <= 0} inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
|
||||
title="previous level"
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Previous
|
||||
</Button>
|
||||
</>}
|
||||
{levelId < gameInfo.data?.worldSize[worldId] &&
|
||||
<Button inverted="true"
|
||||
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
|
||||
disabled={difficulty >= 2 && !(completed || levelId == 0)}
|
||||
onClick={() => setNavOpen(false)}>
|
||||
<FontAwesomeIcon icon={faArrowRight} /> {levelId ? "Next" : "Start"}
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={levelId <= 0} inverted="true" to=""
|
||||
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
|
||||
title="toggle Editor mode">
|
||||
<FontAwesomeIcon icon={faCode} />
|
||||
</Button>
|
||||
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
|
||||
|
||||
const connection = React.useContext(ConnectionContext)
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||
|
||||
|
||||
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
|
||||
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
|
||||
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
|
||||
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
|
||||
|
||||
// Create Editor
|
||||
useEffect(() => {
|
||||
const editor = monaco.editor.create(codeviewRef.current!, {
|
||||
glyphMargin: true,
|
||||
quickSuggestions: false,
|
||||
lightbulb: {
|
||||
enabled: true
|
||||
},
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
},
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
lineNumbersMinChars: 3,
|
||||
'semanticHighlighting.enabled': true,
|
||||
theme: 'vs-code-theme-converted'
|
||||
})
|
||||
|
||||
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
|
||||
|
||||
const editorApi = infoProvider.getApi()
|
||||
|
||||
const editorEvents: EditorEvents = {
|
||||
initialize: new EventEmitter(),
|
||||
gotServerNotification: new EventEmitter(),
|
||||
sentClientNotification: new EventEmitter(),
|
||||
serverRestarted: new EventEmitter(),
|
||||
serverStopped: new EventEmitter(),
|
||||
changedCursorLocation: new EventEmitter(),
|
||||
changedInfoviewConfig: new EventEmitter(),
|
||||
runTestScript: new EventEmitter(),
|
||||
requestedAction: new EventEmitter(),
|
||||
};
|
||||
|
||||
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
|
||||
const infoviewApi: InfoviewApi = {
|
||||
initialize: async l => editorEvents.initialize.fire(l),
|
||||
gotServerNotification: async (method, params) => {
|
||||
editorEvents.gotServerNotification.fire([method, params]);
|
||||
},
|
||||
sentClientNotification: async (method, params) => {
|
||||
editorEvents.sentClientNotification.fire([method, params]);
|
||||
},
|
||||
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
|
||||
serverStopped: async serverStoppedReason => {
|
||||
editorEvents.serverStopped.fire(serverStoppedReason)
|
||||
},
|
||||
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
|
||||
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
|
||||
requestedAction: async action => editorEvents.requestedAction.fire(action),
|
||||
// See https://rollupjs.org/guide/en/#avoiding-eval
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
runTestScript: async script => new Function(script)(),
|
||||
getInfoviewHtml: async () => document.body.innerHTML,
|
||||
};
|
||||
|
||||
const ec = new EditorConnection(editorApi, editorEvents);
|
||||
setEditorConnection(ec)
|
||||
|
||||
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
|
||||
|
||||
setEditor(editor)
|
||||
setInfoProvider(infoProvider)
|
||||
setInfoviewApi(infoviewApi)
|
||||
|
||||
return () => { infoProvider.dispose(); editor.dispose() }
|
||||
}, [])
|
||||
|
||||
const {leanClient, leanClientStarted} = useLeanClient(gameId)
|
||||
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
|
||||
// Create model when level changes
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (!model) {
|
||||
model = monaco.editor.createModel(initialCode, 'lean4', uri)
|
||||
}
|
||||
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
|
||||
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
|
||||
editor.setModel(model)
|
||||
if (initialSelections) {
|
||||
console.debug("Initial Selection: ", initialSelections)
|
||||
// BUG: Somehow I get an `invalid arguments` bug here
|
||||
// editor.setSelections(initialSelections)
|
||||
}
|
||||
}
|
||||
}, [editor, levelId, connection, leanClientStarted])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && leanClientStarted) {
|
||||
|
||||
let model = monaco.editor.getModel(uri)
|
||||
infoviewApi.serverRestarted(leanClient.initializeResult)
|
||||
|
||||
infoProvider.openPreview(editor, infoviewApi)
|
||||
|
||||
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
|
||||
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
|
||||
|
||||
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
|
||||
}
|
||||
}, [editor, connection, leanClientStarted])
|
||||
|
||||
return {editor, infoProvider, editorConnection}
|
||||
}
|
||||
|
||||
/** Open all files in this world on the server so that they will load faster when accessed */
|
||||
function useLoadWorldFiles(worldId) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
const store = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (gameInfo.data) {
|
||||
const models = []
|
||||
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
|
||||
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
|
||||
let model = monaco.editor.getModel(uri)
|
||||
if (model) {
|
||||
models.push(model)
|
||||
} else {
|
||||
const code = selectCode(gameId, worldId, levelId)(store.getState())
|
||||
models.push(monaco.editor.createModel(code, 'lean4', uri))
|
||||
}
|
||||
}
|
||||
return () => { for (let model of models) { model.dispose() } }
|
||||
}
|
||||
}, [gameInfo.data, worldId])
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileOverview The impressum/privacy policy
|
||||
*/
|
||||
import { faShield } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as React from 'react'
|
||||
|
||||
/** Pop-up that is displayed when opening the privacy policy.
|
||||
*
|
||||
* `handleClose` is the function to close it again because it's open/closed state is
|
||||
* controlled by the containing element.
|
||||
*/
|
||||
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
|
||||
return <div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={handleClose} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
<p>
|
||||
Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymized.
|
||||
</p>
|
||||
<p>
|
||||
We do not use cookies, but your game progress is stored in the browser
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
<p>Our server is located in Germany.</p>
|
||||
<p>
|
||||
<strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
+49 211 81-12173<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const PrivacyPolicy: React.FC = () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const handleOpen = () => setOpen(true)
|
||||
const handleClose = () => setOpen(false)
|
||||
return (
|
||||
<>
|
||||
<div className="privacy" onClick={handleOpen} title="Privacy Policy & Impressum">
|
||||
<FontAwesomeIcon icon={faShield} />
|
||||
<p className="p1">legal</p>
|
||||
<p className="p2">notes</p>
|
||||
</div>
|
||||
{open ? <PrivacyPolicyPopup handleClose={handleClose} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Impressum({handleClose}) {
|
||||
return <div className="impressum">
|
||||
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
|
||||
<h2>Funding</h2>
|
||||
<p>
|
||||
This Lean game engine has been developed as part of the
|
||||
project <a href="https://hhu-adam.github.io/" target="_blank">ADAM: Anticipating the Digital
|
||||
Age of Mathematics</a> at
|
||||
Heinrich-Heine-Universität Düsseldorf. It is funded by
|
||||
the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.
|
||||
</p>
|
||||
|
||||
<h2>Development</h2>
|
||||
<p>
|
||||
The source code is <a href="https://github.com/leanprover-community/lean4game" target="_blank">available on Github</a>.
|
||||
If you experience any problems, please
|
||||
file an <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">Issue on Github</a> or
|
||||
get directly in contact.
|
||||
</p>
|
||||
<h2>Privacy Policy & Impressum</h2>
|
||||
<p>
|
||||
Our server collects metadata (such as IP address, browser, operating system)
|
||||
and the data that the user enters into the editor. The data is used to
|
||||
compute the Lean output and display it to the user. The information will be stored
|
||||
as long as the user stays on our website and will be deleted immediately afterwards.
|
||||
We keep logs to improve our software, but the contained data is anonymised.
|
||||
</p>
|
||||
<p>
|
||||
We do not use cookies, but your game progress is stored in the browser storage
|
||||
as site data. Your game progress is not saved on the server; if you delete
|
||||
your browser storage, it is completely gone.
|
||||
</p>
|
||||
<p>Our server is located in Germany.</p>
|
||||
<p>
|
||||
<strong>Contact information:</strong><br />
|
||||
Jon Eugster<br />
|
||||
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
|
||||
Universitätsstr. 1<br />
|
||||
40225 Düsseldorf<br />
|
||||
Germany<br />
|
||||
+49 211 81-12173<br />
|
||||
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import Split from 'react-split'
|
||||
import { Box, Typography, CircularProgress } from '@mui/material'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { useAppDispatch } from '../hooks'
|
||||
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
|
||||
import { useGetGameInfoQuery } from '../state/api'
|
||||
import { Button } from './button'
|
||||
import { MobileContext } from './infoview/context'
|
||||
import { InventoryPanel } from './inventory'
|
||||
import Markdown from './markdown'
|
||||
import {PrivacyPolicy} from './privacy_policy'
|
||||
import { WelcomeMenu, WorldTreePanel } from './world_tree'
|
||||
|
||||
import './welcome.css'
|
||||
|
||||
/** navigation to switch between pages on mobile */
|
||||
function MobileNav({pageNumber, setPageNumber}:
|
||||
{ pageNumber: number,
|
||||
setPageNumber: any}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
let prevText = {0 : null, 1: "Intro", 2: "Game"}[pageNumber]
|
||||
let prevIcon = {0 : faGlobe, 1: null, 2: null}[pageNumber]
|
||||
let prevTitle = {
|
||||
0: "back to games selection",
|
||||
1: "back to introduction",
|
||||
2: "game tree"}[pageNumber]
|
||||
let nextText = {0 : "Game", 1: null, 2: null}[pageNumber]
|
||||
let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber]
|
||||
let nextTitle = {
|
||||
0: "game tree",
|
||||
1: "inventory",
|
||||
2: null}[pageNumber]
|
||||
|
||||
return <div className="mobile-nav">
|
||||
{(prevText || prevTitle || prevIcon) &&
|
||||
<Button className="btn btn-previous" to={pageNumber == 0 ? "/" : ""} title={prevTitle}
|
||||
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
|
||||
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
|
||||
{prevText && `${prevText}`}
|
||||
</Button>
|
||||
}
|
||||
{(nextText || nextTitle || nextIcon) &&
|
||||
<Button className="btn btn-next" to=""
|
||||
title={nextTitle} onClick={() => {
|
||||
console.log(`page number: ${pageNumber}`)
|
||||
setPageNumber(pageNumber+1);
|
||||
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
|
||||
{nextText && `${nextText}`}
|
||||
{nextIcon && <FontAwesomeIcon icon={nextIcon} />}
|
||||
<FontAwesomeIcon icon={faArrowRight}/>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
/** The panel showing the game's introduction text */
|
||||
function IntroductionPanel({introduction}: { introduction: string}) {
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
return <div className="column">
|
||||
<Typography variant="body1" component="div" className="welcome-text">
|
||||
{!mobile && <WelcomeMenu />}
|
||||
<Markdown>{introduction}</Markdown>
|
||||
</Typography>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** main page of the game showing amoung others the tree of worlds/levels */
|
||||
function Welcome() {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const {mobile} = React.useContext(MobileContext)
|
||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||
// On mobile, the intro page should only be shown the first time
|
||||
const openedIntro = useSelector(selectOpenedIntro(gameId))
|
||||
// On mobile, there are multiple pages to switch between
|
||||
const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0)
|
||||
|
||||
// set the window title
|
||||
useEffect(() => {
|
||||
if (gameInfo.data?.title) {
|
||||
window.document.title = gameInfo.data.title
|
||||
}
|
||||
}, [gameInfo.data?.title])
|
||||
|
||||
return <div className="app-content">
|
||||
{ gameInfo.isLoading?
|
||||
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
: mobile ?
|
||||
<>
|
||||
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
|
||||
{(pageNumber == 0 ?
|
||||
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||
: pageNumber == 1 ?
|
||||
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||
:
|
||||
<InventoryPanel />
|
||||
)}
|
||||
</>
|
||||
:
|
||||
<Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}>
|
||||
<IntroductionPanel introduction={gameInfo.data?.introduction} />
|
||||
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
|
||||
<InventoryPanel />
|
||||
</Split>
|
||||
}
|
||||
<PrivacyPolicy />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Welcome
|
||||
@ -0,0 +1,47 @@
|
||||
.world-selection-menu {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.world-selection-menu .btn, .welcome .btn {
|
||||
min-width: 5em;
|
||||
text-align: center;
|
||||
margin-left: .4em;
|
||||
margin-right: .4em;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
.world-selection-menu .slider-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
/* min-width: 16em; */
|
||||
padding-left: 0.5em;
|
||||
padding-right: 3em;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.difficulty-label {
|
||||
font-size: 0.875em;
|
||||
padding-top: 4px;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Test for mobile `title`s */
|
||||
/* @media (pointer: coarse), (hover: none) {
|
||||
[title] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
[title]:focus::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
top: 90%;
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border: 1px solid;
|
||||
width: fit-content;
|
||||
padding: 3px;
|
||||
}
|
||||
} */
|
||||
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @fileOverview Define the menu displayed with the tree of worlds on the welcome page
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useStore, useSelector } from 'react-redux'
|
||||
import { Slider } from '@mui/material'
|
||||
import cytoscape, { LayoutOptions } from 'cytoscape'
|
||||
import klay from 'cytoscape-klay'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { GameIdContext } from '../app'
|
||||
import { useAppDispatch } from '../hooks'
|
||||
import { deleteProgress, selectProgress, loadProgress, GameProgressState,
|
||||
selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
|
||||
import { store } from '../state/store'
|
||||
import { Button } from './button'
|
||||
|
||||
import './world_tree.css'
|
||||
|
||||
// Settings for the world tree
|
||||
cytoscape.use( klay )
|
||||
const N = 18 // max number of levels per world
|
||||
const R = 64 // radius of a world
|
||||
const r = 12 // radius of a level
|
||||
const s = 10 // global scale
|
||||
const padding = R + 2*r // padding of the graphic (on a different scale)
|
||||
const ds = .75 // scale the resulting svg image
|
||||
|
||||
// colours
|
||||
const grey = '#999'
|
||||
const lightgrey = '#bbb'
|
||||
const green = 'green'
|
||||
const lightgreen = '#139e13'
|
||||
const blue = '#1976d2'
|
||||
|
||||
/** svg object for a level in the game tree */
|
||||
export function LevelIcon({ world, level, position, completed, unlocked }:
|
||||
{ world: string,
|
||||
level: number,
|
||||
position: cytoscape.Position,
|
||||
completed: boolean,
|
||||
unlocked: boolean,
|
||||
}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const x = s * position.x + Math.sin(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
|
||||
const y = s * position.y - Math.cos(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
|
||||
const levelDisabled = (difficulty >= 2 && !(unlocked || completed))
|
||||
return (
|
||||
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level}`}
|
||||
className={`level${levelDisabled ? ' disabled' : ''}`}>
|
||||
<circle fill={completed ? lightgreen : unlocked? blue : grey} cx={x} cy={y} r={r} />
|
||||
<foreignObject className="level-title-wrapper" x={x} y={y}
|
||||
width={1.42*r} height={1.42*r} transform={"translate("+ -.71*r +","+ -.71*r +")"}>
|
||||
<div>
|
||||
<p className="level-title" style={{fontSize: Math.floor(r) + "px"}}>
|
||||
{level}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** svg object of one world in the game tree */
|
||||
export function WorldIcon({world, title, position, completedLevels, difficulty}:
|
||||
{ world: string,
|
||||
title: string,
|
||||
position: cytoscape.Position,
|
||||
completedLevels: any,
|
||||
difficulty: number }) {
|
||||
|
||||
// index `0` indicates that all prerequisites are completed
|
||||
let unlocked = completedLevels[0]
|
||||
// indices `1`-`n` indicate that the corresponding level is completed
|
||||
let completed = completedLevels.slice(1).every(Boolean)
|
||||
// select the first non-completed level
|
||||
let nextLevel: number = completedLevels.findIndex(c => !c)
|
||||
if (nextLevel <= 1) {
|
||||
// note: `findIndex` returns `-1` on failure, therefore the indices
|
||||
// `-1, 0, 1` indicate all that the introduction should be shown
|
||||
nextLevel = 0
|
||||
}
|
||||
let playable = difficulty <= 1 || completed || unlocked
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
|
||||
return <Link
|
||||
to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''}
|
||||
className={playable ? '' : 'disabled'}>
|
||||
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
|
||||
fill={completed ? green : unlocked ? blue : grey}/>
|
||||
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
|
||||
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
|
||||
<div className={unlocked && !completed ? "playable-world" : ''}>
|
||||
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
|
||||
{title ? title : world}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</Link>
|
||||
}
|
||||
|
||||
/** svg object for a connection path between worlds in the game tree */
|
||||
export function WorldPath({source, target, unlocked} : {source: any, target: any, unlocked: boolean}) {
|
||||
return <line x1={s*source.position.x} y1={s*source.position.y}
|
||||
x2={s*target.position.x} y2={s*target.position.y}
|
||||
stroke={unlocked ? green : lightgrey} strokeWidth={s}/>
|
||||
}
|
||||
|
||||
/** Download a file containing `data` */
|
||||
const downloadFile = ({ data, fileName, fileType } :
|
||||
{ data: string
|
||||
fileName: string
|
||||
fileType: string}) => {
|
||||
const blob = new Blob([data], { type: fileType })
|
||||
const a = document.createElement('a')
|
||||
a.download = fileName
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
const clickEvt = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
a.dispatchEvent(clickEvt)
|
||||
a.remove()
|
||||
}
|
||||
|
||||
// TODO: I think this should be removed and that single button incorporated differently
|
||||
/** Menu on desktop to go back to game server */
|
||||
export function WelcomeMenu() {
|
||||
|
||||
return <nav className="world-selection-menu">
|
||||
<Button inverted="false" title="back to games selection" to="/">
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> <FontAwesomeIcon icon={faGlobe} />
|
||||
</Button>
|
||||
|
||||
</nav>
|
||||
}
|
||||
|
||||
/** The menu that is shown next to the world selection graph */
|
||||
function WorldSelectionMenu() {
|
||||
const [file, setFile] = React.useState<File>();
|
||||
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const store = useStore()
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
|
||||
|
||||
/* state variables to toggle the pop-up menus */
|
||||
const [eraseMenu, setEraseMenu] = React.useState(false);
|
||||
const openEraseMenu = () => setEraseMenu(true);
|
||||
const closeEraseMenu = () => setEraseMenu(false);
|
||||
const [uploadMenu, setUploadMenu] = React.useState(false);
|
||||
const openUploadMenu = () => setUploadMenu(true);
|
||||
const closeUploadMenu = () => setUploadMenu(false);
|
||||
|
||||
const gameProgress = useSelector(selectProgress(gameId))
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
/** Download the current progress (i.e. what's saved in the browser store) */
|
||||
const downloadProgress = (e) => {
|
||||
e.preventDefault()
|
||||
downloadFile({
|
||||
data: JSON.stringify(gameProgress, null, 2),
|
||||
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
|
||||
fileType: 'text/json',
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload progress from a */
|
||||
const uploadProgress = (e) => {
|
||||
if (!file) {return}
|
||||
const fileReader = new FileReader()
|
||||
fileReader.readAsText(file, "UTF-8")
|
||||
fileReader.onload = (e) => {
|
||||
const data = JSON.parse(e.target.result.toString()) as GameProgressState
|
||||
console.debug("Json Data", data)
|
||||
dispatch(loadProgress({game: gameId, data: data}))
|
||||
}
|
||||
closeUploadMenu()
|
||||
}
|
||||
|
||||
const eraseProgress = () => {
|
||||
dispatch(deleteProgress({game: gameId}))
|
||||
closeEraseMenu()
|
||||
}
|
||||
|
||||
const downloadAndErase = (e) => {
|
||||
downloadProgress(e)
|
||||
eraseProgress()
|
||||
}
|
||||
|
||||
function label(x : number) {
|
||||
return x == 0 ? 'playground' : x == 1 ? 'explorer' : 'regular'
|
||||
}
|
||||
|
||||
return <nav className="world-selection-menu">
|
||||
<Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
|
||||
<Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
|
||||
<Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
|
||||
<div className="slider-wrap">
|
||||
<span className="difficulty-label">difficulty:</span>
|
||||
<Slider
|
||||
title="Difficulties: - regular: 🔐 levels, 🔐 tactics - explorer: 🔓 levels, 🔐 tactics - playground: 🔓 levels, 🔓 tactics"
|
||||
min={0} max={2}
|
||||
aria-label="Mode"
|
||||
defaultValue={difficulty}
|
||||
marks={[
|
||||
{value: 0, label: label(0)},
|
||||
{value: 1, label: label(1)},
|
||||
{value: 2, label: label(2)}
|
||||
]}
|
||||
valueLabelFormat={label}
|
||||
getAriaValueText={label}
|
||||
valueLabelDisplay="auto"
|
||||
onChange={(ev, val: number) => {
|
||||
dispatch(changedDifficulty({game: gameId, difficulty: val}))
|
||||
}}
|
||||
></Slider>
|
||||
|
||||
</div>
|
||||
{eraseMenu?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeEraseMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
|
||||
<h2>Delete Progress?</h2>
|
||||
|
||||
<p>Do you want to delete your saved progress irreversibly?</p>
|
||||
<p>
|
||||
(This deletes your proofs and your collected inventory.
|
||||
Saves from other games are not deleted.)
|
||||
</p>
|
||||
|
||||
<Button onClick={eraseProgress} to="">Delete</Button>
|
||||
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
|
||||
<Button onClick={closeEraseMenu} to="">Cancel</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
{uploadMenu ?
|
||||
<div className="modal-wrapper">
|
||||
<div className="modal-backdrop" onClick={closeUploadMenu} />
|
||||
<div className="modal">
|
||||
<div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
|
||||
<h2>Upload Saved Progress</h2>
|
||||
|
||||
<p>Select a JSON file with the saved game progress to load your progress.</p>
|
||||
|
||||
<p><b>Warning:</b> This will delete your current game progress!
|
||||
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
|
||||
|
||||
<input type="file" onChange={handleFileChange}/>
|
||||
|
||||
<Button to="" onClick={uploadProgress}>Load selected file</Button>
|
||||
</div>
|
||||
</div> : null}
|
||||
</nav>
|
||||
}
|
||||
|
||||
export function computeWorldLayout(worlds) {
|
||||
let elements = []
|
||||
for (let id in worlds.nodes) {
|
||||
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
|
||||
}
|
||||
for (let edge of worlds.edges) {
|
||||
elements.push({
|
||||
data: {
|
||||
id: edge[0] + " --edge-to--> " + edge[1],
|
||||
source: edge[0],
|
||||
target: edge[1]
|
||||
}
|
||||
})
|
||||
}
|
||||
const cy = cytoscape({
|
||||
container: null,
|
||||
elements,
|
||||
headless: true,
|
||||
styleEnabled: false
|
||||
})
|
||||
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
|
||||
let nodes = {}
|
||||
cy.nodes().forEach((node, id) => {
|
||||
nodes[node.id()] = {
|
||||
position: node.position(),
|
||||
data: node.data()
|
||||
}
|
||||
})
|
||||
const bounds = cy.nodes().boundingBox()
|
||||
return { nodes, bounds }
|
||||
}
|
||||
|
||||
|
||||
export function WorldTreePanel({worlds, worldSize}:
|
||||
{ worlds: any,
|
||||
worldSize: any}) {
|
||||
const gameId = React.useContext(GameIdContext)
|
||||
const difficulty = useSelector(selectDifficulty(gameId))
|
||||
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []}
|
||||
|
||||
// scroll to playable world
|
||||
React.useEffect(() => {
|
||||
let elems = Array.from(document.getElementsByClassName("playable-world"))
|
||||
if (elems.length) {
|
||||
// it seems that the last element is the one furthest up in the tree
|
||||
// TODO: I think they appear in random order. Check there position and select the lowest one
|
||||
// of these positions to scroll to.
|
||||
let elem = elems[0]
|
||||
console.debug(`scrolling to ${elem.textContent}`)
|
||||
elem.scrollIntoView({block: "center"})
|
||||
}
|
||||
}, [worlds, worldSize])
|
||||
|
||||
let svgElements = []
|
||||
|
||||
// for each `worldId` as index, this contains a list of booleans with indices
|
||||
// 0, 1, …, n. Index `0` will be set to `false` if any dependency is not completely solved.
|
||||
// Indices `1, …, n` indicate if the corresponding level is completed
|
||||
var completed = {}
|
||||
|
||||
if (worlds && worldSize) {
|
||||
// Fill `completed` with the level data.
|
||||
for (let worldId in nodes) {
|
||||
completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => {
|
||||
// index `0` starts off as `true` but can be set to `false` by any edge with non-completed source
|
||||
return i == 0 || selectCompleted(gameId, worldId, i)(store.getState())
|
||||
})
|
||||
}
|
||||
|
||||
// draw all connecting paths
|
||||
for (let i in worlds.edges) {
|
||||
const edge = worlds.edges[i]
|
||||
let sourceCompleted = completed[edge[0]].slice(1).every(Boolean)
|
||||
// if the origin world is not completed, mark the target world as non-playable
|
||||
if (!sourceCompleted) {completed[edge[1]][0] = false}
|
||||
svgElements.push(
|
||||
<WorldPath source={nodes[edge[0]]} target={nodes[edge[1]]} unlocked={sourceCompleted}/>
|
||||
)
|
||||
}
|
||||
|
||||
// draw the worlds and levels
|
||||
for (let worldId in nodes) {
|
||||
let position: cytoscape.Position = nodes[worldId].position
|
||||
svgElements.push(
|
||||
<WorldIcon world={worldId}
|
||||
title={nodes[worldId].data.title || worldId}
|
||||
position={position}
|
||||
completedLevels={completed[worldId]}
|
||||
difficulty={difficulty}
|
||||
key={`${gameId}-${worldId}`} />
|
||||
)
|
||||
for (let i = 1; i <= worldSize[worldId]; i++) {
|
||||
svgElements.push(
|
||||
<LevelIcon
|
||||
world={worldId}
|
||||
level={i}
|
||||
position={position}
|
||||
completed={completed[worldId][i]} unlocked={completed[worldId][i-1]}
|
||||
key={`${gameId}-${worldId}-${i}`} />
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
|
||||
|
||||
return <div className="column">
|
||||
<WorldSelectionMenu />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
width={bounds ? `${ds * dx}` : ''}
|
||||
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
|
||||
className="world-selection"
|
||||
>
|
||||
{svgElements}
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
const KEY = "game_progress";
|
||||
export function loadState() {
|
||||
try {
|
||||
const serializedState = localStorage.getItem(KEY);
|
||||
if (!serializedState) return undefined;
|
||||
return JSON.parse(serializedState);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: any) {
|
||||
try {
|
||||
const serializedState = JSON.stringify(state);
|
||||
localStorage.setItem(KEY, serializedState);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @fileOverview Load/save the state to the local browser store
|
||||
*/
|
||||
|
||||
const KEY = "game_progress";
|
||||
|
||||
/** Load from browser storage */
|
||||
export function loadState() {
|
||||
try {
|
||||
const serializedState = localStorage.getItem(KEY);
|
||||
if (!serializedState) return undefined;
|
||||
let x = JSON.parse(serializedState);
|
||||
// Complatibilty because `state.level` has been renamed to `x.games`.
|
||||
// TODO: Does this work?
|
||||
if (x.level) {
|
||||
x.games = x.level
|
||||
x.level = undefined
|
||||
}
|
||||
return x
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save to browser storage */
|
||||
export async function saveState(state: any) {
|
||||
try {
|
||||
const serializedState = JSON.stringify(state)
|
||||
localStorage.setItem(KEY, serializedState);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import {useState, useEffect} from 'react'
|
||||
|
||||
function getWindowDimensions() {
|
||||
const {innerWidth: width, innerHeight: height } = window
|
||||
return {width, height}
|
||||
}
|
||||
|
||||
export function useWindowDimensions() {
|
||||
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowDimensions(getWindowDimensions())
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
|
||||
}, [])
|
||||
|
||||
return windowDimensions
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
||||
leanprover/lean4:nightly-2023-03-09
|
||||
leanprover/lean4:nightly-2023-06-20
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import GameServer.Commands
|
||||
|
||||
open Lean
|
||||
|
||||
-- E → A → B → C → A and
|
||||
-- F → G → F
|
||||
open HashMap in
|
||||
def testArrows : HashMap Name (HashSet Name) :=
|
||||
ofList [("a", (HashSet.empty.insert "b": HashSet Name).insert "d"),
|
||||
("b", (HashSet.empty.insert "c": HashSet Name)),
|
||||
("c", (HashSet.empty.insert "a": HashSet Name)),
|
||||
("d", {}),
|
||||
("f", (HashSet.empty.insert "g": HashSet Name)),
|
||||
("g", (HashSet.empty.insert "f": HashSet Name)),
|
||||
("e", (HashSet.empty.insert "a": HashSet Name).insert "f")]
|
||||
|
||||
-- some permutation of ``[`c, `a, `b]`` or ``[`f, `g]``
|
||||
#eval findLoops testArrows
|
||||
Loading…
Reference in New Issue