less code duplication

This commit is contained in:
Joeri Exelmans 2025-11-14 17:19:33 +01:00
parent 970b9d850e
commit 1bd801ce5d
6 changed files with 47 additions and 69 deletions

View file

@ -4,6 +4,7 @@ import { usePersistentState } from "@/hooks/usePersistentState";
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { useShortcuts } from "@/hooks/useShortcuts";
type FindReplaceProps = { type FindReplaceProps = {
setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>, setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
@ -27,27 +28,15 @@ export function FindReplace({setCS, hide}: FindReplaceProps) {
}); });
}, [findTxt, replaceTxt]); }, [findTxt, replaceTxt]);
const onKeyDown = useCallback((e: KeyboardEvent) => { useShortcuts([
if (e.key === "Enter") { {keys: ["Enter"], action: onReplace},
e.preventDefault(); ])
e.stopPropagation();
onReplace();
// setModal(null);
}
}, [onReplace]);
const onSwap = useCallback(() => { const onSwap = useCallback(() => {
setReplaceTxt(findTxt); setReplaceTxt(findTxt);
setFindText(replaceTxt); setFindText(replaceTxt);
}, [findTxt, replaceTxt]); }, [findTxt, replaceTxt]);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
}
}, [])
return <div className="toolbar toolbarGroup" style={{display: 'flex'}}> return <div className="toolbar toolbarGroup" style={{display: 'flex'}}>
<input placeholder="find" value={findTxt} onChange={e => setFindText(e.target.value)} style={{width:300}}/> <input placeholder="find" value={findTxt} onChange={e => setFindText(e.target.value)} style={{width:300}}/>
<button tabIndex={-1} onClick={onSwap}><SwapHorizIcon fontSize="small"/></button> <button tabIndex={-1} onClick={onSwap}><SwapHorizIcon fontSize="small"/></button>

View file

@ -1,24 +1,20 @@
import { Dispatch, ReactElement, SetStateAction, useState, KeyboardEvent, useEffect, useRef } from "react"; import { Dispatch, ReactElement, SetStateAction, useState, useCallback } from "react";
import { cachedParseLabel } from "@/statecharts/parser"; import { cachedParseLabel } from "@/statecharts/parser";
import { useShortcuts } from "@/hooks/useShortcuts";
export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElement|null>>, text: string, done: (newText: string|undefined) => void}) { export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElement|null>>, text: string, done: (newText: string|undefined) => void}) {
const [text, setText] = useState(props.text); const [text, setText] = useState(props.text);
function onKeyDown(e: KeyboardEvent) { useShortcuts([
if (e.key === "Enter") { {keys: ["Enter"], action: useCallback(() => {
if (!e.shiftKey) {
e.preventDefault();
props.done(text); props.done(text);
props.setModal(null); props.setModal(null);
} }, [text, props.done, props.setModal])},
} {keys: ["Escape"], action: useCallback(() => {
if (e.key === "Escape") {
props.setModal(null); props.setModal(null);
e.stopPropagation(); }, [props.setModal])},
} ], false);
e.stopPropagation();
}
let parseError = ""; let parseError = "";
try { try {
@ -28,7 +24,7 @@ export function TextDialog(props: {setModal: Dispatch<SetStateAction<ReactElemen
parseError = e.message; parseError = e.message;
} }
return <div onKeyDown={onKeyDown} style={{padding: 4}}> return <div style={{padding: 4}}>
{/* Text label:<br/> */} {/* Text label:<br/> */}
<textarea autoFocus style={{fontFamily: 'Roboto', width: 400, height: 60}} onChange={e=>setText(e.target.value)} value={text} onFocus={e => e.target.select()}/> <textarea autoFocus style={{fontFamily: 'Roboto', width: 400, height: 60}} onChange={e=>setText(e.target.value)} value={text} onFocus={e => e.target.select()}/>
<br/> <br/>

View file

@ -4,6 +4,7 @@ import { usePersistentState } from "../../hooks/usePersistentState";
import { ConcreteState, stateDescription, Transition, UnstableState } from "../../statecharts/abstract_syntax"; import { ConcreteState, stateDescription, Transition, UnstableState } from "../../statecharts/abstract_syntax";
import { Action, EventTrigger, Expression } from "../../statecharts/label_ast"; import { Action, EventTrigger, Expression } from "../../statecharts/label_ast";
import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo";
import { useShortcuts } from '@/hooks/useShortcuts';
export function ShowTransition(props: {transition: Transition}) { export function ShowTransition(props: {transition: Transition}) {
return <> {stateDescription(props.transition.tgt)}</>; return <> {stateDescription(props.transition.tgt)}</>;
@ -49,7 +50,7 @@ export const ShowAST = memo(function ShowASTx(props: {root: ConcreteState | Unst
}); });
export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean, showKeys: boolean}) { export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) {
const raiseHandlers = inputEvents.map(({event}) => { const raiseHandlers = inputEvents.map(({event}) => {
return () => { return () => {
// @ts-ignore // @ts-ignore
@ -67,23 +68,16 @@ export function ShowInputEvents({inputEvents, onRaise, disabled, showKeys}: {inp
onRaise(event, paramParsed); onRaise(event, paramParsed);
}; };
}); });
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;
const n = (parseInt(e.key)+9) % 10; const shortcutSpec = raiseHandlers.map((handler, i) => {
if (raiseHandlers[n] !== undefined) { const n = (i+1)%10;
raiseHandlers[n](); return {
e.stopPropagation(); keys: [n.toString()],
e.preventDefault(); action: handler,
} };
} });
useEffect(() => { useShortcuts(shortcutSpec);
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [raiseHandlers]);
// const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
const KeyInfo = KeyInfoVisible; // always show keyboard shortcuts on input events, we can't expect the user to remember them const KeyInfo = KeyInfoVisible; // always show keyboard shortcuts on input events, we can't expect the user to remember them
const [inputParams, setInputParams] = usePersistentState<{[eventName:string]: string}>("inputParams", {}); const [inputParams, setInputParams] = usePersistentState<{[eventName:string]: string}>("inputParams", {});

View file

@ -2,6 +2,7 @@ import { Arrow, Diamond, Rountangle, Text, History } from "@/statecharts/concret
import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react"; import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { Selection, VisualEditorState } from "../VisualEditor"; import { Selection, VisualEditorState } from "../VisualEditor";
import { addV2D } from "@/util/geometry"; import { addV2D } from "@/util/geometry";
import { useShortcuts } from "@/hooks/useShortcuts";
// const offset = {x: 40, y: 40}; // const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0}; const offset = {x: 0, y: 0};
@ -12,6 +13,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
if (data) { if (data) {
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
makeCheckPoint();
setState(state => { setState(state => {
try { try {
let nextID = state.nextID; let nextID = state.nextID;
@ -49,7 +51,6 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})), ...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})), ...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
]; ];
makeCheckPoint();
return { return {
...state, ...state,
rountangles: [...state.rountangles, ...copiedRountangles], rountangles: [...state.rountangles, ...copiedRountangles],
@ -72,7 +73,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
} }
e.preventDefault(); e.preventDefault();
} }
}, [setState]); }, [makeCheckPoint, setState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => { const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid)); const uidsToCopy = new Set(selection.map(shape => shape.uid));
@ -106,6 +107,7 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
}, [state, selection]); }, [state, selection]);
const deleteSelection = useCallback(() => { const deleteSelection = useCallback(() => {
makeCheckPoint();
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)), rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
@ -115,23 +117,11 @@ export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorStat
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)), texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [], selection: [],
})); }));
}, [setState]); }, [makeCheckPoint, setState]);
const onKeyDown = (e: KeyboardEvent) => { useShortcuts([
// @ts-ignore {keys: ["Delete"], action: deleteSelection},
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return; ])
if (e.key === "Delete") {
// delete selection
makeCheckPoint();
deleteSelection();
e.preventDefault();
}
}
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
})
return {onCopy, onPaste, onCut, deleteSelection}; return {onCopy, onPaste, onCut, deleteSelection};
} }

View file

@ -1,11 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
export function useShortcuts(spec: {keys: string[], action: () => void}[]) { export function useShortcuts(spec: {keys: string[], action: () => void}[], ignoreInputs = true) {
// I don't know if this is efficient, but I decided to just register one event listener for every shortcut, rather than generating one big event listener for all shortcuts.
// The benefit is that we don't have to memoize anything: useEffect will only be called if the action updated, and React allows calling useEffect for every item in a list as long as the list doesn't change.
for (const {keys, action} of spec) { for (const {keys, action} of spec) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (ignoreInputs) {
// @ts-ignore: don't steal keyboard events while the user is typing in a text box, etc. // @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 (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
}
if (e.ctrlKey !== keys.includes("Ctrl")) return; if (e.ctrlKey !== keys.includes("Ctrl")) return;
if (e.shiftKey !== keys.includes("Shift")) return; if (e.shiftKey !== keys.includes("Shift")) return;

View file

@ -31,7 +31,8 @@
TODO TODO
- bugs - bugs
editing SC <-> Plant connections at runtime doesn't seem to work (*) editing SC <-> Plant connections at runtime doesn't seem to work
(*) non-determinism error highlights only one of enabled transitions
- maybe support: - maybe support:
- explicit order of: - explicit order of:
@ -64,6 +65,10 @@ TODO
- show insert mode also next to cursor - show insert mode also next to cursor
- plot plant signals - plot plant signals
- show error when states partially overlap?
useful when accidentally pasting the same data multiple times
(otherwise, you can't see that you have multiple states)
- performance: - performance:
maybe try this for rendering the execution trace: maybe try this for rendering the execution trace:
https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists