better find/replace (with highlighting of all occurrences) + more consistent error visualization

This commit is contained in:
Joeri Exelmans 2025-11-15 13:14:57 +01:00
parent 64aab1a6df
commit 8b0726ef01
21 changed files with 244 additions and 139 deletions

View file

@ -47,7 +47,8 @@ details:has(+ details) {
}
.toolbar input {
height: 22px;
height: 26px;
box-sizing: border-box;
}
.toolbar div {
vertical-align: bottom;

View file

@ -19,6 +19,7 @@ import { emptyState } from "@/statecharts/concrete_syntax";
import { ModalOverlay } from "./Overlays/ModalOverlay";
import { FindReplace } from "./BottomPanel/FindReplace";
import { useCustomMemo } from "@/hooks/useCustomMemo";
import { usePersistentState } from "@/hooks/usePersistentState";
export type EditHistory = {
current: VisualEditorState,
@ -51,6 +52,8 @@ export function App() {
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [modal, setModal] = useState<ReactElement|null>(null);
const [[findText, replaceText], setFindReplaceText] = usePersistentState("findReplaceTxt", ["", ""]);
const {commitState, replaceState, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
const editorState = editHistory && editHistory.current;
@ -67,9 +70,17 @@ export function App() {
([prevState, prevConns], [nextState, nextConns]) => {
if ((prevState === null) !== (nextState === null)) return false;
if ((prevConns === null) !== (nextConns === null)) return false;
if (prevConns === null) {
return nextConns === null;
}
if (prevState === null) {
return nextState === null;
}
if (nextConns === null) return false;
if (nextState === null) return false;
// the following check is much cheaper than re-rendering everything that depends on
return connectionsEqual(prevConns!, nextConns!)
&& reducedConcreteSyntaxEqual(prevState!, nextState!);
return connectionsEqual(prevConns, nextConns)
&& reducedConcreteSyntaxEqual(prevState, nextState);
});
const ast = parsed && parsed[0];
@ -161,7 +172,7 @@ export function App() {
<div className="stackHorizontal" style={{flexGrow:1, overflow: "auto"}}>
{/* top-to-bottom: top bar, editor */}
<div className="stackVertical" style={{flexGrow:1, overflow: "auto"}}>
<div className="stackVertical" style={{flexGrow:1, overflow: "hidden"}}>
{/* Top bar */}
<div
className="shadowBelow"
@ -174,12 +185,12 @@ export function App() {
{/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState}}/>}
<VisualEditor {...{state: editorState, commitState, replaceState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, ...appState, findText:findText}}/>}
</div>
{appState.showFindReplace &&
<div>
<FindReplace setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
{editorState && appState.showFindReplace &&
<div style={{}}>
<FindReplace findText={findText} replaceText={replaceText} setFindReplaceText={setFindReplaceText} cs={editorState} setCS={setEditorState} hide={() => setters.setShowFindReplace(false)}/>
</div>
}

View file

@ -1,12 +1,27 @@
.errorStatus {
/* background-color: rgb(230,0,0); */
background-color: var(--error-color);
color: var(--background-color);
.statusBar {
background-color: var(--statusbar-bg-color);
color: var(--statusbar-fg-color);
}
.statusBar.error {
background-color: var(--statusbar-error-bg-color);
color: var(--statusbar-error-fg-color);
}
.statusBar summary:hover {
background-color: color-mix(in srgb, var(--statusbar-bg-color) 60%, var(--background-color));
}
.statusBar.error summary:hover {
background-color: color-mix(in srgb, var(--statusbar-error-bg-color) 70%, var(--text-color));
}
.statusBar a {
color: inherit;
}
.greeter {
/* border-top: 1px var(--separator-color) solid; */
background-color: var(--greeter-bg-color);
background-color: var(--statusbar-bg-color);
}
.bottom {

View file

@ -6,7 +6,6 @@ import "./BottomPanel.css";
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";
@ -16,7 +15,8 @@ export function BottomPanel(props: {errors: TraceableError[], setEditorState: Di
const [greeting, setGreeting] = useState(
<div className="greeter" style={{textAlign:'center'}}>
<span style={{fontSize: 18, fontStyle: 'italic'}}>
Welcome to <Logo/>
Welcome to
<Logo width={300} height="auto" style={{verticalAlign: 'middle'}}/>
</span>
</div>);
@ -26,23 +26,38 @@ export function BottomPanel(props: {errors: TraceableError[], setEditorState: Di
}, 2000);
}, []);
return <div className="toolbar bottom">
return <div className="bottom">
{greeting}
{/* {props.showFindReplace &&
<div>
<FindReplace setCS={props.setEditorState} hide={() => props.setShowFindReplace(false)}/>
</div>
} */}
<div className={"statusBar" + props.errors.length ? " error" : ""}>
<div className={"stackHorizontal statusBar" + (props.errors.length ? " error" : "")}>
<div style={{flexGrow:1}}>
<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>
<summary>{props.errors.length} errors</summary>
<div style={{maxHeight: '20vh', overflow: 'auto'}}>
{props.errors.map(({message, shapeUid})=>
<div>
{shapeUid}: {message}
</div>)}
</div>
</PersistentDetailsLocalStorage>
</div>
{/* <div ></div> */}
<div>
switch to&nbsp;
{location.host === "localhost:3000" ?
<a href={`https://deemz.org/public/statebuddy/${location.hash}`}>production</a>
: <a href={`http://localhost:3000/${location.hash}`}>development</a>
}
&nbsp;mode
</div>
&nbsp;|&nbsp;
<div>
Rev: <a title={"git"} href={`https://deemz.org/git/research/statebuddy/commit/${gitRev}`}>{gitRev.slice(0,8)}</a>
</div>
</div>
{greeting}
</div>;
}

View file

@ -1,48 +1,82 @@
import { Dispatch, useCallback, useEffect } from "react";
import { Dispatch, FormEvent, SetStateAction, useCallback } 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';
import { useShortcuts } from "@/hooks/useShortcuts";
import SwapVertIcon from '@mui/icons-material/SwapVert';
type FindReplaceProps = {
findText: string,
replaceText: string,
setFindReplaceText: Dispatch<SetStateAction<[string, string]>>,
cs: VisualEditorState,
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", "");
export function FindReplace({findText, replaceText, setFindReplaceText, cs, setCS, hide}: FindReplaceProps) {
const onReplace = useCallback(() => {
setCS(cs => {
return {
...cs,
texts: cs.texts.map(txt => ({
...txt,
text: txt.text.replaceAll(findTxt, replaceTxt)
text: txt.text.replaceAll(findText, replaceText)
})),
};
});
}, [findTxt, replaceTxt]);
useShortcuts([
{keys: ["Enter"], action: onReplace},
])
}, [findText, replaceText, setCS]);
const onSwap = useCallback(() => {
setReplaceTxt(findTxt);
setFindText(replaceTxt);
}, [findTxt, replaceTxt]);
setFindReplaceText(([findText, replaceText]) => [replaceText, findText]);
}, [findText, replaceText]);
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>;
const onSubmit = useCallback((e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
onReplace();
// onSwap();
}, [findText, replaceText, onSwap, onReplace]);
const n = findText === "" ? 0 : cs.texts.reduce((count, txt) => count+(txt.text.indexOf(findText) !== -1 ? 1: 0), 0);
return <form onSubmit={onSubmit}>
<div className="toolbar toolbarGroup" style={{display: 'flex', flexDirection: 'row'}}>
<div style={{flexGrow:1, display: 'flex', flexDirection: 'column'}}>
<input placeholder="find"
title="old text"
value={findText}
onChange={e => setFindReplaceText(([_, replaceText]) => [e.target.value, replaceText])}
style={{flexGrow: 1, minWidth: 20}}/>
<br/>
<input tabIndex={0} placeholder="replace"
title="new text"
value={replaceText}
onChange={(e => setFindReplaceText(([findText, _]) => [findText, e.target.value]))}
style={{flexGrow: 1, minWidth: 20}}/>
</div>
<div style={{flex: '0 0 content', display: 'flex', justifyItems: 'flex-start', flexDirection: 'column'}}>
<div style={{display: 'flex'}}>
<button
type="button" // <-- prevent form submission on click
title="swap find/replace fields"
onClick={onSwap}
style={{flexGrow: 1}}>
<SwapVertIcon fontSize="small"/>
</button>
<button
type="button" // <-- prevent form submission on click
title="hide find & replace"
onClick={hide}
style={{flexGrow: 1 }}>
<CloseIcon fontSize="small"/>
</button>
</div>
<input type="submit"
disabled={n===0}
title="replace all occurrences in model"
value={`replace all (${n})`}
style={{height: 26}}/>
</div>
</div>
</form>;
}

View file

@ -1,6 +1,8 @@
import { SVGAttributes, SVGProps } from "react";
// i couldn't find a better way to make the text in the logo adapt to light/dark mode...
export function Logo() {
return <svg style={{maxWidth: '100%'}} width="424.29" height="105.72" version="1.1" viewBox="0 0 424.29 105.72">
export function Logo(props: SVGAttributes<SVGElement>) {
return <svg style={{maxWidth: '100%'}} width="424.29" height="105.72" version="1.1" viewBox="0 0 424.29 105.72" {...props} >
<style>{`
.logoText {
fill: var(--text-color);

View file

@ -70,14 +70,15 @@ export const ShowInputEvents = memo(function ShowInputEvents({inputEvents, onRai
};
});
const shortcutSpec = raiseHandlers.map((handler, i) => {
const n = (i+1)%10;
// less painful and more readable than figuring out the equivalent of range(n) in JS:
// (btw, useShortcuts must always be called with an array of the same size)
useShortcuts([0,1,2,3,4,5,6,7,8,9].map(i => {
const n = (i+1) % 10;
return {
keys: [n.toString()],
action: handler,
action: raiseHandlers[n] || (() => {}),
};
});
useShortcuts(shortcutSpec);
}));
const KeyInfo = KeyInfoVisible; // always show keyboard shortcuts on input events, we can't expect the user to remember them
@ -107,7 +108,6 @@ export const ShowInputEvents = memo(function ShowInputEvents({inputEvents, onRai
</div>;
})
}, (prevProps, nextProps) => {
console.log('onRaise changed:', prevProps.onRaise === nextProps.onRaise, prevProps.onRaise, nextProps.onRaise);
return prevProps.onRaise === nextProps.onRaise
&& prevProps.disabled === nextProps.disabled
&& jsonDeepEqual(prevProps.inputEvents, nextProps.inputEvents);

View file

@ -196,7 +196,7 @@ export const SideBar = memo(function SideBar({showExecutionTrace, showConnection
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))} placeholder='write MTL property...'/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>

View file

@ -246,11 +246,5 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
</div>
</div>
</div>
<div className="toolbarGroup">
{location.host === "localhost:3000" ?
<a href={`https://deemz.org/public/statebuddy/${location.hash}`}>production</a>
: <a href={`http://localhost:3000/${location.hash}`}>development</a>
}
</div>
</div>;
});

View file

@ -32,7 +32,7 @@ export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart
data-parts="start end" />
{props.error && <text
className="error"
className="errorHover"
x={(start.x + end.x) / 2 + 5}
y={(start.y + end.y) / 2}
textAnchor="middle"

View file

@ -33,7 +33,7 @@ export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rou
<text x={10} y={20} className="uid">{props.rountangle.uid}</text>
{props.error &&
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.error}</text>}
<text className="errorHover" x={30} y={20} data-uid={uid} data-parts="left top right bottom">{props.error}</text>}
<RectHelper uid={uid} size={minSize}
selected={props.selected}

View file

@ -1,36 +1,46 @@
import { TextDialog } from "@/App/Modals/TextDialog";
import { TraceableError } from "../../statecharts/parser";
import {Text} from "../../statecharts/concrete_syntax";
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
import { Dispatch, memo, ReactElement, SetStateAction, SVGTextElementAttributes } from "react";
import { jsonDeepEqual } from "@/util/util";
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
const commonProps = {
"data-uid": props.text.uid,
"data-parts": "text",
textAnchor: "middle" as "middle",
className: "draggableText"
+ (props.selected ? " selected":"")
+ (props.highlight ? " highlight":""),
style: {whiteSpace: "preserve"},
}
let textNode;
if (props.error?.data?.location) {
const {start,end} = props.error.data.location;
textNode = <><text {...commonProps}>
{props.text.text.slice(0, start.offset)}
<tspan className="error" data-uid={props.text.uid} data-parts="text">
{props.text.text.slice(start.offset, end.offset)}
{start.offset === end.offset && <>_</>}
export const FragmentedText = function FragmentedText({start, end, text, highlightClassName, uid, parts, ...rest}: {start: number, end: number, text: string, highlightClassName: string, uid: string, parts: string} & SVGTextElementAttributes<SVGTextElement>) {
if (start !== -1 && start !== end) {
return <text data-uid={uid} data-parts={parts} {...rest}>
{text.slice(0, start)}
<tspan className={highlightClassName} data-uid={uid} data-parts="text">
{text.slice(start, end)}
</tspan>
{props.text.text.slice(end.offset)}
</text>
<text className="error errorHover" y={20} textAnchor="middle">{props.error.message}</text></>;
{text.slice(end)}
</text>;
}
else {
textNode = <text {...commonProps}>{props.text.text}</text>;
return <text data-uid={uid} data-parts={parts} {...rest}>
{text}
</text>
}
}
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>, findText: string}) {
const className = "draggableText"
+ (props.selected ? " selected":"")
+ (props.highlight ? " highlight":"")
+ (props.error ? " error":"");
const found = props.text.text.indexOf(props.findText);
const start = (found >= 0) ? found : -1
const end = (found >= 0) ? found + props.findText.length : -1;
const textNode = <FragmentedText
{...{start, end}}
text={props.text.text}
textAnchor="middle"
className={className}
highlightClassName="findText"
uid={props.text.uid}
parts="text"
/>;
return <g
key={props.text.uid}
@ -44,6 +54,9 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl
}}>
{textNode}
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text" style={{whiteSpace: "preserve"}}>{props.text.text}</text>
{props.error &&
<text className="errorHover" y={-20} textAnchor="middle">{props.error.message}</text>
}
</g>;
}, (prevProps, newProps) => {
return jsonDeepEqual(prevProps.text, newProps)
@ -52,4 +65,5 @@ export const TextSVG = memo(function TextSVG(props: {text: Text, error: Traceabl
&& prevProps.setModal === newProps.setModal
&& prevProps.error === newProps.error
&& prevProps.selected === newProps.selected
&& prevProps.findText === newProps.findText
});

View file

@ -112,10 +112,6 @@ line.selected, circle.selected {
stroke-width: 4px;
}
.draggableText.selected, .draggableText.selected:hover {
fill: var(--accent-border-color);
font-weight: 600;
}
text.helper {
fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0);
@ -126,24 +122,41 @@ text.helper:hover {
cursor: grab;
}
.draggableText, .draggableText.highlight {
.draggableText {
paint-order: stroke;
fill: var(--text-color);
stroke: var(--background-color);
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
fill-opacity:1;
text-anchor: middle;
white-space: preserve;
}
.draggableText.highlight:not(.selected) {
fill: var(--associated-color);
font-weight: 600;
}
.draggableText.selected {
fill: var(--accent-border-color);
font-weight: 600;
}
.draggableText.highlight:not(.selected) {
fill: var(--associated-color);
}
.draggableText.error:not(.selected) {
fill: var(--error-color);
}
text.errorHover {
display: none;
paint-order: stroke;
fill: var(--error-color);
stroke: var(--background-color);
stroke-width: 4px;
font-weight: 600;
white-space: preserve;
}
g:hover > text.errorHover {
display: inline;
}
tspan.findText {
fill: var(--foundtext-color);
font-weight: 600;
}
.highlight:not(.selected):not(text) {
@ -152,7 +165,7 @@ text.helper:hover {
fill: none;
}
.arrow.error {
.arrow.error:not(.selected) {
stroke: var(--error-color);
}
.arrow.fired {
@ -172,19 +185,13 @@ text.helper:hover {
}
}
text.error, tspan.error {
fill: var(--error-color);
font-weight: 600;
}
.errorHover {
/* .errorHover {
display: none;
}
g:hover > .errorHover {
display: inline;
}
} */
text.uid {
fill: var(--separator-color);

View file

@ -52,9 +52,10 @@ type VisualEditorProps = {
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number;
findText: string;
};
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: VisualEditorProps) {
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom, findText}: VisualEditorProps) {
// While dragging, the editor is in a temporary state (a state that is not committed to the edit history). If the temporary state is not null, then this state will be what you see.
// const [temporaryState, setTemporaryState] = useState<VisualEditorState | null>(null);
@ -199,8 +200,6 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
</marker>
</defs>
{(rootErrors.length>0) && <text className="error" x={5} y={20}>{rootErrors.join(' ')}</text>}
<Rountangles rountangles={state.rountangles} {...{selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}}/>
<Diamonds diamonds={state.diamonds} {...{selection, sidesToHighlight, rountanglesToHighlight, errors}}/>
@ -235,7 +234,9 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
}
)}
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal, findText}}/>
{(rootErrors.length>0) && <text className="errorHover" x={5} y={20} style={{display:'inline'}}>{rootErrors.join('\n')}</text>}
{selectionRect}
</svg>;
@ -282,7 +283,7 @@ const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight,
&& arraysEqual(p.errors, n.errors);
});
const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, onEditText, setModal}: {texts: Text[], selection: Selection, textsToHighlight: {[key: string]: boolean}, errors: TraceableError[], onEditText: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, onEditText, setModal, findText}: {texts: Text[], selection: Selection, textsToHighlight: {[key: string]: boolean}, errors: TraceableError[], onEditText: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>, findText: string}) {
return <>{texts.map(txt => {
return <TextSVG
key={txt.uid}
@ -292,6 +293,7 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText}
setModal={setModal}
findText={findText}
/>
})}</>;
}, (p, n) => {
@ -300,6 +302,7 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
&& objectsEqual(p.textsToHighlight, n.textsToHighlight)
&& arraysEqual(p.errors, n.errors)
&& p.onEditText === n.onEditText
&& p.setModal === n.setModal;
&& p.setModal === n.setModal
&& p.findText === n.findText;
});

View file

@ -239,7 +239,7 @@ export function useMouse(
...t,
topLeft: addV2D(t.topLeft, pointerDelta),
}
}),
}).toSorted((a,b) => a.topLeft.y - b.topLeft.y),
}));
setDragging(true);
}

View file

@ -138,8 +138,6 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
};
}, [cE, currentTraceItem, time, appendNewConfig]);
console.log({onRaise});
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
// console.log('time effect:', time, currentTraceItem);

View file

@ -11,9 +11,9 @@ import { App } from "./App/App";
const elem = document.getElementById("root")!;
const app = (
// <StrictMode>
<StrictMode>
<App />
// </StrictMode>
</StrictMode>
);
if (import.meta.hot) {

View file

@ -3,6 +3,7 @@ import { Dispatch, SetStateAction, useCallback, useState } from "react";
// like useState, but it is persisted in localStorage
// important: values must be JSON-(de-)serializable
export function usePersistentState<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
console.log('usePersistentState ---', key);
const [state, setState] = useState(() => {
const recovered = localStorage.getItem(key);
let parsed;

View file

@ -16,6 +16,12 @@ html, body {
--light-accent-color: light-dark(rgba(0,0,255,0.2), rgba(78, 186, 248, 0.377));
--accent-border-color: light-dark(blue, rgb(64, 185, 255));
--accent-opaque-color: light-dark(#ccccff, #305b73);
--statusbar-bg-color: light-dark(rgb(157, 200, 255), rgb(48, 48, 68));
--statusbar-fg-color: light-dark(rgb(0, 0, 0), white);
--statusbar-error-bg-color: light-dark(rgb(163, 0, 0), rgb(255, 82, 82));
--statusbar-error-fg-color: light-dark(white, black);
--separator-color: light-dark(lightgrey, rgb(58, 58, 58));
--inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29));
--inactive-fg-color: light-dark(grey, rgb(70, 70, 70));
@ -40,6 +46,7 @@ html, body {
--input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40));
--input-event-active-bg-color: light-dark(rgb(176, 204, 158), rgb(77, 117, 53));
--output-event-bg-color: light-dark(rgb(230, 249, 255), rgb(28, 83, 104));
--foundtext-color: light-dark(rgb(0, 189, 164), rgb(202, 149, 218));
background-color: var(--background-color);
color: var(--text-color);
@ -50,16 +57,16 @@ input {
border: 1px solid var(--separator-color);
}
button {
button, input[type="submit"] {
background-color: var(--button-bg-color);
border: 1px var(--separator-color) solid;
}
button:not(:disabled):hover {
button:not(:disabled):hover, input[type="submit"]:not(:disabled):hover {
background-color: var(--light-accent-color);
}
button:disabled {
button:disabled, input[type="submit"]:disabled {
background-color: var(--inactive-bg-color);
color: var(--inactive-fg-color);
}

View file

@ -203,7 +203,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
else if (state.initial.length === 0) {
errors.push({
shapeUid: state.uid,
message: "no initial state",
message: "needs initial state",
});
}
}
@ -216,8 +216,8 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
// step 3: figure out labels
const textsSorted = concreteSyntax.texts.toSorted((a,b) => a.topLeft.y - b.topLeft.y);
for (const text of textsSorted) {
// ASSUMPTION: text is sorted by y-coordinate
for (const text of concreteSyntax.texts) {
let parsed: ParsedText;
try {
parsed = cachedParseLabel(text.text); // may throw
@ -226,7 +226,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
if (e instanceof SyntaxError) {
errors.push({
shapeUid: text.uid,
message: e.message,
message: 'parser: ' + e.message,
data: e,
});
parsed = {
@ -248,7 +248,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
// triggers
if (parsed.trigger.kind === "event") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have event trigger"});
errors.push({shapeUid: text.uid, message: "cannot have trigger"});
}
else {
const {event} = parsed.trigger;
@ -262,7 +262,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
}
else if (parsed.trigger.kind === "after") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have after-trigger"});
errors.push({shapeUid: text.uid, message: "cannot have trigger"});
}
else {
src.timers.push(parsed.trigger.durationMs);
@ -274,7 +274,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
}
else if (parsed.trigger.kind === "triggerless") {
if (src.kind !== "pseudo") {
errors.push({shapeUid: text.uid, message: "triggerless transitions only allowed on pseudo-states"});
errors.push({shapeUid: text.uid, message: "needs trigger"});
}
}
}
@ -296,7 +296,7 @@ export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Co
else {
errors.push({
shapeUid: text.uid,
message: "states can only have entry/exit triggers",
message: "must belong to transition",
data: {start: {offset: 0}, end: {offset: text.text.length}},
});
}

View file

@ -79,6 +79,9 @@ TODO
https://pub.dev/packages/ploeg_tree_layout
- local variable scopes
Breaking changes (after mosis25 is finished):
- state names start with # (not with //)
Publish StateBuddy paper(s):
compare CS approach to other tools, not only YAKINDU
z