editor: better rendering of helper outlines

This commit is contained in:
Joeri Exelmans 2025-10-17 11:34:49 +02:00
parent 9401c02800
commit e8fda9bdf0
8 changed files with 95 additions and 116 deletions

View file

@ -52,6 +52,13 @@ function PseudoStateIcon(props: {}) {
</svg>; </svg>;
} }
function HistoryIcon(props: {kind: "shallow"|"deep"}) {
const w=20, h=20;
const text = props.kind === "shallow" ? "H" : "H*";
return <svg width={w} height={h}><circle cx={w/2} cy={h/2} r={Math.min(w,h)/2-1} fill="white" stroke="black"/><text x={w/2} y={h/2+4} textAnchor="middle" fontSize={11} fontWeight={400}>{text}</text></svg>;
}
export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode, setMode}: TopPanelProps) { export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode, setMode}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState("0.000"); const [displayTime, setDisplayTime] = useState("0.000");
const [timescale, setTimescale] = useState(1); const [timescale, setTimescale] = useState(1);
@ -111,6 +118,8 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode
["and", "AND-states", <RountangleIcon kind="and"/>], ["and", "AND-states", <RountangleIcon kind="and"/>],
["or", "OR-states", <RountangleIcon kind="or"/>], ["or", "OR-states", <RountangleIcon kind="or"/>],
["pseudo", "pseudo-states", <PseudoStateIcon/>], ["pseudo", "pseudo-states", <PseudoStateIcon/>],
["shallow", "shallow history", <HistoryIcon kind="shallow"/>],
["deep", "deep history", <HistoryIcon kind="deep"/>],
["transition", "transitions", <TrendingFlatIcon fontSize="small"/>], ["transition", "transitions", <TrendingFlatIcon fontSize="small"/>],
["text", "text", <>&nbsp;T&nbsp;</>], ["text", "text", <>&nbsp;T&nbsp;</>],
] as [InsertMode, string, ReactElement][]).map(([m, hint, buttonTxt]) => ] as [InsertMode, string, ReactElement][]).map(([m, hint, buttonTxt]) =>

View file

@ -31,7 +31,7 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri
data-parts="start end">{props.errors.join(' ')}</text>} data-parts="start end">{props.errors.join(' ')}</text>}
<path <path
className="pathHelper" className="pathHelper helper"
d={`M ${start.x} ${start.y} d={`M ${start.x} ${start.y}
${arcOrLine} ${arcOrLine}
${end.x} ${end.y}`} ${end.x} ${end.y}`}
@ -39,7 +39,7 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri
data-parts="start end" /> data-parts="start end" />
<circle <circle
className={"circleHelper" className={"circleHelper helper"
+ (props.selected.includes("start") ? " selected" : "")} + (props.selected.includes("start") ? " selected" : "")}
cx={start.x} cx={start.x}
cy={start.y} cy={start.y}
@ -47,7 +47,7 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri
data-uid={uid} data-uid={uid}
data-parts="start" /> data-parts="start" />
<circle <circle
className={"circleHelper" className={"circleHelper helper"
+ (props.selected.includes("end") ? " selected" : "")} + (props.selected.includes("end") ? " selected" : "")}
cx={end.x} cx={end.x}
cy={end.y} cy={end.y}

View file

@ -2,72 +2,50 @@ import { RountanglePart } from "../statecharts/concrete_syntax";
import { Vec2D } from "./geometry"; import { Vec2D } from "./geometry";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters"; import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters";
function lineGeometryProps(size: Vec2D): [RountanglePart, object][] {
return [
["top", {x1: 0, y1: 0, x2: size.x, y2: 0 }],
["right", {x1: size.x, y1: 0, x2: size.x, y2: size.y}],
["bottom", {x1: 0, y1: size.y, x2: size.x, y2: size.y}],
["left", {x1: 0, y1: 0, x2: 0, y2: size.y}],
];
}
export function RectHelper(props: { uid: string, size: Vec2D, selected: string[], highlight: RountanglePart[] }) { export function RectHelper(props: { uid: string, size: Vec2D, selected: string[], highlight: RountanglePart[] }) {
const geomProps = lineGeometryProps(props.size);
return <> return <>
<line {geomProps.map(([side, ps]) => <>
className={"lineHelper" {(props.selected.includes(side) || props.highlight.includes(side)) && <line className={""
+ (props.selected.includes("top") ? " selected" : "") + (props.selected.includes(side) ? " selected" : "")
+ (props.highlight.includes("top") ? " highlight" : "")} + (props.highlight.includes(side) ? " highlight" : "")}
x1={0} {...ps} data-uid={props.uid} data-parts={side}/>
y1={0} }
x2={props.size.x} <line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
y2={0} </>)}
data-uid={props.uid}
data-parts="top" />
<line
className={"lineHelper"
+ (props.selected.includes("right") ? " selected" : "")
+ (props.highlight.includes("right") ? " highlight" : "")}
x1={props.size.x}
y1={0}
x2={props.size.x}
y2={props.size.y}
data-uid={props.uid}
data-parts="right" />
<line
className={"lineHelper"
+ (props.selected.includes("bottom") ? " selected" : "")
+ (props.highlight.includes("bottom") ? " highlight" : "")}
x1={0}
y1={props.size.y}
x2={props.size.x}
y2={props.size.y}
data-uid={props.uid}
data-parts="bottom" />
<line
className={"lineHelper"
+ (props.selected.includes("left") ? " selected" : "")
+ (props.highlight.includes("left") ? " highlight" : "")}
x1={0}
y1={0}
x2={0}
y2={props.size.y}
data-uid={props.uid}
data-parts="left" />
<circle <circle
className="circleHelper corner" className="helper"
cx={CORNER_HELPER_OFFSET} cx={CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET} cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="top left" /> data-parts="top left" />
<circle <circle
className="circleHelper corner" className="helper"
cx={props.size.x - CORNER_HELPER_OFFSET} cx={props.size.x - CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET} cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="top right" /> data-parts="top right" />
<circle <circle
className="circleHelper corner" className="helper"
cx={props.size.x - CORNER_HELPER_OFFSET} cx={props.size.x - CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET} cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}
data-uid={props.uid} data-uid={props.uid}
data-parts="bottom right" /> data-parts="bottom right" />
<circle <circle
className="circleHelper corner" className="helper"
cx={CORNER_HELPER_OFFSET} cx={CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET} cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS} r={CORNER_HELPER_RADIUS}

View file

@ -6,9 +6,9 @@ export function TextSVG(props: {text: Text, error: TraceableError|undefined, sel
"data-uid": props.text.uid, "data-uid": props.text.uid,
"data-parts": "text", "data-parts": "text",
textAnchor: "middle" as "middle", textAnchor: "middle" as "middle",
className: className: "draggableText"
(props.selected ? "selected":"") + (props.selected ? " selected":"")
+(props.highlight ? " highlight":""), + (props.highlight ? " highlight":""),
} }
let textNode; let textNode;
@ -36,6 +36,8 @@ export function TextSVG(props: {text: Text, error: TraceableError|undefined, sel
if (newText) { if (newText) {
props.onEdit(newText); props.onEdit(newText);
} }
}} }}>
>{textNode}</g>; {textNode}
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text">{props.text.text}</text>
</g>;
} }

View file

@ -7,29 +7,16 @@
cursor: grabbing !important; cursor: grabbing !important;
} }
/* do not render helpers while dragging something */
.svgCanvas.dragging .helper:hover {
visibility: hidden !important;
}
.svgCanvas.active { .svgCanvas.active {
background-color: rgb(255, 140, 0, 0.2); background-color: rgb(255, 140, 0, 0.2);
} }
text, text.highlight { /* rectangle drawn while a selection is being made */
user-select: none;
/* text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff; */
/* -webkit-text-stroke: 4px white; */
paint-order: stroke;
stroke: white;
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
fill-opacity:1;
/* font-weight: 800; */
}
text.highlight {
fill: green;
font-weight: 600;
}
.selecting { .selecting {
fill: blue; fill: blue;
fill-opacity: 0.2; fill-opacity: 0.2;
@ -40,23 +27,12 @@ text.highlight {
.rountangle { .rountangle {
fill: white; fill: white;
/* fill: none; */
stroke: black; stroke: black;
stroke-width: 2px; stroke-width: 2px;
} }
.rountangle:hover {
/* stroke: blue; */
/* stroke-opacity: 0.2; */
/* fill: #eee; */
/* stroke-width: 4px; */
/* cursor: grab; */
}
.rountangle.selected { .rountangle.selected {
fill: rgba(0, 0, 255, 0.2); fill: rgba(0, 0, 255, 0.2);
/* stroke: blue;
stroke-width: 4px; */
} }
.rountangle.error { .rountangle.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
@ -72,32 +48,31 @@ text.highlight {
cursor: grab; cursor: grab;
} }
.lineHelper { line.helper {
stroke: rgba(0, 0, 0, 0); stroke: rgba(0, 0, 0, 0);
stroke-width: 16px; stroke-width: 16px;
} }
.lineHelper:hover:not(:active) { line.helper:hover:not(:active) {
stroke: blue; stroke: blue;
stroke-opacity: 0.2; stroke-opacity: 0.2;
cursor: grab; cursor: grab;
} }
.pathHelper { path.helper {
fill: none; fill: none;
stroke: rgba(0, 0, 0, 0); stroke: rgba(0, 0, 0, 0);
stroke-width: 16px; stroke-width: 16px;
} }
.pathHelper:hover:not(:active) { path.helper:hover:not(:active) {
stroke: blue; stroke: blue;
stroke-opacity: 0.2; stroke-opacity: 0.2;
cursor: grab; cursor: grab;
} }
circle.helper {
.circleHelper {
fill: rgba(0, 0, 0, 0); fill: rgba(0, 0, 0, 0);
} }
.circleHelper:hover:not(:active) { circle.helper:hover:not(:active) {
fill: blue; fill: blue;
fill-opacity: 0.2; fill-opacity: 0.2;
cursor: grab; cursor: grab;
@ -118,12 +93,6 @@ text.highlight {
stroke-width: 3px; stroke-width: 3px;
} }
/* .arrow.selected {
stroke: blue;
stroke-width: 4px;
} */
#arrowEnd { #arrowEnd {
fill: context-stroke; fill: context-stroke;
} }
@ -139,14 +108,43 @@ line.selected, circle.selected {
stroke-width: 4px; stroke-width: 4px;
} }
text.selected, text.selected:hover { .draggableText.selected, .draggableText.selected:hover {
fill: blue; fill: blue;
font-weight: 600; font-weight: 600;
} }
text:hover:not(:active) { .draggableText:hover:not(:active) {
fill: blue; fill: blue;
cursor: grab; cursor: grab;
} }
text.helper {
fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0);
stroke-width: 8px;
}
text.helper:hover {
stroke: blue;
stroke-opacity: 0.2;
}
.draggableText, .draggableText.highlight {
user-select: none;
/* text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff; */
/* -webkit-text-stroke: 4px white; */
paint-order: stroke;
stroke: white;
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
fill-opacity:1;
/* font-weight: 800; */
}
.draggableText.highlight {
fill: green;
font-weight: 600;
}
.highlight { .highlight {
stroke: green; stroke: green;
@ -156,7 +154,7 @@ text:hover:not(:active) {
.arrow.error { .arrow.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
} }
text.error, tspan.error { .draggableText.error, tspan.error {
fill: rgb(230,0,0); fill: rgb(230,0,0);
font-weight: 600; font-weight: 600;
} }

View file

@ -21,11 +21,18 @@ export type Arrow = {
uid: string; uid: string;
} & Line2D; } & Line2D;
export type History = {
uid: string;
kind: "shallow" | "deep";
topLeft: Vec2D;
};
export type VisualEditorState = { export type VisualEditorState = {
rountangles: Rountangle[]; rountangles: Rountangle[];
texts: Text[]; texts: Text[];
arrows: Arrow[]; arrows: Arrow[];
diamonds: Diamond[]; diamonds: Diamond[];
history: History[];
nextID: number; nextID: number;
}; };
@ -34,19 +41,7 @@ export type RountanglePart = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end"; export type ArrowPart = "start" | "end";
export const emptyState: VisualEditorState = { export const emptyState: VisualEditorState = {
rountangles: [], texts: [], arrows: [], diamonds: [], nextID: 0, rountangles: [], texts: [], arrows: [], diamonds: [], history: [], nextID: 0,
};
export const onOffStateMachine = {
rountangles: [
{ uid: "0", topLeft: { x: 100, y: 100 }, size: { x: 100, y: 100 }, kind: "and" },
{ uid: "1", topLeft: { x: 100, y: 300 }, size: { x: 100, y: 100 }, kind: "and" },
],
texts: [],
arrows: [
{ uid: "2", start: { x: 150, y: 200 }, end: { x: 160, y: 300 } },
],
nextID: 3,
}; };
// used to find which rountangle an arrow connects to (src/tgt) // used to find which rountangle an arrow connects to (src/tgt)

View file

@ -20,7 +20,7 @@ type EnteredScope = { enteredStates: Mode } & ActionScope;
export function entryActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope { export function entryActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope {
// console.log('enter', stateDescription(state), '...'); // console.log('enter', stateDescription(state), '...');
let {environment, ...rest} = actionScope; let {environment, ...rest} = actionScope;
environment = environment.pushScope(); // environment = environment.pushScope();
for (const action of state.entryActions) { for (const action of state.entryActions) {
({environment, ...rest} = execAction(action, {environment, ...rest})); ({environment, ...rest} = execAction(action, {environment, ...rest}));
} }
@ -52,7 +52,7 @@ export function exitActions(simtime: number, state: ConcreteState, actionScope:
// remove all timers of 'state': // remove all timers of 'state':
return oldTimers.filter(([_, {state: s}]) => s !== state.uid); return oldTimers.filter(([_, {state: s}]) => s !== state.uid);
}, []); }, []);
environment = environment.popScope(); // environment = environment.popScope();
return {...actionScope, environment}; return {...actionScope, environment};
} }

View file

@ -17,8 +17,6 @@ export type TimerElapseEvent = {
export type Mode = Set<string>; // set of active states export type Mode = Set<string>; // set of active states
// export type Environment = ReadonlyMap<string, any>; // variable name -> value
export class Environment { export class Environment {
scopes: ReadonlyMap<string, any>[]; // array of nested scopes - scope at the back of the array is used first scopes: ReadonlyMap<string, any>[]; // array of nested scopes - scope at the back of the array is used first
@ -114,7 +112,6 @@ export type RaisedEvent = {
param?: any, param?: any,
} }
export type RaisedEvents = { export type RaisedEvents = {
internalEvents: RaisedEvent[]; internalEvents: RaisedEvent[];
outputEvents: RaisedEvent[]; outputEvents: RaisedEvent[];