@agent-native/toolkit 0.11.2 → 0.12.1

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.
Files changed (75) hide show
  1. package/README.md +19 -0
  2. package/agent-native.eject.json +38 -0
  3. package/dist/canvas-annotations/CanvasCommentPins.d.ts +68 -0
  4. package/dist/canvas-annotations/CanvasCommentPins.d.ts.map +1 -0
  5. package/dist/canvas-annotations/CanvasCommentPins.js +513 -0
  6. package/dist/canvas-annotations/CanvasCommentPins.js.map +1 -0
  7. package/dist/canvas-annotations/DrawOverlay.d.ts +108 -0
  8. package/dist/canvas-annotations/DrawOverlay.d.ts.map +1 -0
  9. package/dist/canvas-annotations/DrawOverlay.js +746 -0
  10. package/dist/canvas-annotations/DrawOverlay.js.map +1 -0
  11. package/dist/canvas-annotations/DrawOverlay.spec.d.ts +2 -0
  12. package/dist/canvas-annotations/DrawOverlay.spec.d.ts.map +1 -0
  13. package/dist/canvas-annotations/DrawOverlay.spec.js +111 -0
  14. package/dist/canvas-annotations/DrawOverlay.spec.js.map +1 -0
  15. package/dist/canvas-annotations/index.d.ts +4 -0
  16. package/dist/canvas-annotations/index.d.ts.map +1 -0
  17. package/dist/canvas-annotations/index.js +3 -0
  18. package/dist/canvas-annotations/index.js.map +1 -0
  19. package/dist/canvas-annotations/types.d.ts +8 -0
  20. package/dist/canvas-annotations/types.d.ts.map +1 -0
  21. package/dist/canvas-annotations/types.js +2 -0
  22. package/dist/canvas-annotations/types.js.map +1 -0
  23. package/dist/canvas-interactions/canvas-interactions.d.ts +334 -0
  24. package/dist/canvas-interactions/canvas-interactions.d.ts.map +1 -0
  25. package/dist/canvas-interactions/canvas-interactions.js +451 -0
  26. package/dist/canvas-interactions/canvas-interactions.js.map +1 -0
  27. package/dist/canvas-interactions/canvas-interactions.spec.d.ts +2 -0
  28. package/dist/canvas-interactions/canvas-interactions.spec.d.ts.map +1 -0
  29. package/dist/canvas-interactions/canvas-interactions.spec.js +538 -0
  30. package/dist/canvas-interactions/canvas-interactions.spec.js.map +1 -0
  31. package/dist/canvas-interactions/index.d.ts +2 -0
  32. package/dist/canvas-interactions/index.d.ts.map +1 -0
  33. package/dist/canvas-interactions/index.js +2 -0
  34. package/dist/canvas-interactions/index.js.map +1 -0
  35. package/dist/composer/runtime-adapters.d.ts.map +1 -1
  36. package/dist/composer/runtime-adapters.js +13 -10
  37. package/dist/composer/runtime-adapters.js.map +1 -1
  38. package/dist/composer/runtime-adapters.spec.d.ts +2 -0
  39. package/dist/composer/runtime-adapters.spec.d.ts.map +1 -0
  40. package/dist/composer/runtime-adapters.spec.js +54 -0
  41. package/dist/composer/runtime-adapters.spec.js.map +1 -0
  42. package/dist/design-tweaks/scrub-input-utils.d.ts +61 -0
  43. package/dist/design-tweaks/scrub-input-utils.d.ts.map +1 -0
  44. package/dist/design-tweaks/scrub-input-utils.js +235 -0
  45. package/dist/design-tweaks/scrub-input-utils.js.map +1 -0
  46. package/dist/design-tweaks/scrub-input.d.ts +66 -0
  47. package/dist/design-tweaks/scrub-input.d.ts.map +1 -0
  48. package/dist/design-tweaks/scrub-input.js +313 -0
  49. package/dist/design-tweaks/scrub-input.js.map +1 -0
  50. package/dist/design-tweaks/visual-style-controls.d.ts +2 -12
  51. package/dist/design-tweaks/visual-style-controls.d.ts.map +1 -1
  52. package/dist/design-tweaks/visual-style-controls.js +3 -107
  53. package/dist/design-tweaks/visual-style-controls.js.map +1 -1
  54. package/dist/design-tweaks/visual-style-controls.spec.js +26 -0
  55. package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +29 -1
  61. package/src/canvas-annotations/CanvasCommentPins.tsx +861 -0
  62. package/src/canvas-annotations/DrawOverlay.spec.tsx +145 -0
  63. package/src/canvas-annotations/DrawOverlay.tsx +1233 -0
  64. package/src/canvas-annotations/index.ts +15 -0
  65. package/src/canvas-annotations/types.ts +14 -0
  66. package/src/canvas-interactions/canvas-interactions.spec.ts +688 -0
  67. package/src/canvas-interactions/canvas-interactions.ts +933 -0
  68. package/src/canvas-interactions/index.ts +67 -0
  69. package/src/composer/runtime-adapters.spec.tsx +83 -0
  70. package/src/composer/runtime-adapters.tsx +16 -11
  71. package/src/design-tweaks/scrub-input-utils.ts +311 -0
  72. package/src/design-tweaks/scrub-input.tsx +491 -0
  73. package/src/design-tweaks/visual-style-controls.spec.tsx +60 -0
  74. package/src/design-tweaks/visual-style-controls.tsx +23 -168
  75. package/src/index.ts +1 -0
@@ -0,0 +1,15 @@
1
+ export {
2
+ CanvasCommentPins,
3
+ type CanvasCommentPinsProps,
4
+ type CanvasPin,
5
+ } from "./CanvasCommentPins.js";
6
+ export {
7
+ DrawOverlay,
8
+ type DrawAnnotation,
9
+ type DrawOverlayProps,
10
+ } from "./DrawOverlay.js";
11
+ export type {
12
+ CanvasAnnotationTranslate,
13
+ SendCanvasAnnotation,
14
+ SendCanvasAnnotationInput,
15
+ } from "./types.js";
@@ -0,0 +1,14 @@
1
+ export type CanvasAnnotationTranslate = (
2
+ key: string,
3
+ options?: Record<string, unknown>,
4
+ ) => string;
5
+
6
+ export interface SendCanvasAnnotationInput {
7
+ message: string;
8
+ submit: boolean;
9
+ openSidebar: boolean;
10
+ }
11
+
12
+ export type SendCanvasAnnotation = (
13
+ input: SendCanvasAnnotationInput,
14
+ ) => boolean | Promise<boolean>;
@@ -0,0 +1,688 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ clientDeltaToCanvasDelta,
5
+ clientPointToCanvasPoint,
6
+ constrainCanvasDragDelta,
7
+ createCanvasGestureController,
8
+ createCanvasInteractionCore,
9
+ createCanvasShortcutRegistry,
10
+ hasCrossedCanvasDragThreshold,
11
+ resizeCanvasRect,
12
+ resolveCanvasEscape,
13
+ resolveCanvasNudge,
14
+ resolveCanvasShortcut,
15
+ resolveCanvasTextActivation,
16
+ shouldDuplicateCanvasDrag,
17
+ } from "./canvas-interactions.js";
18
+
19
+ describe("canvas interaction conformance", () => {
20
+ it("allows app-specific single or double click text activation", () => {
21
+ expect(
22
+ resolveCanvasTextActivation(
23
+ { clickCount: 1, textEditable: true },
24
+ { activation: "single-click" },
25
+ ),
26
+ ).toBe("edit");
27
+ expect(
28
+ resolveCanvasTextActivation(
29
+ { clickCount: 1, textEditable: true },
30
+ { activation: "double-click" },
31
+ ),
32
+ ).toBe("select");
33
+ expect(
34
+ resolveCanvasTextActivation(
35
+ { clickCount: 2, textEditable: true },
36
+ { activation: "double-click" },
37
+ ),
38
+ ).toBe("edit");
39
+ expect(
40
+ resolveCanvasTextActivation({ clickCount: 2, textEditable: false }),
41
+ ).toBe("select");
42
+ });
43
+
44
+ it("gives Escape editing precedence and selects the edited object", () => {
45
+ expect(
46
+ resolveCanvasEscape(
47
+ { editingObjectId: "title", selectedObjectIds: ["other"] },
48
+ { escapeBehavior: "select-object" },
49
+ ),
50
+ ).toEqual({
51
+ action: "select-object",
52
+ editingObjectId: null,
53
+ selectedObjectIds: ["title"],
54
+ });
55
+ expect(
56
+ resolveCanvasEscape(
57
+ { editingObjectId: "title", selectedObjectIds: ["title"] },
58
+ { escapeBehavior: "cancel-edit" },
59
+ ),
60
+ ).toMatchObject({ action: "cancel-edit", selectedObjectIds: ["title"] });
61
+ expect(
62
+ resolveCanvasEscape(
63
+ { editingObjectId: "title", selectedObjectIds: ["title"] },
64
+ { escapeBehavior: "clear-selection" },
65
+ ),
66
+ ).toMatchObject({ action: "clear-selection", selectedObjectIds: [] });
67
+ expect(resolveCanvasEscape({ editingObjectId: null })).toMatchObject({
68
+ action: "none",
69
+ });
70
+ });
71
+
72
+ it("uses client-space drag thresholds and converts client coordinates at zoom", () => {
73
+ expect(
74
+ hasCrossedCanvasDragThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 3),
75
+ ).toBe(false);
76
+ expect(
77
+ hasCrossedCanvasDragThreshold({ x: 0, y: 0 }, { x: 3, y: 0 }, 3),
78
+ ).toBe(true);
79
+
80
+ const viewport = { left: 100, top: 200, width: 960, height: 540 };
81
+ const canvas = { width: 1920, height: 1080 };
82
+ expect(
83
+ clientPointToCanvasPoint({ x: 580, y: 470 }, viewport, canvas),
84
+ ).toEqual({
85
+ x: 960,
86
+ y: 540,
87
+ });
88
+ expect(
89
+ clientDeltaToCanvasDelta({ x: 40, y: -20 }, viewport, canvas),
90
+ ).toEqual({
91
+ x: 80,
92
+ y: -40,
93
+ });
94
+ });
95
+
96
+ it("resizes all eight handles against their opposite edges", () => {
97
+ const start = { x: 100, y: 100, width: 200, height: 100 };
98
+ const cases = {
99
+ nw: {
100
+ delta: { x: -10, y: -20 },
101
+ rect: { x: 90, y: 80, width: 210, height: 120 },
102
+ },
103
+ n: {
104
+ delta: { x: 0, y: -20 },
105
+ rect: { x: 100, y: 80, width: 200, height: 120 },
106
+ },
107
+ ne: {
108
+ delta: { x: 10, y: -20 },
109
+ rect: { x: 100, y: 80, width: 210, height: 120 },
110
+ },
111
+ w: {
112
+ delta: { x: -10, y: 0 },
113
+ rect: { x: 90, y: 100, width: 210, height: 100 },
114
+ },
115
+ e: {
116
+ delta: { x: 10, y: 0 },
117
+ rect: { x: 100, y: 100, width: 210, height: 100 },
118
+ },
119
+ sw: {
120
+ delta: { x: -10, y: 20 },
121
+ rect: { x: 90, y: 100, width: 210, height: 120 },
122
+ },
123
+ s: {
124
+ delta: { x: 0, y: 20 },
125
+ rect: { x: 100, y: 100, width: 200, height: 120 },
126
+ },
127
+ se: {
128
+ delta: { x: 10, y: 20 },
129
+ rect: { x: 100, y: 100, width: 210, height: 120 },
130
+ },
131
+ } as const;
132
+
133
+ for (const [handle, expected] of Object.entries(cases)) {
134
+ expect(
135
+ resizeCanvasRect(start, {
136
+ handle: handle as keyof typeof cases,
137
+ delta: expected.delta,
138
+ minWidth: 1,
139
+ minHeight: 1,
140
+ }),
141
+ ).toEqual(expected.rect);
142
+ }
143
+ });
144
+
145
+ it("keeps midpoint resizing single-axis while Shift preserves corner aspect ratio", () => {
146
+ const start = { x: 100, y: 100, width: 200, height: 100 };
147
+ expect(
148
+ resizeCanvasRect(start, {
149
+ handle: "se",
150
+ delta: { x: 60, y: 10 },
151
+ preserveAspectRatio: true,
152
+ }),
153
+ ).toMatchObject({ width: 260, height: 130 });
154
+ expect(
155
+ resizeCanvasRect(start, {
156
+ handle: "e",
157
+ delta: { x: 60, y: 10 },
158
+ preserveAspectRatio: true,
159
+ }),
160
+ ).toMatchObject({ width: 260, height: 100 });
161
+ expect(constrainCanvasDragDelta({ x: 8, y: 3 }, true)).toEqual({
162
+ x: 8,
163
+ y: 0,
164
+ });
165
+ expect(constrainCanvasDragDelta({ x: 3, y: 8 }, true)).toEqual({
166
+ x: 0,
167
+ y: 8,
168
+ });
169
+ });
170
+
171
+ it("keeps aspect ratio and the opposite corner anchored at minimum size", () => {
172
+ expect(
173
+ resizeCanvasRect(
174
+ { x: 100, y: 100, width: 200, height: 100 },
175
+ {
176
+ handle: "nw",
177
+ delta: { x: 190, y: 90 },
178
+ preserveAspectRatio: true,
179
+ minWidth: 24,
180
+ minHeight: 24,
181
+ },
182
+ ),
183
+ ).toEqual({ x: 252, y: 176, width: 48, height: 24 });
184
+ });
185
+
186
+ it("uses Alt duplication and standard arrow plus Shift-arrow nudges", () => {
187
+ expect(shouldDuplicateCanvasDrag({ altKey: true })).toBe(true);
188
+ expect(shouldDuplicateCanvasDrag({ metaKey: true })).toBe(false);
189
+ expect(shouldDuplicateCanvasDrag({ metaKey: true }, "meta")).toBe(true);
190
+ expect(resolveCanvasNudge({ key: "ArrowRight" })).toEqual({
191
+ command: "nudge-right",
192
+ delta: { x: 1, y: 0 },
193
+ });
194
+ expect(resolveCanvasNudge({ key: "ArrowUp", shiftKey: true })).toEqual({
195
+ command: "nudge-up",
196
+ delta: { x: 0, y: -10 },
197
+ });
198
+ expect(resolveCanvasNudge({ key: "Enter" })).toBeNull();
199
+ });
200
+
201
+ it("resolves semantic shortcuts without caring whether primary is Cmd or Ctrl", () => {
202
+ expect(resolveCanvasShortcut({ key: "d", metaKey: true })).toBe(
203
+ "duplicate",
204
+ );
205
+ expect(resolveCanvasShortcut({ key: "d", ctrlKey: true })).toBe(
206
+ "duplicate",
207
+ );
208
+ expect(resolveCanvasShortcut({ key: "d", altKey: true })).toBeNull();
209
+ expect(resolveCanvasShortcut({ key: "Delete" })).toBe("delete");
210
+ expect(
211
+ resolveCanvasShortcut({
212
+ key: "}",
213
+ code: "BracketRight",
214
+ metaKey: true,
215
+ shiftKey: true,
216
+ }),
217
+ ).toBe("arrange-front");
218
+ expect(
219
+ resolveCanvasShortcut({
220
+ key: "{",
221
+ code: "BracketLeft",
222
+ ctrlKey: true,
223
+ shiftKey: true,
224
+ }),
225
+ ).toBe("arrange-back");
226
+ const registry = createCanvasShortcutRegistry([
227
+ { command: "align-left", key: "l", modifiers: ["primary"] },
228
+ ]);
229
+ expect(registry.resolve({ key: "l", ctrlKey: true })).toBe("align-left");
230
+ expect(registry.resolve({ key: "d", ctrlKey: true })).toBeNull();
231
+ });
232
+
233
+ it("binds policy once and dispatches semantic commands through an app adapter", () => {
234
+ const dispatch = vi.fn();
235
+ const core = createCanvasInteractionCore(
236
+ {
237
+ textEditing: {
238
+ activation: "single-click",
239
+ escapeBehavior: "select-object",
240
+ },
241
+ drag: { threshold: 5, duplicateModifier: "alt" },
242
+ },
243
+ {
244
+ capabilities: {
245
+ selection: true,
246
+ multiSelection: true,
247
+ move: true,
248
+ resize: true,
249
+ textEditing: true,
250
+ nudge: true,
251
+ duplicate: true,
252
+ clipboard: true,
253
+ delete: true,
254
+ arrange: true,
255
+ snapping: true,
256
+ alignment: true,
257
+ distribution: true,
258
+ grouping: true,
259
+ rotation: true,
260
+ marquee: true,
261
+ },
262
+ dispatch: (command) => {
263
+ dispatch(command);
264
+ return { handled: true };
265
+ },
266
+ },
267
+ );
268
+ expect(core.textActivation({ clickCount: 1, textEditable: true })).toBe(
269
+ "edit",
270
+ );
271
+ expect(core.hasCrossedDragThreshold({ x: 0, y: 0 }, { x: 4, y: 0 })).toBe(
272
+ false,
273
+ );
274
+ expect(core.shouldDuplicateDrag({ altKey: true })).toBe(true);
275
+ const result = core.dispatch({
276
+ id: "align-center",
277
+ objectIds: ["a", "b"],
278
+ });
279
+ expect(result).toEqual({ handled: true });
280
+ expect(dispatch).toHaveBeenCalledWith({
281
+ id: "align-center",
282
+ objectIds: ["a", "b"],
283
+ });
284
+ expect(core.capabilities.marquee).toBe(true);
285
+ expect(createCanvasInteractionCore().dispatch({ id: "delete" })).toEqual({
286
+ handled: false,
287
+ reason: "no-adapter",
288
+ });
289
+ });
290
+
291
+ it("does not report a disabled command as handled when an adapter receives it", () => {
292
+ const dispatch = vi.fn(() => ({ handled: true }) as const);
293
+ const core = createCanvasInteractionCore(
294
+ { capabilities: { delete: false } },
295
+ {
296
+ capabilities: DEFAULT_CAPABILITIES,
297
+ dispatch,
298
+ },
299
+ );
300
+
301
+ expect(core.dispatch({ id: "delete" })).toEqual({
302
+ handled: false,
303
+ reason: "unsupported",
304
+ });
305
+ expect(dispatch).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it("does not expose direct text, duplicate, or nudge affordances disabled by an app", () => {
309
+ const core = createCanvasInteractionCore({
310
+ capabilities: {
311
+ textEditing: false,
312
+ duplicate: false,
313
+ nudge: false,
314
+ },
315
+ textEditing: { activation: "single-click" },
316
+ drag: { duplicateModifier: "alt" },
317
+ });
318
+
319
+ expect(core.textActivation({ clickCount: 1, textEditable: true })).toBe(
320
+ "select",
321
+ );
322
+ expect(core.shouldDuplicateDrag({ altKey: true })).toBe(false);
323
+ expect(core.nudge({ key: "ArrowRight" })).toBeNull();
324
+ });
325
+
326
+ it("commits one zoom-correct, Shift-locked Alt move after its threshold", () => {
327
+ const preview = vi.fn(() => ({ handled: true }) as const);
328
+ const commit = vi.fn(() => ({ handled: true }) as const);
329
+ const controller = createCanvasGestureController({
330
+ drag: { threshold: 3, duplicateModifier: "alt" },
331
+ adapter: { preview, commit },
332
+ });
333
+ const viewport = { left: 10, top: 20, width: 500, height: 250 };
334
+ const canvas = { width: 1000, height: 500 };
335
+
336
+ expect(
337
+ controller.pointerDown({
338
+ kind: "move",
339
+ objectIds: ["title"],
340
+ pointer: { x: 100, y: 100, altKey: true },
341
+ viewport,
342
+ canvas,
343
+ }),
344
+ ).toMatchObject({ accepted: true, state: { phase: "pending" } });
345
+ expect(controller.pointerMove({ x: 102, y: 101 })).toMatchObject({
346
+ phase: "pending",
347
+ });
348
+ expect(preview).not.toHaveBeenCalled();
349
+
350
+ expect(
351
+ controller.pointerMove({ x: 120, y: 106, altKey: true, shiftKey: true }),
352
+ ).toMatchObject({
353
+ phase: "active",
354
+ gesture: {
355
+ kind: "move",
356
+ canvasDelta: { x: 40, y: 0 },
357
+ duplicate: true,
358
+ },
359
+ });
360
+ const end = controller.pointerUp({
361
+ x: 140,
362
+ y: 112,
363
+ altKey: true,
364
+ shiftKey: true,
365
+ });
366
+ expect(end).toMatchObject({
367
+ committed: true,
368
+ gesture: {
369
+ kind: "move",
370
+ canvasDelta: { x: 80, y: 0 },
371
+ duplicate: true,
372
+ },
373
+ state: { phase: "idle" },
374
+ });
375
+ expect(preview).toHaveBeenCalledTimes(2);
376
+ expect(commit).toHaveBeenCalledTimes(1);
377
+ expect(commit).toHaveBeenLastCalledWith(
378
+ expect.objectContaining({
379
+ kind: "move",
380
+ canvasDelta: { x: 80, y: 0 },
381
+ }),
382
+ );
383
+ });
384
+
385
+ it("resizes all geometry through the controller and cancels without committing", () => {
386
+ const preview = vi.fn(() => ({ handled: true }) as const);
387
+ const commit = vi.fn(() => ({ handled: true }) as const);
388
+ const cancel = vi.fn(() => ({ handled: true }) as const);
389
+ const controller = createCanvasGestureController({
390
+ minSize: 24,
391
+ adapter: { preview, commit, cancel },
392
+ });
393
+ const start = {
394
+ kind: "resize" as const,
395
+ objectIds: ["title"],
396
+ pointer: { x: 100, y: 100 },
397
+ viewport: { left: 0, top: 0, width: 500, height: 250 },
398
+ canvas: { width: 1000, height: 500 },
399
+ handle: "nw" as const,
400
+ rect: { x: 100, y: 100, width: 200, height: 100 },
401
+ };
402
+ expect(controller.pointerDown(start)).toMatchObject({ accepted: true });
403
+ expect(
404
+ controller.pointerMove({ x: 190, y: 190, shiftKey: true }),
405
+ ).toMatchObject({
406
+ phase: "active",
407
+ gesture: {
408
+ kind: "resize",
409
+ rect: { x: 252, y: 176, width: 48, height: 24 },
410
+ },
411
+ });
412
+ const result = controller.cancel();
413
+ expect(result).toMatchObject({
414
+ cancelled: true,
415
+ gesture: {
416
+ kind: "resize",
417
+ startRect: start.rect,
418
+ rect: { x: 252, y: 176, width: 48, height: 24 },
419
+ },
420
+ state: { phase: "idle" },
421
+ });
422
+ expect(preview).toHaveBeenCalledTimes(1);
423
+ expect(cancel).toHaveBeenCalledTimes(1);
424
+ expect(commit).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("commits each of the eight resize handles through one common sequence", () => {
428
+ const expected = {
429
+ nw: { x: 60, y: 80, width: 240, height: 120 },
430
+ n: { x: 100, y: 80, width: 200, height: 120 },
431
+ ne: { x: 100, y: 80, width: 240, height: 120 },
432
+ w: { x: 60, y: 100, width: 240, height: 100 },
433
+ e: { x: 100, y: 100, width: 240, height: 100 },
434
+ sw: { x: 60, y: 100, width: 240, height: 120 },
435
+ s: { x: 100, y: 100, width: 200, height: 120 },
436
+ se: { x: 100, y: 100, width: 240, height: 120 },
437
+ } as const;
438
+ const clientMove = {
439
+ nw: { x: 80, y: 90 },
440
+ n: { x: 100, y: 90 },
441
+ ne: { x: 120, y: 90 },
442
+ w: { x: 80, y: 100 },
443
+ e: { x: 120, y: 100 },
444
+ sw: { x: 80, y: 110 },
445
+ s: { x: 100, y: 110 },
446
+ se: { x: 120, y: 110 },
447
+ } as const;
448
+
449
+ for (const handle of Object.keys(expected) as Array<
450
+ keyof typeof expected
451
+ >) {
452
+ const commit = vi.fn(() => ({ handled: true }) as const);
453
+ const controller = createCanvasGestureController({ adapter: { commit } });
454
+ controller.pointerDown({
455
+ kind: "resize",
456
+ objectIds: ["title"],
457
+ pointer: { x: 100, y: 100 },
458
+ viewport: { left: 0, top: 0, width: 500, height: 250 },
459
+ canvas: { width: 1000, height: 500 },
460
+ handle,
461
+ rect: { x: 100, y: 100, width: 200, height: 100 },
462
+ });
463
+ expect(controller.pointerUp(clientMove[handle])).toMatchObject({
464
+ committed: true,
465
+ gesture: { kind: "resize", rect: expected[handle] },
466
+ });
467
+ expect(commit).toHaveBeenCalledTimes(1);
468
+ }
469
+ });
470
+
471
+ it("does not start unsupported gestures or commit pointer clicks", () => {
472
+ const commit = vi.fn(() => ({ handled: true }) as const);
473
+ const controller = createCanvasGestureController({
474
+ capabilities: { move: false, resize: true },
475
+ adapter: { commit },
476
+ });
477
+ const viewport = { left: 0, top: 0, width: 100, height: 100 };
478
+ const canvas = { width: 100, height: 100 };
479
+ expect(
480
+ controller.pointerDown({
481
+ kind: "move",
482
+ objectIds: ["title"],
483
+ pointer: { x: 10, y: 10 },
484
+ viewport,
485
+ canvas,
486
+ }),
487
+ ).toMatchObject({ accepted: false, reason: "unsupported" });
488
+ expect(controller.pointerUp({ x: 10, y: 10 })).toMatchObject({
489
+ committed: false,
490
+ reason: "idle",
491
+ });
492
+
493
+ expect(
494
+ controller.pointerDown({
495
+ kind: "resize",
496
+ objectIds: ["title"],
497
+ pointer: { x: 10, y: 10 },
498
+ viewport,
499
+ canvas,
500
+ handle: "se",
501
+ rect: { x: 0, y: 0, width: 20, height: 20 },
502
+ }),
503
+ ).toMatchObject({ accepted: true });
504
+ expect(controller.pointerUp({ x: 11, y: 11 })).toMatchObject({
505
+ committed: false,
506
+ reason: "below-threshold",
507
+ });
508
+ expect(commit).not.toHaveBeenCalled();
509
+ });
510
+
511
+ it("keeps client-to-canvas conversion correct across zoom levels", () => {
512
+ const canvas = { width: 1920, height: 1080 };
513
+ const cases = [
514
+ {
515
+ viewport: { left: 0, top: 0, width: 1920, height: 1080 },
516
+ clientDelta: { x: 96, y: -54 },
517
+ canvasDelta: { x: 96, y: -54 },
518
+ },
519
+ {
520
+ viewport: { left: 30, top: 70, width: 960, height: 540 },
521
+ clientDelta: { x: 96, y: -54 },
522
+ canvasDelta: { x: 192, y: -108 },
523
+ },
524
+ {
525
+ viewport: { left: -20, top: 15, width: 480, height: 270 },
526
+ clientDelta: { x: 96, y: -54 },
527
+ canvasDelta: { x: 384, y: -216 },
528
+ },
529
+ ];
530
+
531
+ for (const testCase of cases) {
532
+ expect(
533
+ clientDeltaToCanvasDelta(
534
+ testCase.clientDelta,
535
+ testCase.viewport,
536
+ canvas,
537
+ ),
538
+ ).toEqual(testCase.canvasDelta);
539
+ }
540
+ });
541
+
542
+ it("makes one commit after many previews while modifiers remain live", () => {
543
+ const preview = vi.fn(() => ({ handled: true }) as const);
544
+ const commit = vi.fn(() => ({ handled: true }) as const);
545
+ const controller = createCanvasGestureController({
546
+ drag: { threshold: 3, duplicateModifier: "alt" },
547
+ adapter: { preview, commit },
548
+ });
549
+ const start = {
550
+ kind: "move" as const,
551
+ objectIds: ["object"],
552
+ pointer: { x: 0, y: 0, altKey: true },
553
+ viewport: { left: 0, top: 0, width: 100, height: 100 },
554
+ canvas: { width: 200, height: 200 },
555
+ };
556
+
557
+ controller.pointerDown(start);
558
+ controller.pointerMove({ x: 4, y: 1, altKey: true, shiftKey: true });
559
+ controller.pointerMove({ x: 8, y: 3, altKey: false, shiftKey: false });
560
+ const end = controller.pointerUp({ x: 12, y: 5, altKey: false });
561
+
562
+ expect(preview).toHaveBeenCalledTimes(3);
563
+ expect(preview).toHaveBeenNthCalledWith(
564
+ 1,
565
+ expect.objectContaining({
566
+ canvasDelta: { x: 8, y: 0 },
567
+ duplicate: true,
568
+ }),
569
+ );
570
+ expect(preview).toHaveBeenNthCalledWith(
571
+ 2,
572
+ expect.objectContaining({
573
+ canvasDelta: { x: 16, y: 6 },
574
+ duplicate: false,
575
+ }),
576
+ );
577
+ expect(end).toMatchObject({
578
+ committed: true,
579
+ gesture: { canvasDelta: { x: 24, y: 10 }, duplicate: false },
580
+ });
581
+ expect(commit).toHaveBeenCalledTimes(1);
582
+ });
583
+
584
+ it("does not issue a duplicate final preview when pointer-up repeats the last move", () => {
585
+ const preview = vi.fn(() => ({ handled: true }) as const);
586
+ const commit = vi.fn(() => ({ handled: true }) as const);
587
+ const controller = createCanvasGestureController({
588
+ adapter: { preview, commit },
589
+ });
590
+ const start = {
591
+ kind: "move" as const,
592
+ objectIds: ["object"],
593
+ pointer: { x: 10, y: 10 },
594
+ viewport: { left: 0, top: 0, width: 100, height: 100 },
595
+ canvas: { width: 100, height: 100 },
596
+ };
597
+
598
+ controller.pointerDown(start);
599
+ controller.pointerMove({ x: 20, y: 10 });
600
+ controller.pointerUp({ x: 20, y: 10 });
601
+
602
+ expect(preview).toHaveBeenCalledTimes(1);
603
+ expect(commit).toHaveBeenCalledTimes(1);
604
+ });
605
+
606
+ it("duplicates only when the configured modifier was held at drag start and release", () => {
607
+ const commit = vi.fn(() => ({ handled: true }) as const);
608
+ const controller = createCanvasGestureController({
609
+ drag: { duplicateModifier: "alt" },
610
+ adapter: { commit },
611
+ });
612
+ const base = {
613
+ kind: "move" as const,
614
+ objectIds: ["object"],
615
+ viewport: { left: 0, top: 0, width: 100, height: 100 },
616
+ canvas: { width: 100, height: 100 },
617
+ };
618
+
619
+ controller.pointerDown({ ...base, pointer: { x: 0, y: 0 } });
620
+ expect(controller.pointerUp({ x: 10, y: 0, altKey: true })).toMatchObject({
621
+ gesture: { duplicate: false },
622
+ });
623
+
624
+ controller.pointerDown({ ...base, pointer: { x: 0, y: 0, altKey: true } });
625
+ controller.pointerMove({ x: 10, y: 0, altKey: true });
626
+ expect(controller.pointerUp({ x: 10, y: 0, altKey: false })).toMatchObject({
627
+ gesture: { duplicate: false },
628
+ });
629
+ expect(commit).toHaveBeenCalledTimes(2);
630
+ });
631
+
632
+ it("returns to a clean idle state after pending cancellation and sequential gestures", () => {
633
+ const preview = vi.fn(() => ({ handled: true }) as const);
634
+ const commit = vi.fn(() => ({ handled: true }) as const);
635
+ const cancel = vi.fn(() => ({ handled: true }) as const);
636
+ const controller = createCanvasGestureController({
637
+ adapter: { preview, commit, cancel },
638
+ });
639
+ const start = {
640
+ kind: "move" as const,
641
+ objectIds: ["first"],
642
+ pointer: { x: 0, y: 0 },
643
+ viewport: { left: 0, top: 0, width: 100, height: 100 },
644
+ canvas: { width: 100, height: 100 },
645
+ };
646
+
647
+ controller.pointerDown(start);
648
+ expect(controller.cancel()).toMatchObject({
649
+ cancelled: false,
650
+ reason: "idle",
651
+ });
652
+ expect(cancel).not.toHaveBeenCalled();
653
+ expect(controller.getState()).toEqual({ phase: "idle", gesture: null });
654
+
655
+ controller.pointerDown({ ...start, objectIds: ["second"] });
656
+ controller.pointerMove({ x: 10, y: 0 });
657
+ expect(controller.cancel()).toMatchObject({ cancelled: true });
658
+ expect(cancel).toHaveBeenCalledTimes(1);
659
+ expect(controller.getState()).toEqual({ phase: "idle", gesture: null });
660
+
661
+ controller.pointerDown({ ...start, objectIds: ["third"] });
662
+ const end = controller.pointerUp({ x: 10, y: 0 });
663
+ expect(end).toMatchObject({
664
+ committed: true,
665
+ gesture: { objectIds: ["third"] },
666
+ });
667
+ expect(commit).toHaveBeenCalledTimes(1);
668
+ });
669
+ });
670
+
671
+ const DEFAULT_CAPABILITIES = {
672
+ selection: true,
673
+ multiSelection: true,
674
+ move: true,
675
+ resize: true,
676
+ textEditing: true,
677
+ nudge: true,
678
+ duplicate: true,
679
+ clipboard: true,
680
+ delete: true,
681
+ arrange: true,
682
+ snapping: true,
683
+ alignment: true,
684
+ distribution: true,
685
+ grouping: true,
686
+ rotation: true,
687
+ marquee: true,
688
+ } as const;