From 970b9d850e8d4450db96652aa5739ffd5ab1751d Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Fri, 14 Nov 2025 16:52:09 +0100 Subject: [PATCH] getting rid of some code duplication --- .gitignore | 3 + package.json | 3 +- src/App/App.css | 20 ---- src/App/App.tsx | 94 ++++++++------- src/App/BottomPanel/BottomPanel.css | 7 +- src/App/BottomPanel/BottomPanel.tsx | 45 ++++--- src/App/BottomPanel/FindReplace.tsx | 59 +++++++++ .../{ => Components}/PersistentDetails.tsx | 0 src/App/Components/TwoStateButton.tsx | 5 + src/App/{Modals => Overlays}/ModalOverlay.tsx | 0 src/App/Overlays/WindowOverlay.tsx | 14 +++ src/App/SideBar/SideBar.tsx | 2 +- src/App/TopPanel/InsertModes.tsx | 48 ++------ src/App/TopPanel/SpeedControl.tsx | 24 +--- src/App/TopPanel/TopPanel.tsx | 112 ++++++++---------- src/App/TopPanel/UndoRedoButtons.tsx | 23 +--- src/App/TopPanel/ZoomButtons.tsx | 29 ++--- src/App/VisualEditor/hooks/useMouse.tsx | 86 +++++--------- src/hooks/useShortcuts.ts | 26 ++++ src/index.css | 24 +++- todo.txt | 3 + 21 files changed, 325 insertions(+), 302 deletions(-) create mode 100644 src/App/BottomPanel/FindReplace.tsx rename src/App/{ => Components}/PersistentDetails.tsx (100%) create mode 100644 src/App/Components/TwoStateButton.tsx rename src/App/{Modals => Overlays}/ModalOverlay.tsx (100%) create mode 100644 src/App/Overlays/WindowOverlay.tsx create mode 100644 src/hooks/useShortcuts.ts diff --git a/.gitignore b/.gitignore index a14702c..d281236 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index 73cf039..188c9c2 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App/App.css b/src/App/App.css index 5bc6051..2929afe 100644 --- a/src/App/App.css +++ b/src/App/App.css @@ -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%; diff --git a/src/App/App.tsx b/src/App/App.tsx index 9faad11..34595a2 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -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
- {/* top-to-bottom: everything -> bottom panel */} -
+ {/* top-to-bottom: everything -> bottom panel */} +
- {/* left-to-right: main -> sidebar */} -
+ {/* left-to-right: main -> sidebar */} +
+ + {/* top-to-bottom: top bar, editor */} +
+ {/* Top bar */} +
+ {editHistory && } +
+ {/* Editor */} +
+ {editorState && conns && syntaxErrors && + } +
+ + {appState.showFindReplace && +
+ setters.setShowFindReplace(false)}/> +
+ } - {/* top-to-bottom: top bar, editor */} -
- {/* Top bar */} -
- {editHistory && }
- {/* Editor */} -
- {editorState && conns && syntaxErrors && - } + + {/* Right: sidebar */} +
+
+ +
- {/* Right: sidebar */} -
-
- -
+ {/* Bottom panel */} +
+ {syntaxErrors && }
- - {/* Bottom panel */} -
- {syntaxErrors && } -
-
- +
; } export default App; - diff --git a/src/App/BottomPanel/BottomPanel.css b/src/App/BottomPanel/BottomPanel.css index 4a93ad9..098ef48 100644 --- a/src/App/BottomPanel/BottomPanel.css +++ b/src/App/BottomPanel/BottomPanel.css @@ -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); -} +} \ No newline at end of file diff --git a/src/App/BottomPanel/BottomPanel.tsx b/src/App/BottomPanel/BottomPanel.tsx index ad1cd93..d1f5e3a 100644 --- a/src/App/BottomPanel/BottomPanel.tsx +++ b/src/App/BottomPanel/BottomPanel.tsx @@ -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) { const [greeting, setGreeting] = useState( -
+
Welcome to @@ -21,19 +27,22 @@ export function BottomPanel(props: {errors: TraceableError[]}) { }, []); return
- {greeting} - {props.errors.length > 0 && -
- - {props.errors.length} errors -
- {props.errors.map(({message, shapeUid})=> -
- {shapeUid}: {message} -
)} -
-
+ {/* {props.showFindReplace && +
+ props.setShowFindReplace(false)}/>
- } + } */} +
+ + {props.errors.length} errors +
+ {props.errors.map(({message, shapeUid})=> +
+ {shapeUid}: {message} +
)} +
+
+
+ {greeting}
; -} \ No newline at end of file +} diff --git a/src/App/BottomPanel/FindReplace.tsx b/src/App/BottomPanel/FindReplace.tsx new file mode 100644 index 0000000..818dbe2 --- /dev/null +++ b/src/App/BottomPanel/FindReplace.tsx @@ -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
+ setFindText(e.target.value)} style={{width:300}}/> + + setReplaceTxt(e.target.value))} style={{width:300}}/> +   + + +
; +} \ No newline at end of file diff --git a/src/App/PersistentDetails.tsx b/src/App/Components/PersistentDetails.tsx similarity index 100% rename from src/App/PersistentDetails.tsx rename to src/App/Components/PersistentDetails.tsx diff --git a/src/App/Components/TwoStateButton.tsx b/src/App/Components/TwoStateButton.tsx new file mode 100644 index 0000000..60cec2c --- /dev/null +++ b/src/App/Components/TwoStateButton.tsx @@ -0,0 +1,5 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from "react"; + +export function TwoStateButton({active, children, className, ...rest}: PropsWithChildren<{active: boolean} & ButtonHTMLAttributes>) { + return +} diff --git a/src/App/Modals/ModalOverlay.tsx b/src/App/Overlays/ModalOverlay.tsx similarity index 100% rename from src/App/Modals/ModalOverlay.tsx rename to src/App/Overlays/ModalOverlay.tsx diff --git a/src/App/Overlays/WindowOverlay.tsx b/src/App/Overlays/WindowOverlay.tsx new file mode 100644 index 0000000..01f51f7 --- /dev/null +++ b/src/App/Overlays/WindowOverlay.tsx @@ -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 &&
props.setModal(null)}> +//
} + +// {props.children} +// ; +// } diff --git a/src/App/SideBar/SideBar.tsx b/src/App/SideBar/SideBar.tsx index 27d42af..1d24181 100644 --- a/src/App/SideBar/SideBar.tsx +++ b/src/App/SideBar/SideBar.tsx @@ -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[]][]; diff --git a/src/App/TopPanel/InsertModes.tsx b/src/App/TopPanel/InsertModes.tsx index fe1c411..c2c9d65 100644 --- a/src/App/TopPanel/InsertModes.tsx +++ b/src/App/TopPanel/InsertModes.tsx @@ -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>}) { - 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]) => diff --git a/src/App/TopPanel/SpeedControl.tsx b/src/App/TopPanel/SpeedControl.tsx index 0343d1a..6926390 100644 --- a/src/App/TopPanel/SpeedControl.tsx +++ b/src/App/TopPanel/SpeedControl.tsx @@ -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>, setTime: Dispatch>}) { @@ -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 <> diff --git a/src/App/TopPanel/TopPanel.tsx b/src/App/TopPanel/TopPanel.tsx index 1974f83..32f8209 100644 --- a/src/App/TopPanel/TopPanel.tsx +++ b/src/App/TopPanel/TopPanel.tsx @@ -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>, onUndo: () => void, onRedo: () => void, @@ -40,28 +47,32 @@ export type TopPanelProps = { onInit: () => void, onClear: () => void, onBack: () => void, + // lightMode: LightMode, // setLightMode: Dispatch>, - insertMode: InsertMode, - setInsertMode: Dispatch>, + // insertMode: InsertMode, + // setInsertMode: Dispatch>, setModal: Dispatch>, - zoom: number, - setZoom: Dispatch>, - showKeys: boolean, - setShowKeys: Dispatch>, + // zoom: number, + // setZoom: Dispatch>, + // showKeys: boolean, + // setShowKeys: Dispatch>, editHistory: EditHistory, -} + setEditorState: Dispatch<(oldState: VisualEditorState) => VisualEditorState>, +} & AppState & Setters const ShortCutShowKeys = ~; -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
@@ -207,11 +178,26 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on  
+ {/* rotate */}
+ {/* find, replace */} +
+ Ctrl+Shift+F}> + setShowFindReplace(x => !x)} + > + + + +   +
+ {/* execution */}
diff --git a/src/App/TopPanel/UndoRedoButtons.tsx b/src/App/TopPanel/UndoRedoButtons.tsx index 0a9d0e5..c03a71d 100644 --- a/src/App/TopPanel/UndoRedoButtons.tsx +++ b/src/App/TopPanel/UndoRedoButtons.tsx @@ -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 <> diff --git a/src/App/TopPanel/ZoomButtons.tsx b/src/App/TopPanel/ZoomButtons.tsx index 77ae440..718c311 100644 --- a/src/App/TopPanel/ZoomButtons.tsx +++ b/src/App/TopPanel/ZoomButtons.tsx @@ -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 = <>Ctrl+-; const shortcutZoomOut = <>Ctrl++; export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}: {showKeys: boolean, zoom: number, setZoom: Dispatch>}) { + 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 <> diff --git a/src/App/VisualEditor/hooks/useMouse.tsx b/src/App/VisualEditor/hooks/useMouse.tsx index 5dd1596..733910f 100644 --- a/src/App/VisualEditor/hooks/useMouse.tsx +++ b/src/App/VisualEditor/hooks/useMouse.tsx @@ -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); }; diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts new file mode 100644 index 0000000..13cb4e2 --- /dev/null +++ b/src/hooks/useShortcuts.ts @@ -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]); + } +} diff --git a/src/index.css b/src/index.css index f0f2852..78b28ca 100644 --- a/src/index.css +++ b/src/index.css @@ -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%; diff --git a/todo.txt b/todo.txt index 5035608..6044e5c 100644 --- a/todo.txt +++ b/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 \ No newline at end of file