From b6260e9473b55fd0e3eb28fde937ec25dab10f7a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:40:49 +0100 Subject: [PATCH 1/3] Add copy/paste, select all, and escape-to-deselect to petrinaut editor Implement clipboard support with a versioned JSON format (petrinaut-sdcpn v1) validated by zod. Copy serializes selected items (places, transitions, token types, differential equations, parameters) with arc preservation. Paste duplicates items with new UUIDs, deduplicates names, remaps internal references, and offsets node positions. Cmd+A selects all canvas nodes, Escape clears selection. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 3 +- .../petrinaut/src/clipboard/clipboard.ts | 47 + .../src/clipboard/deduplicate-name.test.ts | 78 ++ .../src/clipboard/deduplicate-name.ts | 26 + .../petrinaut/src/clipboard/paste.test.ts | 958 ++++++++++++++++++ .../petrinaut/src/clipboard/paste.ts | 168 +++ .../petrinaut/src/clipboard/serialize.test.ts | 504 +++++++++ .../petrinaut/src/clipboard/serialize.ts | 88 ++ .../petrinaut/src/clipboard/types.ts | 79 ++ .../BottomBar/use-keyboard-shortcuts.ts | 60 +- yarn.lock | 1 + 11 files changed, 2010 insertions(+), 2 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/clipboard/clipboard.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.test.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/paste.test.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/paste.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/serialize.test.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/serialize.ts create mode 100644 libs/@hashintel/petrinaut/src/clipboard/types.ts diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 4a5ad26946f..2ea40c4aac9 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -51,7 +51,8 @@ "typescript": "5.9.3", "uuid": "13.0.0", "vscode-languageserver-types": "3.17.5", - "web-worker": "1.4.1" + "web-worker": "1.4.1", + "zod": "4.1.12" }, "devDependencies": { "@hashintel/ds-helpers": "workspace:*", diff --git a/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts b/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts new file mode 100644 index 00000000000..e89b1732afb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts @@ -0,0 +1,47 @@ +import type { SDCPN } from "../core/types/sdcpn"; +import type { SelectionMap } from "../state/selection"; +import { pastePayloadIntoSDCPN } from "./paste"; +import { parseClipboardPayload, serializeSelection } from "./serialize"; + +export { deduplicateName } from "./deduplicate-name"; +export { pastePayloadIntoSDCPN } from "./paste"; +export { parseClipboardPayload, serializeSelection } from "./serialize"; +export type { ClipboardPayload } from "./types"; +export { CLIPBOARD_FORMAT_VERSION } from "./types"; + +/** + * Copy the current selection to the system clipboard. + */ +export async function copySelectionToClipboard( + sdcpn: SDCPN, + selection: SelectionMap, + documentId: string | null, +): Promise { + const payload = serializeSelection(sdcpn, selection, documentId); + const json = JSON.stringify(payload); + await navigator.clipboard.writeText(json); +} + +/** + * Read from the system clipboard and paste into the SDCPN. + * Returns the IDs of newly created items (for selection), or null if clipboard + * didn't contain valid petrinaut data. + */ +export async function pasteFromClipboard( + mutatePetriNetDefinition: (mutateFn: (sdcpn: SDCPN) => void) => void, +): Promise | null> { + const text = await navigator.clipboard.readText(); + const payload = parseClipboardPayload(text); + + if (!payload) { + return null; + } + + let newItemIds: Array<{ type: string; id: string }> = []; + mutatePetriNetDefinition((sdcpn) => { + const result = pastePayloadIntoSDCPN(sdcpn, payload); + newItemIds = result.newItemIds; + }); + + return newItemIds; +} diff --git a/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.test.ts b/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.test.ts new file mode 100644 index 00000000000..9274b25df25 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { deduplicateName } from "./deduplicate-name"; + +describe("deduplicateName", () => { + it("returns the original name when no conflict exists", () => { + expect(deduplicateName("Place1", new Set())).toBe("Place1"); + }); + + it("returns the original name when existing names are unrelated", () => { + expect(deduplicateName("Place1", new Set(["Foo", "Bar"]))).toBe("Place1"); + }); + + it("appends '2' when a name without a numeric suffix conflicts", () => { + expect(deduplicateName("Foo", new Set(["Foo"]))).toBe("Foo2"); + }); + + it("increments the numeric suffix when a numbered name conflicts", () => { + expect(deduplicateName("Place1", new Set(["Place1"]))).toBe("Place2"); + }); + + it("skips over existing suffixes to find the next available number", () => { + expect( + deduplicateName("Place1", new Set(["Place1", "Place2", "Place3"])), + ).toBe("Place4"); + }); + + it("handles gaps in the numeric sequence", () => { + expect( + deduplicateName("Place1", new Set(["Place1", "Place2", "Place4"])), + ).toBe("Place3"); + }); + + it("handles a name that ends with a large number", () => { + expect(deduplicateName("Item99", new Set(["Item99"]))).toBe("Item100"); + }); + + it("appends suffix to a name without trailing digits when conflicting", () => { + expect(deduplicateName("MyTransition", new Set(["MyTransition"]))).toBe( + "MyTransition2", + ); + }); + + it("skips existing suffixed names for non-numeric base names", () => { + expect( + deduplicateName( + "MyTransition", + new Set(["MyTransition", "MyTransition2", "MyTransition3"]), + ), + ).toBe("MyTransition4"); + }); + + it("handles consecutive pastes of the same item", () => { + const existing = new Set(["Place1"]); + + const first = deduplicateName("Place1", existing); + expect(first).toBe("Place2"); + existing.add(first); + + const second = deduplicateName("Place1", existing); + expect(second).toBe("Place3"); + existing.add(second); + + const third = deduplicateName("Place1", existing); + expect(third).toBe("Place4"); + }); + + it("treats multi-digit suffixes correctly", () => { + expect(deduplicateName("Node10", new Set(["Node10"]))).toBe("Node11"); + }); + + it("does not confuse numbers in the middle of a name", () => { + // "Type2Color" ends with "olor" not a digit, so suffix starts at 2 + expect(deduplicateName("Type2Color", new Set(["Type2Color"]))).toBe( + "Type2Color2", + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.ts b/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.ts new file mode 100644 index 00000000000..d5dd883641f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.ts @@ -0,0 +1,26 @@ +/** + * Generate a unique name by appending a numeric suffix if the name already exists. + * + * If the name ends with a number (e.g. "Place3"), the suffix increments from that + * number. Otherwise a "2" suffix is appended (e.g. "Foo" → "Foo2"). + */ +export function deduplicateName( + name: string, + existingNames: Set, +): string { + if (!existingNames.has(name)) { + return name; + } + + // Strip existing numeric suffix to get the base name + const match = name.match(/^(.+?)(\d+)$/); + const baseName = match ? match[1]! : name; + const startNum = match ? Number(match[2]) + 1 : 2; + + for (let idx = startNum; ; idx++) { + const candidate = `${baseName}${idx}`; + if (!existingNames.has(candidate)) { + return candidate; + } + } +} diff --git a/libs/@hashintel/petrinaut/src/clipboard/paste.test.ts b/libs/@hashintel/petrinaut/src/clipboard/paste.test.ts new file mode 100644 index 00000000000..7a7e123c759 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/paste.test.ts @@ -0,0 +1,958 @@ +import { describe, expect, it } from "vitest"; + +import type { SDCPN } from "../core/types/sdcpn"; +import { pastePayloadIntoSDCPN } from "./paste"; +import type { ClipboardPayload } from "./types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function emptyNet(): SDCPN { + return { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }; +} + +function makePayload( + data: Partial, + documentId: string | null = null, +): ClipboardPayload { + return { + format: "petrinaut-sdcpn", + version: 1, + documentId, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + ...data, + }, + }; +} + +// --------------------------------------------------------------------------- +// ID generation +// --------------------------------------------------------------------------- + +describe("paste — ID generation", () => { + it("assigns new IDs to pasted places", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__old", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + const { newItemIds } = pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.places).toHaveLength(1); + expect(sdcpn.places[0]!.id).not.toBe("place__old"); + expect(sdcpn.places[0]!.id).toMatch(/^place__/); + expect(newItemIds).toHaveLength(1); + expect(newItemIds[0]!.type).toBe("place"); + expect(newItemIds[0]!.id).toBe(sdcpn.places[0]!.id); + }); + + it("assigns new IDs to pasted transitions", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + transitions: [ + { + id: "transition__old", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.transitions[0]!.id).not.toBe("transition__old"); + expect(sdcpn.transitions[0]!.id).toMatch(/^transition__/); + }); + + it("assigns new IDs to pasted types and their elements", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + types: [ + { + id: "old-type-id", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "old-el-id", name: "val", type: "real" }], + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.types[0]!.id).not.toBe("old-type-id"); + expect(sdcpn.types[0]!.elements[0]!.elementId).not.toBe("old-el-id"); + }); + + it("assigns new IDs to pasted differential equations", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + differentialEquations: [ + { id: "old-de", name: "Eq1", colorId: "some-color", code: "dx = 1;" }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.differentialEquations[0]!.id).not.toBe("old-de"); + }); + + it("assigns new IDs to pasted parameters", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + parameters: [ + { + id: "old-param", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.0", + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.parameters[0]!.id).not.toBe("old-param"); + }); +}); + +// --------------------------------------------------------------------------- +// Name deduplication +// --------------------------------------------------------------------------- + +describe("paste — name deduplication", () => { + it("keeps original name when no conflict", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[0]!.name).toBe("Place1"); + }); + + it("renames a place when the name already exists", () => { + const sdcpn = emptyNet(); + sdcpn.places.push({ + id: "existing", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + const payload = makePayload({ + places: [ + { + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[1]!.name).toBe("Place2"); + }); + + it("renames a transition when the name already exists", () => { + const sdcpn = emptyNet(); + sdcpn.transitions.push({ + id: "existing", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }); + + const payload = makePayload({ + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.transitions[1]!.name).toBe("T2"); + }); + + it("renames a type when the name already exists", () => { + const sdcpn = emptyNet(); + sdcpn.types.push({ + id: "existing", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }); + + const payload = makePayload({ + types: [ + { + id: "c1", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.types[1]!.name).toBe("Token2"); + }); + + it("renames a differential equation when the name already exists", () => { + const sdcpn = emptyNet(); + sdcpn.differentialEquations.push({ + id: "existing", + name: "Eq1", + colorId: "c1", + code: "", + }); + + const payload = makePayload({ + differentialEquations: [ + { id: "de1", name: "Eq1", colorId: "c1", code: "" }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.differentialEquations[1]!.name).toBe("Eq2"); + }); + + it("renames parameter name and variableName independently", () => { + const sdcpn = emptyNet(); + sdcpn.parameters.push({ + id: "existing", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.0", + }); + + const payload = makePayload({ + parameters: [ + { + id: "p1", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.0", + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.parameters[1]!.name).toBe("Rate2"); + expect(sdcpn.parameters[1]!.variableName).toBe("rate2"); + }); + + it("deduplicates across multiple pasted items with the same name", () => { + const sdcpn = emptyNet(); + sdcpn.places.push({ + id: "existing", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + const payload = makePayload({ + places: [ + { + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "p2", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 100, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[1]!.name).toBe("Place2"); + expect(sdcpn.places[2]!.name).toBe("Place3"); + }); +}); + +// --------------------------------------------------------------------------- +// Position offset +// --------------------------------------------------------------------------- + +describe("paste — position offset", () => { + it("offsets pasted places by 50px", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "p1", + name: "P", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 200, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[0]!.x).toBe(150); + expect(sdcpn.places[0]!.y).toBe(250); + }); + + it("offsets pasted transitions by 50px", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + transitions: [ + { + id: "t1", + name: "T", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.transitions[0]!.x).toBe(50); + expect(sdcpn.transitions[0]!.y).toBe(50); + }); +}); + +// --------------------------------------------------------------------------- +// Arc remapping +// --------------------------------------------------------------------------- + +describe("paste — arc remapping", () => { + it("remaps arc placeIds to the new place IDs", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__old-p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition__old-t1", + name: "T1", + inputArcs: [{ placeId: "place__old-p1", weight: 3 }], + outputArcs: [{ placeId: "place__old-p1", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const newPlaceId = sdcpn.places[0]!.id; + const transition = sdcpn.transitions[0]!; + expect(transition.inputArcs[0]!.placeId).toBe(newPlaceId); + expect(transition.outputArcs[0]!.placeId).toBe(newPlaceId); + // Weight is preserved + expect(transition.inputArcs[0]!.weight).toBe(3); + expect(transition.outputArcs[0]!.weight).toBe(1); + }); + + it("drops arcs whose target place was not in the payload", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + transitions: [ + { + id: "transition__old", + name: "T1", + inputArcs: [{ placeId: "place__missing", weight: 1 }], + outputArcs: [{ placeId: "place__missing", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.transitions[0]!.inputArcs).toHaveLength(0); + expect(sdcpn.transitions[0]!.outputArcs).toHaveLength(0); + }); + + it("keeps arcs to included places and drops arcs to excluded places", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__included", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition__t1", + name: "T1", + inputArcs: [ + { placeId: "place__included", weight: 1 }, + { placeId: "place__excluded", weight: 2 }, + ], + outputArcs: [{ placeId: "place__excluded", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const transition = sdcpn.transitions[0]!; + expect(transition.inputArcs).toHaveLength(1); + expect(transition.outputArcs).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Reference remapping (colorId, differentialEquationId) +// --------------------------------------------------------------------------- + +describe("paste — reference remapping", () => { + it("remaps place.colorId when the type was also pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + types: [ + { + id: "old-type", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }, + ], + places: [ + { + id: "place__p1", + name: "P1", + colorId: "old-type", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const newTypeId = sdcpn.types[0]!.id; + expect(sdcpn.places[0]!.colorId).toBe(newTypeId); + }); + + it("preserves place.colorId when the type was NOT pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__p1", + name: "P1", + colorId: "external-type-id", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[0]!.colorId).toBe("external-type-id"); + }); + + it("preserves null colorId", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[0]!.colorId).toBeNull(); + }); + + it("remaps place.differentialEquationId when the equation was also pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + differentialEquations: [ + { id: "old-de", name: "Eq1", colorId: "c1", code: "dx = 1;" }, + ], + places: [ + { + id: "place__p1", + name: "P1", + colorId: null, + dynamicsEnabled: true, + differentialEquationId: "old-de", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const newDeId = sdcpn.differentialEquations[0]!.id; + expect(sdcpn.places[0]!.differentialEquationId).toBe(newDeId); + }); + + it("preserves place.differentialEquationId when the equation was NOT pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "place__p1", + name: "P1", + colorId: null, + dynamicsEnabled: true, + differentialEquationId: "external-de-id", + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.places[0]!.differentialEquationId).toBe("external-de-id"); + }); + + it("remaps differentialEquation.colorId when the type was also pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + types: [ + { + id: "old-type", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }, + ], + differentialEquations: [ + { id: "old-de", name: "Eq1", colorId: "old-type", code: "" }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const newTypeId = sdcpn.types[0]!.id; + expect(sdcpn.differentialEquations[0]!.colorId).toBe(newTypeId); + }); + + it("preserves differentialEquation.colorId when the type was NOT pasted", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + differentialEquations: [ + { + id: "old-de", + name: "Eq1", + colorId: "external-type", + code: "", + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.differentialEquations[0]!.colorId).toBe("external-type"); + }); +}); + +// --------------------------------------------------------------------------- +// Empty payload +// --------------------------------------------------------------------------- + +describe("paste — empty payload", () => { + it("does nothing when the payload is empty", () => { + const sdcpn = emptyNet(); + sdcpn.places.push({ + id: "existing", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + const { newItemIds } = pastePayloadIntoSDCPN(sdcpn, makePayload({})); + + expect(newItemIds).toHaveLength(0); + expect(sdcpn.places).toHaveLength(1); // unchanged + }); +}); + +// --------------------------------------------------------------------------- +// Return value (newItemIds) +// --------------------------------------------------------------------------- + +describe("paste — newItemIds return value", () => { + it("returns one entry per pasted item with correct types", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [ + { + id: "c1", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }, + ], + differentialEquations: [ + { id: "de1", name: "Eq1", colorId: "c1", code: "" }, + ], + parameters: [ + { + id: "param1", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.0", + }, + ], + }); + + const { newItemIds } = pastePayloadIntoSDCPN(sdcpn, payload); + + expect(newItemIds).toHaveLength(5); + + const types = new Set(newItemIds.map((item) => item.type)); + expect(types).toContain("place"); + expect(types).toContain("transition"); + expect(types).toContain("type"); + expect(types).toContain("differentialEquation"); + expect(types).toContain("parameter"); + + // All IDs should be the new IDs, not the old ones + const returnedIds = new Set(newItemIds.map((item) => item.id)); + expect(returnedIds.has("p1")).toBe(false); + expect(returnedIds.has("t1")).toBe(false); + expect(returnedIds.has("c1")).toBe(false); + expect(returnedIds.has("de1")).toBe(false); + expect(returnedIds.has("param1")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Data preservation +// --------------------------------------------------------------------------- + +describe("paste — data preservation", () => { + it("preserves all place fields besides id, name, and position", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "p1", + name: "P1", + colorId: "external-color", + dynamicsEnabled: true, + differentialEquationId: "external-de", + visualizerCode: "console.log('hi')", + x: 10, + y: 20, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const place = sdcpn.places[0]!; + expect(place.colorId).toBe("external-color"); + expect(place.dynamicsEnabled).toBe(true); + expect(place.differentialEquationId).toBe("external-de"); + expect(place.visualizerCode).toBe("console.log('hi')"); + }); + + it("preserves all transition fields besides id, name, position, and arcs", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "return 0.5;", + transitionKernelCode: "return output;", + x: 10, + y: 20, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const transition = sdcpn.transitions[0]!; + expect(transition.lambdaType).toBe("stochastic"); + expect(transition.lambdaCode).toBe("return 0.5;"); + expect(transition.transitionKernelCode).toBe("return output;"); + }); + + it("preserves all type fields besides id and name", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + types: [ + { + id: "c1", + name: "Token", + iconSlug: "square", + displayColor: "#00FF00", + elements: [ + { elementId: "e1", name: "count", type: "integer" }, + { elementId: "e2", name: "active", type: "boolean" }, + ], + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const type = sdcpn.types[0]!; + expect(type.iconSlug).toBe("square"); + expect(type.displayColor).toBe("#00FF00"); + expect(type.elements).toHaveLength(2); + expect(type.elements[0]!.name).toBe("count"); + expect(type.elements[0]!.type).toBe("integer"); + expect(type.elements[1]!.name).toBe("active"); + expect(type.elements[1]!.type).toBe("boolean"); + }); + + it("preserves parameter fields besides id, name, and variableName", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + parameters: [ + { + id: "param1", + name: "Threshold", + variableName: "threshold", + type: "integer", + defaultValue: "42", + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + const param = sdcpn.parameters[0]!; + expect(param.type).toBe("integer"); + expect(param.defaultValue).toBe("42"); + }); + + it("preserves differential equation code", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + differentialEquations: [ + { id: "de1", name: "Eq1", colorId: "c1", code: "dx = -k * x;" }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + expect(sdcpn.differentialEquations[0]!.code).toBe("dx = -k * x;"); + }); +}); + +// --------------------------------------------------------------------------- +// Paste into non-empty net (integration) +// --------------------------------------------------------------------------- + +describe("paste — integration with existing data", () => { + it("does not modify existing items in the SDCPN", () => { + const sdcpn = emptyNet(); + sdcpn.places.push({ + id: "existing-place", + name: "ExistingPlace", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 100, + }); + + const payload = makePayload({ + places: [ + { + id: "p1", + name: "NewPlace", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.places).toHaveLength(2); + expect(sdcpn.places[0]!.id).toBe("existing-place"); + expect(sdcpn.places[0]!.name).toBe("ExistingPlace"); + expect(sdcpn.places[0]!.x).toBe(100); + }); + + it("handles pasting the same payload twice (double-paste)", () => { + const sdcpn = emptyNet(); + const payload = makePayload({ + places: [ + { + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + pastePayloadIntoSDCPN(sdcpn, payload); + pastePayloadIntoSDCPN(sdcpn, payload); + + expect(sdcpn.places).toHaveLength(2); + expect(sdcpn.places[0]!.name).toBe("Place1"); + expect(sdcpn.places[1]!.name).toBe("Place2"); + expect(sdcpn.places[0]!.id).not.toBe(sdcpn.places[1]!.id); + + // Second paste is offset from original payload position, not from first paste + expect(sdcpn.places[0]!.x).toBe(50); + expect(sdcpn.places[1]!.x).toBe(50); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/clipboard/paste.ts b/libs/@hashintel/petrinaut/src/clipboard/paste.ts new file mode 100644 index 00000000000..5e465e97083 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/paste.ts @@ -0,0 +1,168 @@ +import { v4 as generateUuid } from "uuid"; + +import type { SDCPN } from "../core/types/sdcpn"; +import { deduplicateName } from "./deduplicate-name"; +import type { ClipboardPayload } from "./types"; + +/** Offset pasted nodes so they don't overlap originals */ +const PASTE_OFFSET = 50; + +/** + * Paste a clipboard payload into an SDCPN, creating new UUIDs and deduplicating names. + * This mutates the sdcpn in place (designed to be called inside mutatePetriNetDefinition). + * + * Returns the IDs of the newly created items so they can be selected. + */ +export function pastePayloadIntoSDCPN( + sdcpn: SDCPN, + payload: ClipboardPayload, +): { newItemIds: Array<{ type: string; id: string }> } { + const { data } = payload; + const newItemIds: Array<{ type: string; id: string }> = []; + + // Build ID remapping: old ID -> new ID + const idMap = new Map(); + + // Collect existing names for deduplication + const existingPlaceNames = new Set(sdcpn.places.map((place) => place.name)); + const existingTransitionNames = new Set( + sdcpn.transitions.map((transition) => transition.name), + ); + const existingTypeNames = new Set(sdcpn.types.map((type) => type.name)); + const existingEquationNames = new Set( + sdcpn.differentialEquations.map((equation) => equation.name), + ); + const existingParameterNames = new Set( + sdcpn.parameters.map((param) => param.name), + ); + const existingVariableNames = new Set( + sdcpn.parameters.map((param) => param.variableName), + ); + + // Pre-generate all new IDs + for (const place of data.places) { + idMap.set(place.id, `place__${generateUuid()}`); + } + for (const transition of data.transitions) { + idMap.set(transition.id, `transition__${generateUuid()}`); + } + for (const type of data.types) { + idMap.set(type.id, generateUuid()); + } + for (const equation of data.differentialEquations) { + idMap.set(equation.id, generateUuid()); + } + for (const parameter of data.parameters) { + idMap.set(parameter.id, generateUuid()); + } + + // Paste types + for (const type of data.types) { + const newName = deduplicateName(type.name, existingTypeNames); + existingTypeNames.add(newName); + const newId = idMap.get(type.id)!; + + sdcpn.types.push({ + ...type, + id: newId, + name: newName, + elements: type.elements.map((el) => ({ + ...el, + elementId: generateUuid(), + })), + }); + newItemIds.push({ type: "type", id: newId }); + } + + // Paste differential equations + for (const equation of data.differentialEquations) { + const newName = deduplicateName(equation.name, existingEquationNames); + existingEquationNames.add(newName); + const newId = idMap.get(equation.id)!; + + sdcpn.differentialEquations.push({ + ...equation, + id: newId, + name: newName, + // Remap colorId if the type was also copied, otherwise keep original + colorId: idMap.get(equation.colorId) ?? equation.colorId, + }); + newItemIds.push({ type: "differentialEquation", id: newId }); + } + + // Paste parameters + for (const parameter of data.parameters) { + const newName = deduplicateName(parameter.name, existingParameterNames); + existingParameterNames.add(newName); + const newVariableName = deduplicateName( + parameter.variableName, + existingVariableNames, + ); + existingVariableNames.add(newVariableName); + const newId = idMap.get(parameter.id)!; + + sdcpn.parameters.push({ + ...parameter, + id: newId, + name: newName, + variableName: newVariableName, + }); + newItemIds.push({ type: "parameter", id: newId }); + } + + // Paste places + for (const place of data.places) { + const newName = deduplicateName(place.name, existingPlaceNames); + existingPlaceNames.add(newName); + const newId = idMap.get(place.id)!; + + sdcpn.places.push({ + ...place, + id: newId, + name: newName, + x: place.x + PASTE_OFFSET, + y: place.y + PASTE_OFFSET, + // Remap references if the referenced items were also copied + colorId: + place.colorId !== null + ? (idMap.get(place.colorId) ?? place.colorId) + : null, + differentialEquationId: + place.differentialEquationId !== null + ? (idMap.get(place.differentialEquationId) ?? + place.differentialEquationId) + : null, + }); + newItemIds.push({ type: "place", id: newId }); + } + + // Paste transitions (with remapped arc references) + for (const transition of data.transitions) { + const newName = deduplicateName(transition.name, existingTransitionNames); + existingTransitionNames.add(newName); + const newId = idMap.get(transition.id)!; + + sdcpn.transitions.push({ + ...transition, + id: newId, + name: newName, + x: transition.x + PASTE_OFFSET, + y: transition.y + PASTE_OFFSET, + inputArcs: transition.inputArcs + .filter((arc) => idMap.has(arc.placeId)) + .map((arc) => ({ + ...arc, + placeId: idMap.get(arc.placeId)!, + })), + outputArcs: transition.outputArcs + .filter((arc) => idMap.has(arc.placeId)) + .map((arc) => ({ + ...arc, + placeId: idMap.get(arc.placeId)!, + })), + }); + newItemIds.push({ type: "transition", id: newId }); + } + + return { newItemIds }; +} diff --git a/libs/@hashintel/petrinaut/src/clipboard/serialize.test.ts b/libs/@hashintel/petrinaut/src/clipboard/serialize.test.ts new file mode 100644 index 00000000000..8bdfff0a7fd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/serialize.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, it } from "vitest"; + +import type { SDCPN } from "../core/types/sdcpn"; +import type { SelectionItem, SelectionMap } from "../state/selection"; +import { parseClipboardPayload, serializeSelection } from "./serialize"; +import { CLIPBOARD_FORMAT_VERSION } from "./types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sel(...items: SelectionItem[]): SelectionMap { + return new Map(items.map((item) => [item.id, item])); +} + +const emptyNet: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const fullNet: SDCPN = { + places: [ + { + id: "p1", + name: "Place1", + colorId: "c1", + dynamicsEnabled: true, + differentialEquationId: "de1", + x: 10, + y: 20, + }, + { + id: "p2", + name: "Place2", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 30, + y: 40, + }, + ], + transitions: [ + { + id: "t1", + name: "Transition1", + inputArcs: [{ placeId: "p1", weight: 1 }], + outputArcs: [{ placeId: "p2", weight: 2 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "return input;", + x: 50, + y: 60, + }, + ], + types: [ + { + id: "c1", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "value", type: "real" }], + }, + ], + differentialEquations: [ + { id: "de1", name: "Equation1", colorId: "c1", code: "dx = -x;" }, + ], + parameters: [ + { + id: "param1", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.0", + }, + ], +}; + +// --------------------------------------------------------------------------- +// serializeSelection +// --------------------------------------------------------------------------- + +describe("serializeSelection", () => { + it("produces an empty payload when nothing is selected", () => { + const payload = serializeSelection(fullNet, new Map(), "doc-1"); + + expect(payload.format).toBe("petrinaut-sdcpn"); + expect(payload.version).toBe(CLIPBOARD_FORMAT_VERSION); + expect(payload.documentId).toBe("doc-1"); + expect(payload.data.places).toHaveLength(0); + expect(payload.data.transitions).toHaveLength(0); + expect(payload.data.types).toHaveLength(0); + expect(payload.data.differentialEquations).toHaveLength(0); + expect(payload.data.parameters).toHaveLength(0); + }); + + it("includes the document ID (or null) in the payload", () => { + const withId = serializeSelection(emptyNet, new Map(), "doc-1"); + expect(withId.documentId).toBe("doc-1"); + + const withNull = serializeSelection(emptyNet, new Map(), null); + expect(withNull.documentId).toBeNull(); + }); + + it("copies only the selected place", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "place", id: "p1" }), + null, + ); + + expect(payload.data.places).toHaveLength(1); + expect(payload.data.places[0]!.id).toBe("p1"); + expect(payload.data.transitions).toHaveLength(0); + }); + + it("copies a transition and strips arcs to non-selected places", () => { + // Select transition t1 but only place p1 (not p2) + const payload = serializeSelection( + fullNet, + sel({ type: "transition", id: "t1" }, { type: "place", id: "p1" }), + null, + ); + + const transition = payload.data.transitions[0]!; + expect(transition.inputArcs).toHaveLength(1); + expect(transition.inputArcs[0]!.placeId).toBe("p1"); + // outputArc references p2, which is not selected → stripped + expect(transition.outputArcs).toHaveLength(0); + }); + + it("keeps all arcs when both connected places are selected", () => { + const payload = serializeSelection( + fullNet, + sel( + { type: "transition", id: "t1" }, + { type: "place", id: "p1" }, + { type: "place", id: "p2" }, + ), + null, + ); + + const transition = payload.data.transitions[0]!; + expect(transition.inputArcs).toHaveLength(1); + expect(transition.outputArcs).toHaveLength(1); + }); + + it("strips all arcs when a transition is selected alone", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "transition", id: "t1" }), + null, + ); + + const transition = payload.data.transitions[0]!; + expect(transition.inputArcs).toHaveLength(0); + expect(transition.outputArcs).toHaveLength(0); + }); + + it("copies a token type", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "type", id: "c1" }), + null, + ); + + expect(payload.data.types).toHaveLength(1); + expect(payload.data.types[0]!.name).toBe("Token"); + }); + + it("copies a differential equation", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "differentialEquation", id: "de1" }), + null, + ); + + expect(payload.data.differentialEquations).toHaveLength(1); + expect(payload.data.differentialEquations[0]!.name).toBe("Equation1"); + }); + + it("copies a parameter", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "parameter", id: "param1" }), + null, + ); + + expect(payload.data.parameters).toHaveLength(1); + expect(payload.data.parameters[0]!.variableName).toBe("rate"); + }); + + it("copies a mixed selection of all item types", () => { + const payload = serializeSelection( + fullNet, + sel( + { type: "place", id: "p1" }, + { type: "place", id: "p2" }, + { type: "transition", id: "t1" }, + { type: "type", id: "c1" }, + { type: "differentialEquation", id: "de1" }, + { type: "parameter", id: "param1" }, + ), + "doc-1", + ); + + expect(payload.data.places).toHaveLength(2); + expect(payload.data.transitions).toHaveLength(1); + expect(payload.data.types).toHaveLength(1); + expect(payload.data.differentialEquations).toHaveLength(1); + expect(payload.data.parameters).toHaveLength(1); + }); + + it("ignores arc selections (arcs come with transitions)", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "arc", id: "$A_p1___t1" }), + null, + ); + + expect(payload.data.places).toHaveLength(0); + expect(payload.data.transitions).toHaveLength(0); + }); + + it("ignores selection IDs that don't match any item in the net", () => { + const payload = serializeSelection( + fullNet, + sel({ type: "place", id: "nonexistent" }), + null, + ); + + expect(payload.data.places).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// parseClipboardPayload +// --------------------------------------------------------------------------- + +describe("parseClipboardPayload", () => { + it("parses a valid payload", () => { + const payload = serializeSelection(fullNet, new Map(), "doc-1"); + const json = JSON.stringify(payload); + + const parsed = parseClipboardPayload(json); + expect(parsed).not.toBeNull(); + expect(parsed!.format).toBe("petrinaut-sdcpn"); + expect(parsed!.documentId).toBe("doc-1"); + }); + + it("returns null for empty string", () => { + expect(parseClipboardPayload("")).toBeNull(); + }); + + it("returns null for plain text", () => { + expect(parseClipboardPayload("hello world")).toBeNull(); + }); + + it("returns null for valid JSON that is not our format", () => { + expect(parseClipboardPayload('{"key": "value"}')).toBeNull(); + }); + + it("returns null for wrong format field", () => { + const json = JSON.stringify({ + format: "something-else", + version: 1, + data: {}, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when version is higher than supported", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION + 1, + data: {}, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("accepts a payload with version equal to current", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).not.toBeNull(); + }); + + it("returns null when version is not a number", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: "1", + data: {}, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when data field is missing", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null for a JSON array", () => { + expect(parseClipboardPayload("[1,2,3]")).toBeNull(); + }); + + it("returns null for JSON null", () => { + expect(parseClipboardPayload("null")).toBeNull(); + }); + + it("returns null when version is zero", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 0, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when version is a float", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1.5, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when data.places contains an invalid place", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: { + places: [{ id: "p1", name: "P" }], // missing required fields + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when a transition has an invalid lambdaType", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: { + places: [], + transitions: [ + { + id: "t1", + name: "T", + inputArcs: [], + outputArcs: [], + lambdaType: "invalid", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when a color element has an invalid type", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: { + places: [], + transitions: [], + types: [ + { + id: "c1", + name: "Token", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [{ elementId: "e1", name: "val", type: "string" }], + }, + ], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when a parameter has an invalid type", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [ + { + id: "p1", + name: "Rate", + variableName: "rate", + type: "float", + defaultValue: "1.0", + }, + ], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when data arrays are missing", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: {}, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("returns null when an arc has a non-numeric weight", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + data: { + places: [], + transitions: [ + { + id: "t1", + name: "T", + inputArcs: [{ placeId: "p1", weight: "heavy" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + expect(parseClipboardPayload(json)).toBeNull(); + }); + + it("strips unknown fields from a valid payload", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: 1, + documentId: null, + extraField: "should be stripped", + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + const parsed = parseClipboardPayload(json); + expect(parsed).not.toBeNull(); + expect((parsed as Record).extraField).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/clipboard/serialize.ts b/libs/@hashintel/petrinaut/src/clipboard/serialize.ts new file mode 100644 index 00000000000..492b30dbca0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/serialize.ts @@ -0,0 +1,88 @@ +import type { SDCPN } from "../core/types/sdcpn"; +import type { SelectionMap } from "../state/selection"; +import { + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, + clipboardPayloadSchema, +} from "./types"; + +/** + * Collect the selected items from the SDCPN into a clipboard payload. + * Arcs between selected nodes are automatically included within their transitions. + */ +export function serializeSelection( + sdcpn: SDCPN, + selection: SelectionMap, + documentId: string | null, +): ClipboardPayload { + const selectedPlaceIds = new Set(); + const selectedTransitionIds = new Set(); + const selectedTypeIds = new Set(); + const selectedEquationIds = new Set(); + const selectedParameterIds = new Set(); + + for (const [id, item] of selection) { + switch (item.type) { + case "place": + selectedPlaceIds.add(id); + break; + case "transition": + selectedTransitionIds.add(id); + break; + case "type": + selectedTypeIds.add(id); + break; + case "differentialEquation": + selectedEquationIds.add(id); + break; + case "parameter": + selectedParameterIds.add(id); + break; + // Arcs are not independently selected for copy — they come with their transitions + } + } + + const places = sdcpn.places.filter((place) => selectedPlaceIds.has(place.id)); + const types = sdcpn.types.filter((type) => selectedTypeIds.has(type.id)); + const differentialEquations = sdcpn.differentialEquations.filter((equation) => + selectedEquationIds.has(equation.id), + ); + const parameters = sdcpn.parameters.filter((param) => + selectedParameterIds.has(param.id), + ); + + // For transitions, only keep arcs that reference selected places + const transitions = sdcpn.transitions + .filter((transition) => selectedTransitionIds.has(transition.id)) + .map((transition) => ({ + ...transition, + inputArcs: transition.inputArcs.filter((arc) => + selectedPlaceIds.has(arc.placeId), + ), + outputArcs: transition.outputArcs.filter((arc) => + selectedPlaceIds.has(arc.placeId), + ), + })); + + return { + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId, + data: { places, transitions, types, differentialEquations, parameters }, + }; +} + +/** + * Try to parse a clipboard string into a ClipboardPayload. + * Returns null if the string is not a valid petrinaut clipboard payload. + */ +export function parseClipboardPayload(text: string): ClipboardPayload | null { + try { + const json: unknown = JSON.parse(text); + const result = clipboardPayloadSchema.safeParse(json); + return result.success ? result.data : null; + } catch { + // Not valid JSON + return null; + } +} diff --git a/libs/@hashintel/petrinaut/src/clipboard/types.ts b/libs/@hashintel/petrinaut/src/clipboard/types.ts new file mode 100644 index 00000000000..20dcde00d8f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/types.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +export const CLIPBOARD_FORMAT_VERSION = 1; + +const arcSchema = z.object({ + placeId: z.string(), + weight: z.number(), +}); + +const placeSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: z.string().nullable(), + dynamicsEnabled: z.boolean(), + differentialEquationId: z.string().nullable(), + visualizerCode: z.string().optional(), + x: z.number(), + y: z.number(), +}); + +const transitionSchema = z.object({ + id: z.string(), + name: z.string(), + inputArcs: z.array(arcSchema), + outputArcs: z.array(arcSchema), + lambdaType: z.enum(["predicate", "stochastic"]), + lambdaCode: z.string(), + transitionKernelCode: z.string(), + x: z.number(), + y: z.number(), +}); + +const colorElementSchema = z.object({ + elementId: z.string(), + name: z.string(), + type: z.enum(["real", "integer", "boolean"]), +}); + +const colorSchema = z.object({ + id: z.string(), + name: z.string(), + iconSlug: z.string(), + displayColor: z.string(), + elements: z.array(colorElementSchema), +}); + +const differentialEquationSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: z.string(), + code: z.string(), +}); + +const parameterSchema = z.object({ + id: z.string(), + name: z.string(), + variableName: z.string(), + type: z.enum(["real", "integer", "boolean"]), + defaultValue: z.string(), +}); + +export const clipboardPayloadSchema = z.object({ + format: z.literal("petrinaut-sdcpn"), + version: z.number().int().min(1).max(CLIPBOARD_FORMAT_VERSION), + documentId: z.string().nullable(), + data: z.object({ + places: z.array(placeSchema), + transitions: z.array(transitionSchema), + types: z.array(colorSchema), + differentialEquations: z.array(differentialEquationSchema), + parameters: z.array(parameterSchema), + }), +}); + +/** + * The clipboard payload format for petrinaut copy/paste. + * Derived from {@link clipboardPayloadSchema}. + */ +export type ClipboardPayload = z.infer; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index a758337063f..6064fd53871 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,8 +1,13 @@ import { use, useEffect, useEffectEvent } from "react"; +import { + copySelectionToClipboard, + pasteFromClipboard, +} from "../../../../clipboard/clipboard"; import type { CursorMode, EditorState } from "../../../../state/editor-context"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; +import type { SelectionItem } from "../../../../state/selection"; import { UndoRedoContext } from "../../../../state/undo-redo-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; @@ -19,11 +24,18 @@ export function useKeyboardShortcuts( selection, hasSelection, clearSelection, + setSelection, isSearchOpen, setSearchOpen, searchInputRef, } = use(EditorContext); - const { deleteItemsByIds, readonly } = use(SDCPNContext); + const { + deleteItemsByIds, + readonly, + petriNetDefinition, + petriNetId, + mutatePetriNetDefinition, + } = use(SDCPNContext); const isSimulationReadOnly = useIsReadOnly(); const isReadonly = isSimulationReadOnly || readonly; @@ -81,6 +93,51 @@ export function useKeyboardShortcuts( return; } + // Handle copy/paste/select-all shortcuts (Cmd/Ctrl + C/V/A) + if (!isInputFocused && (event.metaKey || event.ctrlKey)) { + const key = event.key.toLowerCase(); + + if (key === "c" && hasSelection) { + event.preventDefault(); + void copySelectionToClipboard( + petriNetDefinition, + selection, + petriNetId, + ); + return; + } + + if (key === "v" && !isReadonly) { + event.preventDefault(); + void pasteFromClipboard(mutatePetriNetDefinition).then((newItemIds) => { + if (newItemIds && newItemIds.length > 0) { + setSelection( + new Map( + newItemIds.map((item) => [item.id, item as SelectionItem]), + ), + ); + } + }); + return; + } + + if (key === "a") { + event.preventDefault(); + const items = new Map(); + for (const place of petriNetDefinition.places) { + items.set(place.id, { type: "place", id: place.id }); + } + for (const transition of petriNetDefinition.transitions) { + items.set(transition.id, { + type: "transition", + id: transition.id, + }); + } + setSelection(items); + return; + } + } + if (isInputFocused) { return; } @@ -107,6 +164,7 @@ export function useKeyboardShortcuts( // If escape is pressed, switch to cursor mode (keep current cursor) case "escape": event.preventDefault(); + clearSelection(); onEditionModeChange("cursor"); break; case "v": diff --git a/yarn.lock b/yarn.lock index 289a8528203..da9d2ac2442 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7982,6 +7982,7 @@ __metadata: vitest: "npm:4.1.0" vscode-languageserver-types: "npm:3.17.5" web-worker: "npm:1.4.1" + zod: "npm:4.1.12" peerDependencies: "@hashintel/ds-components": "workspace:^" "@hashintel/ds-helpers": "workspace:^" From c57b0348a84cc65c8fe580bd008143f727323372 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 02:17:30 +0100 Subject: [PATCH 2/3] Add changeset for copy/paste support Co-Authored-By: Claude Opus 4.6 --- .changeset/copy-paste-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/copy-paste-support.md diff --git a/.changeset/copy-paste-support.md b/.changeset/copy-paste-support.md new file mode 100644 index 00000000000..146e2351f01 --- /dev/null +++ b/.changeset/copy-paste-support.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Add copy/paste, select all, and escape-to-deselect keyboard shortcuts From 70f9a7ead925be1ce7310e2fe5091b3799fd246f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 21:00:30 +0100 Subject: [PATCH 3/3] Handle clipboard API errors gracefully in copy/paste Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/clipboard/clipboard.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts b/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts index e89b1732afb..57cd2aa3ad3 100644 --- a/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts +++ b/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts @@ -19,7 +19,11 @@ export async function copySelectionToClipboard( ): Promise { const payload = serializeSelection(sdcpn, selection, documentId); const json = JSON.stringify(payload); - await navigator.clipboard.writeText(json); + try { + await navigator.clipboard.writeText(json); + } catch { + // Clipboard write can fail (permissions denied, non-secure context, etc.) + } } /** @@ -30,7 +34,13 @@ export async function copySelectionToClipboard( export async function pasteFromClipboard( mutatePetriNetDefinition: (mutateFn: (sdcpn: SDCPN) => void) => void, ): Promise | null> { - const text = await navigator.clipboard.readText(); + let text: string; + try { + text = await navigator.clipboard.readText(); + } catch { + // Clipboard read can fail (permissions denied, non-secure context, etc.) + return null; + } const payload = parseClipboardPayload(text); if (!payload) {