Compare commits

..

No commits in common. "456b85bf8f39ae92a15de10a34d94424ebec7cd1" and "64aab1a6dfac5db72e725ac6ed88e7fd6824ee01" have entirely different histories.

22 changed files with 142 additions and 259 deletions

View file

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

View file

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

View file

@ -1,27 +1,12 @@
.statusBar { .errorStatus {
background-color: var(--statusbar-bg-color); /* background-color: rgb(230,0,0); */
color: var(--statusbar-fg-color); background-color: var(--error-color);
} color: var(--background-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 { .greeter {
/* border-top: 1px var(--separator-color) solid; */ /* border-top: 1px var(--separator-color) solid; */
background-color: var(--background-color); background-color: var(--greeter-bg-color);
} }
.bottom { .bottom {

View file

@ -6,6 +6,7 @@ import "./BottomPanel.css";
import { PersistentDetailsLocalStorage } from "../Components/PersistentDetails"; import { PersistentDetailsLocalStorage } from "../Components/PersistentDetails";
import { Logo } from "@/App/Logo/Logo"; import { Logo } from "@/App/Logo/Logo";
import { AppState } from "../App"; import { AppState } from "../App";
import { FindReplace } from "./FindReplace";
import { VisualEditorState } from "../VisualEditor/VisualEditor"; import { VisualEditorState } from "../VisualEditor/VisualEditor";
import { Setters } from "../makePartialSetter"; import { Setters } from "../makePartialSetter";
@ -13,10 +14,9 @@ import gitRev from "@/git-rev.txt";
export function BottomPanel(props: {errors: TraceableError[], setEditorState: Dispatch<(state: VisualEditorState) => VisualEditorState>} & AppState & Setters<AppState>) { export function BottomPanel(props: {errors: TraceableError[], setEditorState: Dispatch<(state: VisualEditorState) => VisualEditorState>} & AppState & Setters<AppState>) {
const [greeting, setGreeting] = useState( const [greeting, setGreeting] = useState(
<div className="greeter" style={{textAlign:'center'}} onClick={() => setGreeting(<></>)}> <div className="greeter" style={{textAlign:'center'}}>
<span style={{fontSize: 18, fontStyle: 'italic'}}> <span style={{fontSize: 18, fontStyle: 'italic'}}>
Welcome to Welcome to <Logo/>
<Logo width={250} height="auto" style={{verticalAlign: 'middle'}}/>
</span> </span>
</div>); </div>);
@ -26,18 +26,16 @@ export function BottomPanel(props: {errors: TraceableError[], setEditorState: Di
}, 2000); }, 2000);
}, []); }, []);
return <div className="bottom"> return <div className="toolbar bottom">
{greeting}
{/* {props.showFindReplace && {/* {props.showFindReplace &&
<div> <div>
<FindReplace setCS={props.setEditorState} hide={() => props.setShowFindReplace(false)}/> <FindReplace setCS={props.setEditorState} hide={() => props.setShowFindReplace(false)}/>
</div> </div>
} */} } */}
<div className={"stackHorizontal statusBar" + (props.errors.length ? " error" : "")}> <div className={"statusBar" + props.errors.length ? " error" : ""}>
<div style={{flexGrow:1}}>
<PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded"> <PersistentDetailsLocalStorage initiallyOpen={false} localStorageKey="errorsExpanded">
<summary>{props.errors.length} errors</summary> <summary>{props.errors.length} errors</summary>
<div style={{maxHeight: '20vh', overflow: 'auto'}}> <div style={{maxHeight: '25vh', overflow: 'auto'}}>
{props.errors.map(({message, shapeUid})=> {props.errors.map(({message, shapeUid})=>
<div> <div>
{shapeUid}: {message} {shapeUid}: {message}
@ -45,16 +43,6 @@ export function BottomPanel(props: {errors: TraceableError[], setEditorState: Di
</div> </div>
</PersistentDetailsLocalStorage> </PersistentDetailsLocalStorage>
</div> </div>
<div style={{display: 'flex', alignItems: 'center'}}> {greeting}
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
&nbsp;|&nbsp;
Rev:&nbsp;<a title={"git"} href={`https://deemz.org/git/research/statebuddy/commit/${gitRev}`}>{gitRev.slice(0,8)}</a>
</div>
</div>
</div>; </div>;
} }

View file

@ -1,82 +1,48 @@
import { Dispatch, FormEvent, SetStateAction, useCallback } from "react"; import { Dispatch, useCallback, useEffect } from "react";
import { VisualEditorState } from "../VisualEditor/VisualEditor"; import { VisualEditorState } from "../VisualEditor/VisualEditor";
import { usePersistentState } from "@/hooks/usePersistentState";
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import SwapVertIcon from '@mui/icons-material/SwapVert'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { useShortcuts } from "@/hooks/useShortcuts";
type FindReplaceProps = { type FindReplaceProps = {
findText: string,
replaceText: string,
setFindReplaceText: Dispatch<SetStateAction<[string, string]>>,
cs: VisualEditorState,
setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>, setCS: Dispatch<(oldState: VisualEditorState) => VisualEditorState>,
// setModal: (modal: null) => void;
hide: () => void, hide: () => void,
}; };
export function FindReplace({findText, replaceText, setFindReplaceText, cs, setCS, hide}: FindReplaceProps) { export function FindReplace({setCS, hide}: FindReplaceProps) {
const [findTxt, setFindText] = usePersistentState("findTxt", "");
const [replaceTxt, setReplaceTxt] = usePersistentState("replaceTxt", "");
const onReplace = useCallback(() => { const onReplace = useCallback(() => {
setCS(cs => { setCS(cs => {
return { return {
...cs, ...cs,
texts: cs.texts.map(txt => ({ texts: cs.texts.map(txt => ({
...txt, ...txt,
text: txt.text.replaceAll(findText, replaceText) text: txt.text.replaceAll(findTxt, replaceTxt)
})), })),
}; };
}); });
}, [findText, replaceText, setCS]); }, [findTxt, replaceTxt]);
useShortcuts([
{keys: ["Enter"], action: onReplace},
])
const onSwap = useCallback(() => { const onSwap = useCallback(() => {
setFindReplaceText(([findText, replaceText]) => [replaceText, findText]); setReplaceTxt(findTxt);
}, [findText, replaceText]); setFindText(replaceTxt);
}, [findTxt, replaceTxt]);
const onSubmit = useCallback((e: FormEvent<HTMLFormElement>) => { return <div className="toolbar toolbarGroup" style={{display: 'flex'}}>
e.preventDefault(); <input placeholder="find" value={findTxt} onChange={e => setFindText(e.target.value)} style={{width:300}}/>
e.stopPropagation(); <button tabIndex={-1} onClick={onSwap}><SwapHorizIcon fontSize="small"/></button>
onReplace(); <input tabIndex={0} placeholder="replace" value={replaceTxt} onChange={(e => setReplaceTxt(e.target.value))} style={{width:300}}/>
// onSwap(); &nbsp;
}, [findText, replaceText, onSwap, onReplace]); <button onClick={onReplace}>replace all</button>
<button onClick={hide} style={{marginLeft: 'auto'}}><CloseIcon fontSize="small"/></button>
const n = findText === "" ? 0 : cs.texts.reduce((count, txt) => count+(txt.text.indexOf(findText) !== -1 ? 1: 0), 0); </div>;
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,8 +1,6 @@
import { SVGAttributes, SVGProps } from "react";
// i couldn't find a better way to make the text in the logo adapt to light/dark mode... // i couldn't find a better way to make the text in the logo adapt to light/dark mode...
export function Logo(props: SVGAttributes<SVGElement>) { export function Logo() {
return <svg style={{maxWidth: '100%'}} width="424.29" height="105.72" version="1.1" viewBox="0 0 424.29 105.72" {...props} > return <svg style={{maxWidth: '100%'}} width="424.29" height="105.72" version="1.1" viewBox="0 0 424.29 105.72">
<style>{` <style>{`
.logoText { .logoText {
fill: var(--text-color); fill: var(--text-color);

View file

@ -70,15 +70,14 @@ export const ShowInputEvents = memo(function ShowInputEvents({inputEvents, onRai
}; };
}); });
// less painful and more readable than figuring out the equivalent of range(n) in JS: const shortcutSpec = raiseHandlers.map((handler, i) => {
// (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; const n = (i+1)%10;
return { return {
keys: [n.toString()], keys: [n.toString()],
action: raiseHandlers[n] || (() => {}), action: handler,
}; };
})); });
useShortcuts(shortcutSpec);
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
@ -108,6 +107,7 @@ export const ShowInputEvents = memo(function ShowInputEvents({inputEvents, onRai
</div>; </div>;
}) })
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
console.log('onRaise changed:', prevProps.onRaise === nextProps.onRaise, prevProps.onRaise, nextProps.onRaise);
return prevProps.onRaise === nextProps.onRaise return prevProps.onRaise === nextProps.onRaise
&& prevProps.disabled === nextProps.disabled && prevProps.disabled === nextProps.disabled
&& jsonDeepEqual(prevProps.inputEvents, nextProps.inputEvents); && 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)}> <button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/> <VisibilityIcon fontSize="small"/>
</button> </button>
<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...'/> <input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}> <button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/> <DeleteOutlineIcon fontSize="small"/>
</button> </button>

View file

@ -246,5 +246,11 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
</div> </div>
</div> </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>; </div>;
}); });

View file

@ -32,7 +32,7 @@ export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart
data-parts="start end" /> data-parts="start end" />
{props.error && <text {props.error && <text
className="errorHover" className="error"
x={(start.x + end.x) / 2 + 5} x={(start.x + end.x) / 2 + 5}
y={(start.y + end.y) / 2} y={(start.y + end.y) / 2}
textAnchor="middle" 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> <text x={10} y={20} className="uid">{props.rountangle.uid}</text>
{props.error && {props.error &&
<text className="errorHover" x={30} y={20} data-uid={uid} data-parts="left top right bottom">{props.error}</text>} <text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.error}</text>}
<RectHelper uid={uid} size={minSize} <RectHelper uid={uid} size={minSize}
selected={props.selected} selected={props.selected}

View file

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

View file

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

View file

@ -52,10 +52,9 @@ type VisualEditorProps = {
highlightTransitions: string[], highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number; zoom: number;
findText: string;
}; };
export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom, findText}: VisualEditorProps) { export const VisualEditor = memo(function VisualEditor({state, commitState, replaceState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, zoom}: 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. // 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); // const [temporaryState, setTemporaryState] = useState<VisualEditorState | null>(null);
@ -200,6 +199,8 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
</marker> </marker>
</defs> </defs>
{(rootErrors.length>0) && <text className="error" x={5} y={20}>{rootErrors.join(' ')}</text>}
<Rountangles rountangles={state.rountangles} {...{selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}}/> <Rountangles rountangles={state.rountangles} {...{selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}}/>
<Diamonds diamonds={state.diamonds} {...{selection, sidesToHighlight, rountanglesToHighlight, errors}}/> <Diamonds diamonds={state.diamonds} {...{selection, sidesToHighlight, rountanglesToHighlight, errors}}/>
@ -234,9 +235,7 @@ export const VisualEditor = memo(function VisualEditor({state, commitState, repl
} }
)} )}
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal, findText}}/> <Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
{(rootErrors.length>0) && <text className="errorHover" x={5} y={20} style={{display:'inline'}}>{rootErrors.join('\n')}</text>}
{selectionRect} {selectionRect}
</svg>; </svg>;
@ -283,7 +282,7 @@ const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight,
&& arraysEqual(p.errors, n.errors); && arraysEqual(p.errors, n.errors);
}); });
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}) { 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>>}) {
return <>{texts.map(txt => { return <>{texts.map(txt => {
return <TextSVG return <TextSVG
key={txt.uid} key={txt.uid}
@ -293,7 +292,6 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
highlight={textsToHighlight.hasOwnProperty(txt.uid)} highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText} onEdit={onEditText}
setModal={setModal} setModal={setModal}
findText={findText}
/> />
})}</>; })}</>;
}, (p, n) => { }, (p, n) => {
@ -302,7 +300,6 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
&& objectsEqual(p.textsToHighlight, n.textsToHighlight) && objectsEqual(p.textsToHighlight, n.textsToHighlight)
&& arraysEqual(p.errors, n.errors) && arraysEqual(p.errors, n.errors)
&& p.onEditText === n.onEditText && 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, ...t,
topLeft: addV2D(t.topLeft, pointerDelta), topLeft: addV2D(t.topLeft, pointerDelta),
} }
}).toSorted((a,b) => a.topLeft.y - b.topLeft.y), }),
})); }));
setDragging(true); setDragging(true);
} }

View file

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

View file

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

View file

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

View file

@ -16,12 +16,6 @@ html, body {
--light-accent-color: light-dark(rgba(0,0,255,0.2), rgba(78, 186, 248, 0.377)); --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-border-color: light-dark(blue, rgb(64, 185, 255));
--accent-opaque-color: light-dark(#ccccff, #305b73); --accent-opaque-color: light-dark(#ccccff, #305b73);
--statusbar-bg-color: light-dark(rgb(225, 229, 235), 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)); --separator-color: light-dark(lightgrey, rgb(58, 58, 58));
--inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29)); --inactive-bg-color: light-dark(#f7f7f7, rgb(29, 29, 29));
--inactive-fg-color: light-dark(grey, rgb(70, 70, 70)); --inactive-fg-color: light-dark(grey, rgb(70, 70, 70));
@ -46,7 +40,6 @@ html, body {
--input-event-hover-bg-color: light-dark(rgb(195, 224, 176), rgb(59, 88, 40)); --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)); --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)); --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); background-color: var(--background-color);
color: var(--text-color); color: var(--text-color);
@ -57,16 +50,16 @@ input {
border: 1px solid var(--separator-color); border: 1px solid var(--separator-color);
} }
button, input[type="submit"] { button {
background-color: var(--button-bg-color); background-color: var(--button-bg-color);
border: 1px var(--separator-color) solid; border: 1px var(--separator-color) solid;
} }
button:not(:disabled):hover, input[type="submit"]:not(:disabled):hover { button:not(:disabled):hover {
background-color: var(--light-accent-color); background-color: var(--light-accent-color);
} }
button:disabled, input[type="submit"]:disabled { button:disabled {
background-color: var(--inactive-bg-color); background-color: var(--inactive-bg-color);
color: var(--inactive-fg-color); color: var(--inactive-fg-color);
} }

View file

@ -40,23 +40,8 @@ export type ConcreteSyntax = {
export type RectSide = "left" | "top" | "right" | "bottom"; export type RectSide = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end"; export type ArrowPart = "start" | "end";
export const initialEditorState: VisualEditorState = { export const emptyState: VisualEditorState = {
rountangles: [{ rountangles: [], texts: [], arrows: [], diamonds: [], history: [], nextID: 0, selection: [],
uid:"0",
topLeft:{x:76.25,y:122.5},
size:{x:133.75,y:103.75},
kind:"and"
}],
diamonds:[],
history:[],
arrows:[{
uid:"39",
start:{x:85,y:67.5},
end:{x:116.25,y:116.25}
}],
texts:[],
nextID: 1,
selection: [],
}; };
// used to find which rountangle an arrow connects to (src/tgt) // used to find which rountangle an arrow connects to (src/tgt)

View file

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

View file

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