@1agh/maude 0.29.0 → 0.31.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/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -18,32 +18,42 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
21
|
-
|
|
21
|
+
import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
|
|
22
22
|
import {
|
|
23
23
|
type ArrowStroke,
|
|
24
|
+
canRotate,
|
|
24
25
|
type EllipseStroke,
|
|
26
|
+
HALO_PAD_PX,
|
|
25
27
|
type ImageStroke,
|
|
26
28
|
type LinkStroke,
|
|
29
|
+
normalizeRotation,
|
|
27
30
|
type PenStroke,
|
|
28
31
|
type PolygonStroke,
|
|
29
32
|
type RectStroke,
|
|
33
|
+
rotatePoint,
|
|
34
|
+
type SectionStroke,
|
|
30
35
|
type StickyStroke,
|
|
31
36
|
type Stroke,
|
|
32
37
|
type StrokesStoreValue,
|
|
33
38
|
strokeBBox,
|
|
39
|
+
strokeCenter,
|
|
40
|
+
strokeRotation,
|
|
34
41
|
} from './annotations-layer.tsx';
|
|
35
42
|
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
36
43
|
import { useAnnotationSelection } from './use-annotation-selection.tsx';
|
|
37
44
|
|
|
38
45
|
const RESIZE_CSS = `
|
|
46
|
+
/* DS selection specimen .sel-handle recipe — accent square, 1px accent-fg
|
|
47
|
+
* border, --radius-xs corners. (8px box kept: the JS centers handles on the
|
|
48
|
+
* half-size offset; the specimen's 7px differs by a hairline only.) Comment
|
|
49
|
+
* must stay backtick-free — it lives inside the RESIZE_CSS template literal. */
|
|
39
50
|
.dc-annot-resize-handle {
|
|
40
51
|
position: fixed;
|
|
41
52
|
width: 8px;
|
|
42
53
|
height: 8px;
|
|
43
|
-
background: var(--maude-hud-accent,
|
|
44
|
-
border: 1px solid var(--
|
|
45
|
-
border-radius:
|
|
46
|
-
box-shadow: 0 0 0 0.5px color-mix(in oklab, var(--fg-0, #1c1917) 30%, transparent);
|
|
54
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
55
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
56
|
+
border-radius: 3px;
|
|
47
57
|
z-index: 6;
|
|
48
58
|
pointer-events: auto;
|
|
49
59
|
touch-action: none;
|
|
@@ -58,6 +68,25 @@ const RESIZE_CSS = `
|
|
|
58
68
|
.dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
|
|
59
69
|
.dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
|
|
60
70
|
.dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
|
|
71
|
+
/* FigJam v3 — mid-edge handles: single-axis resize. Slightly smaller pills so
|
|
72
|
+
the corner squares stay the primary affordance. */
|
|
73
|
+
.dc-annot-resize-handle[data-corner="n"], .dc-annot-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; }
|
|
74
|
+
.dc-annot-resize-handle[data-corner="e"], .dc-annot-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; }
|
|
75
|
+
/* Wave G — rotation lives in INVISIBLE hover zones just outside each corner
|
|
76
|
+
(FigJam): the cursor flips to a rotate glyph there and dragging turns the
|
|
77
|
+
stroke. No knob — the old below-the-bbox knob read as clutter and collided
|
|
78
|
+
with the connector dots. z-index below the handles so a corner square wins
|
|
79
|
+
where they overlap. */
|
|
80
|
+
.dc-annot-rotate-zone {
|
|
81
|
+
position: fixed;
|
|
82
|
+
width: 18px;
|
|
83
|
+
height: 18px;
|
|
84
|
+
background: transparent;
|
|
85
|
+
z-index: 5;
|
|
86
|
+
pointer-events: auto;
|
|
87
|
+
touch-action: none;
|
|
88
|
+
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='white' stroke-width='4' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3Cpath stroke='black' stroke-width='1.8' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3C/g%3E%3C/svg%3E") 10 10, alias !important;
|
|
89
|
+
}
|
|
61
90
|
`.trim();
|
|
62
91
|
|
|
63
92
|
function ensureResizeStyles(): void {
|
|
@@ -69,7 +98,44 @@ function ensureResizeStyles(): void {
|
|
|
69
98
|
document.head.appendChild(s);
|
|
70
99
|
}
|
|
71
100
|
|
|
72
|
-
type Corner =
|
|
101
|
+
type Corner =
|
|
102
|
+
| 'nw'
|
|
103
|
+
| 'ne'
|
|
104
|
+
| 'sw'
|
|
105
|
+
| 'se'
|
|
106
|
+
| 'n'
|
|
107
|
+
| 'e'
|
|
108
|
+
| 's'
|
|
109
|
+
| 'w'
|
|
110
|
+
| 'rot-nw'
|
|
111
|
+
| 'rot-ne'
|
|
112
|
+
| 'rot-sw'
|
|
113
|
+
| 'rot-se'
|
|
114
|
+
| 'ep1'
|
|
115
|
+
| 'ep2';
|
|
116
|
+
|
|
117
|
+
/** FigJam v3 — mid-edge handles resize a single axis. */
|
|
118
|
+
function isEdgeCorner(c: Corner): boolean {
|
|
119
|
+
return c === 'n' || c === 'e' || c === 's' || c === 'w';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wave G — the four invisible rotate zones outside the corners. */
|
|
123
|
+
function isRotCorner(c: Corner): boolean {
|
|
124
|
+
return c === 'rot-nw' || c === 'rot-ne' || c === 'rot-sw' || c === 'rot-se';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wave G/H — handles float a step OFF the stroke (matching the halo's pad —
|
|
129
|
+
* `HALO_PAD_PX` lives in annotations-model as the single source) instead of
|
|
130
|
+
* sitting glued to the bbox. The pad is SCREEN-constant (`HALO_PAD_PX / zoom`
|
|
131
|
+
* world units) so the breathing room reads the same at every zoom.
|
|
132
|
+
* `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
|
|
133
|
+
* corner/edge so the shape doesn't grow by the pad on first move.
|
|
134
|
+
*/
|
|
135
|
+
const padDX = (c: Corner): number =>
|
|
136
|
+
c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
|
|
137
|
+
const padDY = (c: Corner): number =>
|
|
138
|
+
c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
|
|
73
139
|
|
|
74
140
|
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
75
141
|
function isResizable(
|
|
@@ -82,7 +148,8 @@ function isResizable(
|
|
|
82
148
|
| PenStroke
|
|
83
149
|
| StickyStroke
|
|
84
150
|
| ImageStroke
|
|
85
|
-
| LinkStroke
|
|
151
|
+
| LinkStroke
|
|
152
|
+
| SectionStroke {
|
|
86
153
|
return (
|
|
87
154
|
s.tool === 'rect' ||
|
|
88
155
|
s.tool === 'ellipse' ||
|
|
@@ -91,7 +158,8 @@ function isResizable(
|
|
|
91
158
|
s.tool === 'pen' ||
|
|
92
159
|
s.tool === 'sticky' ||
|
|
93
160
|
s.tool === 'image' ||
|
|
94
|
-
s.tool === 'link'
|
|
161
|
+
s.tool === 'link' ||
|
|
162
|
+
s.tool === 'section'
|
|
95
163
|
);
|
|
96
164
|
}
|
|
97
165
|
|
|
@@ -113,6 +181,41 @@ const NO_MODS: ResizeMods = { shift: false, alt: false };
|
|
|
113
181
|
const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
|
|
114
182
|
const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
|
|
115
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Wave H — resizing a ROTATED stroke must keep the anchored corner/edge fixed
|
|
186
|
+
* in WORLD space. The axis-aligned math anchors it in the LOCAL frame, but the
|
|
187
|
+
* rotation pivot is the bbox CENTER — when w/h change, the center moves, so
|
|
188
|
+
* the whole rotated image drifts on screen. The anchor's local point is
|
|
189
|
+
* preserved by `bboxResize` (non-Alt), so its world drift is exactly the
|
|
190
|
+
* difference of rotating that same local point around the OLD vs NEW center.
|
|
191
|
+
* Returns the [dx, dy] world shift to add to the new box's x/y (translation
|
|
192
|
+
* commutes with center-rotation: rotate(p+t, c+t, θ) = rotate(p, c, θ) + t).
|
|
193
|
+
* Alt-resizes keep the center fixed, so there is nothing to compensate.
|
|
194
|
+
*/
|
|
195
|
+
function rotatedAnchorShift(
|
|
196
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
197
|
+
b1: { x: number; y: number; w: number; h: number },
|
|
198
|
+
corner: Corner,
|
|
199
|
+
rot: number
|
|
200
|
+
): [number, number] {
|
|
201
|
+
// Anchor identity: the opposite corner (corner drags) / the opposite edge
|
|
202
|
+
// midpoint (edge drags), as a point of the START box in local coords.
|
|
203
|
+
let ux: number;
|
|
204
|
+
let uy: number;
|
|
205
|
+
if (isEdgeCorner(corner)) {
|
|
206
|
+
ux = corner === 'e' ? 0 : corner === 'w' ? 1 : 0.5;
|
|
207
|
+
uy = corner === 's' ? 0 : corner === 'n' ? 1 : 0.5;
|
|
208
|
+
} else {
|
|
209
|
+
ux = isWestCorner(corner) ? 1 : 0;
|
|
210
|
+
uy = isNorthCorner(corner) ? 1 : 0;
|
|
211
|
+
}
|
|
212
|
+
const px = b0.x + ux * b0.w;
|
|
213
|
+
const py = b0.y + uy * b0.h;
|
|
214
|
+
const [w0x, w0y] = rotatePoint(px, py, b0.x + b0.w / 2, b0.y + b0.h / 2, rot);
|
|
215
|
+
const [w1x, w1y] = rotatePoint(px, py, b1.x + b1.w / 2, b1.y + b1.h / 2, rot);
|
|
216
|
+
return [w0x - w1x, w0y - w1y];
|
|
217
|
+
}
|
|
218
|
+
|
|
116
219
|
/**
|
|
117
220
|
* Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
|
|
118
221
|
* axis-aligned box for the dragged `corner` moving to world (wx, wy):
|
|
@@ -134,6 +237,36 @@ function bboxResize(
|
|
|
134
237
|
const { x, y, w, h } = bbox;
|
|
135
238
|
const cx = x + w / 2;
|
|
136
239
|
const cy = y + h / 2;
|
|
240
|
+
// FigJam v3 — mid-edge handles move ONE axis: the opposite edge anchors
|
|
241
|
+
// (Alt = symmetric around the center). Always-square strokes (sticky) let
|
|
242
|
+
// the dragged axis drive the side, re-centered on the cross axis.
|
|
243
|
+
if (isEdgeCorner(corner)) {
|
|
244
|
+
const horizontal = corner === 'e' || corner === 'w';
|
|
245
|
+
let nx = x;
|
|
246
|
+
let ny = y;
|
|
247
|
+
let nw = w;
|
|
248
|
+
let nh = h;
|
|
249
|
+
if (horizontal) {
|
|
250
|
+
const anchorX = corner === 'w' ? x + w : x;
|
|
251
|
+
nw = Math.max(1, mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX));
|
|
252
|
+
nx = mods.alt ? cx - nw / 2 : wx < anchorX ? anchorX - nw : anchorX;
|
|
253
|
+
} else {
|
|
254
|
+
const anchorY = corner === 'n' ? y + h : y;
|
|
255
|
+
nh = Math.max(1, mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY));
|
|
256
|
+
ny = mods.alt ? cy - nh / 2 : wy < anchorY ? anchorY - nh : anchorY;
|
|
257
|
+
}
|
|
258
|
+
if (square) {
|
|
259
|
+
const side = horizontal ? nw : nh;
|
|
260
|
+
if (horizontal) {
|
|
261
|
+
nh = side;
|
|
262
|
+
ny = cy - side / 2;
|
|
263
|
+
} else {
|
|
264
|
+
nw = side;
|
|
265
|
+
nx = cx - side / 2;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { x: nx, y: ny, w: nw, h: nh };
|
|
269
|
+
}
|
|
137
270
|
const isW = isWestCorner(corner);
|
|
138
271
|
const isN = isNorthCorner(corner);
|
|
139
272
|
const anchorX = isW ? x + w : x;
|
|
@@ -168,6 +301,31 @@ function bboxResize(
|
|
|
168
301
|
return { x: nx, y: ny, w: nw, h: nh };
|
|
169
302
|
}
|
|
170
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Wave H — `bboxResize` plus the rotated-anchor compensation in one step. The
|
|
306
|
+
* box-shaped tools (rect family / image / ellipse) all need the same pair:
|
|
307
|
+
* run the axis-aligned math, then shift the result so the anchored corner /
|
|
308
|
+
* edge stays fixed in WORLD space (Alt keeps the center — nothing to fix).
|
|
309
|
+
*/
|
|
310
|
+
function bboxResizeRotAware(
|
|
311
|
+
start: Stroke,
|
|
312
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
313
|
+
corner: Corner,
|
|
314
|
+
wx: number,
|
|
315
|
+
wy: number,
|
|
316
|
+
mods: ResizeMods,
|
|
317
|
+
square: boolean
|
|
318
|
+
): { x: number; y: number; w: number; h: number } {
|
|
319
|
+
const box = bboxResize(b0, corner, wx, wy, mods, square);
|
|
320
|
+
const rot = strokeRotation(start);
|
|
321
|
+
if (rot !== 0 && !mods.alt) {
|
|
322
|
+
const [dx, dy] = rotatedAnchorShift(b0, box, corner, rot);
|
|
323
|
+
box.x += dx;
|
|
324
|
+
box.y += dy;
|
|
325
|
+
}
|
|
326
|
+
return box;
|
|
327
|
+
}
|
|
328
|
+
|
|
171
329
|
/**
|
|
172
330
|
* Per-tool resize math. Given a stroke + the moved corner + the new world
|
|
173
331
|
* coords for that corner, returns a patched stroke. `start` is the stroke at
|
|
@@ -180,25 +338,49 @@ export function resizeStroke(
|
|
|
180
338
|
corner: Corner,
|
|
181
339
|
wx: number,
|
|
182
340
|
wy: number,
|
|
183
|
-
mods: ResizeMods = NO_MODS
|
|
341
|
+
mods: ResizeMods = NO_MODS,
|
|
342
|
+
/**
|
|
343
|
+
* Wave G — rotation reference for the corner rotate zones: the pointer
|
|
344
|
+
* angle (deg, around the stroke center) at drag start + the stroke's
|
|
345
|
+
* rotation at drag start. Rotation is RELATIVE — the grab point is the
|
|
346
|
+
* zero reference, so the shape follows the hand with no jump (FigJam).
|
|
347
|
+
* Without it, the raw pointer angle is used as-is.
|
|
348
|
+
*/
|
|
349
|
+
rotRef?: { angle0: number; rot0: number }
|
|
184
350
|
): Partial<Stroke> | null {
|
|
351
|
+
if (isRotCorner(corner)) {
|
|
352
|
+
if (!canRotate(start)) return null;
|
|
353
|
+
const c = strokeCenter(start);
|
|
354
|
+
if (!c) return null;
|
|
355
|
+
const cur = (Math.atan2(wy - c[1], wx - c[0]) * 180) / Math.PI;
|
|
356
|
+
let deg = normalizeRotation(rotRef ? rotRef.rot0 + (cur - rotRef.angle0) : cur);
|
|
357
|
+
if (mods.shift) {
|
|
358
|
+
deg = normalizeRotation(Math.round(deg / 15) * 15);
|
|
359
|
+
} else {
|
|
360
|
+
// Magnetic cardinals (FigJam): drifting within 2° of 0/±90/180 locks on,
|
|
361
|
+
// so "straighten it back out" doesn't need pixel-perfect aim.
|
|
362
|
+
for (const cand of [0, 90, 180, -90, -180]) {
|
|
363
|
+
if (Math.abs(deg - cand) <= 2) {
|
|
364
|
+
deg = cand;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const norm = normalizeRotation(deg);
|
|
370
|
+
return { rotation: norm === 0 ? undefined : norm } as Partial<Stroke>;
|
|
371
|
+
}
|
|
185
372
|
if (
|
|
186
373
|
start.tool === 'rect' ||
|
|
187
374
|
start.tool === 'sticky' ||
|
|
188
375
|
start.tool === 'polygon' ||
|
|
189
|
-
start.tool === 'link'
|
|
376
|
+
start.tool === 'link' ||
|
|
377
|
+
start.tool === 'section'
|
|
190
378
|
) {
|
|
191
379
|
// Rect / polygon / sticky / link all resize via their shared x / y / w / h
|
|
192
380
|
// bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
|
|
193
381
|
// 1:1; the link card free-resizes (Shift still locks its current ratio).
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
corner,
|
|
197
|
-
wx,
|
|
198
|
-
wy,
|
|
199
|
-
mods,
|
|
200
|
-
start.tool === 'sticky'
|
|
201
|
-
);
|
|
382
|
+
const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
383
|
+
const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
|
|
202
384
|
return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
|
|
203
385
|
}
|
|
204
386
|
if (start.tool === 'image') {
|
|
@@ -206,8 +388,10 @@ export function resizeStroke(
|
|
|
206
388
|
// (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
|
|
207
389
|
// the Shift flag into bboxResize, which keeps the START ratio when shift is
|
|
208
390
|
// set; the start ratio IS the image's intrinsic aspect.
|
|
209
|
-
const
|
|
210
|
-
|
|
391
|
+
const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
392
|
+
const box = bboxResizeRotAware(
|
|
393
|
+
start,
|
|
394
|
+
b0,
|
|
211
395
|
corner,
|
|
212
396
|
wx,
|
|
213
397
|
wy,
|
|
@@ -218,14 +402,8 @@ export function resizeStroke(
|
|
|
218
402
|
}
|
|
219
403
|
if (start.tool === 'ellipse') {
|
|
220
404
|
// Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
corner,
|
|
224
|
-
wx,
|
|
225
|
-
wy,
|
|
226
|
-
mods,
|
|
227
|
-
false
|
|
228
|
-
);
|
|
405
|
+
const b0 = { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 };
|
|
406
|
+
const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, false);
|
|
229
407
|
return {
|
|
230
408
|
cx: box.x + box.w / 2,
|
|
231
409
|
cy: box.y + box.h / 2,
|
|
@@ -269,6 +447,8 @@ export function resizeStroke(
|
|
|
269
447
|
// Scale all points around an anchor — the opposite corner (normal) or the
|
|
270
448
|
// bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
|
|
271
449
|
// A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
|
|
450
|
+
// Edge handles aren't offered for ink (corner scale only).
|
|
451
|
+
if (isEdgeCorner(corner)) return null;
|
|
272
452
|
const bb = strokeBBox(start);
|
|
273
453
|
if (!bb) return null;
|
|
274
454
|
const cx = bb.x + bb.w / 2;
|
|
@@ -311,23 +491,65 @@ export function resizeStroke(
|
|
|
311
491
|
/**
|
|
312
492
|
* Returns the four corners (or two endpoints, for arrow) of the selected
|
|
313
493
|
* stroke in world coordinates. Used by the overlay component to position
|
|
314
|
-
* the screen-space handle divs each rAF tick.
|
|
494
|
+
* the screen-space handle divs each rAF tick. `pad` is the halo offset in
|
|
495
|
+
* WORLD units (callers pass `HALO_PAD_PX / zoom` for a screen-constant gap).
|
|
315
496
|
*/
|
|
316
|
-
function handlePositions(s: Stroke): Array<{ corner: Corner; x: number; y: number }> {
|
|
497
|
+
function handlePositions(s: Stroke, pad: number): Array<{ corner: Corner; x: number; y: number }> {
|
|
317
498
|
if (s.tool === 'arrow') {
|
|
318
499
|
return [
|
|
319
500
|
{ corner: 'ep1', x: s.x1, y: s.y1 },
|
|
320
501
|
{ corner: 'ep2', x: s.x2, y: s.y2 },
|
|
321
502
|
];
|
|
322
503
|
}
|
|
323
|
-
const
|
|
324
|
-
if (!
|
|
325
|
-
|
|
504
|
+
const bb0 = strokeBBox(s);
|
|
505
|
+
if (!bb0) return [];
|
|
506
|
+
// Wave G — handles sit on the HALO (bbox + pad), not glued to the stroke
|
|
507
|
+
// itself; applyResize shifts the cursor back by the same pad.
|
|
508
|
+
const bb = {
|
|
509
|
+
x: bb0.x - pad,
|
|
510
|
+
y: bb0.y - pad,
|
|
511
|
+
w: bb0.w + pad * 2,
|
|
512
|
+
h: bb0.h + pad * 2,
|
|
513
|
+
};
|
|
514
|
+
const corners: Array<{ corner: Corner; x: number; y: number }> = [
|
|
326
515
|
{ corner: 'nw', x: bb.x, y: bb.y },
|
|
327
516
|
{ corner: 'ne', x: bb.x + bb.w, y: bb.y },
|
|
328
517
|
{ corner: 'sw', x: bb.x, y: bb.y + bb.h },
|
|
329
518
|
{ corner: 'se', x: bb.x + bb.w, y: bb.y + bb.h },
|
|
330
519
|
];
|
|
520
|
+
// FigJam v3 — mid-edge handles (single-axis resize) for the box-shaped
|
|
521
|
+
// strokes; ink (pen) keeps corner-scale only.
|
|
522
|
+
if (s.tool !== 'pen') {
|
|
523
|
+
corners.push(
|
|
524
|
+
{ corner: 'n', x: bb.x + bb.w / 2, y: bb.y },
|
|
525
|
+
{ corner: 'e', x: bb.x + bb.w, y: bb.y + bb.h / 2 },
|
|
526
|
+
{ corner: 's', x: bb.x + bb.w / 2, y: bb.y + bb.h },
|
|
527
|
+
{ corner: 'w', x: bb.x, y: bb.y + bb.h / 2 }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
// Wave G — invisible rotate zones AT the padded corners; the rAF tick adds
|
|
531
|
+
// a screen-constant outward offset (along center→corner) so they hover just
|
|
532
|
+
// beyond the corner squares at every zoom + rotation.
|
|
533
|
+
if (canRotate(s)) {
|
|
534
|
+
corners.push(
|
|
535
|
+
{ corner: 'rot-nw', x: bb.x, y: bb.y },
|
|
536
|
+
{ corner: 'rot-ne', x: bb.x + bb.w, y: bb.y },
|
|
537
|
+
{ corner: 'rot-sw', x: bb.x, y: bb.y + bb.h },
|
|
538
|
+
{ corner: 'rot-se', x: bb.x + bb.w, y: bb.y + bb.h }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
// FigJam v3 — a rotated stroke carries its handles with it: every handle
|
|
542
|
+
// point turns around the bbox center, so they sit on the visible outline.
|
|
543
|
+
const rot = strokeRotation(s);
|
|
544
|
+
if (rot !== 0) {
|
|
545
|
+
const cx = bb.x + bb.w / 2;
|
|
546
|
+
const cy = bb.y + bb.h / 2;
|
|
547
|
+
return corners.map((c) => {
|
|
548
|
+
const [rx, ry] = rotatePoint(c.x, c.y, cx, cy, rot);
|
|
549
|
+
return { corner: c.corner, x: rx, y: ry };
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return corners;
|
|
331
553
|
}
|
|
332
554
|
|
|
333
555
|
export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue | null }): ReactNode {
|
|
@@ -340,7 +562,13 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
340
562
|
const dragRef = useRef<{
|
|
341
563
|
pointerId: number;
|
|
342
564
|
startStroke: Stroke;
|
|
565
|
+
/** Wave H — full strokes snapshot at drag start (the undo `before`). */
|
|
566
|
+
startStrokes: Stroke[];
|
|
343
567
|
corner: Corner;
|
|
568
|
+
/** FigJam v3 — neighbour bbox dims for the dimension-match snap. */
|
|
569
|
+
dims: Array<{ id: string; w: number; h: number }>;
|
|
570
|
+
/** Wave G — rotation drag reference (corner rotate zones only). */
|
|
571
|
+
rotRef?: { angle0: number; rot0: number };
|
|
344
572
|
} | null>(null);
|
|
345
573
|
// Last pointer position (client coords) during a drag — lets a mid-drag
|
|
346
574
|
// Shift/Alt keydown re-apply the resize without waiting for a pointermove.
|
|
@@ -378,28 +606,48 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
378
606
|
rafRef.current = null;
|
|
379
607
|
const c = containerRef.current;
|
|
380
608
|
if (!c) return;
|
|
381
|
-
const positions = handlePositions(selectedStroke);
|
|
382
609
|
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
383
610
|
const z = v.zoom || 1;
|
|
611
|
+
// Wave H — screen-constant halo offset (HALO_PAD_PX at any zoom).
|
|
612
|
+
const positions = handlePositions(selectedStroke, HALO_PAD_PX / z);
|
|
384
613
|
// Ensure enough handle children exist (each corner = one absolutely-
|
|
385
|
-
// positioned div)
|
|
614
|
+
// positioned div); the per-kind class is assigned below.
|
|
386
615
|
while (c.children.length < positions.length) {
|
|
387
|
-
|
|
388
|
-
handle.className = 'dc-annot-resize-handle';
|
|
389
|
-
c.appendChild(handle);
|
|
616
|
+
c.appendChild(document.createElement('div'));
|
|
390
617
|
}
|
|
391
618
|
while (c.children.length > positions.length) {
|
|
392
619
|
c.lastChild && c.removeChild(c.lastChild);
|
|
393
620
|
}
|
|
621
|
+
// Wave G — rotate zones push a screen-constant step OUTWARD from the
|
|
622
|
+
// stroke center so they hover diagonally beyond the corner squares.
|
|
623
|
+
const wc = strokeCenter(selectedStroke);
|
|
624
|
+
const csx = wc ? wc[0] * z + v.x : 0;
|
|
625
|
+
const csy = wc ? wc[1] * z + v.y : 0;
|
|
394
626
|
for (let i = 0; i < positions.length; i++) {
|
|
395
627
|
const pos = positions[i];
|
|
396
628
|
const handle = c.children[i] as HTMLElement | undefined;
|
|
397
629
|
if (!pos || !handle) continue;
|
|
398
|
-
|
|
399
|
-
|
|
630
|
+
let sx = pos.x * z + v.x;
|
|
631
|
+
let sy = pos.y * z + v.y;
|
|
632
|
+
const isRot = isRotCorner(pos.corner);
|
|
633
|
+
if (isRot && wc) {
|
|
634
|
+
const dx = sx - csx;
|
|
635
|
+
const dy = sy - csy;
|
|
636
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
637
|
+
const reach = 13;
|
|
638
|
+
sx += (dx / len) * reach;
|
|
639
|
+
sy += (dy / len) * reach;
|
|
640
|
+
}
|
|
641
|
+
// Edge pills are 14×6 / 6×14, rotate zones 18×18 (corners 8×8) —
|
|
642
|
+
// center each on its point.
|
|
643
|
+
const ns = pos.corner === 'n' || pos.corner === 's';
|
|
644
|
+
const ew = pos.corner === 'e' || pos.corner === 'w';
|
|
645
|
+
const halfW = isRot ? 9 : ns ? 7 : ew ? 3 : 4;
|
|
646
|
+
const halfH = isRot ? 9 : ns ? 3 : ew ? 7 : 4;
|
|
647
|
+
handle.className = isRot ? 'dc-annot-rotate-zone' : 'dc-annot-resize-handle';
|
|
400
648
|
handle.style.display = 'block';
|
|
401
|
-
handle.style.left = `${Math.round(sx -
|
|
402
|
-
handle.style.top = `${Math.round(sy -
|
|
649
|
+
handle.style.left = `${Math.round(sx - halfW)}px`;
|
|
650
|
+
handle.style.top = `${Math.round(sy - halfH)}px`;
|
|
403
651
|
handle.dataset.corner = pos.corner;
|
|
404
652
|
}
|
|
405
653
|
rafRef.current = requestAnimationFrame(tick);
|
|
@@ -417,47 +665,229 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
417
665
|
if (!c) return;
|
|
418
666
|
const onDown = (e: PointerEvent) => {
|
|
419
667
|
const t = e.target as HTMLElement | null;
|
|
420
|
-
if (
|
|
421
|
-
|
|
668
|
+
if (
|
|
669
|
+
!t?.classList.contains('dc-annot-resize-handle') &&
|
|
670
|
+
!t?.classList.contains('dc-annot-rotate-zone')
|
|
671
|
+
)
|
|
672
|
+
return;
|
|
673
|
+
if (!t || !selectedStroke || !store) return;
|
|
422
674
|
const corner = t.dataset.corner as Corner | undefined;
|
|
423
675
|
if (!corner) return;
|
|
424
676
|
e.preventDefault();
|
|
425
677
|
e.stopPropagation();
|
|
426
|
-
|
|
678
|
+
// Wave G — corner rotation is relative to the grab angle (no jump).
|
|
679
|
+
let rotRef: { angle0: number; rot0: number } | undefined;
|
|
680
|
+
if (isRotCorner(corner)) {
|
|
681
|
+
const cw = strokeCenter(selectedStroke);
|
|
682
|
+
if (cw) {
|
|
683
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
684
|
+
rotRef = {
|
|
685
|
+
angle0: (Math.atan2(wy - cw[1], wx - cw[0]) * 180) / Math.PI,
|
|
686
|
+
rot0: strokeRotation(selectedStroke),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// FigJam v3 — dimension-match candidates, captured once per gesture: the
|
|
691
|
+
// bbox dims of every OTHER box-shaped stroke. While resizing, a width /
|
|
692
|
+
// height within the threshold of a neighbour's snaps to it exactly and
|
|
693
|
+
// the neighbour gets a match halo (the "make it the same size" quota).
|
|
694
|
+
const dims: Array<{ id: string; w: number; h: number }> = [];
|
|
695
|
+
for (const s of store.strokes) {
|
|
696
|
+
if (s.id === selectedStroke.id) continue;
|
|
697
|
+
if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'text') continue;
|
|
698
|
+
const bb = strokeBBox(s);
|
|
699
|
+
if (bb && bb.w > 1 && bb.h > 1) dims.push({ id: s.id, w: bb.w, h: bb.h });
|
|
700
|
+
}
|
|
701
|
+
dragRef.current = {
|
|
702
|
+
pointerId: e.pointerId,
|
|
703
|
+
startStroke: selectedStroke,
|
|
704
|
+
startStrokes: store.strokes,
|
|
705
|
+
corner,
|
|
706
|
+
dims,
|
|
707
|
+
rotRef,
|
|
708
|
+
};
|
|
427
709
|
try {
|
|
428
710
|
t.setPointerCapture(e.pointerId);
|
|
429
711
|
} catch {
|
|
430
712
|
/* some browsers reject capture on synthetic events */
|
|
431
713
|
}
|
|
432
714
|
};
|
|
433
|
-
const applyResize = (
|
|
715
|
+
const applyResize = (
|
|
716
|
+
clientX: number,
|
|
717
|
+
clientY: number,
|
|
718
|
+
mods: ResizeMods,
|
|
719
|
+
suppressBind: boolean
|
|
720
|
+
) => {
|
|
434
721
|
const d = dragRef.current;
|
|
435
722
|
if (!d || !store) return;
|
|
436
723
|
const [wx, wy] = screenToWorld(clientX, clientY);
|
|
437
|
-
|
|
438
|
-
|
|
724
|
+
// FigJam v3 — a rotated stroke resizes in its LOCAL frame: inverse-
|
|
725
|
+
// rotate the cursor around the start pivot, run the axis-aligned math,
|
|
726
|
+
// keep the rotation. (The rotation knob itself wants the RAW cursor.)
|
|
727
|
+
let lwx = wx;
|
|
728
|
+
let lwy = wy;
|
|
729
|
+
const startRot = strokeRotation(d.startStroke);
|
|
730
|
+
if (startRot !== 0 && !isRotCorner(d.corner)) {
|
|
731
|
+
const c = strokeCenter(d.startStroke);
|
|
732
|
+
if (c) {
|
|
733
|
+
const [ux, uy] = rotatePoint(wx, wy, c[0], c[1], -startRot);
|
|
734
|
+
lwx = ux;
|
|
735
|
+
lwy = uy;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Wave G/H — handles render padded OFF the bbox (screen-constant); shift
|
|
739
|
+
// the cursor back onto the true corner/edge so the first move doesn't
|
|
740
|
+
// grow the shape.
|
|
741
|
+
const padWorld = HALO_PAD_PX / (vp?.zoom || 1);
|
|
742
|
+
lwx += padDX(d.corner) * padWorld;
|
|
743
|
+
lwy += padDY(d.corner) * padWorld;
|
|
744
|
+
let patch = resizeStroke(d.startStroke, d.corner, lwx, lwy, mods, d.rotRef);
|
|
745
|
+
if (!patch) return;
|
|
746
|
+
// FigJam v3 — dimension match + live size label for box resizes. ⌘
|
|
747
|
+
// suppresses matching (same convention as drag snapping).
|
|
748
|
+
if (
|
|
749
|
+
d.startStroke.tool !== 'arrow' &&
|
|
750
|
+
d.startStroke.tool !== 'pen' &&
|
|
751
|
+
'w' in patch &&
|
|
752
|
+
'h' in patch &&
|
|
753
|
+
typeof patch.w === 'number' &&
|
|
754
|
+
typeof patch.h === 'number' &&
|
|
755
|
+
typeof patch.x === 'number' &&
|
|
756
|
+
typeof patch.y === 'number'
|
|
757
|
+
) {
|
|
758
|
+
const zoom = vp?.zoom || 1;
|
|
759
|
+
const thr = 6 / zoom;
|
|
760
|
+
const matchIds: string[] = [];
|
|
761
|
+
if (!suppressBind && d.dims.length) {
|
|
762
|
+
// Only the axes this handle actually drags participate — an east-
|
|
763
|
+
// edge drag must never quietly re-snap the HEIGHT to a neighbour.
|
|
764
|
+
const edge = isEdgeCorner(d.corner);
|
|
765
|
+
const affectsW = !edge || d.corner === 'e' || d.corner === 'w';
|
|
766
|
+
const affectsH = !edge || d.corner === 'n' || d.corner === 's';
|
|
767
|
+
let bestW: { id: string; w: number } | null = null;
|
|
768
|
+
let bestH: { id: string; h: number } | null = null;
|
|
769
|
+
for (const cand of d.dims) {
|
|
770
|
+
if (
|
|
771
|
+
affectsW &&
|
|
772
|
+
Math.abs(cand.w - patch.w) <= thr &&
|
|
773
|
+
(!bestW || Math.abs(cand.w - patch.w) < Math.abs(bestW.w - patch.w))
|
|
774
|
+
) {
|
|
775
|
+
bestW = { id: cand.id, w: cand.w };
|
|
776
|
+
}
|
|
777
|
+
if (
|
|
778
|
+
affectsH &&
|
|
779
|
+
Math.abs(cand.h - patch.h) <= thr &&
|
|
780
|
+
(!bestH || Math.abs(cand.h - patch.h) < Math.abs(bestH.h - patch.h))
|
|
781
|
+
) {
|
|
782
|
+
bestH = { id: cand.id, h: cand.h };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Snap the box to the matched dim, keeping the anchored edge fixed
|
|
786
|
+
// (the cursor-side edge moves; x/y only shift when the west/north
|
|
787
|
+
// edge was the dragged one).
|
|
788
|
+
if (bestW) {
|
|
789
|
+
if (wx < patch.x + patch.w / 2) patch.x = patch.x + patch.w - bestW.w;
|
|
790
|
+
patch.w = bestW.w;
|
|
791
|
+
matchIds.push(bestW.id);
|
|
792
|
+
}
|
|
793
|
+
if (bestH) {
|
|
794
|
+
if (wy < patch.y + patch.h / 2) patch.y = patch.y + patch.h - bestH.h;
|
|
795
|
+
patch.h = bestH.h;
|
|
796
|
+
matchIds.push(bestH.id);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
document.dispatchEvent(
|
|
800
|
+
new CustomEvent('maude:resize-info', {
|
|
801
|
+
detail: {
|
|
802
|
+
box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
|
|
803
|
+
matchIds,
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
// FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
|
|
809
|
+
// magnet threshold of a bindable host the endpoint snaps + binds; free
|
|
810
|
+
// space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
|
|
811
|
+
// paints the candidate halo via the maude:bind-hint broadcast.
|
|
812
|
+
if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
|
|
813
|
+
const zoom = vp?.zoom || 1;
|
|
814
|
+
const cand = suppressBind
|
|
815
|
+
? null
|
|
816
|
+
: bindCandidate(
|
|
817
|
+
wx,
|
|
818
|
+
wy,
|
|
819
|
+
store.strokes,
|
|
820
|
+
BIND_THRESHOLD_PX / zoom,
|
|
821
|
+
new Set([d.startStroke.id])
|
|
822
|
+
);
|
|
823
|
+
if (cand) {
|
|
824
|
+
const host = store.strokes.find((s) => s.id === cand.hostId);
|
|
825
|
+
const pt = host ? anchorPoint(host, cand.nx, cand.ny) : null;
|
|
826
|
+
if (pt) {
|
|
827
|
+
// An explicit endpoint re-anchor PINS the magnet — auto-routing
|
|
828
|
+
// must not override a side the user chose by hand.
|
|
829
|
+
const pinned = { ...cand, pinned: true };
|
|
830
|
+
patch =
|
|
831
|
+
d.corner === 'ep1'
|
|
832
|
+
? ({ ...patch, startBind: pinned, x1: pt[0], y1: pt[1] } as Partial<Stroke>)
|
|
833
|
+
: ({ ...patch, endBind: pinned, x2: pt[0], y2: pt[1] } as Partial<Stroke>);
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
patch = {
|
|
837
|
+
...patch,
|
|
838
|
+
[d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
|
|
839
|
+
} as Partial<Stroke>;
|
|
840
|
+
}
|
|
841
|
+
document.dispatchEvent(
|
|
842
|
+
new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
// Wave H — transient preview tick: no undo record, no PUT. The gesture
|
|
846
|
+
// commits ONCE on pointerup (undo used to walk every resize pixel).
|
|
847
|
+
store.previewStroke(d.startStroke.id, patch);
|
|
439
848
|
};
|
|
440
849
|
const onMove = (e: PointerEvent) => {
|
|
441
850
|
const d = dragRef.current;
|
|
442
851
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
443
852
|
lastPointRef.current = { x: e.clientX, y: e.clientY };
|
|
444
|
-
applyResize(
|
|
853
|
+
applyResize(
|
|
854
|
+
e.clientX,
|
|
855
|
+
e.clientY,
|
|
856
|
+
{ shift: e.shiftKey, alt: e.altKey },
|
|
857
|
+
e.metaKey || e.ctrlKey
|
|
858
|
+
);
|
|
445
859
|
};
|
|
446
|
-
// Holding/releasing Shift
|
|
447
|
-
// known pointer position so the constraint flips live, FigJam-style.
|
|
860
|
+
// Holding/releasing Shift / Alt / ⌘ mid-drag re-runs the resize at the
|
|
861
|
+
// last known pointer position so the constraint flips live, FigJam-style.
|
|
448
862
|
const onKey = (e: KeyboardEvent) => {
|
|
449
863
|
if (!dragRef.current) return;
|
|
450
|
-
if (e.key !== 'Shift' && e.key !== 'Alt') return;
|
|
864
|
+
if (e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta' && e.key !== 'Control') return;
|
|
451
865
|
const p = lastPointRef.current;
|
|
452
866
|
if (!p) return;
|
|
453
867
|
e.preventDefault();
|
|
454
|
-
applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey });
|
|
868
|
+
applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey }, e.metaKey || e.ctrlKey);
|
|
455
869
|
};
|
|
456
870
|
const onUp = (e: PointerEvent) => {
|
|
457
871
|
const d = dragRef.current;
|
|
458
872
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
459
873
|
dragRef.current = null;
|
|
460
874
|
lastPointRef.current = null;
|
|
875
|
+
// Wave H — close the preview gesture as ONE undo record (no-op when
|
|
876
|
+
// the drag ended exactly where it started).
|
|
877
|
+
store?.commitGesture(
|
|
878
|
+
d.startStrokes,
|
|
879
|
+
isRotCorner(d.corner)
|
|
880
|
+
? 'rotate'
|
|
881
|
+
: d.corner === 'ep1' || d.corner === 'ep2'
|
|
882
|
+
? 'move endpoint'
|
|
883
|
+
: 'resize'
|
|
884
|
+
);
|
|
885
|
+
// Clear the bind-hint halo + size label regardless of what was dragged
|
|
886
|
+
// (no-ops when nothing was hinted).
|
|
887
|
+
document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
|
|
888
|
+
document.dispatchEvent(
|
|
889
|
+
new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
|
|
890
|
+
);
|
|
461
891
|
};
|
|
462
892
|
c.addEventListener('pointerdown', onDown);
|
|
463
893
|
document.addEventListener('pointermove', onMove);
|
|
@@ -473,7 +903,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
473
903
|
document.removeEventListener('keydown', onKey, true);
|
|
474
904
|
document.removeEventListener('keyup', onKey, true);
|
|
475
905
|
};
|
|
476
|
-
}, [selectedStroke, store, screenToWorld]);
|
|
906
|
+
}, [selectedStroke, store, screenToWorld, vp]);
|
|
477
907
|
|
|
478
908
|
// Only render when there's exactly one resizable stroke selected. Multi
|
|
479
909
|
// resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
|