getting rid of some code duplication

This commit is contained in:
Joeri Exelmans 2025-11-14 16:52:09 +01:00
parent 0266675f29
commit 970b9d850e
21 changed files with 325 additions and 302 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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"
},

View file

@ -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%;

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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>;
}
}

View 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}}/>
&nbsp;
<button onClick={onReplace}>replace all</button>
<button onClick={hide} style={{marginLeft: 'auto'}}><CloseIcon fontSize="small"/></button>
</div>;
}

View 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>
}

View 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}
// </>;
// }

View file

@ -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[]][];

View file

@ -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}>

View file

@ -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 <>

View file

@ -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
&emsp;
</div>
{/* rotate */}
<div className="toolbarGroup">
<RotateButtons selection={editHistory.current.selection} onRotate={onRotate}/>
&emsp;
</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>
&emsp;
</div>
{/* execution */}
<div className="toolbarGroup">

View file

@ -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 <>

View file

@ -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>

View file

@ -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
View 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]);
}
}

View file

@ -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%;

View file

@ -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