@oh-just-another/state 0.59.0 → 0.61.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 (163) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/README.md +6 -4
  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 +79 -11
  11. package/dist/actions/actionKeyboard.js.map +1 -1
  12. package/dist/actions/actionMode.d.ts +2 -0
  13. package/dist/actions/actionMode.d.ts.map +1 -1
  14. package/dist/actions/actionMode.js +81 -27
  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 +10 -1
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js.map +1 -1
  36. package/dist/built-in-handlers.d.ts +8 -0
  37. package/dist/built-in-handlers.d.ts.map +1 -1
  38. package/dist/built-in-handlers.js +31 -16
  39. package/dist/built-in-handlers.js.map +1 -1
  40. package/dist/clipboard.d.ts +0 -14
  41. package/dist/clipboard.d.ts.map +1 -1
  42. package/dist/clipboard.js +1 -1
  43. package/dist/clipboard.js.map +1 -1
  44. package/dist/constants.d.ts +253 -18
  45. package/dist/constants.d.ts.map +1 -1
  46. package/dist/constants.js +255 -2
  47. package/dist/constants.js.map +1 -1
  48. package/dist/dom-events.d.ts +1 -2
  49. package/dist/dom-events.d.ts.map +1 -1
  50. package/dist/dom-events.js.map +1 -1
  51. package/dist/editor/animation-scene.d.ts +15 -0
  52. package/dist/editor/animation-scene.d.ts.map +1 -1
  53. package/dist/editor/animation-scene.js +159 -0
  54. package/dist/editor/animation-scene.js.map +1 -1
  55. package/dist/editor/applies/create.d.ts +0 -6
  56. package/dist/editor/applies/create.d.ts.map +1 -1
  57. package/dist/editor/applies/create.js +1 -1
  58. package/dist/editor/applies/create.js.map +1 -1
  59. package/dist/editor/applies/link-move.d.ts +0 -8
  60. package/dist/editor/applies/link-move.d.ts.map +1 -1
  61. package/dist/editor/applies/link-move.js +1 -1
  62. package/dist/editor/applies/link-move.js.map +1 -1
  63. package/dist/editor/event-fanout.d.ts +6 -6
  64. package/dist/editor/event-fanout.d.ts.map +1 -1
  65. package/dist/editor/event-fanout.js +8 -8
  66. package/dist/editor/event-fanout.js.map +1 -1
  67. package/dist/editor/hit-test.d.ts +8 -0
  68. package/dist/editor/hit-test.d.ts.map +1 -1
  69. package/dist/editor/hit-test.js +31 -16
  70. package/dist/editor/hit-test.js.map +1 -1
  71. package/dist/editor/interaction-state.d.ts +184 -0
  72. package/dist/editor/interaction-state.d.ts.map +1 -0
  73. package/dist/editor/interaction-state.js +139 -0
  74. package/dist/editor/interaction-state.js.map +1 -0
  75. package/dist/editor/link-handle-drag.d.ts +172 -0
  76. package/dist/editor/link-handle-drag.d.ts.map +1 -0
  77. package/dist/editor/link-handle-drag.js +369 -0
  78. package/dist/editor/link-handle-drag.js.map +1 -0
  79. package/dist/editor/pointer-binding.d.ts +9 -11
  80. package/dist/editor/pointer-binding.d.ts.map +1 -1
  81. package/dist/editor/pointer-binding.js +1247 -909
  82. package/dist/editor/pointer-binding.js.map +1 -1
  83. package/dist/editor/public/brush.d.ts +93 -13
  84. package/dist/editor/public/brush.d.ts.map +1 -1
  85. package/dist/editor/public/brush.js +240 -22
  86. package/dist/editor/public/brush.js.map +1 -1
  87. package/dist/editor/public/cursor.d.ts +1 -15
  88. package/dist/editor/public/cursor.d.ts.map +1 -1
  89. package/dist/editor/public/cursor.js +17 -7
  90. package/dist/editor/public/cursor.js.map +1 -1
  91. package/dist/editor/public/eraser.d.ts +55 -0
  92. package/dist/editor/public/eraser.d.ts.map +1 -0
  93. package/dist/editor/public/eraser.js +65 -0
  94. package/dist/editor/public/eraser.js.map +1 -0
  95. package/dist/editor/public/laser.d.ts +38 -0
  96. package/dist/editor/public/laser.d.ts.map +1 -0
  97. package/dist/editor/public/laser.js +42 -0
  98. package/dist/editor/public/laser.js.map +1 -0
  99. package/dist/editor/public/placement.d.ts.map +1 -1
  100. package/dist/editor/public/placement.js +9 -1
  101. package/dist/editor/public/placement.js.map +1 -1
  102. package/dist/editor/public/selection-ops.d.ts +12 -0
  103. package/dist/editor/public/selection-ops.d.ts.map +1 -1
  104. package/dist/editor/public/selection-ops.js +19 -1
  105. package/dist/editor/public/selection-ops.js.map +1 -1
  106. package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
  107. package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
  108. package/dist/editor/public/stroke-eraser-coverage.js +168 -0
  109. package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
  110. package/dist/editor/public/stroke-eraser.d.ts +50 -0
  111. package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
  112. package/dist/editor/public/stroke-eraser.js +176 -0
  113. package/dist/editor/public/stroke-eraser.js.map +1 -0
  114. package/dist/editor/public/stroke-smoothing.d.ts +21 -0
  115. package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
  116. package/dist/editor/public/stroke-smoothing.js +37 -0
  117. package/dist/editor/public/stroke-smoothing.js.map +1 -0
  118. package/dist/editor/public/tool-ops.d.ts +130 -0
  119. package/dist/editor/public/tool-ops.d.ts.map +1 -0
  120. package/dist/editor/public/tool-ops.js +392 -0
  121. package/dist/editor/public/tool-ops.js.map +1 -0
  122. package/dist/editor/public/z-order.d.ts +1 -12
  123. package/dist/editor/public/z-order.d.ts.map +1 -1
  124. package/dist/editor/public/z-order.js +1 -1
  125. package/dist/editor/public/z-order.js.map +1 -1
  126. package/dist/editor/public/zoom-pan.d.ts +9 -0
  127. package/dist/editor/public/zoom-pan.d.ts.map +1 -1
  128. package/dist/editor/public/zoom-pan.js +31 -0
  129. package/dist/editor/public/zoom-pan.js.map +1 -1
  130. package/dist/editor/render-orchestrator.d.ts +180 -5
  131. package/dist/editor/render-orchestrator.d.ts.map +1 -1
  132. package/dist/editor/render-orchestrator.js +516 -353
  133. package/dist/editor/render-orchestrator.js.map +1 -1
  134. package/dist/editor/text-edit.d.ts +136 -0
  135. package/dist/editor/text-edit.d.ts.map +1 -0
  136. package/dist/editor/text-edit.js +328 -0
  137. package/dist/editor/text-edit.js.map +1 -0
  138. package/dist/editor-events.d.ts +5 -5
  139. package/dist/editor-events.d.ts.map +1 -1
  140. package/dist/editor.d.ts +560 -250
  141. package/dist/editor.d.ts.map +1 -1
  142. package/dist/editor.js +1650 -743
  143. package/dist/editor.js.map +1 -1
  144. package/dist/handle.d.ts +0 -2
  145. package/dist/handle.d.ts.map +1 -1
  146. package/dist/handle.js +0 -6
  147. package/dist/handle.js.map +1 -1
  148. package/dist/index.d.ts +7 -2
  149. package/dist/index.d.ts.map +1 -1
  150. package/dist/index.js +7 -1
  151. package/dist/index.js.map +1 -1
  152. package/dist/modes.d.ts +34 -1
  153. package/dist/modes.d.ts.map +1 -1
  154. package/dist/modes.js.map +1 -1
  155. package/dist/overlay.d.ts +98 -21
  156. package/dist/overlay.d.ts.map +1 -1
  157. package/dist/overlay.js +442 -116
  158. package/dist/overlay.js.map +1 -1
  159. package/dist/search.d.ts +37 -0
  160. package/dist/search.d.ts.map +1 -0
  161. package/dist/search.js +45 -0
  162. package/dist/search.js.map +1 -0
  163. package/package.json +5 -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,11 @@ 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 ||
173
+ !isResizable(shape) ||
174
+ options.readOnly === true ||
175
+ options.suppressHandles === true)
135
176
  continue;
136
177
  for (const handle of CORNER_HANDLES) {
137
178
  const worldPoint = handlePosition(handle, worldBounds, zoom);
@@ -155,7 +196,11 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
155
196
  drawLockBadge(target, frameScreenBounds(frame, w2s));
156
197
  continue;
157
198
  }
158
- if (multiSelect || !isResizable(shape))
199
+ // Read-only: outline only — no resize/rotate affordances to grab.
200
+ if (multiSelect ||
201
+ !isResizable(shape) ||
202
+ options.readOnly === true ||
203
+ options.suppressHandles === true)
159
204
  continue;
160
205
  // Draw only the four CORNER dots — at the rotated frame corners. Edge
161
206
  // resize is done by dragging the selection-box side itself, so no midpoint
@@ -167,6 +212,26 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
167
212
  // Rotate grip from the shape's template anchor (default bottom-left).
168
213
  drawRotateIcon(target, matrix.applyToPoint(w2s, rotateGripWorld(shape, zoom)), style);
169
214
  }
215
+ };
216
+ /**
217
+ * Sections 2–3.5 — the transient draw/create previews and their landing
218
+ * highlights: the rubber-band rect, the WYSIWYG shape preview, the link
219
+ * preview, the click-create ghost, and the float-attach target outline.
220
+ */
221
+ const renderPreviews = (ctx) => {
222
+ const { target, options, style, w2s } = ctx;
223
+ // 0. Crop-mode ghost — the faint full bitmap behind the crop chrome, so the
224
+ // pixels hidden by the window stay visible. Drawn first (under the frame).
225
+ if (options.cropGhost) {
226
+ drawCropGhost(target, options.cropGhost, w2s);
227
+ }
228
+ // 1. Image-crop frame: dashed accent quad + L-shaped corner brackets (the
229
+ // edge midpoints stay grabbable but aren't drawn, Excalidraw-style).
230
+ if (options.cropFrame && options.cropFrame.length >= 4) {
231
+ const cornersScreen = options.cropFrame.map((p) => matrix.applyToPoint(w2s, p));
232
+ drawCropFrame(target, cornersScreen, style);
233
+ drawCropCornerBrackets(target, cornersScreen, style);
234
+ }
170
235
  // 2. Rubber-band drawing preview (already in world coords if drawn before transform reset)
171
236
  if (options.drawingPreview) {
172
237
  const screenBounds = projectBounds(options.drawingPreview, w2s);
@@ -193,18 +258,45 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
193
258
  target.restore();
194
259
  }
195
260
  }
196
- // 3. Link-drawing preview — dashed line in screen space. An elbow preview
197
- // carries the full orthogonal polyline (`points`); otherwise a straight
198
- // from→to segment.
199
- if (options.edgePreview) {
200
- const pts = options.edgePreview.points;
201
- if (pts && pts.length >= 2) {
202
- drawLinkPreviewPath(target, pts.map((p) => matrix.applyToPoint(w2s, p)), style);
261
+ // 2.6 Flowchart-create node preview — the pending nodes of a Cmd/Ctrl+Arrow
262
+ // grow session, each through its real renderer (faded). The connecting
263
+ // links are drawn by the orchestrator at the same opacity.
264
+ if (options.flowchartPreviewElements && options.flowchartPreviewElements.length > 0) {
265
+ for (const el of options.flowchartPreviewElements) {
266
+ const renderer = getElementRenderer(el.type);
267
+ if (!renderer)
268
+ continue;
269
+ target.save();
270
+ target.setTransform(w2s);
271
+ target.setOpacity(FLOWCHART_PREVIEW_OPACITY);
272
+ target.setDashArray(null);
273
+ target.translate(el.position.x, el.position.y);
274
+ if (el.rotation !== 0)
275
+ target.rotate(el.rotation);
276
+ if (el.scale.x !== 1 || el.scale.y !== 1)
277
+ target.scale(el.scale.x, el.scale.y);
278
+ renderer(el, target);
279
+ target.restore();
203
280
  }
204
- else {
205
- const from = matrix.applyToPoint(w2s, options.edgePreview.from);
206
- const to = matrix.applyToPoint(w2s, options.edgePreview.to);
207
- drawLinkPreview(target, from, to, style);
281
+ }
282
+ // Stroke-erase live fragments — full opacity (the originals are hidden), drawn
283
+ // per-element so the overlay's cursor / trail chrome survive underneath.
284
+ if (options.strokeErasePreviewElements && options.strokeErasePreviewElements.length > 0) {
285
+ for (const el of options.strokeErasePreviewElements) {
286
+ const renderer = getElementRenderer(el.type);
287
+ if (!renderer)
288
+ continue;
289
+ target.save();
290
+ target.setTransform(w2s);
291
+ target.setOpacity(1);
292
+ target.setDashArray(null);
293
+ target.translate(el.position.x, el.position.y);
294
+ if (el.rotation !== 0)
295
+ target.rotate(el.rotation);
296
+ if (el.scale.x !== 1 || el.scale.y !== 1)
297
+ target.scale(el.scale.x, el.scale.y);
298
+ renderer(el, target);
299
+ target.restore();
208
300
  }
209
301
  }
210
302
  // 3.4 Ghost preview for "click a start dot → create element". Just the
@@ -255,6 +347,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
255
347
  target.rect(b.x - pad, b.y - pad, b.width + pad * 2, b.height + pad * 2);
256
348
  target.stroke();
257
349
  }
350
+ };
351
+ /**
352
+ * Sections 4–5 — link chrome: port-dot attach affordances, the persistent
353
+ * selection halo around every selected link, and the selected-edge endpoint /
354
+ * waypoint / midpoint handles.
355
+ */
356
+ const renderLinkHandles = (ctx) => {
357
+ const { target, options, style, w2s, zoom } = ctx;
258
358
  // 4. Port dots — hover affordance in draw-edge mode. May be one set or
259
359
  // several (source start-anchors + target attach-anchors at once).
260
360
  if (options.ports) {
@@ -265,7 +365,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
265
365
  for (let i = 0; i < set.worldPoints.length; i++) {
266
366
  const screen = matrix.applyToPoint(w2s, req(set.worldPoints[i]));
267
367
  const active = set.activeIndex === i;
268
- drawPortDot(target, screen, style, active, set.role, active ? set.activeRadius : undefined);
368
+ drawPortDot(target, screen, active, set.role, active ? set.activeRadius : undefined);
269
369
  }
270
370
  }
271
371
  }
@@ -298,7 +398,9 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
298
398
  target.setTransform(matrix.IDENTITY);
299
399
  }
300
400
  // 5. Selected-edge endpoint handles + bend-point (waypoint) handles.
301
- if (options.edgeSelection) {
401
+ // Read-only: the halo above still marks the selected link, but the
402
+ // endpoint/bend grips are suppressed (nothing to re-bind or drag).
403
+ if (options.edgeSelection && options.readOnly !== true && options.suppressHandles !== true) {
302
404
  const from = matrix.applyToPoint(w2s, options.edgeSelection.from);
303
405
  const to = matrix.applyToPoint(w2s, options.edgeSelection.to);
304
406
  // Segment-midpoint "add waypoint" handles (drawn first, under the rest).
@@ -311,9 +413,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
311
413
  drawLinkEndpointHandle(target, matrix.applyToPoint(w2s, w), style);
312
414
  }
313
415
  }
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.
416
+ };
417
+ /**
418
+ * Section 6 peer selection halos. Dashed outline around shapes selected by
419
+ * remote users, painted in their colour. Drawn before own-selection outlines
420
+ * so own selection stays on top.
421
+ */
422
+ const renderPeerSelections = (ctx) => {
423
+ const { target, options, w2s } = ctx;
317
424
  if (options.peerSelections) {
318
425
  for (const peer of options.peerSelections) {
319
426
  for (const wb of peer.bounds) {
@@ -322,6 +429,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
322
429
  }
323
430
  }
324
431
  }
432
+ };
433
+ /**
434
+ * Section 6.5 — live brush stroke preview. Quad-strip with interpolated
435
+ * widths, same render path as the committed BrushElement. Runs in world coords
436
+ * so the stroke stays anchored to the cursor as the user zooms / pans mid-stroke.
437
+ */
438
+ const renderBrushPreview = (ctx) => {
439
+ const { target, options, w2s } = ctx;
325
440
  // 6.5. Live brush stroke preview — quad-strip with interpolated
326
441
  // widths, same render path as the committed BrushElement. Runs
327
442
  // in world coords so the stroke stays anchored to the cursor
@@ -330,6 +445,7 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
330
445
  const bp = options.brushPreview;
331
446
  target.save();
332
447
  target.setTransform(w2s);
448
+ target.setOpacity(bp.opacity);
333
449
  target.setFill(bp.fill);
334
450
  target.setStroke(null);
335
451
  const pts = bp.points;
@@ -342,35 +458,153 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
342
458
  target.fill();
343
459
  }
344
460
  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;
461
+ // Same single-outline fill as the committed stroke (see `brushOutline` /
462
+ // `drawBrush`), so the preview matches the result and honours `opacity`
463
+ // without the per-segment double-blend at joins.
464
+ const outline = brushOutline(pts);
465
+ if (outline.length >= 3) {
357
466
  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);
467
+ const first = req(outline[0]);
468
+ target.moveTo(ox + first.x, oy + first.y);
469
+ for (let i = 1; i < outline.length; i++) {
470
+ const p = req(outline[i]);
471
+ target.lineTo(ox + p.x, oy + p.y);
472
+ }
362
473
  target.closePath();
363
474
  target.fill();
364
- target.beginPath();
365
- target.ellipse(bx, by, b.width, b.width);
366
- target.fill();
367
475
  }
368
476
  }
369
477
  target.restore();
370
478
  }
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.
479
+ };
480
+ /**
481
+ * Section 6.6 laser-pointer trails. Each segment is stroked in screen space
482
+ * (constant on-screen width at any zoom) with an opacity that decays from the
483
+ * newer endpoint's age: fresh points are opaque, older ones fade out, so a
484
+ * released stroke melts away tail-first. Read-only — never mutates state.
485
+ */
486
+ const renderLaserTrails = (ctx) => {
487
+ const { target, options, w2s } = ctx;
488
+ drawFadingTrail(target, w2s, options.laserStrokes, LASER_COLOR, LASER_WIDTH, LASER_TRAIL_TTL_MS);
489
+ // Eraser drag trail — identical fade mechanics, neutral grey (not laser red).
490
+ // Its width matches the cursor ring's DIAMETER (2 × the panel radius) so the
491
+ // wake is exactly as wide as the eraser; a much shorter TTL than the laser
492
+ // keeps it a tight wake, not a long comet.
493
+ const eraserWidth = (options.eraserCursor?.radius ?? LASER_WIDTH / 2) * 2;
494
+ drawFadingTrail(target, w2s, options.eraserTrail, ERASER_CURSOR_STROKE, eraserWidth, ERASER_TRAIL_TTL_MS);
495
+ };
496
+ /**
497
+ * Paint a trail (shared by the laser pointer and the eraser wake) the way
498
+ * Excalidraw does: ONE filled outline per stroke, not a stack of alpha-blended
499
+ * segments. The smoothed centreline is offset by a half-width that tapers from
500
+ * `width/2` at the head to 0 at the tail, giving a single comet shape with a
501
+ * pointed tail — no overlapping round caps beading at the joints. The whole
502
+ * stroke fills at one opacity that ramps down by the NEWEST point's age over
503
+ * `ttl` ms, so after release the comet dissolves as its tail is pruned. Drawn in
504
+ * screen space (constant on-screen width at any zoom). Read-only.
505
+ */
506
+ const drawFadingTrail = (target, w2s, strokes, color, width, ttl) => {
507
+ if (!strokes || strokes.length === 0)
508
+ return;
509
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
510
+ const maxHalf = width / 2;
511
+ target.setStroke(null);
512
+ target.setDashArray(null);
513
+ target.setFill(color);
514
+ for (const stroke of strokes) {
515
+ const pts = smoothLaserPoints(stroke.points);
516
+ if (pts.length === 0)
517
+ continue;
518
+ // One opacity for the whole shape (Excalidraw fills a single path once);
519
+ // ramps by the freshest point's age so a released stroke fades as a whole.
520
+ const newest = req(pts[pts.length - 1]);
521
+ const op = 1 - (now - newest.t) / ttl;
522
+ if (op <= 0)
523
+ continue;
524
+ target.setOpacity(op);
525
+ const screen = pts.map((p) => matrix.applyToPoint(w2s, { x: p.x, y: p.y }));
526
+ if (screen.length === 1) {
527
+ const s = req(screen[0]);
528
+ target.beginPath();
529
+ target.ellipse(s.x, s.y, maxHalf, maxHalf);
530
+ target.fill();
531
+ continue;
532
+ }
533
+ const outline = taperedTrailOutline(screen, maxHalf);
534
+ const first = req(outline[0]);
535
+ target.beginPath();
536
+ target.moveTo(first.x, first.y);
537
+ for (let i = 1; i < outline.length; i++) {
538
+ const p = req(outline[i]);
539
+ target.lineTo(p.x, p.y);
540
+ }
541
+ target.closePath();
542
+ target.fill();
543
+ }
544
+ target.setOpacity(1);
545
+ };
546
+ /**
547
+ * Build the closed outline polygon of a variable-width ribbon around the screen
548
+ * polyline `pts` (tail → head order). Each vertex is offset by ±`hw` along the
549
+ * local normal, where the half-width tapers linearly from 0 at the tail (index
550
+ * 0) to `maxHalf` at the head (last index). Returns the left side forward then
551
+ * the right side backward — one closed loop to fill in a single pass.
552
+ */
553
+ export const taperedTrailOutline = (pts, maxHalf) => {
554
+ const n = pts.length;
555
+ const left = [];
556
+ const right = [];
557
+ for (let i = 0; i < n; i++) {
558
+ const p = req(pts[i]);
559
+ const prev = req(pts[Math.max(0, i - 1)]);
560
+ const next = req(pts[Math.min(n - 1, i + 1)]);
561
+ let tx = next.x - prev.x;
562
+ let ty = next.y - prev.y;
563
+ const len = Math.hypot(tx, ty) || 1;
564
+ tx /= len;
565
+ ty /= len;
566
+ // Normal (perpendicular) × the tapered half-width.
567
+ const hw = maxHalf * (n > 1 ? i / (n - 1) : 1);
568
+ const nx = -ty * hw;
569
+ const ny = tx * hw;
570
+ left.push({ x: p.x + nx, y: p.y + ny });
571
+ right.push({ x: p.x - nx, y: p.y - ny });
572
+ }
573
+ right.reverse();
574
+ return [...left, ...right];
575
+ };
576
+ /**
577
+ * Section 6.7 — eraser cursor ring. A grey circle following the pointer while
578
+ * the erase tool is active, its radius the panel's eraser width in SCREEN px
579
+ * (so it matches the slider number, unscaled by zoom). Replaces the hidden OS
580
+ * cursor. Read-only — never mutates state.
581
+ */
582
+ const renderEraserCursor = (ctx) => {
583
+ const { target, options, w2s } = ctx;
584
+ const cursor = options.eraserCursor;
585
+ if (!cursor || cursor.radius <= 0)
586
+ return;
587
+ const s = matrix.applyToPoint(w2s, cursor.center);
588
+ target.setDashArray(null);
589
+ target.setOpacity(1);
590
+ target.beginPath();
591
+ target.ellipse(s.x, s.y, cursor.radius, cursor.radius);
592
+ // Solid disc in the trail colour (fully opaque), with the ring on top for a
593
+ // crisp radius edge.
594
+ target.setFill(ERASER_CURSOR_STROKE);
595
+ target.fill();
596
+ target.setFill(null);
597
+ target.setStroke(ERASER_CURSOR_STROKE);
598
+ target.setStrokeWidth(ERASER_CURSOR_LINE_WIDTH);
599
+ target.stroke();
600
+ };
601
+ /**
602
+ * Section 7.0 — container drop-zone highlight. Drawn under selection chrome so
603
+ * handles stay legible. Dashed rect + soft fill — same visual language as the
604
+ * drawing preview.
605
+ */
606
+ const renderContainerDropZone = (ctx) => {
607
+ const { target, options, w2s } = ctx;
374
608
  if (options.containerDropZone) {
375
609
  const zoneScreen = projectBounds(options.containerDropZone, w2s);
376
610
  target.setFill("rgba(26, 115, 232, 0.10)");
@@ -383,13 +617,25 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
383
617
  target.stroke();
384
618
  target.setDashArray(null);
385
619
  }
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.
620
+ };
621
+ /**
622
+ * Section 7 — multi-selection / group-typed combined bounds: outline + handles.
623
+ * Only CORNER dots are drawn (edge resize = drag the box side); the edge
624
+ * handles stay hit-testable via `hitHandle` for non-aspect-locked groups.
625
+ */
626
+ const renderGroupBounds = (ctx) => {
627
+ const { target, options, style, w2s, zoom } = ctx;
628
+ // Crop mode owns the chrome: an image is aspect-locked, so its selection
629
+ // reports `groupBounds` — but its resize/rotate handles must not show on top
630
+ // of the crop brackets. Suppress them exactly like the per-shape handles.
631
+ if (options.cropFrame)
632
+ return;
390
633
  if (options.groupBounds) {
391
634
  const groupScreen = projectBounds(options.groupBounds, w2s);
392
635
  drawOutline(target, groupScreen, style);
636
+ // Read-only / non-select tool: combined-bounds outline only — no handles.
637
+ if (options.readOnly === true || options.suppressHandles === true)
638
+ return;
393
639
  const handleSet = CORNER_HANDLES;
394
640
  for (const handle of handleSet) {
395
641
  const worldPoint = handlePosition(handle, options.groupBounds, zoom);
@@ -398,9 +644,14 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
398
644
  }
399
645
  drawRotateGripForBounds(target, options.groupBounds, zoom, w2s, style);
400
646
  }
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.
647
+ };
648
+ /**
649
+ * Section 7.5 annotation pins. Drawn before peer cursors so cursors stay on
650
+ * top, but on top of selection handles. Each pin shows a comment-count badge
651
+ * when the thread has > 0 replies.
652
+ */
653
+ const renderAnnotations = (ctx) => {
654
+ const { scene, target, options, w2s } = ctx;
404
655
  if (options.annotations && options.annotations.length > 0) {
405
656
  for (const ann of options.annotations) {
406
657
  const world = getAnnotationWorldPosition(scene, ann);
@@ -408,25 +659,39 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
408
659
  drawAnnotationPin(target, screen, ann, options.selectedAnnotation ?? null);
409
660
  }
410
661
  }
411
- // 8. Remote peer cursors — drawn last so they sit on top of every
412
- // other overlay element (including own selection handles).
662
+ };
663
+ /**
664
+ * Section 8 — remote peer cursors. Drawn near-last so they sit on top of every
665
+ * other overlay element (including own selection handles).
666
+ */
667
+ const renderPeerCursors = (ctx) => {
668
+ const { target, options, w2s } = ctx;
413
669
  if (options.peerCursors) {
414
670
  for (const cursor of options.peerCursors) {
415
671
  const screen = matrix.applyToPoint(w2s, cursor.position);
416
672
  drawPeerCursor(target, screen, cursor.color, cursor.name);
417
673
  }
418
674
  }
419
- // 9. GIF "play" badges on paused animated shapes — drawn last
420
- // so they sit above selection chrome.
675
+ };
676
+ /**
677
+ * Section 9 — GIF "play" badges on paused animated shapes. Drawn late so they
678
+ * sit above selection chrome.
679
+ */
680
+ const renderGifBadges = (ctx) => {
681
+ const { target, options, w2s } = ctx;
421
682
  if (options.gifBadges) {
422
683
  for (const b of options.gifBadges) {
423
684
  drawGifBadge(target, projectBounds(b, w2s));
424
685
  }
425
686
  }
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.
687
+ };
688
+ /**
689
+ * Section 10 in-canvas text editing: translucent selection highlight, then
690
+ * the caret bar on top. Both backends draw this via the shared RenderTarget
691
+ * primitives (rect + fill), so it's identical on Canvas2D and WebGL2.
692
+ */
693
+ const renderTextEditing = (ctx) => {
694
+ const { target, options, w2s, zoom } = ctx;
430
695
  if (options.editingText) {
431
696
  const et = options.editingText;
432
697
  if (et.selectionRects.length > 0) {
@@ -449,7 +714,6 @@ export const renderOverlay = (scene, selection, target, options = {}) => {
449
714
  target.fill();
450
715
  }
451
716
  }
452
- target.restore();
453
717
  };
454
718
  const drawLockBadge = (target, b) => {
455
719
  const s = LOCK_BADGE_SIZE;
@@ -558,19 +822,33 @@ const frameScreenBounds = (frame, w2s) => {
558
822
  }
559
823
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
560
824
  };
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);
825
+ /**
826
+ * Shared draw for every "filled circle with an outline" handle affordance —
827
+ * resize handles, port dots, link endpoint / waypoint / midpoint grabs. A
828
+ * circle reads as "draggable handle" more clearly than a sharp rectangle, and
829
+ * `ellipse` is supported by every RenderTarget (canvas, svg). State-setter
830
+ * order is irrelevant to output; only the state at `fill()`/`stroke()` matters.
831
+ */
832
+ const drawHandleDot = (target, center, radius, s) => {
833
+ if (s.opacity !== undefined)
834
+ target.setOpacity(s.opacity);
835
+ target.setStroke(s.stroke);
836
+ target.setStrokeWidth(s.strokeWidth);
569
837
  target.setDashArray(null);
838
+ target.setFill(s.fill);
570
839
  target.beginPath();
571
- target.ellipse(center.x, center.y, HANDLE_SIZE, HANDLE_SIZE);
840
+ target.ellipse(center.x, center.y, radius, radius);
572
841
  target.fill();
573
842
  target.stroke();
843
+ if (s.opacity !== undefined)
844
+ target.setOpacity(1);
845
+ };
846
+ const drawHandle = (target, center, style) => {
847
+ drawHandleDot(target, center, HANDLE_SIZE, {
848
+ fill: style.handleFill,
849
+ stroke: style.handleStroke,
850
+ strokeWidth: 1,
851
+ });
574
852
  };
575
853
  /**
576
854
  * Rotate grip: a clockwise circular-arrow glyph (the `rotate-cw` icon) centred
@@ -631,18 +909,13 @@ const drawDrawingPreview = (target, b, style) => {
631
909
  target.rect(b.x, b.y, b.width, b.height);
632
910
  target.stroke();
633
911
  };
634
- const drawLinkPreview = (target, from, to, style) => {
635
- target.setStroke(style.drawingStroke);
636
- target.setStrokeWidth(1.5);
637
- target.setDashArray(style.drawingDash);
638
- target.beginPath();
639
- target.moveTo(from.x, from.y);
640
- target.lineTo(to.x, to.y);
641
- target.stroke();
642
- };
643
- /** Dashed polyline preview (elbow) in screen space. */
644
- const drawLinkPreviewPath = (target, pts, style) => {
645
- target.setStroke(style.drawingStroke);
912
+ /**
913
+ * Dashed accent quad for the image-crop window. `pts` are already in screen
914
+ * space (4 corners, clockwise). The grab handles are drawn separately (all 8
915
+ * corner + edge nubs) by the caller.
916
+ */
917
+ const drawCropFrame = (target, pts, style) => {
918
+ target.setStroke(style.selectionStroke);
646
919
  target.setStrokeWidth(1.5);
647
920
  target.setDashArray(style.drawingDash);
648
921
  target.beginPath();
@@ -652,9 +925,78 @@ const drawLinkPreviewPath = (target, pts, style) => {
652
925
  const p = req(pts[i]);
653
926
  target.lineTo(p.x, p.y);
654
927
  }
928
+ target.lineTo(first.x, first.y);
655
929
  target.stroke();
930
+ target.setDashArray(null);
656
931
  };
657
- const drawPortDot = (target, center, style, active, role = "link-start", activeRadius) => {
932
+ /**
933
+ * L-shaped corner brackets on the crop frame (Excalidraw-style), replacing the
934
+ * round resize-nubs. `corners` are the 4 window corners in SCREEN space,
935
+ * clockwise (nw, ne, se, sw); each bracket's two arms run along the adjacent
936
+ * edges, so the marks stay aligned when the image is rotated. Only the corners
937
+ * are drawn — the edge midpoints remain grabbable via hit-testing.
938
+ */
939
+ const drawCropCornerBrackets = (target, corners, style) => {
940
+ if (corners.length < 4)
941
+ return;
942
+ target.setStroke(style.selectionStroke);
943
+ target.setStrokeWidth(CROP_BRACKET_WIDTH);
944
+ target.setDashArray(null);
945
+ target.setLineCap("round");
946
+ target.setLineJoin("round");
947
+ const armTo = (from, to) => {
948
+ const dx = to.x - from.x;
949
+ const dy = to.y - from.y;
950
+ const len = Math.hypot(dx, dy) || 1;
951
+ const t = Math.min(CROP_BRACKET_LEN, len) / len;
952
+ return { x: from.x + dx * t, y: from.y + dy * t };
953
+ };
954
+ for (let i = 0; i < 4; i++) {
955
+ const c = req(corners[i]);
956
+ const prev = req(corners[(i + 3) % 4]);
957
+ const next = req(corners[(i + 1) % 4]);
958
+ const a = armTo(c, prev);
959
+ const b = armTo(c, next);
960
+ target.beginPath();
961
+ target.moveTo(a.x, a.y);
962
+ target.lineTo(c.x, c.y);
963
+ target.lineTo(b.x, b.y);
964
+ target.stroke();
965
+ }
966
+ target.setLineCap("butt");
967
+ target.setLineJoin("miter");
968
+ };
969
+ /**
970
+ * Paint the faint full-image ghost behind the crop chrome. Replicates the main
971
+ * image renderer's transform path: apply the element's local→world transform,
972
+ * then draw the whole bitmap over the virtual full-image LOCAL rect at
973
+ * {@link CROP_GHOST_OPACITY}. Resolves the live handle exactly as the built-in
974
+ * image renderer (`metadata.image`, else `resolveImageSource`); skips silently
975
+ * when no handle is available (async decode in flight / headless).
976
+ */
977
+ const drawCropGhost = (target, ghost, w2s) => {
978
+ const el = ghost.element;
979
+ const handle = el.animationKind !== undefined
980
+ ? resolveImageSource(el, undefined)
981
+ : (el.metadata?.image ?? resolveImageSource(el, undefined));
982
+ if (handle === null || handle === undefined)
983
+ return;
984
+ const dynamic = el.metadata?.animated === true || el.animationKind !== undefined;
985
+ const { fullRect } = ghost;
986
+ target.save();
987
+ target.setTransform(w2s);
988
+ target.setDashArray(null);
989
+ target.translate(el.position.x, el.position.y);
990
+ if (el.rotation !== 0)
991
+ target.rotate(el.rotation);
992
+ if (el.scale.x !== 1 || el.scale.y !== 1)
993
+ target.scale(el.scale.x, el.scale.y);
994
+ target.setOpacity(CROP_GHOST_OPACITY);
995
+ target.drawImage(handle, fullRect.x, fullRect.y, fullRect.width, fullRect.height, dynamic);
996
+ target.setOpacity(1);
997
+ target.restore();
998
+ };
999
+ const drawPortDot = (target, center, active, role = "link-start", activeRadius) => {
658
1000
  const radius = active ? (activeRadius ?? ANCHOR_DOT_ACTIVE_RADIUS) : ANCHOR_DOT_RADIUS;
659
1001
  const isStart = role === "link-start";
660
1002
  // When active (snapped), use the inverse fill of the resting state
@@ -667,41 +1009,25 @@ const drawPortDot = (target, center, style, active, role = "link-start", activeR
667
1009
  ? LINK_START_ANCHOR_FILL
668
1010
  : LINK_ATTACH_ANCHOR_FILL;
669
1011
  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();
1012
+ drawHandleDot(target, center, radius, { fill, stroke, strokeWidth: ANCHOR_DOT_STROKE_WIDTH });
678
1013
  };
679
1014
  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();
1015
+ drawHandleDot(target, center, LINK_ENDPOINT_HANDLE_DRAW_RADIUS, {
1016
+ fill: style.handleFill,
1017
+ stroke: style.selectionStroke,
1018
+ strokeWidth: 2,
1019
+ });
689
1020
  };
690
1021
  // Smaller, semi-transparent dot on a segment midpoint — the "drag to add a
691
1022
  // bend point" affordance. Lighter than a real waypoint handle so it reads
692
1023
  // as secondary.
693
1024
  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);
1025
+ drawHandleDot(target, center, LINK_MIDPOINT_HANDLE_DRAW_RADIUS, {
1026
+ fill: style.handleFill,
1027
+ stroke: style.selectionStroke,
1028
+ strokeWidth: 1.5,
1029
+ opacity: 0.55,
1030
+ });
705
1031
  };
706
1032
  const drawPeerSelection = (target, b, color) => {
707
1033
  const pad = PEER_SELECTION_PADDING;