getting rid of some code duplication
This commit is contained in:
parent
0266675f29
commit
970b9d850e
21 changed files with 325 additions and 302 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -32,3 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# When building the app, we include the git rev in the status bar. We do this by calling git and writing the rev to a file, which is then included by the app.
|
||||
src/git-rev.txt
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@
|
|||
"module": "src/index.tsx",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/index.tsx",
|
||||
"build": "NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
||||
"build": "git rev-parse HEAD > src/git-rev.txt && NODE_ENV=production bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
||||
"start": "NODE_ENV=production bun src/index.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
// "argus-wasm": "git+https://deemz.org/git/joeri/argus-wasm.git#a4491b3433d48aa1f941bd5ad37b36f819d3b2ac",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,26 +60,6 @@ details:has(+ details) {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--button-bg-color);
|
||||
border: 1px var(--separator-color) solid;
|
||||
}
|
||||
|
||||
button:not(:disabled):hover {
|
||||
background-color: var(--light-accent-color);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: var(--inactive-bg-color);
|
||||
color: var(--inactive-fg-color);
|
||||
}
|
||||
|
||||
button.active {
|
||||
border: solid var(--accent-border-color) 1px;
|
||||
background-color: var(--light-accent-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modalOuter {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import "../index.css";
|
||||
import "./App.css";
|
||||
|
||||
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { detectConnections } from "@/statecharts/detect_connections";
|
||||
import { parseStatechart } from "../statecharts/parser";
|
||||
|
|
@ -16,7 +16,8 @@ import { useSimulator } from "./hooks/useSimulator";
|
|||
import { useUrlHashState } from "../hooks/useUrlHashState";
|
||||
import { plants } from "./plants";
|
||||
import { emptyState } from "@/statecharts/concrete_syntax";
|
||||
import { ModalOverlay } from "./Modals/ModalOverlay";
|
||||
import { ModalOverlay } from "./Overlays/ModalOverlay";
|
||||
import { FindReplace } from "./BottomPanel/FindReplace";
|
||||
|
||||
export type EditHistory = {
|
||||
current: VisualEditorState,
|
||||
|
|
@ -28,13 +29,18 @@ export type AppState = {
|
|||
showKeys: boolean,
|
||||
zoom: number,
|
||||
insertMode: InsertMode,
|
||||
showFindReplace: boolean,
|
||||
findText: string,
|
||||
replaceText: string,
|
||||
} & SideBarState;
|
||||
|
||||
const defaultAppState: AppState = {
|
||||
showKeys: true,
|
||||
zoom: 1,
|
||||
insertMode: 'and',
|
||||
|
||||
showFindReplace: false,
|
||||
findText: "",
|
||||
replaceText: "",
|
||||
...defaultSideBarState,
|
||||
}
|
||||
|
||||
|
|
@ -143,55 +149,61 @@ export function App() {
|
|||
return <div style={{
|
||||
height:'100%',
|
||||
// doesn't work:
|
||||
colorScheme: lightMode!=="auto"?lightMode:undefined,
|
||||
// colorScheme: lightMode !== "auto" ? lightMode : undefined,
|
||||
}}>
|
||||
<ModalOverlay modal={modal} setModal={setModal}>
|
||||
{/* top-to-bottom: everything -> bottom panel */}
|
||||
<div className="stackVertical" style={{height:'100%'}}>
|
||||
{/* top-to-bottom: everything -> bottom panel */}
|
||||
<div className="stackVertical" style={{height:'100%'}}>
|
||||
|
||||
{/* left-to-right: main -> sidebar */}
|
||||
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
|
||||
{/* left-to-right: main -> sidebar */}
|
||||
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
|
||||
|
||||
{/* top-to-bottom: top bar, editor */}
|
||||
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
|
||||
{/* Top bar */}
|
||||
<div
|
||||
className="shadowBelow"
|
||||
style={{flex: '0 0 content'}}
|
||||
>
|
||||
{editHistory && <TopPanel
|
||||
{...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState, setEditorState}}
|
||||
/>}
|
||||
</div>
|
||||
{/* Editor */}
|
||||
<div style={{flexGrow: 1, overflow: "auto"}}>
|
||||
{editorState && conns && syntaxErrors &&
|
||||
<VisualEditor {...{state: editorState, setState: setEditorState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, makeCheckPoint, ...appState}}/>}
|
||||
</div>
|
||||
|
||||
{appState.showFindReplace &&
|
||||
<div>
|
||||
<FindReplace setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* top-to-bottom: top bar, editor */}
|
||||
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
|
||||
{/* Top bar */}
|
||||
<div
|
||||
className="shadowBelow"
|
||||
style={{flex: '0 0 content'}}
|
||||
>
|
||||
{editHistory && <TopPanel
|
||||
{...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState}}
|
||||
/>}
|
||||
</div>
|
||||
{/* Editor */}
|
||||
<div style={{flexGrow: 1, overflow: "auto"}}>
|
||||
{editorState && conns && syntaxErrors &&
|
||||
<VisualEditor {...{state: editorState, setState: setEditorState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, makeCheckPoint, ...appState}}/>}
|
||||
|
||||
{/* Right: sidebar */}
|
||||
<div style={{
|
||||
flex: '0 0 content',
|
||||
borderLeft: '1px solid var(--separator-color)',
|
||||
overflowY: "auto",
|
||||
overflowX: "auto",
|
||||
maxWidth: 'min(400px, 50vw)',
|
||||
}}>
|
||||
<div className="stackVertical" style={{height:'100%'}}>
|
||||
<SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: sidebar */}
|
||||
<div style={{
|
||||
flex: '0 0 content',
|
||||
borderLeft: '1px solid var(--separator-color)',
|
||||
overflowY: "auto",
|
||||
overflowX: "auto",
|
||||
maxWidth: 'min(400px, 50vw)',
|
||||
}}>
|
||||
<div className="stackVertical" style={{height:'100%'}}>
|
||||
<SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
|
||||
</div>
|
||||
{/* Bottom panel */}
|
||||
<div style={{flex: '0 0 content'}}>
|
||||
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors, ...appState, setEditorState, ...setters}}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom panel */}
|
||||
<div style={{flex: '0 0 content'}}>
|
||||
{syntaxErrors && <BottomPanel {...{errors: syntaxErrors}}/>}
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
</ModalOverlay>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
color: var(--background-color);
|
||||
}
|
||||
|
||||
.greeter {
|
||||
/* border-top: 1px var(--separator-color) solid; */
|
||||
background-color: var(--greeter-bg-color);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
border-top: 1px var(--separator-color) solid;
|
||||
background-color: var(--bottom-panel-bg-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Dispatch, useEffect, useState } from "react";
|
||||
import { TraceableError } from "../../statecharts/parser";
|
||||
|
||||
import "./BottomPanel.css";
|
||||
|
||||
import { PersistentDetailsLocalStorage } from "../PersistentDetails";
|
||||
import { PersistentDetailsLocalStorage } from "../Components/PersistentDetails";
|
||||
import { Logo } from "@/App/Logo/Logo";
|
||||
import { AppState } from "../App";
|
||||
import { FindReplace } from "./FindReplace";
|
||||
import { VisualEditorState } from "../VisualEditor/VisualEditor";
|
||||
import { Setters } from "../makePartialSetter";
|
||||
|
||||
export function BottomPanel(props: {errors: TraceableError[]}) {
|
||||
import gitRev from "@/git-rev.txt";
|
||||
|
||||
export function BottomPanel(props: {errors: TraceableError[], setEditorState: Dispatch<(state: VisualEditorState) => VisualEditorState>} & AppState & Setters<AppState>) {
|
||||
const [greeting, setGreeting] = useState(
|
||||
<div style={{textAlign:'center'}}>
|
||||
<div className="greeter" style={{textAlign:'center'}}>
|
||||
<span style={{fontSize: 18, fontStyle: 'italic'}}>
|
||||
Welcome to <Logo/>
|
||||
</span>
|
||||
|
|
@ -21,19 +27,22 @@ export function BottomPanel(props: {errors: TraceableError[]}) {
|
|||
}, []);
|
||||
|
||||
return <div className="toolbar bottom">
|
||||
{greeting}
|
||||
{props.errors.length > 0 &&
|
||||
<div className="errorStatus">
|
||||
<PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded">
|
||||
<summary>{props.errors.length} errors</summary>
|
||||
<div style={{maxHeight: '25vh', overflow: 'auto'}}>
|
||||
{props.errors.map(({message, shapeUid})=>
|
||||
<div>
|
||||
{shapeUid}: {message}
|
||||
</div>)}
|
||||
</div>
|
||||
</PersistentDetailsLocalStorage>
|
||||
{/* {props.showFindReplace &&
|
||||
<div>
|
||||
<FindReplace setCS={props.setEditorState} hide={() => props.setShowFindReplace(false)}/>
|
||||
</div>
|
||||
}
|
||||
} */}
|
||||
<div className={"statusBar" + props.errors.length ? " error" : ""}>
|
||||
<PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded">
|
||||
<summary>{props.errors.length} errors</summary>
|
||||
<div style={{maxHeight: '25vh', overflow: 'auto'}}>
|
||||
{props.errors.map(({message, shapeUid})=>
|
||||
<div>
|
||||
{shapeUid}: {message}
|
||||
</div>)}
|
||||
</div>
|
||||
</PersistentDetailsLocalStorage>
|
||||
</div>
|
||||
{greeting}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
src/App/BottomPanel/FindReplace.tsx
Normal file
59
src/App/BottomPanel/FindReplace.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Dispatch, useCallback, useEffect } from "react";
|
||||
import { VisualEditorState } from "../VisualEditor/VisualEditor";
|
||||
import { usePersistentState } from "@/hooks/usePersistentState";
|
||||
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
|
||||
type FindReplaceProps = {
|
||||
setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
|
||||
// setModal: (modal: null) => void;
|
||||
hide: () => void,
|
||||
};
|
||||
|
||||
export function FindReplace({setCS, hide}: FindReplaceProps) {
|
||||
const [findTxt, setFindText] = usePersistentState("findTxt", "");
|
||||
const [replaceTxt, setReplaceTxt] = usePersistentState("replaceTxt", "");
|
||||
|
||||
const onReplace = useCallback(() => {
|
||||
setCS(cs => {
|
||||
return {
|
||||
...cs,
|
||||
texts: cs.texts.map(txt => ({
|
||||
...txt,
|
||||
text: txt.text.replaceAll(findTxt, replaceTxt)
|
||||
})),
|
||||
};
|
||||
});
|
||||
}, [findTxt, replaceTxt]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReplace();
|
||||
// setModal(null);
|
||||
}
|
||||
}, [onReplace]);
|
||||
|
||||
const onSwap = useCallback(() => {
|
||||
setReplaceTxt(findTxt);
|
||||
setFindText(replaceTxt);
|
||||
}, [findTxt, replaceTxt]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div className="toolbar toolbarGroup" style={{display: 'flex'}}>
|
||||
<input placeholder="find" value={findTxt} onChange={e => setFindText(e.target.value)} style={{width:300}}/>
|
||||
<button tabIndex={-1} onClick={onSwap}><SwapHorizIcon fontSize="small"/></button>
|
||||
<input tabIndex={0} placeholder="replace" value={replaceTxt} onChange={(e => setReplaceTxt(e.target.value))} style={{width:300}}/>
|
||||
|
||||
<button onClick={onReplace}>replace all</button>
|
||||
<button onClick={hide} style={{marginLeft: 'auto'}}><CloseIcon fontSize="small"/></button>
|
||||
</div>;
|
||||
}
|
||||
5
src/App/Components/TwoStateButton.tsx
Normal file
5
src/App/Components/TwoStateButton.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ButtonHTMLAttributes, PropsWithChildren } from "react";
|
||||
|
||||
export function TwoStateButton({active, children, className, ...rest}: PropsWithChildren<{active: boolean} & ButtonHTMLAttributes<HTMLButtonElement>>) {
|
||||
return <button className={(className||"") + (active?" active":"")} {...rest}>{children}</button>
|
||||
}
|
||||
14
src/App/Overlays/WindowOverlay.tsx
Normal file
14
src/App/Overlays/WindowOverlay.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// import { Dispatch, PropsWithChildren, ReactElement, SetStateAction } from "react";
|
||||
// import { OverlayWindow } from "../App";
|
||||
|
||||
// export function WindowOverlay(props: PropsWithChildren<{overlayWindows: OverlayWindow[]}>) {
|
||||
|
||||
// return <>
|
||||
// {props.modal && <div
|
||||
// className="modalOuter"
|
||||
// onMouseDown={() => props.setModal(null)}>
|
||||
// </div>}
|
||||
|
||||
// {props.children}
|
||||
// </>;
|
||||
// }
|
||||
|
|
@ -15,7 +15,7 @@ import { RTHistory } from './RTHistory';
|
|||
import { BigStepCause, TraceState } from '../hooks/useSimulator';
|
||||
import { plants, UniversalPlantState } from '../plants';
|
||||
import { TimeMode } from '@/statecharts/time';
|
||||
import { PersistentDetails } from '../PersistentDetails';
|
||||
import { PersistentDetails } from '../Components/PersistentDetails';
|
||||
import "./SideBar.css";
|
||||
|
||||
type SavedTraces = [string, BigStepCause[]][];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
|||
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
|
||||
|
||||
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
|
||||
|
||||
|
|
@ -18,45 +19,14 @@ const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
|
|||
|
||||
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch<SetStateAction<InsertMode>>}) {
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setInsertMode("and");
|
||||
}
|
||||
if (e.key === "o") {
|
||||
e.preventDefault();
|
||||
setInsertMode("or");
|
||||
}
|
||||
if (e.key === "p") {
|
||||
e.preventDefault();
|
||||
setInsertMode("pseudo");
|
||||
}
|
||||
if (e.key === "t") {
|
||||
e.preventDefault();
|
||||
setInsertMode("transition");
|
||||
}
|
||||
if (e.key === "x") {
|
||||
e.preventDefault();
|
||||
setInsertMode("text");
|
||||
}
|
||||
if (e.key === "h") {
|
||||
e.preventDefault();
|
||||
setInsertMode(oldMode => {
|
||||
if (oldMode === "shallow") return "deep";
|
||||
return "shallow";
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [setInsertMode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
() => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
useShortcuts([
|
||||
{keys: ["a"], action: () => setInsertMode("and")},
|
||||
{keys: ["o"], action: () => setInsertMode("or")},
|
||||
{keys: ["p"], action: () => setInsertMode("pseudo")},
|
||||
{keys: ["t"], action: () => setInsertMode("transition")},
|
||||
{keys: ["x"], action: () => setInsertMode("text")},
|
||||
{keys: ["h"], action: () => setInsertMode(mode => mode === "shallow" ? "deep" : "shallow")},
|
||||
]);
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
|||
import { setRealtime, TimeMode } from "@/statecharts/time";
|
||||
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
export const SpeedControl = memo(function SpeedControl({showKeys, timescale, setTimescale, setTime}: {showKeys: boolean, timescale: number, setTimescale: Dispatch<SetStateAction<number>>, setTime: Dispatch<SetStateAction<TimeMode>>}) {
|
||||
|
||||
|
|
@ -31,25 +32,10 @@ export const SpeedControl = memo(function SpeedControl({showKeys, timescale, set
|
|||
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
|
||||
}, [onTimeScaleChange, timescale]);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSlower();
|
||||
}
|
||||
if (e.key === "f") {
|
||||
e.preventDefault();
|
||||
onFaster();
|
||||
}
|
||||
}
|
||||
}, [onSlower, onFaster])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown])
|
||||
useShortcuts([
|
||||
{keys: ["s"], action: onSlower},
|
||||
{keys: ["f"], action: onFaster},
|
||||
]);
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
|
||||
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
|
||||
import { InsertMode } from "./InsertModes";
|
||||
import { About } from "../Modals/About";
|
||||
import { EditHistory, LightMode } from "../App";
|
||||
import { AppState, EditHistory, LightMode } from "../App";
|
||||
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
import { ZoomButtons } from "./ZoomButtons";
|
||||
|
|
@ -15,6 +14,8 @@ import BrightnessAutoIcon from '@mui/icons-material/BrightnessAuto';
|
|||
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import FindInPageIcon from '@mui/icons-material/FindInPage';
|
||||
import FindInPageOutlinedIcon from '@mui/icons-material/FindInPageOutlined';
|
||||
|
||||
import AccessAlarmIcon from '@mui/icons-material/AccessAlarm';
|
||||
import CachedIcon from '@mui/icons-material/Cached';
|
||||
|
|
@ -29,10 +30,16 @@ import { usePersistentState } from "@/hooks/usePersistentState";
|
|||
import { RotateButtons } from "./RotateButtons";
|
||||
import { SpeedControl } from "./SpeedControl";
|
||||
import { TraceState } from "../hooks/useSimulator";
|
||||
import { FindReplace } from "../BottomPanel/FindReplace";
|
||||
import { VisualEditorState } from "../VisualEditor/VisualEditor";
|
||||
import { Setters } from "../makePartialSetter";
|
||||
import { TwoStateButton } from "../Components/TwoStateButton";
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
export type TopPanelProps = {
|
||||
trace: TraceState | null,
|
||||
time: TimeMode,
|
||||
|
||||
setTime: Dispatch<SetStateAction<TimeMode>>,
|
||||
onUndo: () => void,
|
||||
onRedo: () => void,
|
||||
|
|
@ -40,28 +47,32 @@ export type TopPanelProps = {
|
|||
onInit: () => void,
|
||||
onClear: () => void,
|
||||
onBack: () => void,
|
||||
|
||||
// lightMode: LightMode,
|
||||
// setLightMode: Dispatch<SetStateAction<LightMode>>,
|
||||
insertMode: InsertMode,
|
||||
setInsertMode: Dispatch<SetStateAction<InsertMode>>,
|
||||
// insertMode: InsertMode,
|
||||
// setInsertMode: Dispatch<SetStateAction<InsertMode>>,
|
||||
setModal: Dispatch<SetStateAction<ReactElement|null>>,
|
||||
zoom: number,
|
||||
setZoom: Dispatch<SetStateAction<number>>,
|
||||
showKeys: boolean,
|
||||
setShowKeys: Dispatch<SetStateAction<boolean>>,
|
||||
// zoom: number,
|
||||
// setZoom: Dispatch<SetStateAction<number>>,
|
||||
// showKeys: boolean,
|
||||
// setShowKeys: Dispatch<SetStateAction<boolean>>,
|
||||
editHistory: EditHistory,
|
||||
}
|
||||
setEditorState: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
|
||||
} & AppState & Setters<AppState>
|
||||
|
||||
const ShortCutShowKeys = <kbd>~</kbd>;
|
||||
|
||||
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) {
|
||||
function toggle(booleanSetter: Dispatch<(state: boolean) => boolean>) {
|
||||
return () => booleanSetter(x => !x);
|
||||
}
|
||||
|
||||
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory, showFindReplace, setShowFindReplace, setEditorState}: TopPanelProps) {
|
||||
const [displayTime, setDisplayTime] = useState(0);
|
||||
const [timescale, setTimescale] = usePersistentState("timescale", 1);
|
||||
|
||||
const config = trace && trace.trace[trace.idx];
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
|
||||
const updateDisplayedTime = useCallback(() => {
|
||||
const now = Math.round(performance.now());
|
||||
const timeMs = getSimTime(time, now);
|
||||
|
|
@ -69,12 +80,8 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
}, [time, setDisplayTime]);
|
||||
|
||||
const formattedDisplayTime = useMemo(() => formatTime(displayTime), [displayTime]);
|
||||
|
||||
// const lastSimTime = useMemo(() => time.kind === "realtime" ? time.since.simtime : time.simtime, [time]);
|
||||
|
||||
const lastSimTime = config?.simtime || 0;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// This has no effect on statechart execution. In between events, the statechart is doing nothing. However, by updating the displayed time, we give the illusion of continuous progress.
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -115,54 +122,18 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
}
|
||||
}, [nextTimedTransition, setTime]);
|
||||
|
||||
useShortcuts([
|
||||
{keys: ["`"], action: toggle(setShowKeys)},
|
||||
{keys: ["Ctrl", "Shift", "F"], action: toggle(setShowFindReplace)},
|
||||
{keys: ["i"], action: onInit},
|
||||
{keys: ["c"], action: onClear},
|
||||
{keys: ["Tab"], action: config && onSkip || onInit},
|
||||
{keys: ["Backspace"], action: onBack},
|
||||
{keys: ["Shift", "Tab"], action: onBack},
|
||||
{keys: [" "], action: () => config && onChangePaused(time.kind !== "paused", Math.round(performance.now()))},
|
||||
]);
|
||||
|
||||
console.log({lastSimTime, displayTime, nxt: nextTimedTransition?.[0]});
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (config) {
|
||||
onChangePaused(time.kind !== "paused", Math.round(performance.now()));
|
||||
}
|
||||
};
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
onInit();
|
||||
}
|
||||
if (e.key === "c") {
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (config === null) {
|
||||
onInit();
|
||||
}
|
||||
else {
|
||||
onSkip();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "`") {
|
||||
e.preventDefault();
|
||||
setShowKeys(show => !show);
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [config, time, onInit, onChangePaused, setShowKeys, onSkip, onBack, onClear]);
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
|
||||
return <div className="toolbar">
|
||||
|
||||
|
|
@ -207,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
|
|||
 
|
||||
</div>
|
||||
|
||||
{/* rotate */}
|
||||
<div className="toolbarGroup">
|
||||
<RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/>
|
||||
 
|
||||
</div>
|
||||
|
||||
{/* find, replace */}
|
||||
<div className="toolbarGroup">
|
||||
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></>}>
|
||||
<TwoStateButton
|
||||
title="show find & replace"
|
||||
active={showFindReplace}
|
||||
onClick={() => setShowFindReplace(x => !x)}
|
||||
>
|
||||
<FindInPageOutlinedIcon fontSize="small"/>
|
||||
</TwoStateButton>
|
||||
</KeyInfo>
|
||||
 
|
||||
</div>
|
||||
|
||||
{/* execution */}
|
||||
<div className="toolbarGroup">
|
||||
|
||||
|
|
|
|||
|
|
@ -3,27 +3,14 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
|||
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import RedoIcon from '@mui/icons-material/Redo';
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
export const UndoRedoButtons = memo(function UndoRedoButtons({showKeys, onUndo, onRedo, historyLength, futureLength}: {showKeys: boolean, onUndo: () => void, onRedo: () => void, historyLength: number, futureLength: number}) {
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
// ctrl is down
|
||||
if (e.key === "z") {
|
||||
e.preventDefault();
|
||||
onUndo();
|
||||
}
|
||||
if (e.key === "Z") {
|
||||
e.preventDefault();
|
||||
onRedo();
|
||||
}
|
||||
}
|
||||
}, [onUndo, onRedo]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
useShortcuts([
|
||||
{keys: ["Ctrl", "z"], action: onUndo},
|
||||
{keys: ["Ctrl", "Shift", "Z"], action: onRedo},
|
||||
])
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
return <>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,20 @@ import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
|
|||
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
const shortcutZoomIn = <><kbd>Ctrl</kbd>+<kbd>-</kbd></>;
|
||||
const shortcutZoomOut = <><kbd>Ctrl</kbd>+<kbd>+</kbd></>;
|
||||
|
||||
export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}: {showKeys: boolean, zoom: number, setZoom: Dispatch<SetStateAction<number>>}) {
|
||||
|
||||
useShortcuts([
|
||||
{keys: ["Ctrl", "+"], action: onZoomIn}, // plus on numerical keypad
|
||||
{keys: ["Ctrl", "Shift", "+"], action: onZoomIn}, // plus on normal keyboard requires Shift key
|
||||
{keys: ["Ctrl", "="], action: onZoomIn}, // most browsers also bind this shortcut so it would be confusing if we also did not override it
|
||||
{keys: ["Ctrl", "-"], action: onZoomOut},
|
||||
]);
|
||||
|
||||
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
|
||||
|
||||
function onZoomIn() {
|
||||
|
|
@ -19,27 +27,6 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
|
|||
setZoom(zoom => Math.max(zoom / ZOOM_STEP, ZOOM_MIN));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "+" || e.key === "=") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onZoomIn();
|
||||
}
|
||||
if (e.key === "-") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onZoomOut();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
<KeyInfo keyInfo={shortcutZoomOut}>
|
||||
<button title="zoom out" onClick={onZoomOut} disabled={zoom <= ZOOM_MIN}><ZoomOutIcon fontSize="small"/></button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MIN_ROUNTANGLE_SIZE } from "../../parameters";
|
|||
import { InsertMode } from "../../TopPanel/InsertModes";
|
||||
import { Selecting, SelectingState } from "../Selection";
|
||||
import { Selection, VisualEditorState } from "../VisualEditor";
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
|
||||
export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
|
@ -300,76 +301,47 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
|
|||
}, [dragging, selectingState, refSVG.current]);
|
||||
|
||||
const trackShiftKey = useCallback((e: KeyboardEvent) => {
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (e.shiftKey || e.ctrlKey) {
|
||||
setShiftOrCtrlPressed(true);
|
||||
}
|
||||
else {
|
||||
setShiftOrCtrlPressed(false);
|
||||
}
|
||||
setShiftOrCtrlPressed(e.shiftKey || e.ctrlKey);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// don't capture keyboard events when focused on an input element:
|
||||
// @ts-ignore
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
const onSelectAll = useCallback(() => {
|
||||
setDragging(false);
|
||||
setState(state => ({
|
||||
...state,
|
||||
// @ts-ignore
|
||||
selection: [
|
||||
...state.rountangles.flatMap(r => ["left", "top", "right", "bottom"].map(part => ({uid: r.uid, part}))),
|
||||
...state.diamonds.flatMap(d => ["left", "top", "right", "bottom"].map(part => ({uid: d.uid, part}))),
|
||||
...state.arrows.flatMap(a => ["start", "end"].map(part => ({uid: a.uid, part}))),
|
||||
...state.texts.map(t => ({uid: t.uid, part: "text"})),
|
||||
...state.history.map(h => ({uid: h.uid, part: "history"})),
|
||||
],
|
||||
}));
|
||||
}, [setState, setDragging]);
|
||||
|
||||
if (e.key === "o") {
|
||||
// selected states become OR-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
|
||||
}));
|
||||
}
|
||||
if (e.key === "a") {
|
||||
// selected states become AND-states
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
|
||||
}));
|
||||
}
|
||||
// if (e.key === "p") {
|
||||
// // selected states become pseudo-states
|
||||
// setSelection(selection => {
|
||||
// setState(state => ({
|
||||
// ...state,
|
||||
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
|
||||
// }));
|
||||
// return selection;
|
||||
// });
|
||||
// }
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "a") {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
setState(state => ({
|
||||
...state,
|
||||
// @ts-ignore
|
||||
selection: [
|
||||
...state.rountangles.flatMap(r => ["left", "top", "right", "bottom"].map(part => ({uid: r.uid, part}))),
|
||||
...state.diamonds.flatMap(d => ["left", "top", "right", "bottom"].map(part => ({uid: d.uid, part}))),
|
||||
...state.arrows.flatMap(a => ["start", "end"].map(part => ({uid: a.uid, part}))),
|
||||
...state.texts.map(t => ({uid: t.uid, part: "text"})),
|
||||
...state.history.map(h => ({uid: h.uid, part: "history"})),
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
|
||||
const convertSelection = useCallback((kind: "or"|"and") => {
|
||||
makeCheckPoint();
|
||||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind}) : r),
|
||||
}));
|
||||
}, [makeCheckPoint, setState]);
|
||||
|
||||
useShortcuts([
|
||||
{keys: ["o"], action: useCallback(() => convertSelection("or"), [convertSelection])},
|
||||
{keys: ["a"], action: useCallback(() => convertSelection("and"), [convertSelection])},
|
||||
{keys: ["Ctrl", "a"], action: onSelectAll},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", trackShiftKey);
|
||||
window.addEventListener("keyup", trackShiftKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keydown", trackShiftKey);
|
||||
window.removeEventListener("keyup", trackShiftKey);
|
||||
};
|
||||
|
|
|
|||
26
src/hooks/useShortcuts.ts
Normal file
26
src/hooks/useShortcuts.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
export function useShortcuts(spec: {keys: string[], action: () => void}[]) {
|
||||
for (const {keys, action} of spec) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// @ts-ignore: don't steal keyboard events while the user is typing in a text box, etc.
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
|
||||
|
||||
if (e.ctrlKey !== keys.includes("Ctrl")) return;
|
||||
if (e.shiftKey !== keys.includes("Shift")) return;
|
||||
if (!keys.includes(e.key)) return;
|
||||
const remainingKeys = keys.filter(key => key !== "Ctrl" && key !== "Shift" && key !== e.key);
|
||||
if (remainingKeys.length !== 0) {
|
||||
console.warn("impossible shortcut sequence:", keys.join(' + '));
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
action();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [action]);
|
||||
}
|
||||
}
|
||||
|
|
@ -32,8 +32,9 @@ html, body {
|
|||
--fired-transition-color: light-dark(rgb(160, 0, 168), rgb(160, 0, 168));
|
||||
--firing-transition-color: light-dark(rgba(255, 128, 9, 1), rgba(255, 128, 9, 1));
|
||||
--associated-color: light-dark(green, rgb(186, 245, 119));
|
||||
--bottom-panel-bg-color: light-dark(rgb(255, 249, 235), rgb(24, 40, 70));
|
||||
--summary-hover-bg-color: light-dark(#eee, #313131);
|
||||
--greeter-bg-color: light-dark(rgb(255, 249, 235), rgb(24, 40, 70));
|
||||
/* --bottom-panel-bg-color: light-dark(rgb(219, 219, 219), rgb(31, 33, 36)); */
|
||||
--summary-hover-bg-color: light-dark(#eee, #2e2f35);
|
||||
--internal-event-bg-color: light-dark(rgb(255, 218, 252), rgb(99, 27, 94));
|
||||
--input-event-bg-color: light-dark(rgb(224, 247, 209), rgb(59, 95, 37));
|
||||
--input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40));
|
||||
|
|
@ -49,6 +50,25 @@ input {
|
|||
border: 1px solid var(--separator-color);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--button-bg-color);
|
||||
border: 1px var(--separator-color) solid;
|
||||
}
|
||||
|
||||
button:not(:disabled):hover {
|
||||
background-color: var(--light-accent-color);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: var(--inactive-bg-color);
|
||||
color: var(--inactive-fg-color);
|
||||
}
|
||||
|
||||
button.active {
|
||||
border: solid var(--accent-border-color) 1px;
|
||||
background-color: var(--light-accent-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
div#root {
|
||||
height: 100%;
|
||||
|
|
|
|||
3
todo.txt
3
todo.txt
|
|
@ -53,9 +53,11 @@ TODO
|
|||
- hovering over event in side panel should highlight all occurrences of the event in the SC
|
||||
- rename events / variables
|
||||
find/replace?
|
||||
|
||||
- hovering over error in bottom panel should highlight that error in the SC
|
||||
- highlight selected shapes while making a selection
|
||||
- highlight about-to-fire transitions
|
||||
|
||||
- integrate undo-history with browser history (back/forward buttons)
|
||||
|
||||
- ability to 'freeze' editor (e.g., to show plant SC)
|
||||
|
|
@ -74,3 +76,4 @@ TODO
|
|||
|
||||
Publish StateBuddy paper(s):
|
||||
compare CS approach to other tools, not only YAKINDU
|
||||
z
|
||||
Loading…
Add table
Add a link
Reference in a new issue