@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.
- package/CHANGELOG.md +91 -0
- package/README.md +2 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/actionArrange.js +4 -4
- package/dist/actions/actionArrange.js.map +1 -1
- package/dist/actions/actionClipboard.d.ts.map +1 -1
- package/dist/actions/actionClipboard.js +5 -0
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionKeyboard.d.ts.map +1 -1
- package/dist/actions/actionKeyboard.js +77 -9
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionMode.d.ts +4 -0
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +54 -0
- package/dist/actions/actionMode.js.map +1 -1
- package/dist/actions/actionSelection.d.ts.map +1 -1
- package/dist/actions/actionSelection.js +1 -0
- package/dist/actions/actionSelection.js.map +1 -1
- package/dist/actions/actionView.d.ts +6 -0
- package/dist/actions/actionView.d.ts.map +1 -1
- package/dist/actions/actionView.js +19 -1
- package/dist/actions/actionView.js.map +1 -1
- package/dist/actions/actionZoom.d.ts.map +1 -1
- package/dist/actions/actionZoom.js +5 -0
- package/dist/actions/actionZoom.js.map +1 -1
- package/dist/actions/index.d.ts +3 -3
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +3 -3
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/registry.d.ts.map +1 -1
- package/dist/actions/registry.js +13 -0
- package/dist/actions/registry.js.map +1 -1
- package/dist/actions/types.d.ts +9 -0
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js.map +1 -1
- package/dist/clipboard.d.ts +0 -14
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +1 -1
- package/dist/clipboard.js.map +1 -1
- package/dist/constants.d.ts +160 -18
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +162 -2
- package/dist/constants.js.map +1 -1
- package/dist/dom-events.d.ts +1 -2
- package/dist/dom-events.d.ts.map +1 -1
- package/dist/dom-events.js.map +1 -1
- package/dist/editor/animation-scene.d.ts +15 -0
- package/dist/editor/animation-scene.d.ts.map +1 -1
- package/dist/editor/animation-scene.js +123 -0
- package/dist/editor/animation-scene.js.map +1 -1
- package/dist/editor/applies/create.d.ts +0 -6
- package/dist/editor/applies/create.d.ts.map +1 -1
- package/dist/editor/applies/create.js +1 -1
- package/dist/editor/applies/create.js.map +1 -1
- package/dist/editor/applies/link-move.d.ts +0 -8
- package/dist/editor/applies/link-move.d.ts.map +1 -1
- package/dist/editor/applies/link-move.js +1 -1
- package/dist/editor/applies/link-move.js.map +1 -1
- package/dist/editor/interaction-state.d.ts +184 -0
- package/dist/editor/interaction-state.d.ts.map +1 -0
- package/dist/editor/interaction-state.js +139 -0
- package/dist/editor/interaction-state.js.map +1 -0
- package/dist/editor/link-handle-drag.d.ts +152 -0
- package/dist/editor/link-handle-drag.d.ts.map +1 -0
- package/dist/editor/link-handle-drag.js +300 -0
- package/dist/editor/link-handle-drag.js.map +1 -0
- package/dist/editor/pointer-binding.d.ts +9 -11
- package/dist/editor/pointer-binding.d.ts.map +1 -1
- package/dist/editor/pointer-binding.js +1185 -901
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/brush.d.ts +31 -12
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +66 -17
- package/dist/editor/public/brush.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +1 -15
- package/dist/editor/public/cursor.d.ts.map +1 -1
- package/dist/editor/public/cursor.js +14 -3
- package/dist/editor/public/cursor.js.map +1 -1
- package/dist/editor/public/eraser.d.ts +55 -0
- package/dist/editor/public/eraser.d.ts.map +1 -0
- package/dist/editor/public/eraser.js +65 -0
- package/dist/editor/public/eraser.js.map +1 -0
- package/dist/editor/public/laser.d.ts +38 -0
- package/dist/editor/public/laser.d.ts.map +1 -0
- package/dist/editor/public/laser.js +42 -0
- package/dist/editor/public/laser.js.map +1 -0
- package/dist/editor/public/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +9 -1
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +12 -0
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +19 -1
- package/dist/editor/public/selection-ops.js.map +1 -1
- package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
- package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser-coverage.js +168 -0
- package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
- package/dist/editor/public/stroke-eraser.d.ts +50 -0
- package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser.js +176 -0
- package/dist/editor/public/stroke-eraser.js.map +1 -0
- package/dist/editor/public/stroke-smoothing.d.ts +21 -0
- package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
- package/dist/editor/public/stroke-smoothing.js +37 -0
- package/dist/editor/public/stroke-smoothing.js.map +1 -0
- package/dist/editor/public/tool-ops.d.ts +130 -0
- package/dist/editor/public/tool-ops.d.ts.map +1 -0
- package/dist/editor/public/tool-ops.js +392 -0
- package/dist/editor/public/tool-ops.js.map +1 -0
- package/dist/editor/public/z-order.d.ts +1 -12
- package/dist/editor/public/z-order.d.ts.map +1 -1
- package/dist/editor/public/z-order.js +1 -1
- package/dist/editor/public/z-order.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +9 -0
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +31 -0
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts +177 -5
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +517 -351
- package/dist/editor/render-orchestrator.js.map +1 -1
- package/dist/editor/text-edit.d.ts +136 -0
- package/dist/editor/text-edit.d.ts.map +1 -0
- package/dist/editor/text-edit.js +328 -0
- package/dist/editor/text-edit.js.map +1 -0
- package/dist/editor.d.ts +504 -239
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1557 -706
- package/dist/editor.js.map +1 -1
- package/dist/handle.d.ts +0 -2
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +0 -6
- package/dist/handle.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/modes.d.ts +15 -1
- package/dist/modes.d.ts.map +1 -1
- package/dist/modes.js.map +1 -1
- package/dist/overlay.d.ts +91 -2
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +450 -93
- package/dist/overlay.js.map +1 -1
- package/dist/search.d.ts +37 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +45 -0
- package/dist/search.js.map +1 -0
- 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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
359
|
-
target.
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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;
|