store app state in URL hash

This commit is contained in:
Joeri Exelmans 2025-10-07 15:49:19 +02:00
parent 41f34ab65e
commit 9dd72484fa
10 changed files with 319 additions and 125 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View file

@ -4,6 +4,7 @@
"": {
"name": "bun-react-template",
"dependencies": {
"@nick/lz4": "npm:@jsr/nick__lz4",
"react": "^19",
"react-dom": "^19",
},
@ -15,6 +16,8 @@
},
},
"packages": {
"@nick/lz4": ["@jsr/nick__lz4@0.3.4", "https://npm.jsr.io/~/11/@jsr/nick__lz4/0.3.4.tgz", {}, "sha512-ZNc+8lCMC8D/cIa9GrSxRcEQC/MyThBOXXlg6rhrvAWSUcKPODwvscsVA+v1UugiBzfJ2dvQIZ/j8484PMadkg=="],
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],

View file

@ -11,6 +11,7 @@
"start": "NODE_ENV=production bun src/index.tsx"
},
"dependencies": {
"@nick/lz4": "npm:@jsr/nick__lz4",
"react": "^19",
"react-dom": "^19"
},

View file

@ -136,3 +136,11 @@ text.error, tspan.error {
fill: rgb(230,0,0);
font-weight: 600;
}
.errorHover {
display: none;
}
g:hover > .errorHover {
display: inline;
}

View file

@ -8,6 +8,8 @@ import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, Rountangle
import { parseStatechart } from "./parser";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters";
import * as lz4 from "@nick/lz4";
type DraggingState = {
lastMousePos: Vec2D;
@ -112,10 +114,15 @@ export function VisualEditor() {
const refSVG = useRef<SVGSVGElement>(null);
useEffect(() => {
const recoveredState = JSON.parse(window.localStorage.getItem("state") || "null");
if (recoveredState) {
const compressedState = window.location.hash.slice(1);
try {
const compressedBuffer = Uint8Array.fromBase64(compressedState);
const recoveredState = JSON.parse(new TextDecoder().decode(lz4.decompress(compressedBuffer)));
setState(recoveredState);
}
catch (e) {
console.error("could not recover state:", e);
}
}, []);
useEffect(() => {
@ -123,8 +130,10 @@ export function VisualEditor() {
// 1) it's a hack - prevents us from writing the initial state to localstorage (before having recovered the state that was in localstorage)
// 2) performance: only save when the user does nothing
const timeout = setTimeout(() => {
window.localStorage.setItem("state", JSON.stringify(state));
// console.log('saved to localStorage');
const stateBuffer = new TextEncoder().encode(JSON.stringify(state));
const compressedStateBuffer = lz4.compress(stateBuffer);
const compressedStateString = compressedStateBuffer.toBase64();
window.location.hash = "#"+compressedStateString;
const [statechart, errors] = parseStatechart(state);
console.log('statechart: ', statechart, 'errors:', errors);
@ -402,6 +411,7 @@ export function VisualEditor() {
const text2ArrowMap = new Map<string,string>();
const arrow2TextMap = new Map<string,string[]>();
const text2RountangleMap = new Map<string, string>();
const rountangle2TextMap = new Map<string, string[]>();
for (const arrow of state.arrows) {
const startSide = findNearestRountangleSide(arrow, "start", state.rountangles);
const endSide = findNearestRountangleSide(arrow, "end", state.rountangles);
@ -433,6 +443,9 @@ export function VisualEditor() {
const rountangle = findRountangle(text.topLeft, state.rountangles);
if (rountangle) {
text2RountangleMap.set(text.uid, rountangle.uid);
const texts = rountangle2TextMap.get(rountangle.uid) || [];
texts.push(text.uid);
rountangle2TextMap.set(rountangle.uid, texts);
}
}
}
@ -449,13 +462,14 @@ export function VisualEditor() {
if (startSide) sidesToHighlight[startSide.uid] = [...sidesToHighlight[startSide.uid]||[], startSide.part];
if (endSide) sidesToHighlight[endSide.uid] = [...sidesToHighlight[endSide.uid]||[], endSide.part];
}
const texts = arrow2TextMap.get(selected.uid);
if (texts) {
for (const textUid of texts) {
textsToHighlight[textUid] = true;
}
const texts = [
...(arrow2TextMap.get(selected.uid) || []),
...(rountangle2TextMap.get(selected.uid) || []),
];
for (const textUid of texts) {
textsToHighlight[textUid] = true;
}
const arrows = side2ArrowMap.get(selected.uid);
const arrows = side2ArrowMap.get(selected.uid) || [];
if (arrows) {
for (const [arrowPart, arrowUid] of arrows) {
arrowsToHighlight[arrowUid] = true;
@ -504,7 +518,6 @@ export function VisualEditor() {
{state.arrows.map(arrow => {
const sides = arrow2SideMap.get(arrow.uid);
console.log(sides, arrow);
let arc = "no" as ArcDirection;
if (sides && sides[0]?.uid === sides[1]?.uid && sides[0].uid !== undefined) {
arc = arcDirection(sides[0]?.part, sides[1]?.part);
@ -522,32 +535,33 @@ export function VisualEditor() {
{state.texts.map(txt => {
const err = errors.find(([uid]) => txt.uid === uid)?.[1];
let markedText;
const commonProps = {
"data-uid": txt.uid,
"data-parts": "text",
textAnchor: "middle",
className:
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":""),
}
let textNode;
if (err) {
const {start,end} = err.location;
markedText = <>
textNode = <><text {...commonProps}>
{txt.text.slice(0, start.offset)}
<tspan className="error" data-uid={txt.uid} data-parts="text">
{txt.text.slice(start.offset, end.offset)}
{start.offset === end.offset && <>_</>}
</tspan>
{txt.text.slice(end.offset)}
</>;
</text>
<text className="error errorHover" y={20} textAnchor="middle">{err.message}</text></>;
}
else {
markedText = <>{txt.text}</>;
textNode = <text {...commonProps}>{txt.text}</text>;
}
return <text
return <g
key={txt.uid}
className={
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"")
}
x={txt.topLeft.x}
y={txt.topLeft.y}
textAnchor="middle"
data-uid={txt.uid}
data-parts="text"
transform={`translate(${txt.topLeft.x} ${txt.topLeft.y})`}
onDoubleClick={() => {
const newText = prompt("", txt.text);
if (newText) {
@ -566,10 +580,14 @@ export function VisualEditor() {
}),
}));
}
else if (newText === "") {
setState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== txt.uid),
}));
}
}}
>
{markedText}
</text>;})}
>{textNode}</g>;})}
{selectingState && <Selecting {...selectingState} />}

View file

@ -3,6 +3,7 @@ import { TransitionLabel } from "./label_ast";
export type AbstractState = {
uid: string;
children: ConcreteState[];
comments: [string, string][]; // array of tuple (text-uid, text-text)
}
export type AndState = {

View file

@ -1,11 +1,18 @@
export type ParsedText = TransitionLabel | Comment;
export type TransitionLabel = {
kind: "transitionLabel";
trigger: Trigger;
guard: Expression;
actions: Action[];
}
export type Comment = {
kind: "comment";
text: string;
}
export type Trigger = EventTrigger | AfterTrigger;
export type Trigger = EventTrigger | AfterTrigger | EntryTrigger | ExitTrigger;
export type EventTrigger = {
kind: "event";
@ -17,6 +24,13 @@ export type AfterTrigger = {
durationMs: number;
}
export type EntryTrigger = {
kind: "entry";
}
export type ExitTrigger = {
kind: "exit";
}
export type Action = Assignment | RaiseEvent;

View file

@ -183,13 +183,15 @@ function peg$parse(input, options) {
const peg$c16 = "true";
const peg$c17 = "false";
const peg$c18 = "^";
const peg$c19 = "//";
const peg$c20 = "\n";
const peg$r0 = /^[a-zA-Z0-9]/;
const peg$r1 = /^[0-9]/;
const peg$r2 = /^[ \t\n\r]/;
const peg$r3 = /^[<>]/;
const peg$r4 = /^[+\-]/;
const peg$r5 = /^[*\/]/;
const peg$r2 = /^[<>]/;
const peg$r3 = /^[+\-]/;
const peg$r4 = /^[*\/]/;
const peg$r5 = /^[ \t\n\r]/;
const peg$e0 = peg$literalExpectation("[", false);
const peg$e1 = peg$literalExpectation("]", false);
@ -203,25 +205,29 @@ function peg$parse(input, options) {
const peg$e9 = peg$literalExpectation("=", false);
const peg$e10 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false);
const peg$e11 = peg$classExpectation([["0", "9"]], false, false, false);
const peg$e12 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false, false);
const peg$e13 = peg$literalExpectation("==", false);
const peg$e14 = peg$literalExpectation("!=", false);
const peg$e15 = peg$classExpectation(["<", ">"], false, false, false);
const peg$e16 = peg$literalExpectation("<=", false);
const peg$e17 = peg$literalExpectation(">=", false);
const peg$e18 = peg$classExpectation(["+", "-"], false, false, false);
const peg$e19 = peg$classExpectation(["*", "/"], false, false, false);
const peg$e20 = peg$literalExpectation("(", false);
const peg$e21 = peg$literalExpectation(")", false);
const peg$e22 = peg$literalExpectation("true", false);
const peg$e23 = peg$literalExpectation("false", false);
const peg$e24 = peg$literalExpectation("^", false);
const peg$e12 = peg$literalExpectation("==", false);
const peg$e13 = peg$literalExpectation("!=", false);
const peg$e14 = peg$classExpectation(["<", ">"], false, false, false);
const peg$e15 = peg$literalExpectation("<=", false);
const peg$e16 = peg$literalExpectation(">=", false);
const peg$e17 = peg$classExpectation(["+", "-"], false, false, false);
const peg$e18 = peg$classExpectation(["*", "/"], false, false, false);
const peg$e19 = peg$literalExpectation("(", false);
const peg$e20 = peg$literalExpectation(")", false);
const peg$e21 = peg$literalExpectation("true", false);
const peg$e22 = peg$literalExpectation("false", false);
const peg$e23 = peg$literalExpectation("^", false);
const peg$e24 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false, false);
const peg$e25 = peg$literalExpectation("//", false);
const peg$e26 = peg$anyExpectation();
const peg$e27 = peg$literalExpectation("\n", false);
function peg$f0(trigger, guard, actions) {
return {
trigger,
guard: guard ? guard[2] : {kind: "literal", value: true},
actions: actions ? actions[2] : [],
kind: "transitionLabel",
trigger,
guard: guard ? guard[2] : {kind: "literal", value: true},
actions: actions ? actions[2] : [],
};
}
function peg$f1(event) {
@ -302,6 +308,13 @@ function peg$parse(input, options) {
function peg$f18(event) {
return {kind: "raise", event};
}
function peg$f19() { return null; }
function peg$f20(text) {
return {
kind: "comment",
text: text.join(''),
};
}
let peg$currPos = options.peg$currPos | 0;
let peg$savedPos = peg$currPos;
const peg$posDetailsCache = [{ line: 1, column: 1 }];
@ -473,6 +486,17 @@ function peg$parse(input, options) {
}
function peg$parsestart() {
let s0;
s0 = peg$parsetlabel();
if (s0 === peg$FAILED) {
s0 = peg$parsecomment();
}
return s0;
}
function peg$parsetlabel() {
let s0, s1, s2, s3, s4, s5, s6, s7, s8, s9;
s0 = peg$currPos;
@ -901,33 +925,6 @@ function peg$parse(input, options) {
return s0;
}
function peg$parse_() {
let s0, s1;
peg$silentFails++;
s0 = [];
s1 = input.charAt(peg$currPos);
if (peg$r2.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e12); }
}
while (s1 !== peg$FAILED) {
s0.push(s1);
s1 = input.charAt(peg$currPos);
if (peg$r2.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e12); }
}
}
peg$silentFails--;
return s0;
}
function peg$parsecompare() {
let s0, s1, s2, s3, s4, s5, s6;
@ -942,7 +939,7 @@ function peg$parse(input, options) {
peg$currPos += 2;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e13); }
if (peg$silentFails === 0) { peg$fail(peg$e12); }
}
if (s5 === peg$FAILED) {
if (input.substr(peg$currPos, 2) === peg$c11) {
@ -950,15 +947,15 @@ function peg$parse(input, options) {
peg$currPos += 2;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e14); }
if (peg$silentFails === 0) { peg$fail(peg$e13); }
}
if (s5 === peg$FAILED) {
s5 = input.charAt(peg$currPos);
if (peg$r3.test(s5)) {
if (peg$r2.test(s5)) {
peg$currPos++;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e15); }
if (peg$silentFails === 0) { peg$fail(peg$e14); }
}
if (s5 === peg$FAILED) {
if (input.substr(peg$currPos, 2) === peg$c12) {
@ -966,7 +963,7 @@ function peg$parse(input, options) {
peg$currPos += 2;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e16); }
if (peg$silentFails === 0) { peg$fail(peg$e15); }
}
if (s5 === peg$FAILED) {
if (input.substr(peg$currPos, 2) === peg$c13) {
@ -974,7 +971,7 @@ function peg$parse(input, options) {
peg$currPos += 2;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e17); }
if (peg$silentFails === 0) { peg$fail(peg$e16); }
}
}
}
@ -1024,11 +1021,11 @@ function peg$parse(input, options) {
s3 = peg$currPos;
s4 = peg$parse_();
s5 = input.charAt(peg$currPos);
if (peg$r4.test(s5)) {
if (peg$r3.test(s5)) {
peg$currPos++;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e18); }
if (peg$silentFails === 0) { peg$fail(peg$e17); }
}
if (s5 !== peg$FAILED) {
s6 = peg$parse_();
@ -1074,11 +1071,11 @@ function peg$parse(input, options) {
s3 = peg$currPos;
s4 = peg$parse_();
s5 = input.charAt(peg$currPos);
if (peg$r5.test(s5)) {
if (peg$r4.test(s5)) {
peg$currPos++;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e19); }
if (peg$silentFails === 0) { peg$fail(peg$e18); }
}
if (s5 !== peg$FAILED) {
s6 = peg$parse_();
@ -1137,7 +1134,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e20); }
if (peg$silentFails === 0) { peg$fail(peg$e19); }
}
if (s1 !== peg$FAILED) {
s2 = peg$parse_();
@ -1149,7 +1146,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e21); }
if (peg$silentFails === 0) { peg$fail(peg$e20); }
}
if (s5 !== peg$FAILED) {
peg$savedPos = s0;
@ -1210,7 +1207,7 @@ function peg$parse(input, options) {
peg$currPos += 4;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e22); }
if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
if (s1 === peg$FAILED) {
if (input.substr(peg$currPos, 5) === peg$c17) {
@ -1218,7 +1215,7 @@ function peg$parse(input, options) {
peg$currPos += 5;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e23); }
if (peg$silentFails === 0) { peg$fail(peg$e22); }
}
}
if (s1 !== peg$FAILED) {
@ -1239,7 +1236,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e24); }
if (peg$silentFails === 0) { peg$fail(peg$e23); }
}
if (s1 !== peg$FAILED) {
s2 = peg$parse_();
@ -1259,6 +1256,125 @@ function peg$parse(input, options) {
return s0;
}
function peg$parse_() {
let s0, s1, s2;
peg$silentFails++;
s0 = peg$currPos;
s1 = [];
s2 = peg$parsecomment();
if (s2 === peg$FAILED) {
s2 = input.charAt(peg$currPos);
if (peg$r5.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
while (s2 !== peg$FAILED) {
s1.push(s2);
s2 = peg$parsecomment();
if (s2 === peg$FAILED) {
s2 = input.charAt(peg$currPos);
if (peg$r5.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
}
peg$savedPos = s0;
s1 = peg$f19();
s0 = s1;
peg$silentFails--;
return s0;
}
function peg$parsecomment() {
let s0, s1, s2, s3, s4, s5, s6;
s0 = peg$currPos;
if (input.substr(peg$currPos, 2) === peg$c19) {
s1 = peg$c19;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e25); }
}
if (s1 !== peg$FAILED) {
s2 = peg$parse_();
if (s2 !== peg$FAILED) {
s3 = [];
if (input.length > peg$currPos) {
s4 = input.charAt(peg$currPos);
peg$currPos++;
} else {
s4 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e26); }
}
while (s4 !== peg$FAILED) {
s3.push(s4);
if (input.length > peg$currPos) {
s4 = input.charAt(peg$currPos);
peg$currPos++;
} else {
s4 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e26); }
}
}
s4 = peg$parse_();
if (s4 !== peg$FAILED) {
if (input.charCodeAt(peg$currPos) === 10) {
s5 = peg$c20;
peg$currPos++;
} else {
s5 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e27); }
}
if (s5 === peg$FAILED) {
s5 = peg$currPos;
peg$silentFails++;
if (input.length > peg$currPos) {
s6 = input.charAt(peg$currPos);
peg$currPos++;
} else {
s6 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e26); }
}
peg$silentFails--;
if (s6 === peg$FAILED) {
s5 = undefined;
} else {
peg$currPos = s5;
s5 = peg$FAILED;
}
}
if (s5 !== peg$FAILED) {
peg$savedPos = s0;
s0 = peg$f20(s3);
} else {
peg$currPos = s0;
s0 = peg$FAILED;
}
} else {
peg$currPos = s0;
s0 = peg$FAILED;
}
} else {
peg$currPos = s0;
s0 = peg$FAILED;
}
} else {
peg$currPos = s0;
s0 = peg$FAILED;
}
return s0;
}
peg$result = peg$startRuleFunction();
const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length);

View file

@ -1,8 +1,8 @@
import { act } from "react";
import { ConcreteState, OrState, Statechart, Transition } from "./ast";
import { findNearestArrow, findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types";
import { findNearestArrow, findNearestRountangleSide, findRountangle, Rountangle, VisualEditorState } from "./editor_types";
import { isEntirelyWithin, transformLine } from "./geometry";
import { Action, Expression, TransitionLabel } from "./label_ast";
import { Action, Expression, ParsedText, TransitionLabel } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser";
@ -37,6 +37,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
kind: rt.kind,
uid: rt.uid,
children: [],
comments: [],
}
if (state.kind === "or") {
state.initial = [];
@ -122,18 +123,28 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
// step 3: figure out labels
for (const text of state.texts) {
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
if (belongsToArrow) {
const belongsToTransition = uid2Transition.get(belongsToArrow.uid);
if (belongsToTransition) {
// parse as transition label
let transitionLabel: TransitionLabel;
try {
transitionLabel = parseLabel(text.text); // may throw
belongsToTransition.label.push(transitionLabel);
let parsed: ParsedText;
try {
parsed = parseLabel(text.text); // may throw
} catch (e) {
if (e instanceof SyntaxError) {
errorShapes.push([text.uid, e]);
continue;
}
else {
throw e;
}
}
if (parsed.kind === "transitionLabel") {
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
if (belongsToArrow) {
const belongsToTransition = uid2Transition.get(belongsToArrow.uid);
if (belongsToTransition) {
// parse as transition label
belongsToTransition.label.push(parsed);
// collect events
if (transitionLabel.trigger.kind === "event") {
const {event} = transitionLabel.trigger;
if (parsed.trigger.kind === "event") {
const {event} = parsed.trigger;
if (event.startsWith("_")) {
internalEvents.add(event);
}
@ -141,7 +152,7 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
inputEvents.add(event);
}
}
for (const action of transitionLabel.actions) {
for (const action of parsed.actions) {
if (action.kind === "raise") {
const {event} = action;
if (event.startsWith("_")) {
@ -154,22 +165,31 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
}
// collect variables
variables = variables
.union(findVariables(transitionLabel.guard));
for (const action of transitionLabel.actions) {
.union(findVariables(parsed.guard));
for (const action of parsed.actions) {
variables = variables.union(findVariablesAction(action));
}
}
catch (e) {
if (e instanceof SyntaxError) {
belongsToTransition.label.push(null);
errorShapes.push([text.uid, e]);
}
else {
throw e;
}
}
continue;
}
}
// text does not belong to transition...
// so it belongs to a rountangle (a state)
const rountangle = findRountangle(text.topLeft, state.rountangles);
if (parsed.kind === "transitionLabel") {
// labels belonging to a rountangle (= a state) must by entry/exit actions
// if we cannot find a containing state, then it belong to the root
const state = rountangle ? uid2State.get(rountangle.uid)! : root;
if (parsed.trigger.kind !== "entry" && parsed.trigger.kind !== "exit") {
errorShapes.push([text.uid, {
message: "states can only have entry/exit triggers",
location: {start: {offset: 0}, end: {offset: text.text.length}},
}]);
}
}
else if (parsed.kind === "comment") {
// just append comments to their respective states
}
}
for (const transition of uid2Transition.values()) {

View file

@ -1,9 +1,12 @@
start = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ {
return {
trigger,
guard: guard ? guard[2] : {kind: "literal", value: true},
actions: actions ? actions[2] : [],
};
start = tlabel / comment
tlabel = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ {
return {
kind: "transitionLabel",
trigger,
guard: guard ? guard[2] : {kind: "literal", value: true},
actions: actions ? actions[2] : [],
};
}
trigger = afterTrigger / entryTrigger / exitTrigger / eventTrigger
@ -52,9 +55,6 @@ number = [0-9]+ {
return parseInt(text());
}
_ "whitespace"
= [ \t\n\r]*
expr = compare
compare = sum:sum rest:((_ ("==" / "!=" / "<" / ">" / "<=" / ">=") _) compare)? {
@ -114,3 +114,15 @@ boolean = ("true" / "false") {
raise = "^" _ event:identifier {
return {kind: "raise", event};
}
_ "whitespace"
= (comment / [ \t\n\r])*
{ return null; }
comment = "//" _ text:.* _ ('\n' / !.) {
return {
kind: "comment",
text: text.join(''),
};
}