@oh-just-another/state 0.58.0 → 0.60.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 (272) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +78 -27
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.d.ts +13 -0
  5. package/dist/actions/actionArrange.d.ts.map +1 -0
  6. package/dist/actions/actionArrange.js +130 -0
  7. package/dist/actions/actionArrange.js.map +1 -0
  8. package/dist/actions/actionClipboard.d.ts +2 -0
  9. package/dist/actions/actionClipboard.d.ts.map +1 -1
  10. package/dist/actions/actionClipboard.js +32 -1
  11. package/dist/actions/actionClipboard.js.map +1 -1
  12. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  13. package/dist/actions/actionKeyboard.js +77 -9
  14. package/dist/actions/actionKeyboard.js.map +1 -1
  15. package/dist/actions/actionMode.d.ts +4 -0
  16. package/dist/actions/actionMode.d.ts.map +1 -1
  17. package/dist/actions/actionMode.js +54 -0
  18. package/dist/actions/actionMode.js.map +1 -1
  19. package/dist/actions/actionSelection.d.ts.map +1 -1
  20. package/dist/actions/actionSelection.js +1 -0
  21. package/dist/actions/actionSelection.js.map +1 -1
  22. package/dist/actions/actionText.d.ts +5 -0
  23. package/dist/actions/actionText.d.ts.map +1 -0
  24. package/dist/actions/actionText.js +37 -0
  25. package/dist/actions/actionText.js.map +1 -0
  26. package/dist/actions/actionView.d.ts +6 -0
  27. package/dist/actions/actionView.d.ts.map +1 -1
  28. package/dist/actions/actionView.js +20 -2
  29. package/dist/actions/actionView.js.map +1 -1
  30. package/dist/actions/actionZoom.d.ts.map +1 -1
  31. package/dist/actions/actionZoom.js +5 -0
  32. package/dist/actions/actionZoom.js.map +1 -1
  33. package/dist/actions/index.d.ts +6 -4
  34. package/dist/actions/index.d.ts.map +1 -1
  35. package/dist/actions/index.js +6 -4
  36. package/dist/actions/index.js.map +1 -1
  37. package/dist/actions/registry.d.ts.map +1 -1
  38. package/dist/actions/registry.js +17 -0
  39. package/dist/actions/registry.js.map +1 -1
  40. package/dist/actions/types.d.ts +10 -1
  41. package/dist/actions/types.d.ts.map +1 -1
  42. package/dist/actions/types.js.map +1 -1
  43. package/dist/auto-layout-scheduler.d.ts +1 -3
  44. package/dist/auto-layout-scheduler.d.ts.map +1 -1
  45. package/dist/auto-layout-scheduler.js +1 -2
  46. package/dist/auto-layout-scheduler.js.map +1 -1
  47. package/dist/built-in-handlers.d.ts.map +1 -1
  48. package/dist/built-in-handlers.js +14 -1
  49. package/dist/built-in-handlers.js.map +1 -1
  50. package/dist/clipboard.d.ts +0 -14
  51. package/dist/clipboard.d.ts.map +1 -1
  52. package/dist/clipboard.js +1 -1
  53. package/dist/clipboard.js.map +1 -1
  54. package/dist/constants.d.ts +208 -41
  55. package/dist/constants.d.ts.map +1 -1
  56. package/dist/constants.js +210 -25
  57. package/dist/constants.js.map +1 -1
  58. package/dist/dom-events.d.ts +8 -2
  59. package/dist/dom-events.d.ts.map +1 -1
  60. package/dist/dom-events.js +12 -0
  61. package/dist/dom-events.js.map +1 -1
  62. package/dist/editor/animation-scene.d.ts +36 -0
  63. package/dist/editor/animation-scene.d.ts.map +1 -0
  64. package/dist/editor/animation-scene.js +194 -0
  65. package/dist/editor/animation-scene.js.map +1 -0
  66. package/dist/editor/animation.d.ts +43 -0
  67. package/dist/editor/animation.d.ts.map +1 -0
  68. package/dist/editor/animation.js +80 -0
  69. package/dist/editor/animation.js.map +1 -0
  70. package/dist/editor/applies/arrange.d.ts +46 -0
  71. package/dist/editor/applies/arrange.d.ts.map +1 -0
  72. package/dist/editor/applies/arrange.js +162 -0
  73. package/dist/editor/applies/arrange.js.map +1 -0
  74. package/dist/editor/applies/create.d.ts +0 -6
  75. package/dist/editor/applies/create.d.ts.map +1 -1
  76. package/dist/editor/applies/create.js +1 -1
  77. package/dist/editor/applies/create.js.map +1 -1
  78. package/dist/editor/applies/edge.d.ts +8 -1
  79. package/dist/editor/applies/edge.d.ts.map +1 -1
  80. package/dist/editor/applies/edge.js +31 -1
  81. package/dist/editor/applies/edge.js.map +1 -1
  82. package/dist/editor/applies/link-move.d.ts +0 -8
  83. package/dist/editor/applies/link-move.d.ts.map +1 -1
  84. package/dist/editor/applies/link-move.js +1 -1
  85. package/dist/editor/applies/link-move.js.map +1 -1
  86. package/dist/editor/applies/move.d.ts +6 -0
  87. package/dist/editor/applies/move.d.ts.map +1 -1
  88. package/dist/editor/applies/move.js +6 -0
  89. package/dist/editor/applies/move.js.map +1 -1
  90. package/dist/editor/applies/resize.d.ts +26 -5
  91. package/dist/editor/applies/resize.d.ts.map +1 -1
  92. package/dist/editor/applies/resize.js +91 -13
  93. package/dist/editor/applies/resize.js.map +1 -1
  94. package/dist/editor/caret-blink.d.ts +18 -0
  95. package/dist/editor/caret-blink.d.ts.map +1 -0
  96. package/dist/editor/caret-blink.js +42 -0
  97. package/dist/editor/caret-blink.js.map +1 -0
  98. package/dist/editor/container-ops.d.ts.map +1 -1
  99. package/dist/editor/container-ops.js +2 -2
  100. package/dist/editor/container-ops.js.map +1 -1
  101. package/dist/editor/dirty-rect.d.ts +23 -0
  102. package/dist/editor/dirty-rect.d.ts.map +1 -0
  103. package/dist/editor/dirty-rect.js +115 -0
  104. package/dist/editor/dirty-rect.js.map +1 -0
  105. package/dist/editor/gif-playback.d.ts +45 -0
  106. package/dist/editor/gif-playback.d.ts.map +1 -0
  107. package/dist/editor/gif-playback.js +125 -0
  108. package/dist/editor/gif-playback.js.map +1 -0
  109. package/dist/editor/hit-test.d.ts +3 -3
  110. package/dist/editor/hit-test.d.ts.map +1 -1
  111. package/dist/editor/hit-test.js +40 -15
  112. package/dist/editor/hit-test.js.map +1 -1
  113. package/dist/editor/interaction-state.d.ts +184 -0
  114. package/dist/editor/interaction-state.d.ts.map +1 -0
  115. package/dist/editor/interaction-state.js +139 -0
  116. package/dist/editor/interaction-state.js.map +1 -0
  117. package/dist/editor/link-handle-drag.d.ts +152 -0
  118. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  119. package/dist/editor/link-handle-drag.js +300 -0
  120. package/dist/editor/link-handle-drag.js.map +1 -0
  121. package/dist/editor/long-press.d.ts.map +1 -1
  122. package/dist/editor/long-press.js +2 -2
  123. package/dist/editor/long-press.js.map +1 -1
  124. package/dist/editor/pinch.d.ts.map +1 -1
  125. package/dist/editor/pinch.js +6 -6
  126. package/dist/editor/pinch.js.map +1 -1
  127. package/dist/editor/pointer-binding.d.ts +9 -11
  128. package/dist/editor/pointer-binding.d.ts.map +1 -1
  129. package/dist/editor/pointer-binding.js +1191 -890
  130. package/dist/editor/pointer-binding.js.map +1 -1
  131. package/dist/editor/public/arrange-group.d.ts +3 -9
  132. package/dist/editor/public/arrange-group.d.ts.map +1 -1
  133. package/dist/editor/public/arrange-group.js +4 -6
  134. package/dist/editor/public/arrange-group.js.map +1 -1
  135. package/dist/editor/public/brush.d.ts +31 -12
  136. package/dist/editor/public/brush.d.ts.map +1 -1
  137. package/dist/editor/public/brush.js +66 -17
  138. package/dist/editor/public/brush.js.map +1 -1
  139. package/dist/editor/public/clipboard.d.ts +2 -4
  140. package/dist/editor/public/clipboard.d.ts.map +1 -1
  141. package/dist/editor/public/clipboard.js +2 -4
  142. package/dist/editor/public/clipboard.js.map +1 -1
  143. package/dist/editor/public/cursor.d.ts +29 -0
  144. package/dist/editor/public/cursor.d.ts.map +1 -0
  145. package/dist/editor/public/cursor.js +146 -0
  146. package/dist/editor/public/cursor.js.map +1 -0
  147. package/dist/editor/public/eraser.d.ts +55 -0
  148. package/dist/editor/public/eraser.d.ts.map +1 -0
  149. package/dist/editor/public/eraser.js +65 -0
  150. package/dist/editor/public/eraser.js.map +1 -0
  151. package/dist/editor/public/frame-name.d.ts +20 -0
  152. package/dist/editor/public/frame-name.d.ts.map +1 -0
  153. package/dist/editor/public/frame-name.js +54 -0
  154. package/dist/editor/public/frame-name.js.map +1 -0
  155. package/dist/editor/public/image-insert.d.ts +1 -1
  156. package/dist/editor/public/image-insert.d.ts.map +1 -1
  157. package/dist/editor/public/laser.d.ts +38 -0
  158. package/dist/editor/public/laser.d.ts.map +1 -0
  159. package/dist/editor/public/laser.js +42 -0
  160. package/dist/editor/public/laser.js.map +1 -0
  161. package/dist/editor/public/layers.d.ts.map +1 -1
  162. package/dist/editor/public/layers.js +2 -2
  163. package/dist/editor/public/layers.js.map +1 -1
  164. package/dist/editor/public/link.d.ts +11 -2
  165. package/dist/editor/public/link.d.ts.map +1 -1
  166. package/dist/editor/public/link.js +70 -2
  167. package/dist/editor/public/link.js.map +1 -1
  168. package/dist/editor/public/placement.d.ts +60 -2
  169. package/dist/editor/public/placement.d.ts.map +1 -1
  170. package/dist/editor/public/placement.js +193 -2
  171. package/dist/editor/public/placement.js.map +1 -1
  172. package/dist/editor/public/selection-ops.d.ts +35 -1
  173. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  174. package/dist/editor/public/selection-ops.js +97 -2
  175. package/dist/editor/public/selection-ops.js.map +1 -1
  176. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  177. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  178. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  179. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  180. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  181. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  182. package/dist/editor/public/stroke-eraser.js +176 -0
  183. package/dist/editor/public/stroke-eraser.js.map +1 -0
  184. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  185. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  186. package/dist/editor/public/stroke-smoothing.js +37 -0
  187. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  188. package/dist/editor/public/text-edit.d.ts +1 -10
  189. package/dist/editor/public/text-edit.d.ts.map +1 -1
  190. package/dist/editor/public/text-edit.js +2 -16
  191. package/dist/editor/public/text-edit.js.map +1 -1
  192. package/dist/editor/public/tool-ops.d.ts +130 -0
  193. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  194. package/dist/editor/public/tool-ops.js +392 -0
  195. package/dist/editor/public/tool-ops.js.map +1 -0
  196. package/dist/editor/public/z-order.d.ts +1 -12
  197. package/dist/editor/public/z-order.d.ts.map +1 -1
  198. package/dist/editor/public/z-order.js +5 -5
  199. package/dist/editor/public/z-order.js.map +1 -1
  200. package/dist/editor/public/zoom-pan.d.ts +15 -4
  201. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  202. package/dist/editor/public/zoom-pan.js +39 -7
  203. package/dist/editor/public/zoom-pan.js.map +1 -1
  204. package/dist/editor/render-orchestrator.d.ts +177 -5
  205. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  206. package/dist/editor/render-orchestrator.js +519 -358
  207. package/dist/editor/render-orchestrator.js.map +1 -1
  208. package/dist/editor/resize-helpers.d.ts +21 -5
  209. package/dist/editor/resize-helpers.d.ts.map +1 -1
  210. package/dist/editor/resize-helpers.js +59 -19
  211. package/dist/editor/resize-helpers.js.map +1 -1
  212. package/dist/editor/text-edit.d.ts +136 -0
  213. package/dist/editor/text-edit.d.ts.map +1 -0
  214. package/dist/editor/text-edit.js +328 -0
  215. package/dist/editor/text-edit.js.map +1 -0
  216. package/dist/editor/viewport-helpers.d.ts.map +1 -1
  217. package/dist/editor/viewport-helpers.js +3 -3
  218. package/dist/editor/viewport-helpers.js.map +1 -1
  219. package/dist/editor-events.d.ts +1 -1
  220. package/dist/editor-events.d.ts.map +1 -1
  221. package/dist/editor.d.ts +603 -346
  222. package/dist/editor.d.ts.map +1 -1
  223. package/dist/editor.js +1843 -1630
  224. package/dist/editor.js.map +1 -1
  225. package/dist/file-drop.d.ts.map +1 -1
  226. package/dist/file-drop.js +1 -2
  227. package/dist/file-drop.js.map +1 -1
  228. package/dist/frame-helpers.d.ts.map +1 -1
  229. package/dist/frame-helpers.js +5 -5
  230. package/dist/frame-helpers.js.map +1 -1
  231. package/dist/group-helpers.d.ts.map +1 -1
  232. package/dist/group-helpers.js +4 -4
  233. package/dist/group-helpers.js.map +1 -1
  234. package/dist/handle.d.ts +59 -6
  235. package/dist/handle.d.ts.map +1 -1
  236. package/dist/handle.js +99 -7
  237. package/dist/handle.js.map +1 -1
  238. package/dist/hotkeys-binding.d.ts +26 -0
  239. package/dist/hotkeys-binding.d.ts.map +1 -0
  240. package/dist/hotkeys-binding.js +35 -0
  241. package/dist/hotkeys-binding.js.map +1 -0
  242. package/dist/index.d.ts +9 -3
  243. package/dist/index.d.ts.map +1 -1
  244. package/dist/index.js +10 -3
  245. package/dist/index.js.map +1 -1
  246. package/dist/link-selection.d.ts +3 -5
  247. package/dist/link-selection.d.ts.map +1 -1
  248. package/dist/link-selection.js +3 -12
  249. package/dist/link-selection.js.map +1 -1
  250. package/dist/machine.d.ts +27 -1
  251. package/dist/machine.d.ts.map +1 -1
  252. package/dist/machine.js +38 -0
  253. package/dist/machine.js.map +1 -1
  254. package/dist/modes.d.ts +15 -1
  255. package/dist/modes.d.ts.map +1 -1
  256. package/dist/modes.js.map +1 -1
  257. package/dist/overlay.d.ts +92 -6
  258. package/dist/overlay.d.ts.map +1 -1
  259. package/dist/overlay.js +582 -125
  260. package/dist/overlay.js.map +1 -1
  261. package/dist/search.d.ts +37 -0
  262. package/dist/search.d.ts.map +1 -0
  263. package/dist/search.js +45 -0
  264. package/dist/search.js.map +1 -0
  265. package/dist/selection.d.ts.map +1 -1
  266. package/dist/selection.js +2 -10
  267. package/dist/selection.js.map +1 -1
  268. package/dist/util.d.ts +8 -0
  269. package/dist/util.d.ts.map +1 -0
  270. package/dist/util.js +17 -0
  271. package/dist/util.js.map +1 -0
  272. package/package.json +7 -7
package/dist/overlay.js CHANGED
@@ -1,16 +1,12 @@
1
- import { getAnnotationWorldPosition, getElementWorldBounds, getWorldToScreen, } from "@oh-just-another/scene";
1
+ import { getAnnotationWorldPosition, getElementWorldBounds, getWorldToScreen, isGroup, brushOutline, } from "@oh-just-another/scene";
2
2
  import { bounds as B, matrix } from "@oh-just-another/math";
3
- import { getElementRenderer, strokeRoundedPolyline, LINK_CORNER_RADIUS, } from "@oh-just-another/renderer-core";
4
- import { ANCHOR_DOT_ACTIVE_RADIUS, ANCHOR_DOT_RADIUS, ANCHOR_DOT_STROKE_WIDTH, ANNOTATION_PIN_BADGE_FONT_SIZE, ANNOTATION_PIN_FILL, ANNOTATION_PIN_RADIUS, ANNOTATION_PIN_RESOLVED_FILL, ANNOTATION_PIN_STROKE, CURSOR_ARROW_SIZE, CURSOR_NAME_CHIP_OFFSET, CURSOR_NAME_CHIP_PADDING_X, CURSOR_NAME_CHIP_PADDING_Y, CURSOR_NAME_FONT_SIZE, DRAW_PREVIEW_OPACITY, GHOST_PREVIEW_OPACITY, LINK_ATTACH_ANCHOR_FILL, LINK_ATTACH_ANCHOR_STROKE, LINK_ENDPOINT_HANDLE_DRAW_RADIUS, LINK_MIDPOINT_HANDLE_DRAW_RADIUS, SELECTION_HALO_PEEK_PX, LINK_START_ANCHOR_FILL, LINK_START_ANCHOR_STROKE, PEER_SELECTION_DASH, PEER_SELECTION_PADDING, PEER_SELECTION_STROKE_WIDTH, TEXT_CARET_WIDTH_PX, TEXT_SELECTION_FILL, TEXT_SELECTION_OPACITY, } from "./constants.js";
5
- import { CORNER_HANDLES, HANDLE_SIZE, handlePosition } from "./handle.js";
3
+ import { getElementRenderer, resolveImageSource, strokeRoundedPolyline, LINK_CORNER_RADIUS, } from "@oh-just-another/renderer-core";
4
+ import { ANCHOR_DOT_ACTIVE_RADIUS, ANCHOR_DOT_RADIUS, ANCHOR_DOT_STROKE_WIDTH, ANNOTATION_PIN_BADGE_FONT_SIZE, ANNOTATION_PIN_FILL, ANNOTATION_PIN_RADIUS, ANNOTATION_PIN_RESOLVED_FILL, ANNOTATION_PIN_STROKE, CURSOR_ARROW_SIZE, CURSOR_NAME_CHIP_OFFSET, CURSOR_NAME_CHIP_PADDING_X, CURSOR_NAME_CHIP_PADDING_Y, CURSOR_NAME_FONT_SIZE, CROP_BRACKET_LEN, CROP_BRACKET_WIDTH, CROP_GHOST_OPACITY, DRAW_PREVIEW_OPACITY, FLOWCHART_PREVIEW_OPACITY, GHOST_PREVIEW_OPACITY, LINK_ATTACH_ANCHOR_FILL, LINK_ATTACH_ANCHOR_STROKE, LINK_ENDPOINT_HANDLE_DRAW_RADIUS, LINK_MIDPOINT_HANDLE_DRAW_RADIUS, SELECTION_HALO_PEEK_PX, LINK_START_ANCHOR_FILL, LINK_START_ANCHOR_STROKE, PEER_SELECTION_DASH, PEER_SELECTION_PADDING, PEER_SELECTION_STROKE_WIDTH, TEXT_CARET_WIDTH_PX, TEXT_SELECTION_FILL, TEXT_SELECTION_OPACITY, GIF_BADGE_W, GIF_BADGE_H, GIF_BADGE_PAD, GIF_BADGE_RADIUS, GIF_BADGE_BG_COLOR, LOCK_BADGE_SIZE, LOCK_BADGE_COLOR, LOCK_BADGE_KEYHOLE_COLOR, ROTATE_ICON_RADIUS, LASER_COLOR, LASER_WIDTH, LASER_TRAIL_TTL_MS, ERASER_CURSOR_STROKE, ERASER_CURSOR_LINE_WIDTH, ERASER_TRAIL_TTL_MS, } from "./constants.js";
5
+ import { smoothLaserPoints } from "./editor/public/laser.js";
6
+ import { CORNER_HANDLES, HANDLE_SIZE, frameCorners, handlePosition, handleWorldOnFrame, rotateGripForBounds, rotateGripWorld, shapeSelectionFrame, } from "./handle.js";
6
7
  import { isResizable } from "./editor/shape-traits.js";
7
8
  import { drawHitZones, hitZoneVisibility, } from "./editor/hit-test.js";
8
- /** Index-access helper: throws on out-of-range instead of returning `undefined`. */
9
- const req = (v) => {
10
- if (v === undefined)
11
- throw new Error("packages/state: index out of range");
12
- return v;
13
- };
9
+ import { req } from "./util.js";
14
10
  /**
15
11
  * Union AABB of every direct child of `groupId` (recursive). Returns
16
12
  * `null` for empty groups so callers can skip the outline pass. Used
@@ -21,7 +17,7 @@ const groupWorldBounds = (scene, groupId) => {
21
17
  for (const shape of scene.elements.values()) {
22
18
  if (shape.parentId !== groupId)
23
19
  continue;
24
- const inner = shape.type === "group" ? groupWorldBounds(scene, shape.id) : getElementWorldBounds(shape);
20
+ const inner = isGroup(shape) ? groupWorldBounds(scene, shape.id) : getElementWorldBounds(shape);
25
21
  if (!inner)
26
22
  continue;
27
23
  acc = acc ? B.union(acc, inner) : inner;
@@ -85,6 +81,12 @@ export const paintElementSelectionHalo = (target, w2s, halos, zoom, style = DEFA
85
81
  target.setOpacity(1);
86
82
  target.setTransform(matrix.IDENTITY);
87
83
  };
84
+ /**
85
+ * Draws selection outlines, resize handles, previews, peer chrome and other
86
+ * affordances on the overlay layer. Pure draw — does not alter scene or state.
87
+ * A thin dispatcher: each numbered layer lives in its own `render*` helper,
88
+ * called here in strict back-to-front paint order.
89
+ */
88
90
  export const renderOverlay = (scene, selection, target, options = {}) => {
89
91
  const style = { ...DEFAULT_OVERLAY_STYLE, ...options.style };
90
92
  target.clear();
@@ -93,8 +95,29 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
93
95
  const zoom = scene.viewport.zoom;
94
96
  target.save();
95
97
  target.setTransform(matrix.IDENTITY);
96
- // 0. Debug hit-zones drawn first so the real selection chrome sits
97
- // on top. Visualises every element's mouse hit-targets.
98
+ const ctx = { scene, selection, target, options, style, w2s, zoom };
99
+ renderDebugHitZones(ctx);
100
+ renderSelectionHandles(ctx);
101
+ renderPreviews(ctx);
102
+ renderLinkHandles(ctx);
103
+ renderPeerSelections(ctx);
104
+ renderBrushPreview(ctx);
105
+ renderLaserTrails(ctx);
106
+ renderEraserCursor(ctx);
107
+ renderContainerDropZone(ctx);
108
+ renderGroupBounds(ctx);
109
+ renderAnnotations(ctx);
110
+ renderPeerCursors(ctx);
111
+ renderGifBadges(ctx);
112
+ renderTextEditing(ctx);
113
+ target.restore();
114
+ };
115
+ /**
116
+ * Section 0 — debug hit-zones. Drawn first so the real selection chrome sits
117
+ * on top. Visualises every element's mouse hit-targets.
118
+ */
119
+ const renderDebugHitZones = (ctx) => {
120
+ const { scene, selection, target, options, w2s, zoom } = ctx;
98
121
  if (options.debugHitZones) {
99
122
  drawHitZones(target, {
100
123
  scene,
@@ -112,41 +135,96 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
112
135
  : {}),
113
136
  });
114
137
  }
115
- // 1. Selection outlines (+ handles only when a single shape is
116
- // selected). Multi-selection skips per-shape handles in favour of
117
- // the combined group bbox handles drawn later otherwise the
118
- // overlay would look like a forest of corner squares and the user
119
- // could grab a child handle, which `hitTest` also blocks.
138
+ };
139
+ /**
140
+ * Section 1 per-shape selection outlines (+ resize/rotate handles only when a
141
+ * single shape is selected). Multi-selection skips per-shape handles in favour
142
+ * of the combined group bbox handles (section 7) — otherwise the overlay would
143
+ * look like a forest of corner squares and the user could grab a child handle,
144
+ * which `hitTest` also blocks.
145
+ */
146
+ const renderSelectionHandles = (ctx) => {
147
+ const { scene, selection, target, options, style, w2s, zoom } = ctx;
148
+ // Crop mode owns the chrome: the crop frame + handles replace the normal
149
+ // per-shape resize/rotate affordances (drawn by renderPreviews).
150
+ if (options.cropFrame)
151
+ return;
120
152
  const multiSelect = selection.size > 1;
121
153
  for (const id of selection) {
122
154
  const shape = scene.elements.get(id);
123
155
  if (!shape)
124
156
  continue;
125
157
  // Groups have no intrinsic geometry — outline the union of their
126
- // descendants instead. Otherwise the selection chrome would collapse
127
- // to a zero-size point at the group's origin.
128
- const worldBounds = shape.type === "group" ? groupWorldBounds(scene, id) : getElementWorldBounds(shape);
129
- if (!worldBounds)
158
+ // descendants as an axis-aligned box. A single shape gets an oriented
159
+ // frame that turns with its rotation, so the selection box hugs the body
160
+ // instead of its (larger, axis-aligned) AABB.
161
+ if (isGroup(shape)) {
162
+ const worldBounds = groupWorldBounds(scene, id);
163
+ if (!worldBounds)
164
+ continue;
165
+ const screenBounds = projectBounds(worldBounds, w2s);
166
+ drawOutline(target, screenBounds, style);
167
+ if (shape.locked === true) {
168
+ drawLockBadge(target, screenBounds);
169
+ continue;
170
+ }
171
+ // Read-only: outline only — no resize/rotate affordances to grab.
172
+ if (multiSelect || !isResizable(shape) || options.readOnly === true)
173
+ continue;
174
+ for (const handle of CORNER_HANDLES) {
175
+ const worldPoint = handlePosition(handle, worldBounds, zoom);
176
+ drawHandle(target, matrix.applyToPoint(w2s, worldPoint), style);
177
+ }
178
+ drawRotateGripForBounds(target, worldBounds, zoom, w2s, style);
130
179
  continue;
131
- const screenBounds = projectBounds(worldBounds, w2s);
132
- drawOutline(target, screenBounds, style);
180
+ }
181
+ const frame = shapeSelectionFrame(shape);
182
+ // Unrotated → the plain AABB rect (unchanged rendering); rotated → a quad
183
+ // through the frame's corners so the box turns with the shape.
184
+ if (frame.rotation === 0) {
185
+ drawOutline(target, projectBounds(frame.bounds, w2s), style);
186
+ }
187
+ else {
188
+ drawFrameOutline(target, frame, w2s, style);
189
+ }
133
190
  // Locked element: no resize handles; show a small lock badge instead so
134
191
  // the user sees why it won't move/resize (click still selects → unlock).
135
192
  if (shape.locked === true) {
136
- drawLockBadge(target, screenBounds);
193
+ drawLockBadge(target, frameScreenBounds(frame, w2s));
137
194
  continue;
138
195
  }
139
- if (multiSelect || !isResizable(shape))
196
+ // Read-only: outline only — no resize/rotate affordances to grab.
197
+ if (multiSelect || !isResizable(shape) || options.readOnly === true)
140
198
  continue;
141
- // Draw only the four CORNER dots. The edge-midpoint handles are gone from
142
- // the chrome — edge resize is done by dragging the selection-box side
143
- // (see `hitHandle`), so no midpoint dot is needed. `resizeHandlesFor`
144
- // (ALL_HANDLES) still drives hit-test capability.
199
+ // Draw only the four CORNER dots at the rotated frame corners. Edge
200
+ // resize is done by dragging the selection-box side itself, so no midpoint
201
+ // dot is needed; the edge handles stay hit-testable via `hitHandleOnFrame`.
145
202
  for (const handle of CORNER_HANDLES) {
146
- const worldPoint = handlePosition(handle, worldBounds, zoom);
147
- const screenPoint = matrix.applyToPoint(w2s, worldPoint);
148
- drawHandle(target, screenPoint, style);
203
+ const worldPoint = handleWorldOnFrame(handle, frame, zoom);
204
+ drawHandle(target, matrix.applyToPoint(w2s, worldPoint), style);
149
205
  }
206
+ // Rotate grip from the shape's template anchor (default bottom-left).
207
+ drawRotateIcon(target, matrix.applyToPoint(w2s, rotateGripWorld(shape, zoom)), style);
208
+ }
209
+ };
210
+ /**
211
+ * Sections 2–3.5 — the transient draw/create previews and their landing
212
+ * highlights: the rubber-band rect, the WYSIWYG shape preview, the link
213
+ * preview, the click-create ghost, and the float-attach target outline.
214
+ */
215
+ const renderPreviews = (ctx) => {
216
+ const { target, options, style, w2s } = ctx;
217
+ // 0. Crop-mode ghost — the faint full bitmap behind the crop chrome, so the
218
+ // pixels hidden by the window stay visible. Drawn first (under the frame).
219
+ if (options.cropGhost) {
220
+ drawCropGhost(target, options.cropGhost, w2s);
221
+ }
222
+ // 1. Image-crop frame: dashed accent quad + L-shaped corner brackets (the
223
+ // edge midpoints stay grabbable but aren't drawn, Excalidraw-style).
224
+ if (options.cropFrame && options.cropFrame.length >= 4) {
225
+ const cornersScreen = options.cropFrame.map((p) => matrix.applyToPoint(w2s, p));
226
+ drawCropFrame(target, cornersScreen, style);
227
+ drawCropCornerBrackets(target, cornersScreen, style);
150
228
  }
151
229
  // 2. Rubber-band drawing preview (already in world coords if drawn before transform reset)
152
230
  if (options.drawingPreview) {
@@ -174,6 +252,47 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
174
252
  target.restore();
175
253
  }
176
254
  }
255
+ // 2.6 Flowchart-create node preview — the pending nodes of a Cmd/Ctrl+Arrow
256
+ // grow session, each through its real renderer (faded). The connecting
257
+ // links are drawn by the orchestrator at the same opacity.
258
+ if (options.flowchartPreviewElements && options.flowchartPreviewElements.length > 0) {
259
+ for (const el of options.flowchartPreviewElements) {
260
+ const renderer = getElementRenderer(el.type);
261
+ if (!renderer)
262
+ continue;
263
+ target.save();
264
+ target.setTransform(w2s);
265
+ target.setOpacity(FLOWCHART_PREVIEW_OPACITY);
266
+ target.setDashArray(null);
267
+ target.translate(el.position.x, el.position.y);
268
+ if (el.rotation !== 0)
269
+ target.rotate(el.rotation);
270
+ if (el.scale.x !== 1 || el.scale.y !== 1)
271
+ target.scale(el.scale.x, el.scale.y);
272
+ renderer(el, target);
273
+ target.restore();
274
+ }
275
+ }
276
+ // Stroke-erase live fragments — full opacity (the originals are hidden), drawn
277
+ // per-element so the overlay's cursor / trail chrome survive underneath.
278
+ if (options.strokeErasePreviewElements && options.strokeErasePreviewElements.length > 0) {
279
+ for (const el of options.strokeErasePreviewElements) {
280
+ const renderer = getElementRenderer(el.type);
281
+ if (!renderer)
282
+ continue;
283
+ target.save();
284
+ target.setTransform(w2s);
285
+ target.setOpacity(1);
286
+ target.setDashArray(null);
287
+ target.translate(el.position.x, el.position.y);
288
+ if (el.rotation !== 0)
289
+ target.rotate(el.rotation);
290
+ if (el.scale.x !== 1 || el.scale.y !== 1)
291
+ target.scale(el.scale.x, el.scale.y);
292
+ renderer(el, target);
293
+ target.restore();
294
+ }
295
+ }
177
296
  // 3. Link-drawing preview — dashed line in screen space. An elbow preview
178
297
  // carries the full orthogonal polyline (`points`); otherwise a straight
179
298
  // from→to segment.
@@ -236,6 +355,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
236
355
  target.rect(b.x - pad, b.y - pad, b.width + pad * 2, b.height + pad * 2);
237
356
  target.stroke();
238
357
  }
358
+ };
359
+ /**
360
+ * Sections 4–5 — link chrome: port-dot attach affordances, the persistent
361
+ * selection halo around every selected link, and the selected-edge endpoint /
362
+ * waypoint / midpoint handles.
363
+ */
364
+ const renderLinkHandles = (ctx) => {
365
+ const { target, options, style, w2s, zoom } = ctx;
239
366
  // 4. Port dots — hover affordance in draw-edge mode. May be one set or
240
367
  // several (source start-anchors + target attach-anchors at once).
241
368
  if (options.ports) {
@@ -246,7 +373,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
246
373
  for (let i = 0; i < set.worldPoints.length; i++) {
247
374
  const screen = matrix.applyToPoint(w2s, req(set.worldPoints[i]));
248
375
  const active = set.activeIndex === i;
249
- drawPortDot(target, screen, style, active, set.role, active ? set.activeRadius : undefined);
376
+ drawPortDot(target, screen, active, set.role, active ? set.activeRadius : undefined);
250
377
  }
251
378
  }
252
379
  }
@@ -256,7 +383,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
256
383
  if (options.selectedLinkPaths && options.selectedLinkPaths.length > 0) {
257
384
  target.setTransform(w2s);
258
385
  target.setStroke(style.selectionStroke);
259
- target.setOpacity(0.32);
386
+ target.setOpacity(SELECTION_HALO_OPACITY);
260
387
  target.setDashArray(null);
261
388
  target.setLineJoin("round");
262
389
  target.setLineCap("round");
@@ -279,7 +406,9 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
279
406
  target.setTransform(matrix.IDENTITY);
280
407
  }
281
408
  // 5. Selected-edge endpoint handles + bend-point (waypoint) handles.
282
- if (options.edgeSelection) {
409
+ // Read-only: the halo above still marks the selected link, but the
410
+ // endpoint/bend grips are suppressed (nothing to re-bind or drag).
411
+ if (options.edgeSelection && options.readOnly !== true) {
283
412
  const from = matrix.applyToPoint(w2s, options.edgeSelection.from);
284
413
  const to = matrix.applyToPoint(w2s, options.edgeSelection.to);
285
414
  // Segment-midpoint "add waypoint" handles (drawn first, under the rest).
@@ -292,9 +421,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
292
421
  drawLinkEndpointHandle(target, matrix.applyToPoint(w2s, w), style);
293
422
  }
294
423
  }
295
- // 6. Peer selection halos — dashed outline around shapes selected
296
- // by remote users, painted in their colour. Drawn before own-
297
- // selection outlines so own selection stays on top.
424
+ };
425
+ /**
426
+ * Section 6 peer selection halos. Dashed outline around shapes selected by
427
+ * remote users, painted in their colour. Drawn before own-selection outlines
428
+ * so own selection stays on top.
429
+ */
430
+ const renderPeerSelections = (ctx) => {
431
+ const { target, options, w2s } = ctx;
298
432
  if (options.peerSelections) {
299
433
  for (const peer of options.peerSelections) {
300
434
  for (const wb of peer.bounds) {
@@ -303,6 +437,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
303
437
  }
304
438
  }
305
439
  }
440
+ };
441
+ /**
442
+ * Section 6.5 — live brush stroke preview. Quad-strip with interpolated
443
+ * widths, same render path as the committed BrushElement. Runs in world coords
444
+ * so the stroke stays anchored to the cursor as the user zooms / pans mid-stroke.
445
+ */
446
+ const renderBrushPreview = (ctx) => {
447
+ const { target, options, w2s } = ctx;
306
448
  // 6.5. Live brush stroke preview — quad-strip with interpolated
307
449
  // widths, same render path as the committed BrushElement. Runs
308
450
  // in world coords so the stroke stays anchored to the cursor
@@ -311,6 +453,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
311
453
  const bp = options.brushPreview;
312
454
  target.save();
313
455
  target.setTransform(w2s);
456
+ target.setOpacity(bp.opacity);
314
457
  target.setFill(bp.fill);
315
458
  target.setStroke(null);
316
459
  const pts = bp.points;
@@ -323,35 +466,153 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
323
466
  target.fill();
324
467
  }
325
468
  else {
326
- for (let i = 0; i < pts.length - 1; i++) {
327
- const a = req(pts[i]);
328
- const b = req(pts[i + 1]);
329
- const ax = ox + a.x;
330
- const ay = oy + a.y;
331
- const bx = ox + b.x;
332
- const by = oy + b.y;
333
- const dx = bx - ax;
334
- const dy = by - ay;
335
- const len = Math.hypot(dx, dy) || 1;
336
- const nx = -dy / len;
337
- const ny = dx / len;
469
+ // Same single-outline fill as the committed stroke (see `brushOutline` /
470
+ // `drawBrush`), so the preview matches the result and honours `opacity`
471
+ // without the per-segment double-blend at joins.
472
+ const outline = brushOutline(pts);
473
+ if (outline.length >= 3) {
338
474
  target.beginPath();
339
- target.moveTo(ax + nx * a.width, ay + ny * a.width);
340
- target.lineTo(bx + nx * b.width, by + ny * b.width);
341
- target.lineTo(bx - nx * b.width, by - ny * b.width);
342
- target.lineTo(ax - nx * a.width, ay - ny * a.width);
475
+ const first = req(outline[0]);
476
+ target.moveTo(ox + first.x, oy + first.y);
477
+ for (let i = 1; i < outline.length; i++) {
478
+ const p = req(outline[i]);
479
+ target.lineTo(ox + p.x, oy + p.y);
480
+ }
343
481
  target.closePath();
344
482
  target.fill();
345
- target.beginPath();
346
- target.ellipse(bx, by, b.width, b.width);
347
- target.fill();
348
483
  }
349
484
  }
350
485
  target.restore();
351
486
  }
352
- // 7.0. Container drop-zone highlight — drawn under selection chrome
353
- // so handles stay legible. Dashed rect + soft fill — same
354
- // visual language as the drawing preview.
487
+ };
488
+ /**
489
+ * Section 6.6 laser-pointer trails. Each segment is stroked in screen space
490
+ * (constant on-screen width at any zoom) with an opacity that decays from the
491
+ * newer endpoint's age: fresh points are opaque, older ones fade out, so a
492
+ * released stroke melts away tail-first. Read-only — never mutates state.
493
+ */
494
+ const renderLaserTrails = (ctx) => {
495
+ const { target, options, w2s } = ctx;
496
+ drawFadingTrail(target, w2s, options.laserStrokes, LASER_COLOR, LASER_WIDTH, LASER_TRAIL_TTL_MS);
497
+ // Eraser drag trail — identical fade mechanics, neutral grey (not laser red).
498
+ // Its width matches the cursor ring's DIAMETER (2 × the panel radius) so the
499
+ // wake is exactly as wide as the eraser; a much shorter TTL than the laser
500
+ // keeps it a tight wake, not a long comet.
501
+ const eraserWidth = (options.eraserCursor?.radius ?? LASER_WIDTH / 2) * 2;
502
+ drawFadingTrail(target, w2s, options.eraserTrail, ERASER_CURSOR_STROKE, eraserWidth, ERASER_TRAIL_TTL_MS);
503
+ };
504
+ /**
505
+ * Paint a trail (shared by the laser pointer and the eraser wake) the way
506
+ * Excalidraw does: ONE filled outline per stroke, not a stack of alpha-blended
507
+ * segments. The smoothed centreline is offset by a half-width that tapers from
508
+ * `width/2` at the head to 0 at the tail, giving a single comet shape with a
509
+ * pointed tail — no overlapping round caps beading at the joints. The whole
510
+ * stroke fills at one opacity that ramps down by the NEWEST point's age over
511
+ * `ttl` ms, so after release the comet dissolves as its tail is pruned. Drawn in
512
+ * screen space (constant on-screen width at any zoom). Read-only.
513
+ */
514
+ const drawFadingTrail = (target, w2s, strokes, color, width, ttl) => {
515
+ if (!strokes || strokes.length === 0)
516
+ return;
517
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
518
+ const maxHalf = width / 2;
519
+ target.setStroke(null);
520
+ target.setDashArray(null);
521
+ target.setFill(color);
522
+ for (const stroke of strokes) {
523
+ const pts = smoothLaserPoints(stroke.points);
524
+ if (pts.length === 0)
525
+ continue;
526
+ // One opacity for the whole shape (Excalidraw fills a single path once);
527
+ // ramps by the freshest point's age so a released stroke fades as a whole.
528
+ const newest = req(pts[pts.length - 1]);
529
+ const op = 1 - (now - newest.t) / ttl;
530
+ if (op <= 0)
531
+ continue;
532
+ target.setOpacity(op);
533
+ const screen = pts.map((p) => matrix.applyToPoint(w2s, { x: p.x, y: p.y }));
534
+ if (screen.length === 1) {
535
+ const s = req(screen[0]);
536
+ target.beginPath();
537
+ target.ellipse(s.x, s.y, maxHalf, maxHalf);
538
+ target.fill();
539
+ continue;
540
+ }
541
+ const outline = taperedTrailOutline(screen, maxHalf);
542
+ const first = req(outline[0]);
543
+ target.beginPath();
544
+ target.moveTo(first.x, first.y);
545
+ for (let i = 1; i < outline.length; i++) {
546
+ const p = req(outline[i]);
547
+ target.lineTo(p.x, p.y);
548
+ }
549
+ target.closePath();
550
+ target.fill();
551
+ }
552
+ target.setOpacity(1);
553
+ };
554
+ /**
555
+ * Build the closed outline polygon of a variable-width ribbon around the screen
556
+ * polyline `pts` (tail → head order). Each vertex is offset by ±`hw` along the
557
+ * local normal, where the half-width tapers linearly from 0 at the tail (index
558
+ * 0) to `maxHalf` at the head (last index). Returns the left side forward then
559
+ * the right side backward — one closed loop to fill in a single pass.
560
+ */
561
+ export const taperedTrailOutline = (pts, maxHalf) => {
562
+ const n = pts.length;
563
+ const left = [];
564
+ const right = [];
565
+ for (let i = 0; i < n; i++) {
566
+ const p = req(pts[i]);
567
+ const prev = req(pts[Math.max(0, i - 1)]);
568
+ const next = req(pts[Math.min(n - 1, i + 1)]);
569
+ let tx = next.x - prev.x;
570
+ let ty = next.y - prev.y;
571
+ const len = Math.hypot(tx, ty) || 1;
572
+ tx /= len;
573
+ ty /= len;
574
+ // Normal (perpendicular) × the tapered half-width.
575
+ const hw = maxHalf * (n > 1 ? i / (n - 1) : 1);
576
+ const nx = -ty * hw;
577
+ const ny = tx * hw;
578
+ left.push({ x: p.x + nx, y: p.y + ny });
579
+ right.push({ x: p.x - nx, y: p.y - ny });
580
+ }
581
+ right.reverse();
582
+ return [...left, ...right];
583
+ };
584
+ /**
585
+ * Section 6.7 — eraser cursor ring. A grey circle following the pointer while
586
+ * the erase tool is active, its radius the panel's eraser width in SCREEN px
587
+ * (so it matches the slider number, unscaled by zoom). Replaces the hidden OS
588
+ * cursor. Read-only — never mutates state.
589
+ */
590
+ const renderEraserCursor = (ctx) => {
591
+ const { target, options, w2s } = ctx;
592
+ const cursor = options.eraserCursor;
593
+ if (!cursor || cursor.radius <= 0)
594
+ return;
595
+ const s = matrix.applyToPoint(w2s, cursor.center);
596
+ target.setDashArray(null);
597
+ target.setOpacity(1);
598
+ target.beginPath();
599
+ target.ellipse(s.x, s.y, cursor.radius, cursor.radius);
600
+ // Solid disc in the trail colour (fully opaque), with the ring on top for a
601
+ // crisp radius edge.
602
+ target.setFill(ERASER_CURSOR_STROKE);
603
+ target.fill();
604
+ target.setFill(null);
605
+ target.setStroke(ERASER_CURSOR_STROKE);
606
+ target.setStrokeWidth(ERASER_CURSOR_LINE_WIDTH);
607
+ target.stroke();
608
+ };
609
+ /**
610
+ * Section 7.0 — container drop-zone highlight. Drawn under selection chrome so
611
+ * handles stay legible. Dashed rect + soft fill — same visual language as the
612
+ * drawing preview.
613
+ */
614
+ const renderContainerDropZone = (ctx) => {
615
+ const { target, options, w2s } = ctx;
355
616
  if (options.containerDropZone) {
356
617
  const zoneScreen = projectBounds(options.containerDropZone, w2s);
357
618
  target.setFill("rgba(26, 115, 232, 0.10)");
@@ -364,23 +625,41 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
364
625
  target.stroke();
365
626
  target.setDashArray(null);
366
627
  }
367
- // 7. Multi-selection / group-typed combined bounds — outline + handles.
368
- // Only CORNER dots are drawn (edge resize = drag the box side); the
369
- // edge handles stay hit-testable via `hitHandle` for non-aspect-locked
370
- // groups.
628
+ };
629
+ /**
630
+ * Section 7 — multi-selection / group-typed combined bounds: outline + handles.
631
+ * Only CORNER dots are drawn (edge resize = drag the box side); the edge
632
+ * handles stay hit-testable via `hitHandle` for non-aspect-locked groups.
633
+ */
634
+ const renderGroupBounds = (ctx) => {
635
+ const { target, options, style, w2s, zoom } = ctx;
636
+ // Crop mode owns the chrome: an image is aspect-locked, so its selection
637
+ // reports `groupBounds` — but its resize/rotate handles must not show on top
638
+ // of the crop brackets. Suppress them exactly like the per-shape handles.
639
+ if (options.cropFrame)
640
+ return;
371
641
  if (options.groupBounds) {
372
642
  const groupScreen = projectBounds(options.groupBounds, w2s);
373
643
  drawOutline(target, groupScreen, style);
644
+ // Read-only: combined-bounds outline only — no resize/rotate handles.
645
+ if (options.readOnly === true)
646
+ return;
374
647
  const handleSet = CORNER_HANDLES;
375
648
  for (const handle of handleSet) {
376
649
  const worldPoint = handlePosition(handle, options.groupBounds, zoom);
377
650
  const screenPoint = matrix.applyToPoint(w2s, worldPoint);
378
651
  drawHandle(target, screenPoint, style);
379
652
  }
653
+ drawRotateGripForBounds(target, options.groupBounds, zoom, w2s, style);
380
654
  }
381
- // 7.5. Annotation pins — drawn before peer cursors so cursors stay
382
- // on top, but on top of selection handles. Each pin shows a comment
383
- // count badge when the thread has > 0 replies.
655
+ };
656
+ /**
657
+ * Section 7.5 annotation pins. Drawn before peer cursors so cursors stay on
658
+ * top, but on top of selection handles. Each pin shows a comment-count badge
659
+ * when the thread has > 0 replies.
660
+ */
661
+ const renderAnnotations = (ctx) => {
662
+ const { scene, target, options, w2s } = ctx;
384
663
  if (options.annotations && options.annotations.length > 0) {
385
664
  for (const ann of options.annotations) {
386
665
  const world = getAnnotationWorldPosition(scene, ann);
@@ -388,25 +667,39 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
388
667
  drawAnnotationPin(target, screen, ann, options.selectedAnnotation ?? null);
389
668
  }
390
669
  }
391
- // 8. Remote peer cursors — drawn last so they sit on top of every
392
- // other overlay element (including own selection handles).
670
+ };
671
+ /**
672
+ * Section 8 — remote peer cursors. Drawn near-last so they sit on top of every
673
+ * other overlay element (including own selection handles).
674
+ */
675
+ const renderPeerCursors = (ctx) => {
676
+ const { target, options, w2s } = ctx;
393
677
  if (options.peerCursors) {
394
678
  for (const cursor of options.peerCursors) {
395
679
  const screen = matrix.applyToPoint(w2s, cursor.position);
396
680
  drawPeerCursor(target, screen, cursor.color, cursor.name);
397
681
  }
398
682
  }
399
- // 9. GIF "play" badges on paused animated shapes — drawn last
400
- // so they sit above selection chrome.
683
+ };
684
+ /**
685
+ * Section 9 — GIF "play" badges on paused animated shapes. Drawn late so they
686
+ * sit above selection chrome.
687
+ */
688
+ const renderGifBadges = (ctx) => {
689
+ const { target, options, w2s } = ctx;
401
690
  if (options.gifBadges) {
402
691
  for (const b of options.gifBadges) {
403
692
  drawGifBadge(target, projectBounds(b, w2s));
404
693
  }
405
694
  }
406
- // 10. In-canvas text editing: translucent selection highlight, then
407
- // the caret bar on top. Both backends draw this via the shared
408
- // RenderTarget primitives (rect + fill), so it's identical on
409
- // Canvas2D and WebGL2.
695
+ };
696
+ /**
697
+ * Section 10 in-canvas text editing: translucent selection highlight, then
698
+ * the caret bar on top. Both backends draw this via the shared RenderTarget
699
+ * primitives (rect + fill), so it's identical on Canvas2D and WebGL2.
700
+ */
701
+ const renderTextEditing = (ctx) => {
702
+ const { target, options, w2s, zoom } = ctx;
410
703
  if (options.editingText) {
411
704
  const et = options.editingText;
412
705
  if (et.selectionRects.length > 0) {
@@ -429,21 +722,12 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
429
722
  target.fill();
430
723
  }
431
724
  }
432
- target.restore();
433
725
  };
434
- /** Rounded "gif" chip in a shape's top-left corner — signals a paused
435
- * GIF the user can click (or hover) to resume. */
436
- const GIF_BADGE_W = 30;
437
- const GIF_BADGE_H = 16;
438
- const GIF_BADGE_PAD = 4;
439
- const GIF_BADGE_RADIUS = 4;
440
- /** Small padlock badge at a selected locked element's top-right corner. */
441
- const LOCK_BADGE_SIZE = 16;
442
726
  const drawLockBadge = (target, b) => {
443
727
  const s = LOCK_BADGE_SIZE;
444
728
  const x = b.x + b.width - s - 2;
445
729
  const y = b.y + 2;
446
- const color = "#1a73e8";
730
+ const color = LOCK_BADGE_COLOR;
447
731
  // Shackle — a ring above the body; its lower half is hidden by the body so
448
732
  // the visible top arc reads as the lock's bow.
449
733
  target.setStroke(color);
@@ -461,7 +745,7 @@ const drawLockBadge = (target, b) => {
461
745
  target.rect(x + s * 0.2, y + s * 0.44, s * 0.6, s * 0.48);
462
746
  target.fill();
463
747
  // Keyhole.
464
- target.setFill("#fff");
748
+ target.setFill(LOCK_BADGE_KEYHOLE_COLOR);
465
749
  target.beginPath();
466
750
  target.ellipse(x + s / 2, y + s * 0.64, s * 0.07, s * 0.07);
467
751
  target.fill();
@@ -474,7 +758,7 @@ const drawGifBadge = (target, screen) => {
474
758
  const r = GIF_BADGE_RADIUS;
475
759
  // Rounded-rect background.
476
760
  target.setStroke(null);
477
- target.setFill("rgba(0,0,0,0.65)");
761
+ target.setFill(GIF_BADGE_BG_COLOR);
478
762
  target.beginPath();
479
763
  target.moveTo(x + r, y);
480
764
  target.lineTo(x + w - r, y);
@@ -509,19 +793,121 @@ const drawOutline = (target, b, style) => {
509
793
  target.rect(b.x, b.y, b.width, b.height);
510
794
  target.stroke();
511
795
  };
796
+ /** Selection outline as a (possibly rotated) quad through the frame's corners. */
797
+ const drawFrameOutline = (target, frame, w2s, style) => {
798
+ const [wnw, wne, wse, wsw] = frameCorners(frame);
799
+ const nw = matrix.applyToPoint(w2s, wnw);
800
+ const ne = matrix.applyToPoint(w2s, wne);
801
+ const se = matrix.applyToPoint(w2s, wse);
802
+ const sw = matrix.applyToPoint(w2s, wsw);
803
+ target.setStroke(style.selectionStroke);
804
+ target.setStrokeWidth(style.selectionStrokeWidth);
805
+ target.setDashArray(null);
806
+ target.beginPath();
807
+ target.moveTo(nw.x, nw.y);
808
+ target.lineTo(ne.x, ne.y);
809
+ target.lineTo(se.x, se.y);
810
+ target.lineTo(sw.x, sw.y);
811
+ target.closePath();
812
+ target.stroke();
813
+ };
814
+ /** Screen-space AABB enclosing the frame's (rotated) corners — for badges. */
815
+ const frameScreenBounds = (frame, w2s) => {
816
+ const pts = frameCorners(frame).map((c) => matrix.applyToPoint(w2s, c));
817
+ let minX = Infinity;
818
+ let minY = Infinity;
819
+ let maxX = -Infinity;
820
+ let maxY = -Infinity;
821
+ for (const p of pts) {
822
+ if (p.x < minX)
823
+ minX = p.x;
824
+ if (p.y < minY)
825
+ minY = p.y;
826
+ if (p.x > maxX)
827
+ maxX = p.x;
828
+ if (p.y > maxY)
829
+ maxY = p.y;
830
+ }
831
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
832
+ };
833
+ /**
834
+ * Shared draw for every "filled circle with an outline" handle affordance —
835
+ * resize handles, port dots, link endpoint / waypoint / midpoint grabs. A
836
+ * circle reads as "draggable handle" more clearly than a sharp rectangle, and
837
+ * `ellipse` is supported by every RenderTarget (canvas, svg). State-setter
838
+ * order is irrelevant to output; only the state at `fill()`/`stroke()` matters.
839
+ */
840
+ const drawHandleDot = (target, center, radius, s) => {
841
+ if (s.opacity !== undefined)
842
+ target.setOpacity(s.opacity);
843
+ target.setStroke(s.stroke);
844
+ target.setStrokeWidth(s.strokeWidth);
845
+ target.setDashArray(null);
846
+ target.setFill(s.fill);
847
+ target.beginPath();
848
+ target.ellipse(center.x, center.y, radius, radius);
849
+ target.fill();
850
+ target.stroke();
851
+ if (s.opacity !== undefined)
852
+ target.setOpacity(1);
853
+ };
512
854
  const drawHandle = (target, center, style) => {
513
- // Circle of radius HANDLE_SIZE — visually equivalent to a rounded
514
- // square with maximum corner radius, but renders via `ellipse`
515
- // which every RenderTarget already supports (canvas, svg). A rounded
516
- // shape reads as "draggable handle" more clearly than a sharp rectangle.
517
- target.setFill(style.handleFill);
855
+ drawHandleDot(target, center, HANDLE_SIZE, {
856
+ fill: style.handleFill,
857
+ stroke: style.handleStroke,
858
+ strokeWidth: 1,
859
+ });
860
+ };
861
+ /**
862
+ * Rotate grip: a clockwise circular-arrow glyph (the `rotate-cw` icon) centred
863
+ * at `grip` (screen px), with no connector line back to the shape. Authored in
864
+ * lucide's 24×24 space — a near-full circle (radius 9) opening on the right plus
865
+ * an `L`-shaped arrowhead at the opening — scaled so the circle radius is
866
+ * `ROTATE_ICON_RADIUS` screen px.
867
+ */
868
+ const drawRotateIcon = (target, grip, style) => {
869
+ const s = ROTATE_ICON_RADIUS / 9; // icon radius 9 (in its 24×24 box) → screen px
870
+ // Map a point from the icon's 24×24 space (centre 12,12) to screen.
871
+ const tx = (px, py) => ({
872
+ x: grip.x + (px - 12) * s,
873
+ y: grip.y + (py - 12) * s,
874
+ });
518
875
  target.setStroke(style.handleStroke);
519
- target.setStrokeWidth(1);
876
+ target.setFill(null);
877
+ target.setStrokeWidth(2 * s); // lucide strokeWidth 2
878
+ target.setLineCap("round");
879
+ target.setLineJoin("round");
520
880
  target.setDashArray(null);
881
+ // Arc: centre (12,12), radius 9, from 0° (east tail) clockwise to (21,8)
882
+ // (≈ 336° swept) — a polyline is smooth enough at this size.
883
+ const END_DEG = 336;
884
+ const STEPS = 30;
521
885
  target.beginPath();
522
- target.ellipse(center.x, center.y, HANDLE_SIZE, HANDLE_SIZE);
523
- target.fill();
886
+ for (let i = 0; i <= STEPS; i++) {
887
+ const a = ((END_DEG * i) / STEPS) * (Math.PI / 180);
888
+ const p = tx(12 + 9 * Math.cos(a), 12 + 9 * Math.sin(a));
889
+ if (i === 0)
890
+ target.moveTo(p.x, p.y);
891
+ else
892
+ target.lineTo(p.x, p.y);
893
+ }
524
894
  target.stroke();
895
+ // Arrowhead at the arc's end (lucide `M21 3 v5 h-5`): a right-angle chevron.
896
+ const a1 = tx(21, 3);
897
+ const a2 = tx(21, 8);
898
+ const a3 = tx(16, 8);
899
+ target.beginPath();
900
+ target.moveTo(a1.x, a1.y);
901
+ target.lineTo(a2.x, a2.y);
902
+ target.lineTo(a3.x, a3.y);
903
+ target.stroke();
904
+ target.setLineCap("butt");
905
+ target.setLineJoin("miter");
906
+ };
907
+ /** Project the rotate grip to screen and draw the icon for AABB `b`. */
908
+ const drawRotateGripForBounds = (target, b, zoom, w2s, style) => {
909
+ const grip = rotateGripForBounds(b, zoom);
910
+ drawRotateIcon(target, matrix.applyToPoint(w2s, grip), style);
525
911
  };
526
912
  const drawDrawingPreview = (target, b, style) => {
527
913
  target.setStroke(style.drawingStroke);
@@ -541,6 +927,93 @@ const drawLinkPreview = (target, from, to, style) => {
541
927
  target.stroke();
542
928
  };
543
929
  /** Dashed polyline preview (elbow) in screen space. */
930
+ /**
931
+ * Dashed accent quad for the image-crop window. `pts` are already in screen
932
+ * space (4 corners, clockwise). The grab handles are drawn separately (all 8
933
+ * corner + edge nubs) by the caller.
934
+ */
935
+ const drawCropFrame = (target, pts, style) => {
936
+ target.setStroke(style.selectionStroke);
937
+ target.setStrokeWidth(1.5);
938
+ target.setDashArray(style.drawingDash);
939
+ target.beginPath();
940
+ const first = req(pts[0]);
941
+ target.moveTo(first.x, first.y);
942
+ for (let i = 1; i < pts.length; i++) {
943
+ const p = req(pts[i]);
944
+ target.lineTo(p.x, p.y);
945
+ }
946
+ target.lineTo(first.x, first.y);
947
+ target.stroke();
948
+ target.setDashArray(null);
949
+ };
950
+ /**
951
+ * L-shaped corner brackets on the crop frame (Excalidraw-style), replacing the
952
+ * round resize-nubs. `corners` are the 4 window corners in SCREEN space,
953
+ * clockwise (nw, ne, se, sw); each bracket's two arms run along the adjacent
954
+ * edges, so the marks stay aligned when the image is rotated. Only the corners
955
+ * are drawn — the edge midpoints remain grabbable via hit-testing.
956
+ */
957
+ const drawCropCornerBrackets = (target, corners, style) => {
958
+ if (corners.length < 4)
959
+ return;
960
+ target.setStroke(style.selectionStroke);
961
+ target.setStrokeWidth(CROP_BRACKET_WIDTH);
962
+ target.setDashArray(null);
963
+ target.setLineCap("round");
964
+ target.setLineJoin("round");
965
+ const armTo = (from, to) => {
966
+ const dx = to.x - from.x;
967
+ const dy = to.y - from.y;
968
+ const len = Math.hypot(dx, dy) || 1;
969
+ const t = Math.min(CROP_BRACKET_LEN, len) / len;
970
+ return { x: from.x + dx * t, y: from.y + dy * t };
971
+ };
972
+ for (let i = 0; i < 4; i++) {
973
+ const c = req(corners[i]);
974
+ const prev = req(corners[(i + 3) % 4]);
975
+ const next = req(corners[(i + 1) % 4]);
976
+ const a = armTo(c, prev);
977
+ const b = armTo(c, next);
978
+ target.beginPath();
979
+ target.moveTo(a.x, a.y);
980
+ target.lineTo(c.x, c.y);
981
+ target.lineTo(b.x, b.y);
982
+ target.stroke();
983
+ }
984
+ target.setLineCap("butt");
985
+ target.setLineJoin("miter");
986
+ };
987
+ /**
988
+ * Paint the faint full-image ghost behind the crop chrome. Replicates the main
989
+ * image renderer's transform path: apply the element's local→world transform,
990
+ * then draw the whole bitmap over the virtual full-image LOCAL rect at
991
+ * {@link CROP_GHOST_OPACITY}. Resolves the live handle exactly as the built-in
992
+ * image renderer (`metadata.image`, else `resolveImageSource`); skips silently
993
+ * when no handle is available (async decode in flight / headless).
994
+ */
995
+ const drawCropGhost = (target, ghost, w2s) => {
996
+ const el = ghost.element;
997
+ const handle = el.animationKind !== undefined
998
+ ? resolveImageSource(el, undefined)
999
+ : (el.metadata?.image ?? resolveImageSource(el, undefined));
1000
+ if (handle === null || handle === undefined)
1001
+ return;
1002
+ const dynamic = el.metadata?.animated === true || el.animationKind !== undefined;
1003
+ const { fullRect } = ghost;
1004
+ target.save();
1005
+ target.setTransform(w2s);
1006
+ target.setDashArray(null);
1007
+ target.translate(el.position.x, el.position.y);
1008
+ if (el.rotation !== 0)
1009
+ target.rotate(el.rotation);
1010
+ if (el.scale.x !== 1 || el.scale.y !== 1)
1011
+ target.scale(el.scale.x, el.scale.y);
1012
+ target.setOpacity(CROP_GHOST_OPACITY);
1013
+ target.drawImage(handle, fullRect.x, fullRect.y, fullRect.width, fullRect.height, dynamic);
1014
+ target.setOpacity(1);
1015
+ target.restore();
1016
+ };
544
1017
  const drawLinkPreviewPath = (target, pts, style) => {
545
1018
  target.setStroke(style.drawingStroke);
546
1019
  target.setStrokeWidth(1.5);
@@ -554,7 +1027,7 @@ const drawLinkPreviewPath = (target, pts, style) => {
554
1027
  }
555
1028
  target.stroke();
556
1029
  };
557
- const drawPortDot = (target, center, style, active, role = "link-start", activeRadius) => {
1030
+ const drawPortDot = (target, center, active, role = "link-start", activeRadius) => {
558
1031
  const radius = active ? (activeRadius ?? ANCHOR_DOT_ACTIVE_RADIUS) : ANCHOR_DOT_RADIUS;
559
1032
  const isStart = role === "link-start";
560
1033
  // When active (snapped), use the inverse fill of the resting state
@@ -567,41 +1040,25 @@ const drawPortDot = (target, center, style, active, role = "link-start", activeR
567
1040
  ? LINK_START_ANCHOR_FILL
568
1041
  : LINK_ATTACH_ANCHOR_FILL;
569
1042
  const stroke = isStart ? LINK_START_ANCHOR_STROKE : LINK_ATTACH_ANCHOR_STROKE;
570
- target.setStroke(stroke);
571
- target.setStrokeWidth(ANCHOR_DOT_STROKE_WIDTH);
572
- target.setDashArray(null);
573
- target.setFill(fill);
574
- target.beginPath();
575
- target.ellipse(center.x, center.y, radius, radius);
576
- target.fill();
577
- target.stroke();
1043
+ drawHandleDot(target, center, radius, { fill, stroke, strokeWidth: ANCHOR_DOT_STROKE_WIDTH });
578
1044
  };
579
1045
  const drawLinkEndpointHandle = (target, center, style) => {
580
- const radius = LINK_ENDPOINT_HANDLE_DRAW_RADIUS;
581
- target.setStroke(style.selectionStroke);
582
- target.setStrokeWidth(2);
583
- target.setDashArray(null);
584
- target.setFill(style.handleFill);
585
- target.beginPath();
586
- target.ellipse(center.x, center.y, radius, radius);
587
- target.fill();
588
- target.stroke();
1046
+ drawHandleDot(target, center, LINK_ENDPOINT_HANDLE_DRAW_RADIUS, {
1047
+ fill: style.handleFill,
1048
+ stroke: style.selectionStroke,
1049
+ strokeWidth: 2,
1050
+ });
589
1051
  };
590
1052
  // Smaller, semi-transparent dot on a segment midpoint — the "drag to add a
591
1053
  // bend point" affordance. Lighter than a real waypoint handle so it reads
592
1054
  // as secondary.
593
1055
  const drawLinkMidpointHandle = (target, center, style) => {
594
- const radius = LINK_MIDPOINT_HANDLE_DRAW_RADIUS;
595
- target.setOpacity(0.55);
596
- target.setStroke(style.selectionStroke);
597
- target.setStrokeWidth(1.5);
598
- target.setDashArray(null);
599
- target.setFill(style.handleFill);
600
- target.beginPath();
601
- target.ellipse(center.x, center.y, radius, radius);
602
- target.fill();
603
- target.stroke();
604
- target.setOpacity(1);
1056
+ drawHandleDot(target, center, LINK_MIDPOINT_HANDLE_DRAW_RADIUS, {
1057
+ fill: style.handleFill,
1058
+ stroke: style.selectionStroke,
1059
+ strokeWidth: 1.5,
1060
+ opacity: 0.55,
1061
+ });
605
1062
  };
606
1063
  const drawPeerSelection = (target, b, color) => {
607
1064
  const pad = PEER_SELECTION_PADDING;