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 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..57cd2aa3ad3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/clipboard/clipboard.ts @@ -0,0 +1,57 @@ +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); + try { + await navigator.clipboard.writeText(json); + } catch { + // Clipboard write can fail (permissions denied, non-secure context, etc.) + } +} + +/** + * 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> { + 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) { + 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:^"