implement zoom

This commit is contained in:
Joeri Exelmans 2025-10-22 09:39:50 +02:00
parent 523e00d5dc
commit 6bbb230636
5 changed files with 54 additions and 14 deletions

View file

@ -45,6 +45,7 @@ export function App() {
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0}); const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null); const [modal, setModal] = useState<ReactElement|null>(null);
const [plantName, setPlantName] = usePersistentState("plant", "dummy"); const [plantName, setPlantName] = usePersistentState("plant", "dummy");
const [zoom, setZoom] = usePersistentState("zoom", 1);
const plant = plants.find(([pn, p]) => pn === plantName)![1]; const plant = plants.find(([pn, p]) => pn === plantName)![1];
@ -248,12 +249,12 @@ export function App() {
> >
<TopPanel <TopPanel
rt={rtIdx === undefined ? undefined : rt[rtIdx]} rt={rtIdx === undefined ? undefined : rt[rtIdx]}
{...{rtIdx, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, mode, setMode, setModal}} {...{rtIdx, ast, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, mode, setMode, setModal, zoom, setZoom}}
/> />
</Box> </Box>
{/* Below the top bar: Editor */} {/* Below the top bar: Editor */}
<Box sx={{flexGrow:1, overflow: "auto"}}> <Box sx={{flexGrow:1, overflow: "auto"}}>
<VisualEditor {...{state: editorState, setState: setEditorState, ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}}/> <VisualEditor {...{state: editorState, setState: setEditorState, ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>
</Box> </Box>
</Stack> </Stack>
</Box> </Box>

View file

@ -15,6 +15,8 @@ import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo'; import RedoIcon from '@mui/icons-material/Redo';
import InfoOutlineIcon from '@mui/icons-material/InfoOutline'; import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
import KeyboardIcon from '@mui/icons-material/Keyboard'; import KeyboardIcon from '@mui/icons-material/Keyboard';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { formatTime } from "./util"; import { formatTime } from "./util";
import { InsertMode } from "../VisualEditor/VisualEditor"; import { InsertMode } from "../VisualEditor/VisualEditor";
@ -38,9 +40,11 @@ export type TopPanelProps = {
mode: InsertMode, mode: InsertMode,
setMode: Dispatch<SetStateAction<InsertMode>>, setMode: Dispatch<SetStateAction<InsertMode>>,
setModal: Dispatch<SetStateAction<ReactElement|null>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
zoom: number,
setZoom: Dispatch<SetStateAction<number>>,
} }
export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, ast, mode, setMode, setModal}: TopPanelProps) { export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onClear, onRaise, onBack, ast, mode, setMode, setModal, zoom, setZoom}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState("0.000"); const [displayTime, setDisplayTime] = useState("0.000");
const [timescale, setTimescale] = useState(1); const [timescale, setTimescale] = useState(1);
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true); const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
@ -99,6 +103,14 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
e.preventDefault(); e.preventDefault();
onRedo(); onRedo();
} }
if (e.key === "+") {
e.preventDefault();
onZoomIn();
}
if (e.key === "-") {
e.preventDefault();
onZoomOut();
}
} }
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
@ -123,6 +135,13 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
} }
}, [time]); }, [time]);
function onZoomIn() {
setZoom(zoom => Math.min(zoom * 1.25, 4));
}
function onZoomOut() {
setZoom(zoom => Math.max(zoom / 1.25, 1/4));
}
function onChangePaused(paused: boolean, wallclktime: number) { function onChangePaused(paused: boolean, wallclktime: number) {
setTime(time => { setTime(time => {
if (paused) { if (paused) {
@ -189,6 +208,16 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
&emsp; &emsp;
</div> </div>
{/* zoom */}
<div className="toolbarGroup">
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>+</kbd></>}>
<button title="zoom in" onClick={onZoomIn}><ZoomInIcon fontSize="small"/></button>
</KeyInfo>
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>-</kbd></>}>
<button title="zoom out" onClick={onZoomOut}><ZoomOutIcon fontSize="small"/></button>
</KeyInfo>
&emsp;
</div>
{/* undo / redo */} {/* undo / redo */}
<div className="toolbarGroup"> <div className="toolbarGroup">

View file

@ -45,19 +45,19 @@ export function DigitalWatch({light, h, m, s, alarm, callbacks}: DigitalWatchPro
<text x="111" y="126" dominantBaseline="middle" textAnchor="middle" fontFamily="digital-font" fontSize={28} style={{whiteSpace:'preserve'}}>{hhmmss}</text> <text x="111" y="126" dominantBaseline="middle" textAnchor="middle" fontFamily="digital-font" fontSize={28} style={{whiteSpace:'preserve'}}>{hhmmss}</text>
<rect x="0" y="59" width="16" height="16" fill="#fff" fill-opacity="0" <rect x="0" y="59" width="16" height="16" fill="#fff" fillOpacity="0"
onMouseDown={() => callbacks.onTopLeftPressed()} onMouseDown={() => callbacks.onTopLeftPressed()}
onMouseUp={() => callbacks.onTopLeftReleased()} onMouseUp={() => callbacks.onTopLeftReleased()}
/> />
<rect x="206" y="57" width="16" height="16" fill="#fff" fill-opacity="0" <rect x="206" y="57" width="16" height="16" fill="#fff" fillOpacity="0"
onMouseDown={() => callbacks.onTopRightPressed()} onMouseDown={() => callbacks.onTopRightPressed()}
onMouseUp={() => callbacks.onTopRightReleased()} onMouseUp={() => callbacks.onTopRightReleased()}
/> />
<rect x="0" y="158" width="16" height="16" fill="#fff" fill-opacity="0" <rect x="0" y="158" width="16" height="16" fill="#fff" fillOpacity="0"
onMouseDown={() => callbacks.onBottomLeftPressed()} onMouseDown={() => callbacks.onBottomLeftPressed()}
onMouseUp={() => callbacks.onBottomLeftReleased()} onMouseUp={() => callbacks.onBottomLeftReleased()}
/> />
<rect x="208" y="158" width="16" height="16" fill="#fff" fill-opacity="0" <rect x="208" y="158" width="16" height="16" fill="#fff" fillOpacity="0"
onMouseDown={() => callbacks.onBottomRightPressed()} onMouseDown={() => callbacks.onBottomRightPressed()}
onMouseUp={() => callbacks.onBottomRightReleased()} onMouseUp={() => callbacks.onBottomRightReleased()}
/> />

View file

@ -4,7 +4,7 @@ import { Statechart } from "../statecharts/abstract_syntax";
import { Arrow, ArrowPart, Diamond, History, Rountangle, RountanglePart, Text } from "../statecharts/concrete_syntax"; import { Arrow, ArrowPart, Diamond, History, Rountangle, RountanglePart, Text } from "../statecharts/concrete_syntax";
import { parseStatechart, TraceableError } from "../statecharts/parser"; import { parseStatechart, TraceableError } from "../statecharts/parser";
import { BigStep } from "../statecharts/runtime_types"; import { BigStep } from "../statecharts/runtime_types";
import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry"; import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "./geometry";
import { MIN_ROUNTANGLE_SIZE } from "./parameters"; import { MIN_ROUNTANGLE_SIZE } from "./parameters";
import { getBBoxInSvgCoords } from "./svg_helper"; import { getBBoxInSvgCoords } from "./svg_helper";
import { ArrowSVG } from "./ArrowSVG"; import { ArrowSVG } from "./ArrowSVG";
@ -73,9 +73,10 @@ type VisualEditorProps = {
highlightTransitions: string[], highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>, setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void; makeCheckPoint: () => void;
zoom: number;
}; };
export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint}: VisualEditorProps) { export function VisualEditor({state, setState, ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@ -163,8 +164,8 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
function getCurrentPointer(e: {pageX: number, pageY: number}) { function getCurrentPointer(e: {pageX: number, pageY: number}) {
const bbox = refSVG.current!.getBoundingClientRect(); const bbox = refSVG.current!.getBoundingClientRect();
return { return {
x: e.pageX - bbox.left, x: (e.pageX - bbox.left)/zoom,
y: e.pageY - bbox.top, y: (e.pageY - bbox.top)/zoom,
} }
} }
@ -293,7 +294,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const currentPointer = getCurrentPointer(e); const currentPointer = getCurrentPointer(e);
if (dragging) { if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos); // const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const pointerDelta = {x: e.movementX, y: e.movementY}; const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
setState(state => ({ setState(state => ({
...state, ...state,
rountangles: state.rountangles.map(r => { rountangles: state.rountangles.map(r => {
@ -398,7 +399,11 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[]; const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => { const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!); const bbox = getBBoxInSvgCoords(el, refSVG.current!);
return isEntirelyWithin(bbox, normalizedSS); const scaledBBox = {
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
size: scaleV2D(bbox.size, 1/zoom),
}
return isEntirelyWithin(scaledBBox, normalizedSS);
}).filter(el => !el.classList.contains("corner")); }).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map(); const uidToParts = new Map();
@ -666,12 +671,16 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
return <svg width="4000px" height="4000px" const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")} className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
ref={refSVG} ref={refSVG}
viewBox={`0 0 4000 4000`}
onCopy={onCopy} onCopy={onCopy}
onPaste={onPaste} onPaste={onPaste}
onCut={onCut} onCut={onCut}

View file

@ -45,6 +45,7 @@ TODO
- stuck in pseudo-state - stuck in pseudo-state
- ??? - ???
don't crash and show the error don't crash and show the error
- buttons to rotate selection 90 degrees
- experimental features: - experimental features:
- multiverse execution history - multiverse execution history