Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canvas-viewport-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Remember the canvas viewport per net, so switching between nets or reloading returns to the same position and zoom.
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ The editor has two cursor modes, toggled from the bottom toolbar dropdown:
| **Pan** | H | Click and drag to pan the canvas. This is the default. |
| **Select** | V | Click and drag to draw a selection box around nodes. |

The canvas remembers where you left each net. Switching to another net and back, or reloading the app, brings back the same position and zoom; a net you open for the first time is fitted to the screen.

With a selection, you can:

- **Move** -- drag selected nodes to reposition them.
Expand Down
39 changes: 21 additions & 18 deletions libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PlaybackProvider } from "./playback/provider";
import { SDCPNProvider } from "./sdcpn-provider";
import { SimulationProvider } from "./simulation/provider";
import { ActiveNetProvider } from "./state/active-net-provider";
import { CanvasViewportProvider } from "./state/canvas-viewport-provider";
import { EditorProvider } from "./state/editor-provider";
import { UndoRedoContext } from "./state/undo-redo-context";
import { UserSettingsProvider } from "./state/user-settings-provider";
Expand Down Expand Up @@ -89,24 +90,26 @@ export const PetrinautProvider: React.FC<PetrinautProviderProps> = ({
{/* Above SimulationProvider: the simulation provider reads the
Ad-hoc scenarios setting to gate the inline definition. */}
<UserSettingsProvider>
<SimulationProvider
key={instance.handle.id}
workerFactory={simulationWorkerFactory}
>
<ExperimentsProvider workerFactory={monteCarloWorkerFactory}>
<OptimizationsProvider>
<PlaybackProvider>
<ActiveNetProvider>
<EditorProvider>
<ExecutionFrameProvider>
{children}
</ExecutionFrameProvider>
</EditorProvider>
</ActiveNetProvider>
</PlaybackProvider>
</OptimizationsProvider>
</ExperimentsProvider>
</SimulationProvider>
<CanvasViewportProvider>
<SimulationProvider
key={instance.handle.id}
workerFactory={simulationWorkerFactory}
>
<ExperimentsProvider workerFactory={monteCarloWorkerFactory}>
<OptimizationsProvider>
<PlaybackProvider>
<ActiveNetProvider>
<EditorProvider>
<ExecutionFrameProvider>
{children}
</ExecutionFrameProvider>
</EditorProvider>
</ActiveNetProvider>
</PlaybackProvider>
</OptimizationsProvider>
</ExperimentsProvider>
</SimulationProvider>
</CanvasViewportProvider>
</UserSettingsProvider>
</NotificationsProvider>
</LanguageClientProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createContext } from "react";

/** Where the canvas looks: screen = scene × zoom + (x, y), in canvas pixels. */
export type CanvasViewport = { x: number; y: number; zoom: number };

/**
* A viewport as it is persisted. The stamp is what orders the entries for the
* cap: object key order does not, because a document id that reads as an
* integer is enumerated numerically rather than in insertion order.
*/
export type SavedCanvasViewport = CanvasViewport & {
/** Absent on entries written before viewports carried a stamp. */
savedAt?: number;
};

export type CanvasViewportContextValue = {
/**
* The viewport last saved for the active document, or null when the
* document has never been viewed. Renderers read it when they mount and fit
* the net when it is null.
*/
savedViewport: CanvasViewport | null;
/**
* Saves the viewport for the active document. Renderers call it once a move
* has settled, so each call is one write and a reload straight after a move
* still comes back to it.
*/
rememberViewport: (viewport: CanvasViewport) => void;
};

export const CanvasViewportContext = createContext<CanvasViewportContextValue>({
savedViewport: null,
rememberViewport: () => {},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { use, type FC, type PropsWithChildren } from "react";

import {
CanvasViewportContext,
type CanvasViewport,
} from "./canvas-viewport-context";
import { SDCPNContext } from "./sdcpn-context";
import { UserSettingsContext } from "./user-settings-context";

/**
* Keeps the canvas viewport per document in the user settings, so a net
* reopens where it was left, whether after switching documents or reloading.
*
* Every report is written as it arrives. Renderers report a settled viewport
* rather than each frame of a gesture, so there is nothing to coalesce here,
* and nothing left pending to lose when the page goes away.
*/
export const CanvasViewportProvider: FC<PropsWithChildren> = ({ children }) => {
const { petriNetId } = use(SDCPNContext);
const { canvasViewports, setCanvasViewport } = use(UserSettingsContext);

const rememberViewport = (viewport: CanvasViewport) => {
if (!petriNetId) {
return;
}
setCanvasViewport(petriNetId, viewport);
};

return (
<CanvasViewportContext
value={{
savedViewport: petriNetId
? (canvasViewports[petriNetId] ?? null)
: null,
rememberViewport,
}}
>
{children}
</CanvasViewportContext>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
DEFAULT_PROPERTIES_PANEL_WIDTH,
} from "./panel-defaults";

import type {
CanvasViewport,
SavedCanvasViewport,
} from "./canvas-viewport-context";
import type {
BottomPanelTab,
CursorMode,
Expand Down Expand Up @@ -91,6 +95,8 @@ export type UserSettings = {
*/
enableOptimizationSurface: boolean;
subViewPanels: SubViewPanelsSettings;
/** Where each document's canvas was last left, keyed by document id. */
canvasViewports: Record<string, SavedCanvasViewport>;
};

export type UserSettingsActions = {
Expand Down Expand Up @@ -123,6 +129,7 @@ export type UserSettingsActions = {
sectionId: string,
update: Partial<SubViewSectionSettings>,
) => void;
setCanvasViewport: (petriNetId: string, viewport: CanvasViewport) => void;
};

export type UserSettingsContextValue = UserSettings & UserSettingsActions;
Expand Down Expand Up @@ -153,6 +160,7 @@ export const defaultUserSettings: UserSettings = {
enableParameterSweeps: false,
enableOptimizationSurface: false,
subViewPanels: {},
canvasViewports: {},
};

const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = {
Expand Down Expand Up @@ -182,6 +190,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = {
setEnableParameterSweeps: () => {},
setEnableOptimizationSurface: () => {},
updateSubViewSection: () => {},
setCanvasViewport: () => {},
};

export const UserSettingsContext = createContext<UserSettingsContextValue>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
defaultUserSettings,
UserSettingsContext,
} from "./user-settings-context";
import { rememberCanvasViewport } from "./user-settings-provider/remember-canvas-viewport";

import type { CanvasViewport } from "./canvas-viewport-context";
import type {
BottomPanelTab,
CursorMode,
Expand Down Expand Up @@ -112,6 +114,19 @@ export const UserSettingsProvider: React.FC<React.PropsWithChildren> = ({
setState((prev) => ({ ...prev, enableParameterSweeps: value })),
setEnableOptimizationSurface: (value: boolean) =>
setState((prev) => ({ ...prev, enableOptimizationSurface: value })),
setCanvasViewport: (petriNetId: string, viewport: CanvasViewport) => {
// Stamped out here: an updater runs more than once and has to be pure.
const savedAt = Date.now();
setState((prev) => ({
...prev,
canvasViewports: rememberCanvasViewport(
prev.canvasViewports,
petriNetId,
viewport,
savedAt,
),
}));
},
updateSubViewSection: (
containerName: string,
sectionId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";

import { rememberCanvasViewport } from "./remember-canvas-viewport";

const viewport = (zoom: number) => ({ x: 0, y: 0, zoom });
const saved = (zoom: number, savedAt: number) => ({
...viewport(zoom),
savedAt,
});

describe("rememberCanvasViewport", () => {
it("adds and replaces the viewport of a document", () => {
const once = rememberCanvasViewport({}, "a", viewport(1), 10);
expect(once).toEqual({ a: saved(1, 10) });
expect(rememberCanvasViewport(once, "a", viewport(2), 20)).toEqual({
a: saved(2, 20),
});
});

it("keeps the other documents and stamps the saved one", () => {
expect(
rememberCanvasViewport(
{ a: saved(1, 10), b: saved(2, 20) },
"a",
viewport(3),
30,
),
).toEqual({ b: saved(2, 20), a: saved(3, 30) });
});

it("drops the least recently saved documents past the limit", () => {
const result = rememberCanvasViewport(
{ a: saved(1, 30), b: saved(2, 10), c: saved(3, 20) },
"d",
viewport(4),
40,
3,
);
expect(Object.keys(result).sort()).toEqual(["a", "c", "d"]);
});

it("evicts by save time when document ids read as integers", () => {
const result = rememberCanvasViewport(
{ 1: saved(1, 30), 2: saved(2, 10) },
"3",
viewport(3),
40,
2,
);
expect(Object.keys(result).sort()).toEqual(["1", "3"]);
});

it("treats entries saved before stamping as the oldest", () => {
const result = rememberCanvasViewport(
{ unstamped: viewport(1), recent: saved(2, 50) },
"current",
viewport(3),
60,
2,
);
expect(Object.keys(result).sort()).toEqual(["current", "recent"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {
CanvasViewport,
SavedCanvasViewport,
} from "../canvas-viewport-context";

/** How many documents keep a saved viewport before the oldest is dropped. */
export const rememberedViewportLimit = 50;

const savedAtOf = (entry: SavedCanvasViewport) => entry.savedAt ?? 0;

/**
* The viewports record with `viewport` stamped and saved for `petriNetId`,
* capped so settings do not grow with every net ever opened. The least
* recently saved entries go first, read off the stamps: key order cannot say
* which those are, because JavaScript enumerates integer-like keys numerically
* and a document id is an unrestricted string.
*/
export const rememberCanvasViewport = (
viewports: Record<string, SavedCanvasViewport>,
petriNetId: string,
viewport: CanvasViewport,
savedAt: number,
limit = rememberedViewportLimit,
): Record<string, SavedCanvasViewport> => {
const others = Object.entries(viewports)
.filter(([id]) => id !== petriNetId)
.sort(([, first], [, second]) => savedAtOf(first) - savedAtOf(second));
const kept = others.slice(Math.max(0, others.length - (limit - 1)));
return Object.fromEntries([...kept, [petriNetId, { ...viewport, savedAt }]]);
};
Loading
Loading