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

Filter by extension

Filter by extension


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

Add copy/paste, select all, and escape-to-deselect keyboard shortcuts
3 changes: 2 additions & 1 deletion libs/@hashintel/petrinaut/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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:*",
Expand Down
57 changes: 57 additions & 0 deletions libs/@hashintel/petrinaut/src/clipboard/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Array<{ type: string; id: string }> | 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;
}
78 changes: 78 additions & 0 deletions libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
26 changes: 26 additions & 0 deletions libs/@hashintel/petrinaut/src/clipboard/deduplicate-name.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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;
}
}
}
Loading
Loading