@oh-just-another/state 0.59.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 (149) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +2 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/actions/actionArrange.js +4 -4
  5. package/dist/actions/actionArrange.js.map +1 -1
  6. package/dist/actions/actionClipboard.d.ts.map +1 -1
  7. package/dist/actions/actionClipboard.js +5 -0
  8. package/dist/actions/actionClipboard.js.map +1 -1
  9. package/dist/actions/actionKeyboard.d.ts.map +1 -1
  10. package/dist/actions/actionKeyboard.js +77 -9
  11. package/dist/actions/actionKeyboard.js.map +1 -1
  12. package/dist/actions/actionMode.d.ts +4 -0
  13. package/dist/actions/actionMode.d.ts.map +1 -1
  14. package/dist/actions/actionMode.js +54 -0
  15. package/dist/actions/actionMode.js.map +1 -1
  16. package/dist/actions/actionSelection.d.ts.map +1 -1
  17. package/dist/actions/actionSelection.js +1 -0
  18. package/dist/actions/actionSelection.js.map +1 -1
  19. package/dist/actions/actionView.d.ts +6 -0
  20. package/dist/actions/actionView.d.ts.map +1 -1
  21. package/dist/actions/actionView.js +19 -1
  22. package/dist/actions/actionView.js.map +1 -1
  23. package/dist/actions/actionZoom.d.ts.map +1 -1
  24. package/dist/actions/actionZoom.js +5 -0
  25. package/dist/actions/actionZoom.js.map +1 -1
  26. package/dist/actions/index.d.ts +3 -3
  27. package/dist/actions/index.d.ts.map +1 -1
  28. package/dist/actions/index.js +3 -3
  29. package/dist/actions/index.js.map +1 -1
  30. package/dist/actions/registry.d.ts.map +1 -1
  31. package/dist/actions/registry.js +13 -0
  32. package/dist/actions/registry.js.map +1 -1
  33. package/dist/actions/types.d.ts +9 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js.map +1 -1
  36. package/dist/clipboard.d.ts +0 -14
  37. package/dist/clipboard.d.ts.map +1 -1
  38. package/dist/clipboard.js +1 -1
  39. package/dist/clipboard.js.map +1 -1
  40. package/dist/constants.d.ts +160 -18
  41. package/dist/constants.d.ts.map +1 -1
  42. package/dist/constants.js +162 -2
  43. package/dist/constants.js.map +1 -1
  44. package/dist/dom-events.d.ts +1 -2
  45. package/dist/dom-events.d.ts.map +1 -1
  46. package/dist/dom-events.js.map +1 -1
  47. package/dist/editor/animation-scene.d.ts +15 -0
  48. package/dist/editor/animation-scene.d.ts.map +1 -1
  49. package/dist/editor/animation-scene.js +123 -0
  50. package/dist/editor/animation-scene.js.map +1 -1
  51. package/dist/editor/applies/create.d.ts +0 -6
  52. package/dist/editor/applies/create.d.ts.map +1 -1
  53. package/dist/editor/applies/create.js +1 -1
  54. package/dist/editor/applies/create.js.map +1 -1
  55. package/dist/editor/applies/link-move.d.ts +0 -8
  56. package/dist/editor/applies/link-move.d.ts.map +1 -1
  57. package/dist/editor/applies/link-move.js +1 -1
  58. package/dist/editor/applies/link-move.js.map +1 -1
  59. package/dist/editor/interaction-state.d.ts +184 -0
  60. package/dist/editor/interaction-state.d.ts.map +1 -0
  61. package/dist/editor/interaction-state.js +139 -0
  62. package/dist/editor/interaction-state.js.map +1 -0
  63. package/dist/editor/link-handle-drag.d.ts +152 -0
  64. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  65. package/dist/editor/link-handle-drag.js +300 -0
  66. package/dist/editor/link-handle-drag.js.map +1 -0
  67. package/dist/editor/pointer-binding.d.ts +9 -11
  68. package/dist/editor/pointer-binding.d.ts.map +1 -1
  69. package/dist/editor/pointer-binding.js +1185 -901
  70. package/dist/editor/pointer-binding.js.map +1 -1
  71. package/dist/editor/public/brush.d.ts +31 -12
  72. package/dist/editor/public/brush.d.ts.map +1 -1
  73. package/dist/editor/public/brush.js +66 -17
  74. package/dist/editor/public/brush.js.map +1 -1
  75. package/dist/editor/public/cursor.d.ts +1 -15
  76. package/dist/editor/public/cursor.d.ts.map +1 -1
  77. package/dist/editor/public/cursor.js +14 -3
  78. package/dist/editor/public/cursor.js.map +1 -1
  79. package/dist/editor/public/eraser.d.ts +55 -0
  80. package/dist/editor/public/eraser.d.ts.map +1 -0
  81. package/dist/editor/public/eraser.js +65 -0
  82. package/dist/editor/public/eraser.js.map +1 -0
  83. package/dist/editor/public/laser.d.ts +38 -0
  84. package/dist/editor/public/laser.d.ts.map +1 -0
  85. package/dist/editor/public/laser.js +42 -0
  86. package/dist/editor/public/laser.js.map +1 -0
  87. package/dist/editor/public/placement.d.ts.map +1 -1
  88. package/dist/editor/public/placement.js +9 -1
  89. package/dist/editor/public/placement.js.map +1 -1
  90. package/dist/editor/public/selection-ops.d.ts +12 -0
  91. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  92. package/dist/editor/public/selection-ops.js +19 -1
  93. package/dist/editor/public/selection-ops.js.map +1 -1
  94. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  95. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  96. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  97. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  98. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  99. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  100. package/dist/editor/public/stroke-eraser.js +176 -0
  101. package/dist/editor/public/stroke-eraser.js.map +1 -0
  102. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  103. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  104. package/dist/editor/public/stroke-smoothing.js +37 -0
  105. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  106. package/dist/editor/public/tool-ops.d.ts +130 -0
  107. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  108. package/dist/editor/public/tool-ops.js +392 -0
  109. package/dist/editor/public/tool-ops.js.map +1 -0
  110. package/dist/editor/public/z-order.d.ts +1 -12
  111. package/dist/editor/public/z-order.d.ts.map +1 -1
  112. package/dist/editor/public/z-order.js +1 -1
  113. package/dist/editor/public/z-order.js.map +1 -1
  114. package/dist/editor/public/zoom-pan.d.ts +9 -0
  115. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  116. package/dist/editor/public/zoom-pan.js +31 -0
  117. package/dist/editor/public/zoom-pan.js.map +1 -1
  118. package/dist/editor/render-orchestrator.d.ts +177 -5
  119. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  120. package/dist/editor/render-orchestrator.js +517 -351
  121. package/dist/editor/render-orchestrator.js.map +1 -1
  122. package/dist/editor/text-edit.d.ts +136 -0
  123. package/dist/editor/text-edit.d.ts.map +1 -0
  124. package/dist/editor/text-edit.js +328 -0
  125. package/dist/editor/text-edit.js.map +1 -0
  126. package/dist/editor.d.ts +504 -239
  127. package/dist/editor.d.ts.map +1 -1
  128. package/dist/editor.js +1557 -706
  129. package/dist/editor.js.map +1 -1
  130. package/dist/handle.d.ts +0 -2
  131. package/dist/handle.d.ts.map +1 -1
  132. package/dist/handle.js +0 -6
  133. package/dist/handle.js.map +1 -1
  134. package/dist/index.d.ts +6 -1
  135. package/dist/index.d.ts.map +1 -1
  136. package/dist/index.js +7 -1
  137. package/dist/index.js.map +1 -1
  138. package/dist/modes.d.ts +15 -1
  139. package/dist/modes.d.ts.map +1 -1
  140. package/dist/modes.js.map +1 -1
  141. package/dist/overlay.d.ts +91 -2
  142. package/dist/overlay.d.ts.map +1 -1
  143. package/dist/overlay.js +450 -93
  144. package/dist/overlay.js.map +1 -1
  145. package/dist/search.d.ts +37 -0
  146. package/dist/search.d.ts.map +1 -0
  147. package/dist/search.js +45 -0
  148. package/dist/search.js.map +1 -0
  149. package/package.json +4 -4
package/dist/overlay.js CHANGED
@@ -1,7 +1,8 @@
1
- import { getAnnotationWorldPosition, getElementWorldBounds, getWorldToScreen, isGroup, } 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, 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, } from "./constants.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";
5
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";
@@ -80,6 +81,12 @@ export const paintElementSelectionHalo = (target, w2s, halos, zoom, style = DEFA
80
81
  target.setOpacity(1);
81
82
  target.setTransform(matrix.IDENTITY);
82
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
+ */
83
90
  export const renderOverlay = (scene, selection, target, options = {}) => {
84
91
  const style = { ...DEFAULT_OVERLAY_STYLE, ...options.style };
85
92
  target.clear();
@@ -88,8 +95,29 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
88
95
  const zoom = scene.viewport.zoom;
89
96
  target.save();
90
97
  target.setTransform(matrix.IDENTITY);
91
- // 0. Debug hit-zones drawn first so the real selection chrome sits
92
- // 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;
93
121
  if (options.debugHitZones) {
94
122
  drawHitZones(target, {
95
123
  scene,
@@ -107,11 +135,20 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
107
135
  : {}),
108
136
  });
109
137
  }
110
- // 1. Selection outlines (+ handles only when a single shape is
111
- // selected). Multi-selection skips per-shape handles in favour of
112
- // the combined group bbox handles drawn later otherwise the
113
- // overlay would look like a forest of corner squares and the user
114
- // 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;
115
152
  const multiSelect = selection.size > 1;
116
153
  for (const id of selection) {
117
154
  const shape = scene.elements.get(id);
@@ -131,7 +168,8 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
131
168
  drawLockBadge(target, screenBounds);
132
169
  continue;
133
170
  }
134
- if (multiSelect || !isResizable(shape))
171
+ // Read-only: outline only — no resize/rotate affordances to grab.
172
+ if (multiSelect || !isResizable(shape) || options.readOnly === true)
135
173
  continue;
136
174
  for (const handle of CORNER_HANDLES) {
137
175
  const worldPoint = handlePosition(handle, worldBounds, zoom);
@@ -155,7 +193,8 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
155
193
  drawLockBadge(target, frameScreenBounds(frame, w2s));
156
194
  continue;
157
195
  }
158
- if (multiSelect || !isResizable(shape))
196
+ // Read-only: outline only — no resize/rotate affordances to grab.
197
+ if (multiSelect || !isResizable(shape) || options.readOnly === true)
159
198
  continue;
160
199
  // Draw only the four CORNER dots — at the rotated frame corners. Edge
161
200
  // resize is done by dragging the selection-box side itself, so no midpoint
@@ -167,6 +206,26 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
167
206
  // Rotate grip from the shape's template anchor (default bottom-left).
168
207
  drawRotateIcon(target, matrix.applyToPoint(w2s, rotateGripWorld(shape, zoom)), style);
169
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);
228
+ }
170
229
  // 2. Rubber-band drawing preview (already in world coords if drawn before transform reset)
171
230
  if (options.drawingPreview) {
172
231
  const screenBounds = projectBounds(options.drawingPreview, w2s);
@@ -193,6 +252,47 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
193
252
  target.restore();
194
253
  }
195
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
+ }
196
296
  // 3. Link-drawing preview — dashed line in screen space. An elbow preview
197
297
  // carries the full orthogonal polyline (`points`); otherwise a straight
198
298
  // from→to segment.
@@ -255,6 +355,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
255
355
  target.rect(b.x - pad, b.y - pad, b.width + pad * 2, b.height + pad * 2);
256
356
  target.stroke();
257
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;
258
366
  // 4. Port dots — hover affordance in draw-edge mode. May be one set or
259
367
  // several (source start-anchors + target attach-anchors at once).
260
368
  if (options.ports) {
@@ -265,7 +373,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
265
373
  for (let i = 0; i < set.worldPoints.length; i++) {
266
374
  const screen = matrix.applyToPoint(w2s, req(set.worldPoints[i]));
267
375
  const active = set.activeIndex === i;
268
- drawPortDot(target, screen, style, active, set.role, active ? set.activeRadius : undefined);
376
+ drawPortDot(target, screen, active, set.role, active ? set.activeRadius : undefined);
269
377
  }
270
378
  }
271
379
  }
@@ -298,7 +406,9 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
298
406
  target.setTransform(matrix.IDENTITY);
299
407
  }
300
408
  // 5. Selected-edge endpoint handles + bend-point (waypoint) handles.
301
- 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) {
302
412
  const from = matrix.applyToPoint(w2s, options.edgeSelection.from);
303
413
  const to = matrix.applyToPoint(w2s, options.edgeSelection.to);
304
414
  // Segment-midpoint "add waypoint" handles (drawn first, under the rest).
@@ -311,9 +421,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
311
421
  drawLinkEndpointHandle(target, matrix.applyToPoint(w2s, w), style);
312
422
  }
313
423
  }
314
- // 6. Peer selection halos — dashed outline around shapes selected
315
- // by remote users, painted in their colour. Drawn before own-
316
- // 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;
317
432
  if (options.peerSelections) {
318
433
  for (const peer of options.peerSelections) {
319
434
  for (const wb of peer.bounds) {
@@ -322,6 +437,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
322
437
  }
323
438
  }
324
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;
325
448
  // 6.5. Live brush stroke preview — quad-strip with interpolated
326
449
  // widths, same render path as the committed BrushElement. Runs
327
450
  // in world coords so the stroke stays anchored to the cursor
@@ -330,6 +453,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
330
453
  const bp = options.brushPreview;
331
454
  target.save();
332
455
  target.setTransform(w2s);
456
+ target.setOpacity(bp.opacity);
333
457
  target.setFill(bp.fill);
334
458
  target.setStroke(null);
335
459
  const pts = bp.points;
@@ -342,35 +466,153 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
342
466
  target.fill();
343
467
  }
344
468
  else {
345
- for (let i = 0; i < pts.length - 1; i++) {
346
- const a = req(pts[i]);
347
- const b = req(pts[i + 1]);
348
- const ax = ox + a.x;
349
- const ay = oy + a.y;
350
- const bx = ox + b.x;
351
- const by = oy + b.y;
352
- const dx = bx - ax;
353
- const dy = by - ay;
354
- const len = Math.hypot(dx, dy) || 1;
355
- const nx = -dy / len;
356
- 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) {
357
474
  target.beginPath();
358
- target.moveTo(ax + nx * a.width, ay + ny * a.width);
359
- target.lineTo(bx + nx * b.width, by + ny * b.width);
360
- target.lineTo(bx - nx * b.width, by - ny * b.width);
361
- 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
+ }
362
481
  target.closePath();
363
482
  target.fill();
364
- target.beginPath();
365
- target.ellipse(bx, by, b.width, b.width);
366
- target.fill();
367
483
  }
368
484
  }
369
485
  target.restore();
370
486
  }
371
- // 7.0. Container drop-zone highlight — drawn under selection chrome
372
- // so handles stay legible. Dashed rect + soft fill — same
373
- // 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;
374
616
  if (options.containerDropZone) {
375
617
  const zoneScreen = projectBounds(options.containerDropZone, w2s);
376
618
  target.setFill("rgba(26, 115, 232, 0.10)");
@@ -383,13 +625,25 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
383
625
  target.stroke();
384
626
  target.setDashArray(null);
385
627
  }
386
- // 7. Multi-selection / group-typed combined bounds — outline + handles.
387
- // Only CORNER dots are drawn (edge resize = drag the box side); the
388
- // edge handles stay hit-testable via `hitHandle` for non-aspect-locked
389
- // 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;
390
641
  if (options.groupBounds) {
391
642
  const groupScreen = projectBounds(options.groupBounds, w2s);
392
643
  drawOutline(target, groupScreen, style);
644
+ // Read-only: combined-bounds outline only — no resize/rotate handles.
645
+ if (options.readOnly === true)
646
+ return;
393
647
  const handleSet = CORNER_HANDLES;
394
648
  for (const handle of handleSet) {
395
649
  const worldPoint = handlePosition(handle, options.groupBounds, zoom);
@@ -398,9 +652,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
398
652
  }
399
653
  drawRotateGripForBounds(target, options.groupBounds, zoom, w2s, style);
400
654
  }
401
- // 7.5. Annotation pins — drawn before peer cursors so cursors stay
402
- // on top, but on top of selection handles. Each pin shows a comment
403
- // 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;
404
663
  if (options.annotations && options.annotations.length > 0) {
405
664
  for (const ann of options.annotations) {
406
665
  const world = getAnnotationWorldPosition(scene, ann);
@@ -408,25 +667,39 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
408
667
  drawAnnotationPin(target, screen, ann, options.selectedAnnotation ?? null);
409
668
  }
410
669
  }
411
- // 8. Remote peer cursors — drawn last so they sit on top of every
412
- // 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;
413
677
  if (options.peerCursors) {
414
678
  for (const cursor of options.peerCursors) {
415
679
  const screen = matrix.applyToPoint(w2s, cursor.position);
416
680
  drawPeerCursor(target, screen, cursor.color, cursor.name);
417
681
  }
418
682
  }
419
- // 9. GIF "play" badges on paused animated shapes — drawn last
420
- // 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;
421
690
  if (options.gifBadges) {
422
691
  for (const b of options.gifBadges) {
423
692
  drawGifBadge(target, projectBounds(b, w2s));
424
693
  }
425
694
  }
426
- // 10. In-canvas text editing: translucent selection highlight, then
427
- // the caret bar on top. Both backends draw this via the shared
428
- // RenderTarget primitives (rect + fill), so it's identical on
429
- // 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;
430
703
  if (options.editingText) {
431
704
  const et = options.editingText;
432
705
  if (et.selectionRects.length > 0) {
@@ -449,7 +722,6 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
449
722
  target.fill();
450
723
  }
451
724
  }
452
- target.restore();
453
725
  };
454
726
  const drawLockBadge = (target, b) => {
455
727
  const s = LOCK_BADGE_SIZE;
@@ -558,19 +830,33 @@ const frameScreenBounds = (frame, w2s) => {
558
830
  }
559
831
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
560
832
  };
561
- const drawHandle = (target, center, style) => {
562
- // Circle of radius HANDLE_SIZE visually equivalent to a rounded
563
- // square with maximum corner radius, but renders via `ellipse`
564
- // which every RenderTarget already supports (canvas, svg). A rounded
565
- // shape reads as "draggable handle" more clearly than a sharp rectangle.
566
- target.setFill(style.handleFill);
567
- target.setStroke(style.handleStroke);
568
- target.setStrokeWidth(1);
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);
569
845
  target.setDashArray(null);
846
+ target.setFill(s.fill);
570
847
  target.beginPath();
571
- target.ellipse(center.x, center.y, HANDLE_SIZE, HANDLE_SIZE);
848
+ target.ellipse(center.x, center.y, radius, radius);
572
849
  target.fill();
573
850
  target.stroke();
851
+ if (s.opacity !== undefined)
852
+ target.setOpacity(1);
853
+ };
854
+ const drawHandle = (target, center, style) => {
855
+ drawHandleDot(target, center, HANDLE_SIZE, {
856
+ fill: style.handleFill,
857
+ stroke: style.handleStroke,
858
+ strokeWidth: 1,
859
+ });
574
860
  };
575
861
  /**
576
862
  * Rotate grip: a clockwise circular-arrow glyph (the `rotate-cw` icon) centred
@@ -641,6 +927,93 @@ const drawLinkPreview = (target, from, to, style) => {
641
927
  target.stroke();
642
928
  };
643
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
+ };
644
1017
  const drawLinkPreviewPath = (target, pts, style) => {
645
1018
  target.setStroke(style.drawingStroke);
646
1019
  target.setStrokeWidth(1.5);
@@ -654,7 +1027,7 @@ const drawLinkPreviewPath = (target, pts, style) => {
654
1027
  }
655
1028
  target.stroke();
656
1029
  };
657
- const drawPortDot = (target, center, style, active, role = "link-start", activeRadius) => {
1030
+ const drawPortDot = (target, center, active, role = "link-start", activeRadius) => {
658
1031
  const radius = active ? (activeRadius ?? ANCHOR_DOT_ACTIVE_RADIUS) : ANCHOR_DOT_RADIUS;
659
1032
  const isStart = role === "link-start";
660
1033
  // When active (snapped), use the inverse fill of the resting state
@@ -667,41 +1040,25 @@ const drawPortDot = (target, center, style, active, role = "link-start", activeR
667
1040
  ? LINK_START_ANCHOR_FILL
668
1041
  : LINK_ATTACH_ANCHOR_FILL;
669
1042
  const stroke = isStart ? LINK_START_ANCHOR_STROKE : LINK_ATTACH_ANCHOR_STROKE;
670
- target.setStroke(stroke);
671
- target.setStrokeWidth(ANCHOR_DOT_STROKE_WIDTH);
672
- target.setDashArray(null);
673
- target.setFill(fill);
674
- target.beginPath();
675
- target.ellipse(center.x, center.y, radius, radius);
676
- target.fill();
677
- target.stroke();
1043
+ drawHandleDot(target, center, radius, { fill, stroke, strokeWidth: ANCHOR_DOT_STROKE_WIDTH });
678
1044
  };
679
1045
  const drawLinkEndpointHandle = (target, center, style) => {
680
- const radius = LINK_ENDPOINT_HANDLE_DRAW_RADIUS;
681
- target.setStroke(style.selectionStroke);
682
- target.setStrokeWidth(2);
683
- target.setDashArray(null);
684
- target.setFill(style.handleFill);
685
- target.beginPath();
686
- target.ellipse(center.x, center.y, radius, radius);
687
- target.fill();
688
- target.stroke();
1046
+ drawHandleDot(target, center, LINK_ENDPOINT_HANDLE_DRAW_RADIUS, {
1047
+ fill: style.handleFill,
1048
+ stroke: style.selectionStroke,
1049
+ strokeWidth: 2,
1050
+ });
689
1051
  };
690
1052
  // Smaller, semi-transparent dot on a segment midpoint — the "drag to add a
691
1053
  // bend point" affordance. Lighter than a real waypoint handle so it reads
692
1054
  // as secondary.
693
1055
  const drawLinkMidpointHandle = (target, center, style) => {
694
- const radius = LINK_MIDPOINT_HANDLE_DRAW_RADIUS;
695
- target.setOpacity(0.55);
696
- target.setStroke(style.selectionStroke);
697
- target.setStrokeWidth(1.5);
698
- target.setDashArray(null);
699
- target.setFill(style.handleFill);
700
- target.beginPath();
701
- target.ellipse(center.x, center.y, radius, radius);
702
- target.fill();
703
- target.stroke();
704
- 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
+ });
705
1062
  };
706
1063
  const drawPeerSelection = (target, b, color) => {
707
1064
  const pad = PEER_SELECTION_PADDING;