@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.
- package/CHANGELOG.md +189 -0
- package/README.md +6 -4
- 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 +79 -11
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionMode.d.ts +2 -0
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +81 -27
- 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 +10 -1
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js.map +1 -1
- package/dist/built-in-handlers.d.ts +8 -0
- package/dist/built-in-handlers.d.ts.map +1 -1
- package/dist/built-in-handlers.js +31 -16
- package/dist/built-in-handlers.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 +253 -18
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +255 -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 +159 -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/event-fanout.d.ts +6 -6
- package/dist/editor/event-fanout.d.ts.map +1 -1
- package/dist/editor/event-fanout.js +8 -8
- package/dist/editor/event-fanout.js.map +1 -1
- package/dist/editor/hit-test.d.ts +8 -0
- package/dist/editor/hit-test.d.ts.map +1 -1
- package/dist/editor/hit-test.js +31 -16
- package/dist/editor/hit-test.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 +172 -0
- package/dist/editor/link-handle-drag.d.ts.map +1 -0
- package/dist/editor/link-handle-drag.js +369 -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 +1247 -909
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/brush.d.ts +93 -13
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +240 -22
- 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 +17 -7
- 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 +180 -5
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +516 -353
- 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-events.d.ts +5 -5
- package/dist/editor-events.d.ts.map +1 -1
- package/dist/editor.d.ts +560 -250
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1650 -743
- 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 +7 -2
- 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 +34 -1
- package/dist/modes.d.ts.map +1 -1
- package/dist/modes.js.map +1 -1
- package/dist/overlay.d.ts +98 -21
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +442 -116
- 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 +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
|
-
|
|
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,11 @@ 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 ||
|
|
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
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
if (options.
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
359
|
-
target.
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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,
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
target.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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);
|
|
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;
|