@canvas-harness/core 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -2529,15 +2529,36 @@ type SerializedClipboard = {
2529
2529
  */
2530
2530
  declare const serializeSelection: (store: CanvasStore) => SerializedClipboard;
2531
2531
  type DeserializeOptions = {
2532
- /** World-space offset applied to all pasted nodes. Default (20, 20). */
2532
+ /**
2533
+ * Relative world-space offset added to every pasted node's `x/y`
2534
+ * (and to free-floating edge endpoints). Takes precedence over
2535
+ * `at` when both are passed. Default `(20, 20)` when neither is
2536
+ * given.
2537
+ */
2533
2538
  offset?: Vec2;
2539
+ /**
2540
+ * Absolute world-space target — the *center* of the pasted bbox
2541
+ * lands here. Used by `paste()` to place the paste at the cursor;
2542
+ * pass directly for programmatic absolute placement. Ignored if
2543
+ * `offset` is also set.
2544
+ */
2545
+ at?: Vec2;
2534
2546
  /** Override the selection on the store after applying. Default true. */
2535
2547
  select?: boolean;
2536
2548
  };
2537
2549
  /**
2538
2550
  * Applies a clipboard payload to the store. New ids are minted; edge
2539
- * endpoints are rewired; offset defaults to `(20, 20)` world units;
2540
- * the resulting nodes + edges become the new selection by default.
2551
+ * endpoints are rewired; the resulting nodes + edges become the new
2552
+ * selection by default. Positioning precedence:
2553
+ *
2554
+ * 1. `opts.offset` (relative) — used as-is.
2555
+ * 2. `opts.at` (absolute) — offset computed so the clip's bbox
2556
+ * center lands on this point.
2557
+ * 3. Default — relative `(20, 20)` offset.
2558
+ *
2559
+ * Free-floating edge endpoints (`{ worldPoint }`) also receive the
2560
+ * offset so an edge with an unattached end stays connected to the
2561
+ * surrounding nodes after the paste.
2541
2562
  *
2542
2563
  * One `store.batch` — one undo step.
2543
2564
  *
@@ -2577,8 +2598,17 @@ declare const cut: (store: CanvasStore) => Promise<SerializedClipboard>;
2577
2598
  /**
2578
2599
  * Paste from the system clipboard (or a supplied payload). Every node
2579
2600
  * + edge gets a fresh id; edge endpoints rewire to the new ids; the
2580
- * paste is offset by `(+20, +20)` world units so it doesn't overlay
2581
- * the original. Wrapped in one undoable batch.
2601
+ * resulting nodes + edges become the new selection. Wrapped in one
2602
+ * undoable batch.
2603
+ *
2604
+ * Positioning, in precedence order:
2605
+ * 1. `opts.offset` — relative offset, used as-is.
2606
+ * 2. `opts.at` — absolute target; the paste's bbox center lands here.
2607
+ * 3. The store's current cursor (`interactionState.pointer`) — the
2608
+ * paste lands centered under the cursor. This is the default
2609
+ * `paste(store)` behavior on a Cmd+V keybind.
2610
+ * 4. Fallback `(20, 20)` relative offset when nothing else is known
2611
+ * (e.g. fresh session with no pointermove yet).
2582
2612
  *
2583
2613
  * Returns the new node ids on success, or `null` if the clipboard
2584
2614
  * didn't contain a canvas-harness payload.
@@ -2587,8 +2617,8 @@ declare const cut: (store: CanvasStore) => Promise<SerializedClipboard>;
2587
2617
  * <button onClick={() => paste(store)}>Paste</button>
2588
2618
  *
2589
2619
  * @example
2590
- * // Programmatic paste from a saved JSON snippet:
2591
- * paste(store, savedClip, { offset: { x: 0, y: 0 }, select: false })
2620
+ * // Programmatic paste at a specific world point:
2621
+ * paste(store, savedClip, { at: { x: 300, y: 200 }, select: false })
2592
2622
  */
2593
2623
  declare const paste: (store: CanvasStore, payload?: SerializedClipboard, opts?: DeserializeOptions) => Promise<(NodeId | EdgeId)[] | null>;
2594
2624
 
package/dist/index.d.ts CHANGED
@@ -2529,15 +2529,36 @@ type SerializedClipboard = {
2529
2529
  */
2530
2530
  declare const serializeSelection: (store: CanvasStore) => SerializedClipboard;
2531
2531
  type DeserializeOptions = {
2532
- /** World-space offset applied to all pasted nodes. Default (20, 20). */
2532
+ /**
2533
+ * Relative world-space offset added to every pasted node's `x/y`
2534
+ * (and to free-floating edge endpoints). Takes precedence over
2535
+ * `at` when both are passed. Default `(20, 20)` when neither is
2536
+ * given.
2537
+ */
2533
2538
  offset?: Vec2;
2539
+ /**
2540
+ * Absolute world-space target — the *center* of the pasted bbox
2541
+ * lands here. Used by `paste()` to place the paste at the cursor;
2542
+ * pass directly for programmatic absolute placement. Ignored if
2543
+ * `offset` is also set.
2544
+ */
2545
+ at?: Vec2;
2534
2546
  /** Override the selection on the store after applying. Default true. */
2535
2547
  select?: boolean;
2536
2548
  };
2537
2549
  /**
2538
2550
  * Applies a clipboard payload to the store. New ids are minted; edge
2539
- * endpoints are rewired; offset defaults to `(20, 20)` world units;
2540
- * the resulting nodes + edges become the new selection by default.
2551
+ * endpoints are rewired; the resulting nodes + edges become the new
2552
+ * selection by default. Positioning precedence:
2553
+ *
2554
+ * 1. `opts.offset` (relative) — used as-is.
2555
+ * 2. `opts.at` (absolute) — offset computed so the clip's bbox
2556
+ * center lands on this point.
2557
+ * 3. Default — relative `(20, 20)` offset.
2558
+ *
2559
+ * Free-floating edge endpoints (`{ worldPoint }`) also receive the
2560
+ * offset so an edge with an unattached end stays connected to the
2561
+ * surrounding nodes after the paste.
2541
2562
  *
2542
2563
  * One `store.batch` — one undo step.
2543
2564
  *
@@ -2577,8 +2598,17 @@ declare const cut: (store: CanvasStore) => Promise<SerializedClipboard>;
2577
2598
  /**
2578
2599
  * Paste from the system clipboard (or a supplied payload). Every node
2579
2600
  * + edge gets a fresh id; edge endpoints rewire to the new ids; the
2580
- * paste is offset by `(+20, +20)` world units so it doesn't overlay
2581
- * the original. Wrapped in one undoable batch.
2601
+ * resulting nodes + edges become the new selection. Wrapped in one
2602
+ * undoable batch.
2603
+ *
2604
+ * Positioning, in precedence order:
2605
+ * 1. `opts.offset` — relative offset, used as-is.
2606
+ * 2. `opts.at` — absolute target; the paste's bbox center lands here.
2607
+ * 3. The store's current cursor (`interactionState.pointer`) — the
2608
+ * paste lands centered under the cursor. This is the default
2609
+ * `paste(store)` behavior on a Cmd+V keybind.
2610
+ * 4. Fallback `(20, 20)` relative offset when nothing else is known
2611
+ * (e.g. fresh session with no pointermove yet).
2582
2612
  *
2583
2613
  * Returns the new node ids on success, or `null` if the clipboard
2584
2614
  * didn't contain a canvas-harness payload.
@@ -2587,8 +2617,8 @@ declare const cut: (store: CanvasStore) => Promise<SerializedClipboard>;
2587
2617
  * <button onClick={() => paste(store)}>Paste</button>
2588
2618
  *
2589
2619
  * @example
2590
- * // Programmatic paste from a saved JSON snippet:
2591
- * paste(store, savedClip, { offset: { x: 0, y: 0 }, select: false })
2620
+ * // Programmatic paste at a specific world point:
2621
+ * paste(store, savedClip, { at: { x: 300, y: 200 }, select: false })
2592
2622
  */
2593
2623
  declare const paste: (store: CanvasStore, payload?: SerializedClipboard, opts?: DeserializeOptions) => Promise<(NodeId | EdgeId)[] | null>;
2594
2624
 
package/dist/index.js CHANGED
@@ -5957,8 +5957,30 @@ var endInside = (end, ids) => {
5957
5957
  if (!isAttached(end)) return true;
5958
5958
  return ids.has(end.nodeId);
5959
5959
  };
5960
+ var clipBboxCenter = (nodes) => {
5961
+ if (nodes.length === 0) return { x: 0, y: 0 };
5962
+ let minX = Number.POSITIVE_INFINITY;
5963
+ let minY = Number.POSITIVE_INFINITY;
5964
+ let maxX = Number.NEGATIVE_INFINITY;
5965
+ let maxY = Number.NEGATIVE_INFINITY;
5966
+ for (const n of nodes) {
5967
+ if (n.x < minX) minX = n.x;
5968
+ if (n.y < minY) minY = n.y;
5969
+ if (n.x + n.w > maxX) maxX = n.x + n.w;
5970
+ if (n.y + n.h > maxY) maxY = n.y + n.h;
5971
+ }
5972
+ return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
5973
+ };
5960
5974
  var deserializeClipboard = (store, clip, opts = {}) => {
5961
- const offset = opts.offset ?? { x: 20, y: 20 };
5975
+ let offset;
5976
+ if (opts.offset) {
5977
+ offset = opts.offset;
5978
+ } else if (opts.at && clip.nodes.length > 0) {
5979
+ const center = clipBboxCenter(clip.nodes);
5980
+ offset = { x: opts.at.x - center.x, y: opts.at.y - center.y };
5981
+ } else {
5982
+ offset = { x: 20, y: 20 };
5983
+ }
5962
5984
  const select = opts.select ?? true;
5963
5985
  const nodeMap = /* @__PURE__ */ new Map();
5964
5986
  const edgeMap = /* @__PURE__ */ new Map();
@@ -5971,7 +5993,9 @@ var deserializeClipboard = (store, clip, opts = {}) => {
5971
5993
  y: n.y + offset.y
5972
5994
  }));
5973
5995
  const remapEnd = (end) => {
5974
- if (!isAttached(end)) return end;
5996
+ if (!isAttached(end)) {
5997
+ return { worldPoint: { x: end.worldPoint.x + offset.x, y: end.worldPoint.y + offset.y } };
5998
+ }
5975
5999
  const newId = nodeMap.get(end.nodeId);
5976
6000
  return newId ? { nodeId: newId, localOffset: end.localOffset } : end;
5977
6001
  };
@@ -6014,7 +6038,14 @@ var cut = async (store) => {
6014
6038
  var paste = async (store, payload, opts) => {
6015
6039
  const clip = payload ?? await readClipboard();
6016
6040
  if (!clip) return null;
6017
- const ids = deserializeClipboard(store, clip, opts);
6041
+ let effective = opts;
6042
+ if (!opts?.offset && !opts?.at) {
6043
+ const pointer = store.getInteractionState().pointer;
6044
+ if (pointer) {
6045
+ effective = { ...opts, at: { x: pointer.worldX, y: pointer.worldY } };
6046
+ }
6047
+ }
6048
+ const ids = deserializeClipboard(store, clip, effective);
6018
6049
  return ids;
6019
6050
  };
6020
6051
  var writeClipboard = async (clip) => {