@canvas-harness/core 0.1.8 → 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
@@ -4988,8 +4988,7 @@ var createRenderer = (opts) => {
4988
4988
  if (excludedNodes?.has(node.id)) continue;
4989
4989
  const isEditingThis = editingNodeId === node.id;
4990
4990
  if (isDrawablePrimitive(node.type)) {
4991
- const isSolidStroke = (node.style?.strokeStyle ?? "solid") === "solid";
4992
- const useRough = isSolidStroke && roughEnabled && (node.style?.roughness ?? 0) > 0;
4991
+ const useRough = roughEnabled && (node.style?.roughness ?? 0) > 0;
4993
4992
  const roughReady = useRough ? getRoughCanvasCtor() !== null : false;
4994
4993
  const composite = isCompositePrimitive(node.type);
4995
4994
  drawWithNodeTransform(surface.ctx, node, () => {
@@ -5329,8 +5328,7 @@ var createRenderer = (opts) => {
5329
5328
  return;
5330
5329
  }
5331
5330
  if (isDrawablePrimitive(node.type)) {
5332
- const isSolidStroke = (node.style?.strokeStyle ?? "solid") === "solid";
5333
- const useRough = isSolidStroke && dragRoughEnabled && (node.style?.roughness ?? 0) > 0;
5331
+ const useRough = dragRoughEnabled && (node.style?.roughness ?? 0) > 0;
5334
5332
  const roughReady = useRough ? getRoughCanvasCtor() !== null : false;
5335
5333
  if (useRough && roughReady) {
5336
5334
  if (isCompositePrimitive(node.type)) {
@@ -5959,8 +5957,30 @@ var endInside = (end, ids) => {
5959
5957
  if (!isAttached(end)) return true;
5960
5958
  return ids.has(end.nodeId);
5961
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
+ };
5962
5974
  var deserializeClipboard = (store, clip, opts = {}) => {
5963
- 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
+ }
5964
5984
  const select = opts.select ?? true;
5965
5985
  const nodeMap = /* @__PURE__ */ new Map();
5966
5986
  const edgeMap = /* @__PURE__ */ new Map();
@@ -5973,7 +5993,9 @@ var deserializeClipboard = (store, clip, opts = {}) => {
5973
5993
  y: n.y + offset.y
5974
5994
  }));
5975
5995
  const remapEnd = (end) => {
5976
- 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
+ }
5977
5999
  const newId = nodeMap.get(end.nodeId);
5978
6000
  return newId ? { nodeId: newId, localOffset: end.localOffset } : end;
5979
6001
  };
@@ -6016,7 +6038,14 @@ var cut = async (store) => {
6016
6038
  var paste = async (store, payload, opts) => {
6017
6039
  const clip = payload ?? await readClipboard();
6018
6040
  if (!clip) return null;
6019
- 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);
6020
6049
  return ids;
6021
6050
  };
6022
6051
  var writeClipboard = async (clip) => {