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 [modal, setModal] = useState<ReactElement|null>(null);
const [plantName, setPlantName] = usePersistentState("plant", "dummy");
const [zoom, setZoom] = usePersistentState("zoom", 1);
const plant = plants.find(([pn, p]) => pn === plantName)![1];
@ -248,12 +249,12 @@ export function App() {
>
<TopPanel
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>
{/* Below the top bar: Editor */}
<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>
</Stack>
</Box>

View file

@ -15,6 +15,8 @@ import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import InfoOutlineIcon from '@mui/icons-material/InfoOutline';
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 { InsertMode } from "../VisualEditor/VisualEditor";
@ -38,9 +40,11 @@ export type TopPanelProps = {
mode: InsertMode,
setMode: Dispatch<SetStateAction<InsertMode>>,
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 [timescale, setTimescale] = useState(1);
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
@ -99,6 +103,14 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
e.preventDefault();
onRedo();
}
if (e.key === "+") {
e.preventDefault();
onZoomIn();
}
if (e.key === "-") {
e.preventDefault();
onZoomOut();
}
}
};
window.addEventListener("keydown", onKeyDown);
@ -123,6 +135,13 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
}
}, [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) {
setTime(time => {
if (paused) {
@ -189,6 +208,16 @@ export function TopPanel({rt, rtIdx, time, setTime, onUndo, onRedo, onInit, onCl
&emsp;
</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 */}
<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>
<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()}
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()}
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()}
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()}
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 { parseStatechart, TraceableError } from "../statecharts/parser";
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 { getBBoxInSvgCoords } from "./svg_helper";
import { ArrowSVG } from "./ArrowSVG";
@ -73,9 +73,10 @@ type VisualEditorProps = {
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
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);
@ -163,8 +164,8 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
function getCurrentPointer(e: {pageX: number, pageY: number}) {
const bbox = refSVG.current!.getBoundingClientRect();
return {
x: e.pageX - bbox.left,
y: e.pageY - bbox.top,
x: (e.pageX - bbox.left)/zoom,
y: (e.pageY - bbox.top)/zoom,
}
}
@ -293,7 +294,7 @@ export function VisualEditor({state, setState, ast, setAST, rt, errors, setError
const currentPointer = getCurrentPointer(e);
if (dragging) {
// 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 => ({
...state,
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 shapesInSelection = shapes.filter(el => {
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"));
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);
return <svg width="4000px" height="4000px"
const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}
viewBox={`0 0 4000 4000`}
onCopy={onCopy}
onPaste={onPaste}
onCut={onCut}

View file

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