@1agh/maude 0.29.0 → 0.31.0

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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -18,32 +18,42 @@
18
18
  */
19
19
 
20
20
  import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
21
-
21
+ import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
22
22
  import {
23
23
  type ArrowStroke,
24
+ canRotate,
24
25
  type EllipseStroke,
26
+ HALO_PAD_PX,
25
27
  type ImageStroke,
26
28
  type LinkStroke,
29
+ normalizeRotation,
27
30
  type PenStroke,
28
31
  type PolygonStroke,
29
32
  type RectStroke,
33
+ rotatePoint,
34
+ type SectionStroke,
30
35
  type StickyStroke,
31
36
  type Stroke,
32
37
  type StrokesStoreValue,
33
38
  strokeBBox,
39
+ strokeCenter,
40
+ strokeRotation,
34
41
  } from './annotations-layer.tsx';
35
42
  import { useViewportControllerContext } from './canvas-lib.tsx';
36
43
  import { useAnnotationSelection } from './use-annotation-selection.tsx';
37
44
 
38
45
  const RESIZE_CSS = `
46
+ /* DS selection specimen .sel-handle recipe — accent square, 1px accent-fg
47
+ * border, --radius-xs corners. (8px box kept: the JS centers handles on the
48
+ * half-size offset; the specimen's 7px differs by a hairline only.) Comment
49
+ * must stay backtick-free — it lives inside the RESIZE_CSS template literal. */
39
50
  .dc-annot-resize-handle {
40
51
  position: fixed;
41
52
  width: 8px;
42
53
  height: 8px;
43
- background: var(--maude-hud-accent, #d63b1f);
44
- border: 1px solid var(--bg-0, #ffffff);
45
- border-radius: 1px;
46
- box-shadow: 0 0 0 0.5px color-mix(in oklab, var(--fg-0, #1c1917) 30%, transparent);
54
+ background: var(--maude-hud-accent, oklch(0.680 0.180 268));
55
+ border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
56
+ border-radius: 3px;
47
57
  z-index: 6;
48
58
  pointer-events: auto;
49
59
  touch-action: none;
@@ -58,6 +68,25 @@ const RESIZE_CSS = `
58
68
  .dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
59
69
  .dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
60
70
  .dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
71
+ /* FigJam v3 — mid-edge handles: single-axis resize. Slightly smaller pills so
72
+ the corner squares stay the primary affordance. */
73
+ .dc-annot-resize-handle[data-corner="n"], .dc-annot-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; }
74
+ .dc-annot-resize-handle[data-corner="e"], .dc-annot-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; }
75
+ /* Wave G — rotation lives in INVISIBLE hover zones just outside each corner
76
+ (FigJam): the cursor flips to a rotate glyph there and dragging turns the
77
+ stroke. No knob — the old below-the-bbox knob read as clutter and collided
78
+ with the connector dots. z-index below the handles so a corner square wins
79
+ where they overlap. */
80
+ .dc-annot-rotate-zone {
81
+ position: fixed;
82
+ width: 18px;
83
+ height: 18px;
84
+ background: transparent;
85
+ z-index: 5;
86
+ pointer-events: auto;
87
+ touch-action: none;
88
+ cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='white' stroke-width='4' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3Cpath stroke='black' stroke-width='1.8' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3C/g%3E%3C/svg%3E") 10 10, alias !important;
89
+ }
61
90
  `.trim();
62
91
 
63
92
  function ensureResizeStyles(): void {
@@ -69,7 +98,44 @@ function ensureResizeStyles(): void {
69
98
  document.head.appendChild(s);
70
99
  }
71
100
 
72
- type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
101
+ type Corner =
102
+ | 'nw'
103
+ | 'ne'
104
+ | 'sw'
105
+ | 'se'
106
+ | 'n'
107
+ | 'e'
108
+ | 's'
109
+ | 'w'
110
+ | 'rot-nw'
111
+ | 'rot-ne'
112
+ | 'rot-sw'
113
+ | 'rot-se'
114
+ | 'ep1'
115
+ | 'ep2';
116
+
117
+ /** FigJam v3 — mid-edge handles resize a single axis. */
118
+ function isEdgeCorner(c: Corner): boolean {
119
+ return c === 'n' || c === 'e' || c === 's' || c === 'w';
120
+ }
121
+
122
+ /** Wave G — the four invisible rotate zones outside the corners. */
123
+ function isRotCorner(c: Corner): boolean {
124
+ return c === 'rot-nw' || c === 'rot-ne' || c === 'rot-sw' || c === 'rot-se';
125
+ }
126
+
127
+ /**
128
+ * Wave G/H — handles float a step OFF the stroke (matching the halo's pad —
129
+ * `HALO_PAD_PX` lives in annotations-model as the single source) instead of
130
+ * sitting glued to the bbox. The pad is SCREEN-constant (`HALO_PAD_PX / zoom`
131
+ * world units) so the breathing room reads the same at every zoom.
132
+ * `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
133
+ * corner/edge so the shape doesn't grow by the pad on first move.
134
+ */
135
+ const padDX = (c: Corner): number =>
136
+ c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
137
+ const padDY = (c: Corner): number =>
138
+ c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
73
139
 
74
140
  /** Stroke types that expose resize handles. Text inherits its anchor bbox. */
75
141
  function isResizable(
@@ -82,7 +148,8 @@ function isResizable(
82
148
  | PenStroke
83
149
  | StickyStroke
84
150
  | ImageStroke
85
- | LinkStroke {
151
+ | LinkStroke
152
+ | SectionStroke {
86
153
  return (
87
154
  s.tool === 'rect' ||
88
155
  s.tool === 'ellipse' ||
@@ -91,7 +158,8 @@ function isResizable(
91
158
  s.tool === 'pen' ||
92
159
  s.tool === 'sticky' ||
93
160
  s.tool === 'image' ||
94
- s.tool === 'link'
161
+ s.tool === 'link' ||
162
+ s.tool === 'section'
95
163
  );
96
164
  }
97
165
 
@@ -113,6 +181,41 @@ const NO_MODS: ResizeMods = { shift: false, alt: false };
113
181
  const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
114
182
  const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
115
183
 
184
+ /**
185
+ * Wave H — resizing a ROTATED stroke must keep the anchored corner/edge fixed
186
+ * in WORLD space. The axis-aligned math anchors it in the LOCAL frame, but the
187
+ * rotation pivot is the bbox CENTER — when w/h change, the center moves, so
188
+ * the whole rotated image drifts on screen. The anchor's local point is
189
+ * preserved by `bboxResize` (non-Alt), so its world drift is exactly the
190
+ * difference of rotating that same local point around the OLD vs NEW center.
191
+ * Returns the [dx, dy] world shift to add to the new box's x/y (translation
192
+ * commutes with center-rotation: rotate(p+t, c+t, θ) = rotate(p, c, θ) + t).
193
+ * Alt-resizes keep the center fixed, so there is nothing to compensate.
194
+ */
195
+ function rotatedAnchorShift(
196
+ b0: { x: number; y: number; w: number; h: number },
197
+ b1: { x: number; y: number; w: number; h: number },
198
+ corner: Corner,
199
+ rot: number
200
+ ): [number, number] {
201
+ // Anchor identity: the opposite corner (corner drags) / the opposite edge
202
+ // midpoint (edge drags), as a point of the START box in local coords.
203
+ let ux: number;
204
+ let uy: number;
205
+ if (isEdgeCorner(corner)) {
206
+ ux = corner === 'e' ? 0 : corner === 'w' ? 1 : 0.5;
207
+ uy = corner === 's' ? 0 : corner === 'n' ? 1 : 0.5;
208
+ } else {
209
+ ux = isWestCorner(corner) ? 1 : 0;
210
+ uy = isNorthCorner(corner) ? 1 : 0;
211
+ }
212
+ const px = b0.x + ux * b0.w;
213
+ const py = b0.y + uy * b0.h;
214
+ const [w0x, w0y] = rotatePoint(px, py, b0.x + b0.w / 2, b0.y + b0.h / 2, rot);
215
+ const [w1x, w1y] = rotatePoint(px, py, b1.x + b1.w / 2, b1.y + b1.h / 2, rot);
216
+ return [w0x - w1x, w0y - w1y];
217
+ }
218
+
116
219
  /**
117
220
  * Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
118
221
  * axis-aligned box for the dragged `corner` moving to world (wx, wy):
@@ -134,6 +237,36 @@ function bboxResize(
134
237
  const { x, y, w, h } = bbox;
135
238
  const cx = x + w / 2;
136
239
  const cy = y + h / 2;
240
+ // FigJam v3 — mid-edge handles move ONE axis: the opposite edge anchors
241
+ // (Alt = symmetric around the center). Always-square strokes (sticky) let
242
+ // the dragged axis drive the side, re-centered on the cross axis.
243
+ if (isEdgeCorner(corner)) {
244
+ const horizontal = corner === 'e' || corner === 'w';
245
+ let nx = x;
246
+ let ny = y;
247
+ let nw = w;
248
+ let nh = h;
249
+ if (horizontal) {
250
+ const anchorX = corner === 'w' ? x + w : x;
251
+ nw = Math.max(1, mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX));
252
+ nx = mods.alt ? cx - nw / 2 : wx < anchorX ? anchorX - nw : anchorX;
253
+ } else {
254
+ const anchorY = corner === 'n' ? y + h : y;
255
+ nh = Math.max(1, mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY));
256
+ ny = mods.alt ? cy - nh / 2 : wy < anchorY ? anchorY - nh : anchorY;
257
+ }
258
+ if (square) {
259
+ const side = horizontal ? nw : nh;
260
+ if (horizontal) {
261
+ nh = side;
262
+ ny = cy - side / 2;
263
+ } else {
264
+ nw = side;
265
+ nx = cx - side / 2;
266
+ }
267
+ }
268
+ return { x: nx, y: ny, w: nw, h: nh };
269
+ }
137
270
  const isW = isWestCorner(corner);
138
271
  const isN = isNorthCorner(corner);
139
272
  const anchorX = isW ? x + w : x;
@@ -168,6 +301,31 @@ function bboxResize(
168
301
  return { x: nx, y: ny, w: nw, h: nh };
169
302
  }
170
303
 
304
+ /**
305
+ * Wave H — `bboxResize` plus the rotated-anchor compensation in one step. The
306
+ * box-shaped tools (rect family / image / ellipse) all need the same pair:
307
+ * run the axis-aligned math, then shift the result so the anchored corner /
308
+ * edge stays fixed in WORLD space (Alt keeps the center — nothing to fix).
309
+ */
310
+ function bboxResizeRotAware(
311
+ start: Stroke,
312
+ b0: { x: number; y: number; w: number; h: number },
313
+ corner: Corner,
314
+ wx: number,
315
+ wy: number,
316
+ mods: ResizeMods,
317
+ square: boolean
318
+ ): { x: number; y: number; w: number; h: number } {
319
+ const box = bboxResize(b0, corner, wx, wy, mods, square);
320
+ const rot = strokeRotation(start);
321
+ if (rot !== 0 && !mods.alt) {
322
+ const [dx, dy] = rotatedAnchorShift(b0, box, corner, rot);
323
+ box.x += dx;
324
+ box.y += dy;
325
+ }
326
+ return box;
327
+ }
328
+
171
329
  /**
172
330
  * Per-tool resize math. Given a stroke + the moved corner + the new world
173
331
  * coords for that corner, returns a patched stroke. `start` is the stroke at
@@ -180,25 +338,49 @@ export function resizeStroke(
180
338
  corner: Corner,
181
339
  wx: number,
182
340
  wy: number,
183
- mods: ResizeMods = NO_MODS
341
+ mods: ResizeMods = NO_MODS,
342
+ /**
343
+ * Wave G — rotation reference for the corner rotate zones: the pointer
344
+ * angle (deg, around the stroke center) at drag start + the stroke's
345
+ * rotation at drag start. Rotation is RELATIVE — the grab point is the
346
+ * zero reference, so the shape follows the hand with no jump (FigJam).
347
+ * Without it, the raw pointer angle is used as-is.
348
+ */
349
+ rotRef?: { angle0: number; rot0: number }
184
350
  ): Partial<Stroke> | null {
351
+ if (isRotCorner(corner)) {
352
+ if (!canRotate(start)) return null;
353
+ const c = strokeCenter(start);
354
+ if (!c) return null;
355
+ const cur = (Math.atan2(wy - c[1], wx - c[0]) * 180) / Math.PI;
356
+ let deg = normalizeRotation(rotRef ? rotRef.rot0 + (cur - rotRef.angle0) : cur);
357
+ if (mods.shift) {
358
+ deg = normalizeRotation(Math.round(deg / 15) * 15);
359
+ } else {
360
+ // Magnetic cardinals (FigJam): drifting within 2° of 0/±90/180 locks on,
361
+ // so "straighten it back out" doesn't need pixel-perfect aim.
362
+ for (const cand of [0, 90, 180, -90, -180]) {
363
+ if (Math.abs(deg - cand) <= 2) {
364
+ deg = cand;
365
+ break;
366
+ }
367
+ }
368
+ }
369
+ const norm = normalizeRotation(deg);
370
+ return { rotation: norm === 0 ? undefined : norm } as Partial<Stroke>;
371
+ }
185
372
  if (
186
373
  start.tool === 'rect' ||
187
374
  start.tool === 'sticky' ||
188
375
  start.tool === 'polygon' ||
189
- start.tool === 'link'
376
+ start.tool === 'link' ||
377
+ start.tool === 'section'
190
378
  ) {
191
379
  // Rect / polygon / sticky / link all resize via their shared x / y / w / h
192
380
  // bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
193
381
  // 1:1; the link card free-resizes (Shift still locks its current ratio).
194
- const box = bboxResize(
195
- { x: start.x, y: start.y, w: start.w, h: start.h },
196
- corner,
197
- wx,
198
- wy,
199
- mods,
200
- start.tool === 'sticky'
201
- );
382
+ const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
383
+ const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
202
384
  return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
203
385
  }
204
386
  if (start.tool === 'image') {
@@ -206,8 +388,10 @@ export function resizeStroke(
206
388
  // (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
207
389
  // the Shift flag into bboxResize, which keeps the START ratio when shift is
208
390
  // set; the start ratio IS the image's intrinsic aspect.
209
- const box = bboxResize(
210
- { x: start.x, y: start.y, w: start.w, h: start.h },
391
+ const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
392
+ const box = bboxResizeRotAware(
393
+ start,
394
+ b0,
211
395
  corner,
212
396
  wx,
213
397
  wy,
@@ -218,14 +402,8 @@ export function resizeStroke(
218
402
  }
219
403
  if (start.tool === 'ellipse') {
220
404
  // Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
221
- const box = bboxResize(
222
- { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 },
223
- corner,
224
- wx,
225
- wy,
226
- mods,
227
- false
228
- );
405
+ const b0 = { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 };
406
+ const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, false);
229
407
  return {
230
408
  cx: box.x + box.w / 2,
231
409
  cy: box.y + box.h / 2,
@@ -269,6 +447,8 @@ export function resizeStroke(
269
447
  // Scale all points around an anchor — the opposite corner (normal) or the
270
448
  // bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
271
449
  // A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
450
+ // Edge handles aren't offered for ink (corner scale only).
451
+ if (isEdgeCorner(corner)) return null;
272
452
  const bb = strokeBBox(start);
273
453
  if (!bb) return null;
274
454
  const cx = bb.x + bb.w / 2;
@@ -311,23 +491,65 @@ export function resizeStroke(
311
491
  /**
312
492
  * Returns the four corners (or two endpoints, for arrow) of the selected
313
493
  * stroke in world coordinates. Used by the overlay component to position
314
- * the screen-space handle divs each rAF tick.
494
+ * the screen-space handle divs each rAF tick. `pad` is the halo offset in
495
+ * WORLD units (callers pass `HALO_PAD_PX / zoom` for a screen-constant gap).
315
496
  */
316
- function handlePositions(s: Stroke): Array<{ corner: Corner; x: number; y: number }> {
497
+ function handlePositions(s: Stroke, pad: number): Array<{ corner: Corner; x: number; y: number }> {
317
498
  if (s.tool === 'arrow') {
318
499
  return [
319
500
  { corner: 'ep1', x: s.x1, y: s.y1 },
320
501
  { corner: 'ep2', x: s.x2, y: s.y2 },
321
502
  ];
322
503
  }
323
- const bb = strokeBBox(s);
324
- if (!bb) return [];
325
- return [
504
+ const bb0 = strokeBBox(s);
505
+ if (!bb0) return [];
506
+ // Wave G — handles sit on the HALO (bbox + pad), not glued to the stroke
507
+ // itself; applyResize shifts the cursor back by the same pad.
508
+ const bb = {
509
+ x: bb0.x - pad,
510
+ y: bb0.y - pad,
511
+ w: bb0.w + pad * 2,
512
+ h: bb0.h + pad * 2,
513
+ };
514
+ const corners: Array<{ corner: Corner; x: number; y: number }> = [
326
515
  { corner: 'nw', x: bb.x, y: bb.y },
327
516
  { corner: 'ne', x: bb.x + bb.w, y: bb.y },
328
517
  { corner: 'sw', x: bb.x, y: bb.y + bb.h },
329
518
  { corner: 'se', x: bb.x + bb.w, y: bb.y + bb.h },
330
519
  ];
520
+ // FigJam v3 — mid-edge handles (single-axis resize) for the box-shaped
521
+ // strokes; ink (pen) keeps corner-scale only.
522
+ if (s.tool !== 'pen') {
523
+ corners.push(
524
+ { corner: 'n', x: bb.x + bb.w / 2, y: bb.y },
525
+ { corner: 'e', x: bb.x + bb.w, y: bb.y + bb.h / 2 },
526
+ { corner: 's', x: bb.x + bb.w / 2, y: bb.y + bb.h },
527
+ { corner: 'w', x: bb.x, y: bb.y + bb.h / 2 }
528
+ );
529
+ }
530
+ // Wave G — invisible rotate zones AT the padded corners; the rAF tick adds
531
+ // a screen-constant outward offset (along center→corner) so they hover just
532
+ // beyond the corner squares at every zoom + rotation.
533
+ if (canRotate(s)) {
534
+ corners.push(
535
+ { corner: 'rot-nw', x: bb.x, y: bb.y },
536
+ { corner: 'rot-ne', x: bb.x + bb.w, y: bb.y },
537
+ { corner: 'rot-sw', x: bb.x, y: bb.y + bb.h },
538
+ { corner: 'rot-se', x: bb.x + bb.w, y: bb.y + bb.h }
539
+ );
540
+ }
541
+ // FigJam v3 — a rotated stroke carries its handles with it: every handle
542
+ // point turns around the bbox center, so they sit on the visible outline.
543
+ const rot = strokeRotation(s);
544
+ if (rot !== 0) {
545
+ const cx = bb.x + bb.w / 2;
546
+ const cy = bb.y + bb.h / 2;
547
+ return corners.map((c) => {
548
+ const [rx, ry] = rotatePoint(c.x, c.y, cx, cy, rot);
549
+ return { corner: c.corner, x: rx, y: ry };
550
+ });
551
+ }
552
+ return corners;
331
553
  }
332
554
 
333
555
  export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue | null }): ReactNode {
@@ -340,7 +562,13 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
340
562
  const dragRef = useRef<{
341
563
  pointerId: number;
342
564
  startStroke: Stroke;
565
+ /** Wave H — full strokes snapshot at drag start (the undo `before`). */
566
+ startStrokes: Stroke[];
343
567
  corner: Corner;
568
+ /** FigJam v3 — neighbour bbox dims for the dimension-match snap. */
569
+ dims: Array<{ id: string; w: number; h: number }>;
570
+ /** Wave G — rotation drag reference (corner rotate zones only). */
571
+ rotRef?: { angle0: number; rot0: number };
344
572
  } | null>(null);
345
573
  // Last pointer position (client coords) during a drag — lets a mid-drag
346
574
  // Shift/Alt keydown re-apply the resize without waiting for a pointermove.
@@ -378,28 +606,48 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
378
606
  rafRef.current = null;
379
607
  const c = containerRef.current;
380
608
  if (!c) return;
381
- const positions = handlePositions(selectedStroke);
382
609
  const v = vp ?? { x: 0, y: 0, zoom: 1 };
383
610
  const z = v.zoom || 1;
611
+ // Wave H — screen-constant halo offset (HALO_PAD_PX at any zoom).
612
+ const positions = handlePositions(selectedStroke, HALO_PAD_PX / z);
384
613
  // Ensure enough handle children exist (each corner = one absolutely-
385
- // positioned div). 4 for rect/ellipse/pen; 2 for arrow.
614
+ // positioned div); the per-kind class is assigned below.
386
615
  while (c.children.length < positions.length) {
387
- const handle = document.createElement('div');
388
- handle.className = 'dc-annot-resize-handle';
389
- c.appendChild(handle);
616
+ c.appendChild(document.createElement('div'));
390
617
  }
391
618
  while (c.children.length > positions.length) {
392
619
  c.lastChild && c.removeChild(c.lastChild);
393
620
  }
621
+ // Wave G — rotate zones push a screen-constant step OUTWARD from the
622
+ // stroke center so they hover diagonally beyond the corner squares.
623
+ const wc = strokeCenter(selectedStroke);
624
+ const csx = wc ? wc[0] * z + v.x : 0;
625
+ const csy = wc ? wc[1] * z + v.y : 0;
394
626
  for (let i = 0; i < positions.length; i++) {
395
627
  const pos = positions[i];
396
628
  const handle = c.children[i] as HTMLElement | undefined;
397
629
  if (!pos || !handle) continue;
398
- const sx = pos.x * z + v.x;
399
- const sy = pos.y * z + v.y;
630
+ let sx = pos.x * z + v.x;
631
+ let sy = pos.y * z + v.y;
632
+ const isRot = isRotCorner(pos.corner);
633
+ if (isRot && wc) {
634
+ const dx = sx - csx;
635
+ const dy = sy - csy;
636
+ const len = Math.hypot(dx, dy) || 1;
637
+ const reach = 13;
638
+ sx += (dx / len) * reach;
639
+ sy += (dy / len) * reach;
640
+ }
641
+ // Edge pills are 14×6 / 6×14, rotate zones 18×18 (corners 8×8) —
642
+ // center each on its point.
643
+ const ns = pos.corner === 'n' || pos.corner === 's';
644
+ const ew = pos.corner === 'e' || pos.corner === 'w';
645
+ const halfW = isRot ? 9 : ns ? 7 : ew ? 3 : 4;
646
+ const halfH = isRot ? 9 : ns ? 3 : ew ? 7 : 4;
647
+ handle.className = isRot ? 'dc-annot-rotate-zone' : 'dc-annot-resize-handle';
400
648
  handle.style.display = 'block';
401
- handle.style.left = `${Math.round(sx - 4)}px`;
402
- handle.style.top = `${Math.round(sy - 4)}px`;
649
+ handle.style.left = `${Math.round(sx - halfW)}px`;
650
+ handle.style.top = `${Math.round(sy - halfH)}px`;
403
651
  handle.dataset.corner = pos.corner;
404
652
  }
405
653
  rafRef.current = requestAnimationFrame(tick);
@@ -417,47 +665,229 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
417
665
  if (!c) return;
418
666
  const onDown = (e: PointerEvent) => {
419
667
  const t = e.target as HTMLElement | null;
420
- if (!t?.classList.contains('dc-annot-resize-handle')) return;
421
- if (!selectedStroke || !store) return;
668
+ if (
669
+ !t?.classList.contains('dc-annot-resize-handle') &&
670
+ !t?.classList.contains('dc-annot-rotate-zone')
671
+ )
672
+ return;
673
+ if (!t || !selectedStroke || !store) return;
422
674
  const corner = t.dataset.corner as Corner | undefined;
423
675
  if (!corner) return;
424
676
  e.preventDefault();
425
677
  e.stopPropagation();
426
- dragRef.current = { pointerId: e.pointerId, startStroke: selectedStroke, corner };
678
+ // Wave G corner rotation is relative to the grab angle (no jump).
679
+ let rotRef: { angle0: number; rot0: number } | undefined;
680
+ if (isRotCorner(corner)) {
681
+ const cw = strokeCenter(selectedStroke);
682
+ if (cw) {
683
+ const [wx, wy] = screenToWorld(e.clientX, e.clientY);
684
+ rotRef = {
685
+ angle0: (Math.atan2(wy - cw[1], wx - cw[0]) * 180) / Math.PI,
686
+ rot0: strokeRotation(selectedStroke),
687
+ };
688
+ }
689
+ }
690
+ // FigJam v3 — dimension-match candidates, captured once per gesture: the
691
+ // bbox dims of every OTHER box-shaped stroke. While resizing, a width /
692
+ // height within the threshold of a neighbour's snaps to it exactly and
693
+ // the neighbour gets a match halo (the "make it the same size" quota).
694
+ const dims: Array<{ id: string; w: number; h: number }> = [];
695
+ for (const s of store.strokes) {
696
+ if (s.id === selectedStroke.id) continue;
697
+ if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'text') continue;
698
+ const bb = strokeBBox(s);
699
+ if (bb && bb.w > 1 && bb.h > 1) dims.push({ id: s.id, w: bb.w, h: bb.h });
700
+ }
701
+ dragRef.current = {
702
+ pointerId: e.pointerId,
703
+ startStroke: selectedStroke,
704
+ startStrokes: store.strokes,
705
+ corner,
706
+ dims,
707
+ rotRef,
708
+ };
427
709
  try {
428
710
  t.setPointerCapture(e.pointerId);
429
711
  } catch {
430
712
  /* some browsers reject capture on synthetic events */
431
713
  }
432
714
  };
433
- const applyResize = (clientX: number, clientY: number, mods: ResizeMods) => {
715
+ const applyResize = (
716
+ clientX: number,
717
+ clientY: number,
718
+ mods: ResizeMods,
719
+ suppressBind: boolean
720
+ ) => {
434
721
  const d = dragRef.current;
435
722
  if (!d || !store) return;
436
723
  const [wx, wy] = screenToWorld(clientX, clientY);
437
- const patch = resizeStroke(d.startStroke, d.corner, wx, wy, mods);
438
- if (patch) store.updateStroke(d.startStroke.id, patch);
724
+ // FigJam v3 a rotated stroke resizes in its LOCAL frame: inverse-
725
+ // rotate the cursor around the start pivot, run the axis-aligned math,
726
+ // keep the rotation. (The rotation knob itself wants the RAW cursor.)
727
+ let lwx = wx;
728
+ let lwy = wy;
729
+ const startRot = strokeRotation(d.startStroke);
730
+ if (startRot !== 0 && !isRotCorner(d.corner)) {
731
+ const c = strokeCenter(d.startStroke);
732
+ if (c) {
733
+ const [ux, uy] = rotatePoint(wx, wy, c[0], c[1], -startRot);
734
+ lwx = ux;
735
+ lwy = uy;
736
+ }
737
+ }
738
+ // Wave G/H — handles render padded OFF the bbox (screen-constant); shift
739
+ // the cursor back onto the true corner/edge so the first move doesn't
740
+ // grow the shape.
741
+ const padWorld = HALO_PAD_PX / (vp?.zoom || 1);
742
+ lwx += padDX(d.corner) * padWorld;
743
+ lwy += padDY(d.corner) * padWorld;
744
+ let patch = resizeStroke(d.startStroke, d.corner, lwx, lwy, mods, d.rotRef);
745
+ if (!patch) return;
746
+ // FigJam v3 — dimension match + live size label for box resizes. ⌘
747
+ // suppresses matching (same convention as drag snapping).
748
+ if (
749
+ d.startStroke.tool !== 'arrow' &&
750
+ d.startStroke.tool !== 'pen' &&
751
+ 'w' in patch &&
752
+ 'h' in patch &&
753
+ typeof patch.w === 'number' &&
754
+ typeof patch.h === 'number' &&
755
+ typeof patch.x === 'number' &&
756
+ typeof patch.y === 'number'
757
+ ) {
758
+ const zoom = vp?.zoom || 1;
759
+ const thr = 6 / zoom;
760
+ const matchIds: string[] = [];
761
+ if (!suppressBind && d.dims.length) {
762
+ // Only the axes this handle actually drags participate — an east-
763
+ // edge drag must never quietly re-snap the HEIGHT to a neighbour.
764
+ const edge = isEdgeCorner(d.corner);
765
+ const affectsW = !edge || d.corner === 'e' || d.corner === 'w';
766
+ const affectsH = !edge || d.corner === 'n' || d.corner === 's';
767
+ let bestW: { id: string; w: number } | null = null;
768
+ let bestH: { id: string; h: number } | null = null;
769
+ for (const cand of d.dims) {
770
+ if (
771
+ affectsW &&
772
+ Math.abs(cand.w - patch.w) <= thr &&
773
+ (!bestW || Math.abs(cand.w - patch.w) < Math.abs(bestW.w - patch.w))
774
+ ) {
775
+ bestW = { id: cand.id, w: cand.w };
776
+ }
777
+ if (
778
+ affectsH &&
779
+ Math.abs(cand.h - patch.h) <= thr &&
780
+ (!bestH || Math.abs(cand.h - patch.h) < Math.abs(bestH.h - patch.h))
781
+ ) {
782
+ bestH = { id: cand.id, h: cand.h };
783
+ }
784
+ }
785
+ // Snap the box to the matched dim, keeping the anchored edge fixed
786
+ // (the cursor-side edge moves; x/y only shift when the west/north
787
+ // edge was the dragged one).
788
+ if (bestW) {
789
+ if (wx < patch.x + patch.w / 2) patch.x = patch.x + patch.w - bestW.w;
790
+ patch.w = bestW.w;
791
+ matchIds.push(bestW.id);
792
+ }
793
+ if (bestH) {
794
+ if (wy < patch.y + patch.h / 2) patch.y = patch.y + patch.h - bestH.h;
795
+ patch.h = bestH.h;
796
+ matchIds.push(bestH.id);
797
+ }
798
+ }
799
+ document.dispatchEvent(
800
+ new CustomEvent('maude:resize-info', {
801
+ detail: {
802
+ box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
803
+ matchIds,
804
+ },
805
+ })
806
+ );
807
+ }
808
+ // FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
809
+ // magnet threshold of a bindable host the endpoint snaps + binds; free
810
+ // space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
811
+ // paints the candidate halo via the maude:bind-hint broadcast.
812
+ if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
813
+ const zoom = vp?.zoom || 1;
814
+ const cand = suppressBind
815
+ ? null
816
+ : bindCandidate(
817
+ wx,
818
+ wy,
819
+ store.strokes,
820
+ BIND_THRESHOLD_PX / zoom,
821
+ new Set([d.startStroke.id])
822
+ );
823
+ if (cand) {
824
+ const host = store.strokes.find((s) => s.id === cand.hostId);
825
+ const pt = host ? anchorPoint(host, cand.nx, cand.ny) : null;
826
+ if (pt) {
827
+ // An explicit endpoint re-anchor PINS the magnet — auto-routing
828
+ // must not override a side the user chose by hand.
829
+ const pinned = { ...cand, pinned: true };
830
+ patch =
831
+ d.corner === 'ep1'
832
+ ? ({ ...patch, startBind: pinned, x1: pt[0], y1: pt[1] } as Partial<Stroke>)
833
+ : ({ ...patch, endBind: pinned, x2: pt[0], y2: pt[1] } as Partial<Stroke>);
834
+ }
835
+ } else {
836
+ patch = {
837
+ ...patch,
838
+ [d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
839
+ } as Partial<Stroke>;
840
+ }
841
+ document.dispatchEvent(
842
+ new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
843
+ );
844
+ }
845
+ // Wave H — transient preview tick: no undo record, no PUT. The gesture
846
+ // commits ONCE on pointerup (undo used to walk every resize pixel).
847
+ store.previewStroke(d.startStroke.id, patch);
439
848
  };
440
849
  const onMove = (e: PointerEvent) => {
441
850
  const d = dragRef.current;
442
851
  if (!d || e.pointerId !== d.pointerId) return;
443
852
  lastPointRef.current = { x: e.clientX, y: e.clientY };
444
- applyResize(e.clientX, e.clientY, { shift: e.shiftKey, alt: e.altKey });
853
+ applyResize(
854
+ e.clientX,
855
+ e.clientY,
856
+ { shift: e.shiftKey, alt: e.altKey },
857
+ e.metaKey || e.ctrlKey
858
+ );
445
859
  };
446
- // Holding/releasing Shift or Alt mid-drag re-runs the resize at the last
447
- // known pointer position so the constraint flips live, FigJam-style.
860
+ // Holding/releasing Shift / Alt / ⌘ mid-drag re-runs the resize at the
861
+ // last known pointer position so the constraint flips live, FigJam-style.
448
862
  const onKey = (e: KeyboardEvent) => {
449
863
  if (!dragRef.current) return;
450
- if (e.key !== 'Shift' && e.key !== 'Alt') return;
864
+ if (e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta' && e.key !== 'Control') return;
451
865
  const p = lastPointRef.current;
452
866
  if (!p) return;
453
867
  e.preventDefault();
454
- applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey });
868
+ applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey }, e.metaKey || e.ctrlKey);
455
869
  };
456
870
  const onUp = (e: PointerEvent) => {
457
871
  const d = dragRef.current;
458
872
  if (!d || e.pointerId !== d.pointerId) return;
459
873
  dragRef.current = null;
460
874
  lastPointRef.current = null;
875
+ // Wave H — close the preview gesture as ONE undo record (no-op when
876
+ // the drag ended exactly where it started).
877
+ store?.commitGesture(
878
+ d.startStrokes,
879
+ isRotCorner(d.corner)
880
+ ? 'rotate'
881
+ : d.corner === 'ep1' || d.corner === 'ep2'
882
+ ? 'move endpoint'
883
+ : 'resize'
884
+ );
885
+ // Clear the bind-hint halo + size label regardless of what was dragged
886
+ // (no-ops when nothing was hinted).
887
+ document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
888
+ document.dispatchEvent(
889
+ new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
890
+ );
461
891
  };
462
892
  c.addEventListener('pointerdown', onDown);
463
893
  document.addEventListener('pointermove', onMove);
@@ -473,7 +903,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
473
903
  document.removeEventListener('keydown', onKey, true);
474
904
  document.removeEventListener('keyup', onKey, true);
475
905
  };
476
- }, [selectedStroke, store, screenToWorld]);
906
+ }, [selectedStroke, store, screenToWorld, vp]);
477
907
 
478
908
  // Only render when there's exactly one resizable stroke selected. Multi
479
909
  // resize is undefined for v1 (no canonical UX); text inherits anchor bbox.