can draw history states
This commit is contained in:
parent
e8fda9bdf0
commit
28071eb1f3
8 changed files with 166 additions and 63 deletions
|
|
@ -28,31 +28,47 @@ export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: stri
|
|||
y={(start.y + end.y) / 2}
|
||||
textAnchor="middle"
|
||||
data-uid={uid}
|
||||
data-parts="start end">{props.errors.join(' ')}</text>}
|
||||
data-parts="start end">{props.errors.join(', ')}</text>}
|
||||
|
||||
<path
|
||||
className="pathHelper helper"
|
||||
className="helper"
|
||||
d={`M ${start.x} ${start.y}
|
||||
${arcOrLine}
|
||||
${end.x} ${end.y}`}
|
||||
data-uid={uid}
|
||||
data-parts="start end" />
|
||||
|
||||
{/* selection helper circles */}
|
||||
<circle
|
||||
className={"circleHelper helper"
|
||||
+ (props.selected.includes("start") ? " selected" : "")}
|
||||
className="helper"
|
||||
cx={start.x}
|
||||
cy={start.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="start" />
|
||||
<circle
|
||||
className={"circleHelper helper"
|
||||
+ (props.selected.includes("end") ? " selected" : "")}
|
||||
className="helper"
|
||||
cx={end.x}
|
||||
cy={end.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="end" />
|
||||
|
||||
{/* selection indicator circles */}
|
||||
{props.selected.includes("start") && <circle
|
||||
className="selected"
|
||||
cx={start.x}
|
||||
cy={start.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="start" />}
|
||||
{props.selected.includes("end") && <circle
|
||||
className="selected"
|
||||
cx={end.x}
|
||||
cy={end.y}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={uid}
|
||||
data-parts="end" />}
|
||||
|
||||
</g>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,10 @@ export function DiamondSVG(props: { diamond: Diamond; selected: string[]; highli
|
|||
return <g transform={`translate(${props.diamond.topLeft.x} ${props.diamond.topLeft.y})`}>
|
||||
<DiamondShape size={minSize} extraAttrs={extraAttrs}/>
|
||||
|
||||
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
|
||||
|
||||
<text x={minSize.x/2} y={minSize.y/2}
|
||||
className="uid"
|
||||
textAnchor="middle"
|
||||
data-uid={props.diamond.uid}>{props.diamond.uid}</text>
|
||||
textAnchor="middle">{props.diamond.uid}</text>
|
||||
|
||||
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
|
||||
</g>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,33 @@
|
|||
export function ShallowHistorySVG() {
|
||||
import { Vec2D } from "./geometry";
|
||||
import { HISTORY_RADIUS } from "./parameters";
|
||||
|
||||
export function HistorySVG(props: {uid: string, topLeft: Vec2D, kind: "shallow"|"deep", selected: boolean}) {
|
||||
const text = props.kind === "shallow" ? "H" : "H*";
|
||||
return <>
|
||||
<circle
|
||||
className={props.selected ? "selected":""}
|
||||
cx={props.topLeft.x+HISTORY_RADIUS}
|
||||
cy={props.topLeft.y+HISTORY_RADIUS}
|
||||
r={HISTORY_RADIUS}
|
||||
fill="white"
|
||||
stroke="black"
|
||||
strokeWidth={2}
|
||||
data-uid={props.uid}
|
||||
data-parts="history"
|
||||
/>
|
||||
<text
|
||||
x={props.topLeft.x+HISTORY_RADIUS}
|
||||
y={props.topLeft.y+HISTORY_RADIUS+4}
|
||||
textAnchor="middle"
|
||||
fontWeight={500}
|
||||
>{text}</text>
|
||||
<circle
|
||||
className="helper"
|
||||
cx={props.topLeft.x+HISTORY_RADIUS}
|
||||
cy={props.topLeft.y+HISTORY_RADIUS}
|
||||
r={HISTORY_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="history"
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
|
@ -23,29 +23,30 @@ export function RectHelper(props: { uid: string, size: Vec2D, selected: string[]
|
|||
<line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
|
||||
</>)}
|
||||
|
||||
{/* The corner-helpers have the DOM class 'corner' added to them, because we ignore them when the user is making a selection. Only if the user clicks directly on them, do we select their respective parts. */}
|
||||
<circle
|
||||
className="helper"
|
||||
className="helper corner"
|
||||
cx={CORNER_HELPER_OFFSET}
|
||||
cy={CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="top left" />
|
||||
<circle
|
||||
className="helper"
|
||||
className="helper corner"
|
||||
cx={props.size.x - CORNER_HELPER_OFFSET}
|
||||
cy={CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="top right" />
|
||||
<circle
|
||||
className="helper"
|
||||
className="helper corner"
|
||||
cx={props.size.x - CORNER_HELPER_OFFSET}
|
||||
cy={props.size.y - CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
data-uid={props.uid}
|
||||
data-parts="bottom right" />
|
||||
<circle
|
||||
className="helper"
|
||||
className="helper corner"
|
||||
cx={CORNER_HELPER_OFFSET}
|
||||
cy={props.size.y - CORNER_HELPER_OFFSET}
|
||||
r={CORNER_HELPER_RADIUS}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
|
|||
{...extraAttrs}
|
||||
/>
|
||||
|
||||
<text x={10} y={20} className="uid">{props.rountangle.uid}</text>
|
||||
|
||||
{(props.errors.length > 0) &&
|
||||
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
|
||||
|
||||
|
|
@ -35,9 +37,6 @@ export function RountangleSVG(props: { rountangle: Rountangle; selected: string[
|
|||
selected={props.selected}
|
||||
highlight={props.highlight} />
|
||||
|
||||
<text x={10} y={20}
|
||||
className="uid"
|
||||
data-uid={props.rountangle.uid}>{props.rountangle.uid}</text>
|
||||
|
||||
</g>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
background-color: rgb(255, 140, 0, 0.2);
|
||||
}
|
||||
|
||||
.svgCanvas text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* rectangle drawn while a selection is being made */
|
||||
.selecting {
|
||||
fill: blue;
|
||||
|
|
@ -32,7 +36,7 @@
|
|||
}
|
||||
|
||||
.rountangle.selected {
|
||||
fill: rgba(0, 0, 255, 0.2);
|
||||
/* fill: rgba(0, 0, 255, 0.2); */
|
||||
}
|
||||
.rountangle.error {
|
||||
stroke: rgb(230,0,0);
|
||||
|
|
@ -127,7 +131,6 @@ text.helper:hover {
|
|||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -154,7 +157,7 @@ text.helper:hover {
|
|||
.arrow.error {
|
||||
stroke: rgb(230,0,0);
|
||||
}
|
||||
.draggableText.error, tspan.error {
|
||||
text.error, tspan.error {
|
||||
fill: rgb(230,0,0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { ArrowSVG } from "./ArrowSVG";
|
|||
import { RountangleSVG } from "./RountangleSVG";
|
||||
import { TextSVG } from "./TextSVG";
|
||||
import { DiamondSVG } from "./DiamondSVG";
|
||||
import { HistorySVG } from "./HistorySVG";
|
||||
|
||||
|
||||
type DraggingState = {
|
||||
|
|
@ -36,7 +37,11 @@ type TextSelectable = {
|
|||
parts: ["text"];
|
||||
uid: string;
|
||||
}
|
||||
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
|
||||
type HistorySelectable = {
|
||||
parts: ["history"];
|
||||
uid: string;
|
||||
}
|
||||
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
|
||||
type Selection = Selectable[];
|
||||
|
||||
type HistoryState = {
|
||||
|
|
@ -52,7 +57,7 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
|
|||
["bottom", getBottomSide],
|
||||
];
|
||||
|
||||
export type InsertMode = "and"|"or"|"pseudo"|"transition"|"text";
|
||||
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
|
||||
|
||||
type VisualEditorProps = {
|
||||
setAST: Dispatch<SetStateAction<Statechart>>,
|
||||
|
|
@ -65,8 +70,6 @@ type VisualEditorProps = {
|
|||
export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) {
|
||||
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
|
||||
|
||||
const [clipboard, setClipboard] = useState<Set<string>>(new Set());
|
||||
|
||||
const state = historyState.current;
|
||||
const setState = (s: SetStateAction<VisualEditorState>) => {
|
||||
setHistoryState(historyState => {
|
||||
|
|
@ -170,7 +173,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
}
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const onMouseDown = (e: {button: number, target: any, pageX: number, pageY: number}) => {
|
||||
const currentPointer = getCurrentPointer(e);
|
||||
|
||||
if (e.button === 2) {
|
||||
|
|
@ -204,6 +207,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
nextID: state.nextID+1,
|
||||
};
|
||||
}
|
||||
else if (mode === "shallow" || mode === "deep") {
|
||||
setSelection([{uid: newID, parts: ["history"]}]);
|
||||
return {
|
||||
...state,
|
||||
history: [...state.history, {
|
||||
uid: newID,
|
||||
kind: mode,
|
||||
topLeft: currentPointer,
|
||||
}],
|
||||
nextID: state.nextID+1,
|
||||
}
|
||||
}
|
||||
else if (mode === "transition") {
|
||||
setSelection([{uid: newID, parts: ["end"]}]);
|
||||
return {
|
||||
|
|
@ -238,11 +253,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
|
||||
if (e.button === 0) {
|
||||
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
|
||||
// @ts-ignore
|
||||
const uid = e.target?.dataset.uid;
|
||||
// @ts-ignore
|
||||
const parts: string[] = e.target?.dataset.parts?.split(' ') || [];
|
||||
if (uid) {
|
||||
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
|
||||
if (uid && parts.length > 0) {
|
||||
console.log('start drag');
|
||||
checkPoint();
|
||||
|
||||
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
|
||||
|
|
@ -254,7 +268,18 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
}
|
||||
}
|
||||
if (!allPartsInSelection) {
|
||||
setSelection([{uid, parts}] as Selection);
|
||||
if (e.target.classList.contains("helper")) {
|
||||
setSelection([{uid, parts}] as Selection);
|
||||
}
|
||||
else {
|
||||
setDragging(null);
|
||||
setSelectingState({
|
||||
topLeft: currentPointer,
|
||||
size: {x: 0, y: 0},
|
||||
});
|
||||
setSelection([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// start dragging
|
||||
|
|
@ -291,6 +316,26 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
};
|
||||
})
|
||||
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
|
||||
diamonds: state.diamonds.map(d => {
|
||||
const parts = selection.find(selected => selected.uid === d.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
...transformRect(d, parts, pointerDelta),
|
||||
}
|
||||
}),
|
||||
history: state.history.map(h => {
|
||||
const parts = selection.find(selected => selected.uid === h.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return h;
|
||||
}
|
||||
return {
|
||||
...h,
|
||||
topLeft: addV2D(h.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
arrows: state.arrows.map(a => {
|
||||
const parts = selection.find(selected => selected.uid === a.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
|
|
@ -311,16 +356,6 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
topLeft: addV2D(t.topLeft, pointerDelta),
|
||||
}
|
||||
}),
|
||||
diamonds: state.diamonds.map(d => {
|
||||
const parts = selection.find(selected => selected.uid === d.uid)?.parts || [];
|
||||
if (parts.length === 0) {
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
...transformRect(d, parts, pointerDelta),
|
||||
}
|
||||
})
|
||||
}));
|
||||
setDragging({lastMousePos: currentPointer});
|
||||
}
|
||||
|
|
@ -335,7 +370,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e: {pageX: number, pageY: number}) => {
|
||||
const onMouseUp = (e: {target: any, pageX: number, pageY: number}) => {
|
||||
if (dragging) {
|
||||
setDragging(null);
|
||||
// do not persist sizes smaller than 40x40
|
||||
|
|
@ -354,29 +389,43 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
});
|
||||
}
|
||||
if (selectingState) {
|
||||
// we were making a selection
|
||||
const normalizedSS = normalizeRect(selectingState);
|
||||
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
||||
const shapesInSelection = shapes.filter(el => {
|
||||
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
||||
return isEntirelyWithin(bbox, normalizedSS);
|
||||
}).filter(el => !el.classList.contains("corner"));
|
||||
|
||||
const uidToParts = new Map();
|
||||
for (const shape of shapesInSelection) {
|
||||
const uid = shape.dataset.uid;
|
||||
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
|
||||
const uid = e.target?.dataset.uid;
|
||||
if (uid) {
|
||||
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
||||
for (const part of shape.dataset.parts?.split(' ') || []) {
|
||||
parts.add(part);
|
||||
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
|
||||
if (uid) {
|
||||
setSelection(() => [{
|
||||
uid,
|
||||
parts,
|
||||
}]);
|
||||
}
|
||||
uidToParts.set(uid, parts);
|
||||
}
|
||||
}
|
||||
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
||||
uid,
|
||||
parts: [...parts],
|
||||
})));
|
||||
else {
|
||||
// we were making a selection
|
||||
const normalizedSS = normalizeRect(selectingState);
|
||||
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
|
||||
const shapesInSelection = shapes.filter(el => {
|
||||
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
|
||||
return isEntirelyWithin(bbox, normalizedSS);
|
||||
}).filter(el => !el.classList.contains("corner"));
|
||||
|
||||
const uidToParts = new Map();
|
||||
for (const shape of shapesInSelection) {
|
||||
const uid = shape.dataset.uid;
|
||||
if (uid) {
|
||||
const parts: Set<string> = uidToParts.get(uid) || new Set();
|
||||
for (const part of shape.dataset.parts?.split(' ') || []) {
|
||||
parts.add(part);
|
||||
}
|
||||
uidToParts.set(uid, parts);
|
||||
}
|
||||
}
|
||||
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
|
||||
uid,
|
||||
parts: [...parts],
|
||||
})));
|
||||
}
|
||||
}
|
||||
setSelectingState(null); // no longer making a selection
|
||||
};
|
||||
|
|
@ -385,9 +434,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
setState(state => ({
|
||||
...state,
|
||||
rountangles: state.rountangles.filter(r => !selection.some(rs => rs.uid === r.uid)),
|
||||
diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)),
|
||||
history: state.history.filter(h => !selection.some(hs => hs.uid === h.uid)),
|
||||
arrows: state.arrows.filter(a => !selection.some(as => as.uid === a.uid)),
|
||||
texts: state.texts.filter(t => !selection.some(ts => ts.uid === t.uid)),
|
||||
diamonds: state.diamonds.filter(d => !selection.some(ds => ds.uid === d.uid)),
|
||||
}));
|
||||
setSelection([]);
|
||||
}
|
||||
|
|
@ -462,7 +512,7 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [selectingState, dragging, clipboard]);
|
||||
}, [selectingState, dragging]);
|
||||
|
||||
// detect what is 'connected'
|
||||
const arrow2SideMap = new Map<string,[{ uid: string; part: RountanglePart; } | undefined, { uid: string; part: RountanglePart; } | undefined]>();
|
||||
|
|
@ -712,6 +762,10 @@ export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditor
|
|||
active={active.has(diamond.uid)}/>
|
||||
</>)}
|
||||
|
||||
{state.history.map(history => <>
|
||||
<HistorySVG {...history} selected={selection.find(h => h.uid === history.uid)} />
|
||||
</>)}
|
||||
|
||||
{state.arrows.map(arrow => {
|
||||
const sides = arrow2SideMap.get(arrow.uid);
|
||||
let arc = "no" as ArcDirection;
|
||||
|
|
|
|||
|
|
@ -8,3 +8,5 @@ export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIU
|
|||
// those hoverable green transparent circles in the corners of rountangles:
|
||||
export const CORNER_HELPER_OFFSET = 4;
|
||||
export const CORNER_HELPER_RADIUS = 16;
|
||||
|
||||
export const HISTORY_RADIUS = 20;
|
||||
Loading…
Add table
Add a link
Reference in a new issue