@1agh/maude 0.23.0 → 0.25.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -203,14 +203,30 @@ interface StoreHost {
|
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
205
|
* Prefer `window.top` so all canvas iframes (children of the dev-server
|
|
206
|
-
* shell) read + write the same Map. Falls back to `window`
|
|
207
|
-
* cross-origin —
|
|
208
|
-
*
|
|
206
|
+
* shell) read + write the same Map. Falls back to `window` when top is
|
|
207
|
+
* cross-origin — which IS the case under the T2 (9.1-A) segregated canvas
|
|
208
|
+
* origin: the canvas iframe and the shell are different origins, so any
|
|
209
|
+
* PROPERTY access on `window.top` throws SecurityError. Then `globalThis`
|
|
210
|
+
* (Node / Bun test runtime where window is absent).
|
|
211
|
+
*
|
|
212
|
+
* Note: merely reading `window.top` (the reference) never throws — only
|
|
213
|
+
* touching a property of a cross-origin window does. So the guard MUST poke a
|
|
214
|
+
* property inside the try, or the cross-origin throw escapes into React render
|
|
215
|
+
* (it did: the UndoStackProvider's useRef initializer crashed the whole canvas
|
|
216
|
+
* mount to 0 children). Under A1 each iframe falls back to its own `window`
|
|
217
|
+
* store — undo works per-session; cross-close/reopen sharing via window.top is
|
|
218
|
+
* intentionally dropped for the origin isolation (see DDR-0xx / phase-9.1).
|
|
209
219
|
*/
|
|
210
220
|
function getStoreHost(): StoreHost {
|
|
211
221
|
if (typeof window !== 'undefined') {
|
|
212
222
|
try {
|
|
213
|
-
|
|
223
|
+
const top = window.top;
|
|
224
|
+
if (top && top !== window) {
|
|
225
|
+
// Poke a property — throws if `top` is a cross-origin window.
|
|
226
|
+
void (top as unknown as StoreHost).__maude_undo_stacks;
|
|
227
|
+
return top as unknown as StoreHost;
|
|
228
|
+
}
|
|
229
|
+
return window as unknown as StoreHost;
|
|
214
230
|
} catch {
|
|
215
231
|
return window as unknown as StoreHost;
|
|
216
232
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* @file use-annotation-resize.tsx — Task 23 (Wave 2, G4)
|
|
3
3
|
* @scope plugins/design/dev-server/use-annotation-resize.tsx
|
|
4
4
|
* @purpose Screen-space corner / endpoint handles for the selected
|
|
5
|
-
* annotation. Per-tool resize math
|
|
6
|
-
*
|
|
5
|
+
* annotation. Per-tool resize math with FigJam resize modifiers:
|
|
6
|
+
* Shift = lock aspect ratio (45° angle snap for arrows), Alt =
|
|
7
|
+
* scale from center, Shift+Alt = both. Modifiers update live on
|
|
8
|
+
* keydown/keyup mid-drag (re-applied at the last pointer position).
|
|
7
9
|
*
|
|
8
10
|
* Handles are `position: fixed` DOM siblings of the canvas (the
|
|
9
11
|
* same pattern element selection uses) — they stay 8 × 8 CSS px
|
|
@@ -21,7 +23,9 @@ import {
|
|
|
21
23
|
type ArrowStroke,
|
|
22
24
|
type EllipseStroke,
|
|
23
25
|
type PenStroke,
|
|
26
|
+
type PolygonStroke,
|
|
24
27
|
type RectStroke,
|
|
28
|
+
type StickyStroke,
|
|
25
29
|
type Stroke,
|
|
26
30
|
type StrokesStoreValue,
|
|
27
31
|
strokeBBox,
|
|
@@ -42,9 +46,16 @@ const RESIZE_CSS = `
|
|
|
42
46
|
pointer-events: auto;
|
|
43
47
|
touch-action: none;
|
|
44
48
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
/* Phase 24 — '!important' so the scale/move affordance beats use-tool-mode's
|
|
50
|
+
blanket '* { cursor: <tool> !important }' (move mode). Without it the move
|
|
51
|
+
glyph clobbered the resize cursors and the user saw no scale affordance over
|
|
52
|
+
a handle. Specificity already wins ('.class[attr]' > '*'); the '!important'
|
|
53
|
+
is what lets it through against the other '!important' rule. See DDR-067.
|
|
54
|
+
NOTE: keep this comment backtick-free — it lives inside the RESIZE_CSS
|
|
55
|
+
template literal and a stray backtick closes it (bun parse fail, §6). */
|
|
56
|
+
.dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
|
|
57
|
+
.dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
|
|
58
|
+
.dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
|
|
48
59
|
`.trim();
|
|
49
60
|
|
|
50
61
|
function ensureResizeStyles(): void {
|
|
@@ -58,79 +69,203 @@ function ensureResizeStyles(): void {
|
|
|
58
69
|
|
|
59
70
|
type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
|
|
60
71
|
|
|
61
|
-
/** Stroke types that expose resize handles
|
|
62
|
-
function isResizable(
|
|
63
|
-
|
|
72
|
+
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
73
|
+
function isResizable(
|
|
74
|
+
s: Stroke
|
|
75
|
+
): s is RectStroke | EllipseStroke | PolygonStroke | ArrowStroke | PenStroke | StickyStroke {
|
|
76
|
+
return (
|
|
77
|
+
s.tool === 'rect' ||
|
|
78
|
+
s.tool === 'ellipse' ||
|
|
79
|
+
s.tool === 'polygon' ||
|
|
80
|
+
s.tool === 'arrow' ||
|
|
81
|
+
s.tool === 'pen' ||
|
|
82
|
+
s.tool === 'sticky'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resize modifiers (FigJam-parity). Held during a handle drag:
|
|
88
|
+
* • `shift` — lock to the start aspect ratio (45° angle snap for arrows;
|
|
89
|
+
* always-1:1 stickies ignore it — they're square regardless).
|
|
90
|
+
* • `alt` — scale symmetrically around the stroke's center / midpoint.
|
|
91
|
+
* Both together combine (ratio-locked AND center-anchored). With neither held
|
|
92
|
+
* the result is byte-identical to the pre-modifier behaviour.
|
|
93
|
+
*/
|
|
94
|
+
export interface ResizeMods {
|
|
95
|
+
shift: boolean;
|
|
96
|
+
alt: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NO_MODS: ResizeMods = { shift: false, alt: false };
|
|
100
|
+
|
|
101
|
+
const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
|
|
102
|
+
const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
|
|
106
|
+
* axis-aligned box for the dragged `corner` moving to world (wx, wy):
|
|
107
|
+
* • normal — the diagonally-opposite corner is the fixed anchor.
|
|
108
|
+
* • Alt — the box's center is the fixed anchor (symmetric scale).
|
|
109
|
+
* • Shift — keep the start aspect ratio (the dominant axis drives scale).
|
|
110
|
+
* • square — force 1:1 regardless of Shift (sticky notes).
|
|
111
|
+
* The no-modifier branch is algebraically identical to the previous
|
|
112
|
+
* min/max corner math (verified against the resize round-trip tests).
|
|
113
|
+
*/
|
|
114
|
+
function bboxResize(
|
|
115
|
+
bbox: { x: number; y: number; w: number; h: number },
|
|
116
|
+
corner: Corner,
|
|
117
|
+
wx: number,
|
|
118
|
+
wy: number,
|
|
119
|
+
mods: ResizeMods,
|
|
120
|
+
square: boolean
|
|
121
|
+
): { x: number; y: number; w: number; h: number } {
|
|
122
|
+
const { x, y, w, h } = bbox;
|
|
123
|
+
const cx = x + w / 2;
|
|
124
|
+
const cy = y + h / 2;
|
|
125
|
+
const isW = isWestCorner(corner);
|
|
126
|
+
const isN = isNorthCorner(corner);
|
|
127
|
+
const anchorX = isW ? x + w : x;
|
|
128
|
+
const anchorY = isN ? y + h : y;
|
|
129
|
+
|
|
130
|
+
// Raw extents from the dragged corner — center-relative under Alt.
|
|
131
|
+
let nw = mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX);
|
|
132
|
+
let nh = mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY);
|
|
133
|
+
|
|
134
|
+
if (square) {
|
|
135
|
+
const side = Math.max(nw, nh, 1);
|
|
136
|
+
nw = side;
|
|
137
|
+
nh = side;
|
|
138
|
+
} else if (mods.shift && w > 0 && h > 0) {
|
|
139
|
+
const s = Math.max(nw / w, nh / h);
|
|
140
|
+
nw = w * s;
|
|
141
|
+
nh = h * s;
|
|
142
|
+
}
|
|
143
|
+
nw = Math.max(1, nw);
|
|
144
|
+
nh = Math.max(1, nh);
|
|
145
|
+
|
|
146
|
+
let nx: number;
|
|
147
|
+
let ny: number;
|
|
148
|
+
if (mods.alt) {
|
|
149
|
+
nx = cx - nw / 2;
|
|
150
|
+
ny = cy - nh / 2;
|
|
151
|
+
} else {
|
|
152
|
+
// Grow away from the anchor toward the cursor's side (handles flips).
|
|
153
|
+
nx = wx < anchorX ? anchorX - nw : anchorX;
|
|
154
|
+
ny = wy < anchorY ? anchorY - nh : anchorY;
|
|
155
|
+
}
|
|
156
|
+
return { x: nx, y: ny, w: nw, h: nh };
|
|
64
157
|
}
|
|
65
158
|
|
|
66
159
|
/**
|
|
67
160
|
* Per-tool resize math. Given a stroke + the moved corner + the new world
|
|
68
161
|
* coords for that corner, returns a patched stroke. `start` is the stroke at
|
|
69
162
|
* the moment the drag began (used as the source-of-truth for scaling math —
|
|
70
|
-
* avoids drift from rounding successive deltas).
|
|
163
|
+
* avoids drift from rounding successive deltas). `mods` carries the live
|
|
164
|
+
* FigJam resize modifiers (Shift aspect-lock, Alt scale-from-center).
|
|
71
165
|
*/
|
|
72
|
-
function resizeStroke(
|
|
166
|
+
export function resizeStroke(
|
|
73
167
|
start: Stroke,
|
|
74
168
|
corner: Corner,
|
|
75
169
|
wx: number,
|
|
76
|
-
wy: number
|
|
170
|
+
wy: number,
|
|
171
|
+
mods: ResizeMods = NO_MODS
|
|
77
172
|
): Partial<Stroke> | null {
|
|
78
|
-
if (start.tool === 'rect') {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
173
|
+
if (start.tool === 'rect' || start.tool === 'sticky' || start.tool === 'polygon') {
|
|
174
|
+
// Rect / polygon / sticky all resize via their shared x / y / w / h bbox.
|
|
175
|
+
// Text re-wraps inside the foreignObject automatically. Sticky stays 1:1.
|
|
176
|
+
const box = bboxResize(
|
|
177
|
+
{ x: start.x, y: start.y, w: start.w, h: start.h },
|
|
178
|
+
corner,
|
|
179
|
+
wx,
|
|
180
|
+
wy,
|
|
181
|
+
mods,
|
|
182
|
+
start.tool === 'sticky'
|
|
183
|
+
);
|
|
184
|
+
return box as Partial<RectStroke | StickyStroke | PolygonStroke>;
|
|
90
185
|
}
|
|
91
186
|
if (start.tool === 'ellipse') {
|
|
92
|
-
// Treat the four corners as the bbox of the ellipse
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
|
|
102
|
-
const top = corner === 'nw' || corner === 'ne' ? wy : bbox.y;
|
|
103
|
-
const bottom = corner === 'sw' || corner === 'se' ? wy : bbox.y + bbox.h;
|
|
104
|
-
const nx = Math.min(left, right);
|
|
105
|
-
const ny = Math.min(top, bottom);
|
|
106
|
-
const nw = Math.abs(right - left);
|
|
107
|
-
const nh = Math.abs(bottom - top);
|
|
187
|
+
// Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
|
|
188
|
+
const box = bboxResize(
|
|
189
|
+
{ x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 },
|
|
190
|
+
corner,
|
|
191
|
+
wx,
|
|
192
|
+
wy,
|
|
193
|
+
mods,
|
|
194
|
+
false
|
|
195
|
+
);
|
|
108
196
|
return {
|
|
109
|
-
cx:
|
|
110
|
-
cy:
|
|
111
|
-
rx: Math.max(1,
|
|
112
|
-
ry: Math.max(1,
|
|
197
|
+
cx: box.x + box.w / 2,
|
|
198
|
+
cy: box.y + box.h / 2,
|
|
199
|
+
rx: Math.max(1, box.w / 2),
|
|
200
|
+
ry: Math.max(1, box.h / 2),
|
|
113
201
|
} as Partial<EllipseStroke>;
|
|
114
202
|
}
|
|
115
203
|
if (start.tool === 'arrow') {
|
|
116
|
-
if (corner
|
|
117
|
-
|
|
118
|
-
|
|
204
|
+
if (corner !== 'ep1' && corner !== 'ep2') return null;
|
|
205
|
+
const otherX = corner === 'ep1' ? start.x2 : start.x1;
|
|
206
|
+
const otherY = corner === 'ep1' ? start.y2 : start.y1;
|
|
207
|
+
const midX = (start.x1 + start.x2) / 2;
|
|
208
|
+
const midY = (start.y1 + start.y2) / 2;
|
|
209
|
+
// Alt pins the midpoint (both ends mirror); otherwise the far end is fixed.
|
|
210
|
+
const refX = mods.alt ? midX : otherX;
|
|
211
|
+
const refY = mods.alt ? midY : otherY;
|
|
212
|
+
let dragX = wx;
|
|
213
|
+
let dragY = wy;
|
|
214
|
+
if (mods.shift) {
|
|
215
|
+
// Snap the shaft angle (relative to the reference) to 45° increments.
|
|
216
|
+
const dx = wx - refX;
|
|
217
|
+
const dy = wy - refY;
|
|
218
|
+
const dist = Math.hypot(dx, dy);
|
|
219
|
+
const step = Math.PI / 4;
|
|
220
|
+
const ang = Math.round(Math.atan2(dy, dx) / step) * step;
|
|
221
|
+
dragX = refX + Math.cos(ang) * dist;
|
|
222
|
+
dragY = refY + Math.sin(ang) * dist;
|
|
223
|
+
}
|
|
224
|
+
if (mods.alt) {
|
|
225
|
+
const mirrorX = 2 * midX - dragX;
|
|
226
|
+
const mirrorY = 2 * midY - dragY;
|
|
227
|
+
return corner === 'ep1'
|
|
228
|
+
? ({ x1: dragX, y1: dragY, x2: mirrorX, y2: mirrorY } as Partial<ArrowStroke>)
|
|
229
|
+
: ({ x2: dragX, y2: dragY, x1: mirrorX, y1: mirrorY } as Partial<ArrowStroke>);
|
|
230
|
+
}
|
|
231
|
+
return corner === 'ep1'
|
|
232
|
+
? ({ x1: dragX, y1: dragY } as Partial<ArrowStroke>)
|
|
233
|
+
: ({ x2: dragX, y2: dragY } as Partial<ArrowStroke>);
|
|
119
234
|
}
|
|
120
235
|
if (start.tool === 'pen') {
|
|
121
|
-
// Scale all points
|
|
122
|
-
//
|
|
123
|
-
// (single-point pen stroke)
|
|
236
|
+
// Scale all points around an anchor — the opposite corner (normal) or the
|
|
237
|
+
// bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
|
|
238
|
+
// A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
|
|
124
239
|
const bb = strokeBBox(start);
|
|
125
240
|
if (!bb) return null;
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
241
|
+
const cx = bb.x + bb.w / 2;
|
|
242
|
+
const cy = bb.y + bb.h / 2;
|
|
243
|
+
const isW = isWestCorner(corner);
|
|
244
|
+
const isN = isNorthCorner(corner);
|
|
245
|
+
let anchorX: number;
|
|
246
|
+
let anchorY: number;
|
|
247
|
+
let sx: number;
|
|
248
|
+
let sy: number;
|
|
249
|
+
if (mods.alt) {
|
|
250
|
+
anchorX = cx;
|
|
251
|
+
anchorY = cy;
|
|
252
|
+
sx = bb.w === 0 ? 1 : (wx - cx) / (isW ? -bb.w / 2 : bb.w / 2);
|
|
253
|
+
sy = bb.h === 0 ? 1 : (wy - cy) / (isN ? -bb.h / 2 : bb.h / 2);
|
|
254
|
+
} else {
|
|
255
|
+
anchorX = isW ? bb.x + bb.w : bb.x;
|
|
256
|
+
anchorY = isN ? bb.y + bb.h : bb.y;
|
|
257
|
+
const newLeft = isW ? wx : bb.x;
|
|
258
|
+
const newTop = isN ? wy : bb.y;
|
|
259
|
+
const newRight = isW ? bb.x + bb.w : wx;
|
|
260
|
+
const newBottom = isN ? bb.y + bb.h : wy;
|
|
261
|
+
sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
|
|
262
|
+
sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
|
|
263
|
+
}
|
|
264
|
+
if (mods.shift) {
|
|
265
|
+
const s = Math.max(Math.abs(sx), Math.abs(sy));
|
|
266
|
+
sx = (sx < 0 ? -1 : 1) * s;
|
|
267
|
+
sy = (sy < 0 ? -1 : 1) * s;
|
|
268
|
+
}
|
|
134
269
|
const scaled = start.points.map(
|
|
135
270
|
([px, py]) =>
|
|
136
271
|
[anchorX + (px - anchorX) * sx, anchorY + (py - anchorY) * sy] as [number, number]
|
|
@@ -178,6 +313,9 @@ export function AnnotationResizeOverlay({
|
|
|
178
313
|
startStroke: Stroke;
|
|
179
314
|
corner: Corner;
|
|
180
315
|
} | null>(null);
|
|
316
|
+
// Last pointer position (client coords) during a drag — lets a mid-drag
|
|
317
|
+
// Shift/Alt keydown re-apply the resize without waiting for a pointermove.
|
|
318
|
+
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
181
319
|
|
|
182
320
|
const selectedId = annotSel.selectedIds.length === 1 ? (annotSel.selectedIds[0] ?? null) : null;
|
|
183
321
|
const selectedStroke: Stroke | null = useMemo(() => {
|
|
@@ -263,28 +401,48 @@ export function AnnotationResizeOverlay({
|
|
|
263
401
|
/* some browsers reject capture on synthetic events */
|
|
264
402
|
}
|
|
265
403
|
};
|
|
404
|
+
const applyResize = (clientX: number, clientY: number, mods: ResizeMods) => {
|
|
405
|
+
const d = dragRef.current;
|
|
406
|
+
if (!d || !store) return;
|
|
407
|
+
const [wx, wy] = screenToWorld(clientX, clientY);
|
|
408
|
+
const patch = resizeStroke(d.startStroke, d.corner, wx, wy, mods);
|
|
409
|
+
if (patch) store.updateStroke(d.startStroke.id, patch);
|
|
410
|
+
};
|
|
266
411
|
const onMove = (e: PointerEvent) => {
|
|
267
412
|
const d = dragRef.current;
|
|
268
413
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
414
|
+
lastPointRef.current = { x: e.clientX, y: e.clientY };
|
|
415
|
+
applyResize(e.clientX, e.clientY, { shift: e.shiftKey, alt: e.altKey });
|
|
416
|
+
};
|
|
417
|
+
// Holding/releasing Shift or Alt mid-drag re-runs the resize at the last
|
|
418
|
+
// known pointer position so the constraint flips live, FigJam-style.
|
|
419
|
+
const onKey = (e: KeyboardEvent) => {
|
|
420
|
+
if (!dragRef.current) return;
|
|
421
|
+
if (e.key !== 'Shift' && e.key !== 'Alt') return;
|
|
422
|
+
const p = lastPointRef.current;
|
|
423
|
+
if (!p) return;
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey });
|
|
273
426
|
};
|
|
274
427
|
const onUp = (e: PointerEvent) => {
|
|
275
428
|
const d = dragRef.current;
|
|
276
429
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
277
430
|
dragRef.current = null;
|
|
431
|
+
lastPointRef.current = null;
|
|
278
432
|
};
|
|
279
433
|
c.addEventListener('pointerdown', onDown);
|
|
280
434
|
document.addEventListener('pointermove', onMove);
|
|
281
435
|
document.addEventListener('pointerup', onUp);
|
|
282
436
|
document.addEventListener('pointercancel', onUp);
|
|
437
|
+
document.addEventListener('keydown', onKey, true);
|
|
438
|
+
document.addEventListener('keyup', onKey, true);
|
|
283
439
|
return () => {
|
|
284
440
|
c.removeEventListener('pointerdown', onDown);
|
|
285
441
|
document.removeEventListener('pointermove', onMove);
|
|
286
442
|
document.removeEventListener('pointerup', onUp);
|
|
287
443
|
document.removeEventListener('pointercancel', onUp);
|
|
444
|
+
document.removeEventListener('keydown', onKey, true);
|
|
445
|
+
document.removeEventListener('keyup', onKey, true);
|
|
288
446
|
};
|
|
289
447
|
}, [selectedStroke, store, screenToWorld]);
|
|
290
448
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
useState,
|
|
22
22
|
} from 'react';
|
|
23
23
|
|
|
24
|
+
import { TOOL_CURSORS } from './canvas-cursors.ts';
|
|
24
25
|
import type { Tool } from './input-router.tsx';
|
|
25
26
|
|
|
26
27
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -35,19 +36,29 @@ export interface ToolDescriptor {
|
|
|
35
36
|
cursor: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Phase 24 — the six primitives the single Shape tool can draw. Maps onto the
|
|
41
|
+
* stroke model: square/rounded → `rect` (cornerRadius 0 / 8); circle →
|
|
42
|
+
* `ellipse`; diamond/triangle/triangle-down → `polygon`.
|
|
43
|
+
*/
|
|
44
|
+
export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle' | 'triangle-down';
|
|
45
|
+
|
|
46
|
+
// Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
|
|
47
|
+
// with a white outline halo so the glyph reads on any background. The native
|
|
48
|
+
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
|
|
49
|
+
// the tool-palette icons. `move` keeps the system arrow on purpose.
|
|
38
50
|
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
|
|
39
|
-
{ id: 'move', label: 'Move', shortcut: 'V', cursor:
|
|
40
|
-
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor:
|
|
41
|
-
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor:
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
{ id: '
|
|
47
|
-
{ id: '
|
|
48
|
-
{ id: '
|
|
49
|
-
{ id: '
|
|
50
|
-
{ id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: 'cell' },
|
|
51
|
+
{ id: 'move', label: 'Move', shortcut: 'V', cursor: TOOL_CURSORS.move },
|
|
52
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
|
|
53
|
+
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
|
|
54
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
|
|
55
|
+
// Phase 24 — one Shape tool replaces the separate Rect (R) + Ellipse (O)
|
|
56
|
+
// buttons; the primitive is chosen from the palette popover.
|
|
57
|
+
{ id: 'shape', label: 'Shape', shortcut: 'R', cursor: TOOL_CURSORS.shape },
|
|
58
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
|
|
59
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
|
|
60
|
+
{ id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
|
|
61
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
|
|
51
62
|
]);
|
|
52
63
|
|
|
53
64
|
interface ToolContextValue {
|
|
@@ -61,6 +72,9 @@ interface ToolContextValue {
|
|
|
61
72
|
sticky: { tool: Tool | null; locked: boolean };
|
|
62
73
|
toggleSticky: (t: Tool) => void;
|
|
63
74
|
clearSticky: () => void;
|
|
75
|
+
/** Phase 24 — the primitive the Shape tool will draw next. */
|
|
76
|
+
shapeKind: ShapeKind;
|
|
77
|
+
setShapeKind: (k: ShapeKind) => void;
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
const ToolContext = createContext<ToolContextValue | null>(null);
|
|
@@ -98,25 +112,60 @@ export function ToolProvider({
|
|
|
98
112
|
const clearSticky = useCallback(() => {
|
|
99
113
|
setSticky({ tool: null, locked: false });
|
|
100
114
|
}, []);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// inside the
|
|
104
|
-
//
|
|
105
|
-
//
|
|
115
|
+
const [shapeKind, setShapeKind] = useState<ShapeKind>('square');
|
|
116
|
+
|
|
117
|
+
// Cursor sync — applied inside the canvas (this hook runs in the canvas
|
|
118
|
+
// context). The active tool's cursor is set on <body> AND forced across the
|
|
119
|
+
// whole canvas working area via an `!important` rule, so the custom cursor
|
|
120
|
+
// shows EVERYWHERE — including over artboard CONTENT, whose own `cursor:
|
|
121
|
+
// pointer`/`text`/… would otherwise win (Phase 24, the "custom cursors in the
|
|
122
|
+
// whole app" requirement; FigJam behaviour). Chrome that lives OUTSIDE
|
|
123
|
+
// `.dc-world` (tool palette, context toolbar, resize handles) is intentionally
|
|
124
|
+
// NOT matched, so its buttons/handles keep their affordance cursors. The
|
|
125
|
+
// viewport-controller still owns the grab/grabbing swap during space-pan.
|
|
106
126
|
useEffect(() => {
|
|
107
127
|
if (typeof document === 'undefined') return;
|
|
108
128
|
const desc = tools.find((t) => t.id === tool);
|
|
109
129
|
if (!desc) return;
|
|
110
130
|
const prev = document.body.style.cursor;
|
|
111
131
|
document.body.style.cursor = desc.cursor;
|
|
132
|
+
let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
|
|
133
|
+
if (!styleEl) {
|
|
134
|
+
styleEl = document.createElement('style');
|
|
135
|
+
styleEl.id = 'dc-tool-cursor';
|
|
136
|
+
document.head.appendChild(styleEl);
|
|
137
|
+
}
|
|
138
|
+
// Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
|
|
139
|
+
// host, `.dc-world`, every artboard + its content, AND the floating chrome
|
|
140
|
+
// (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
|
|
141
|
+
// canvas / minimap on their own cursors; the brief is "prostě všude". (Mirrors
|
|
142
|
+
// the outer-shell `*` rule so both documents are uniformly covered.)
|
|
143
|
+
styleEl.textContent = `* { cursor: ${desc.cursor} !important; }`;
|
|
144
|
+
// Phase 24 — broadcast the active tool TOKEN to the OUTER app shell (this
|
|
145
|
+
// hook runs in the canvas iframe) so the shell shows the same custom cursor
|
|
146
|
+
// across the whole maude UI (sidebar / top bar). We send the tool *id*, NOT
|
|
147
|
+
// the cursor string: the shell resolves it against its own trusted
|
|
148
|
+
// TOOL_CURSORS copy (resolveToolCursor), so an untrusted synced canvas
|
|
149
|
+
// (DDR-054) can only pick a known, always-visible glyph — it can't inject an
|
|
150
|
+
// invisible/displaced cursor as a clickjacking aid (phase-24 ethical-hacker
|
|
151
|
+
// Finding 2; DDR-067).
|
|
152
|
+
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
153
|
+
try {
|
|
154
|
+
window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
|
|
155
|
+
} catch {
|
|
156
|
+
/* cross-origin parent rejected — shell keeps its default cursor */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
112
159
|
return () => {
|
|
113
160
|
document.body.style.cursor = prev;
|
|
161
|
+
const el = document.getElementById('dc-tool-cursor');
|
|
162
|
+
if (el) el.textContent = '';
|
|
114
163
|
};
|
|
115
164
|
}, [tool, tools]);
|
|
116
165
|
|
|
117
166
|
const value = useMemo<ToolContextValue>(
|
|
118
|
-
() => ({ tool, setTool, tools, sticky, toggleSticky, clearSticky }),
|
|
119
|
-
[tool, setTool, tools, sticky, toggleSticky, clearSticky]
|
|
167
|
+
() => ({ tool, setTool, tools, sticky, toggleSticky, clearSticky, shapeKind, setShapeKind }),
|
|
168
|
+
[tool, setTool, tools, sticky, toggleSticky, clearSticky, shapeKind]
|
|
120
169
|
);
|
|
121
170
|
|
|
122
171
|
return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
|
|
@@ -25,6 +25,16 @@ export type WsData =
|
|
|
25
25
|
remote: string;
|
|
26
26
|
kind: 'collab';
|
|
27
27
|
slug: string;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
// T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
|
|
31
|
+
// ONLY `canvas-hmr` broadcasts; never the privileged inspector feed
|
|
32
|
+
// (comments / ai-activity / git-lifecycle / sync:status / selection) and
|
|
33
|
+
// ignores all inbound messages. Hub-pushed canvas code on the canvas
|
|
34
|
+
// origin can open this, but it leaks nothing and mutates nothing.
|
|
35
|
+
id: string;
|
|
36
|
+
remote: string;
|
|
37
|
+
kind: 'canvas-hmr';
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -77,6 +87,8 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
// Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
|
|
91
|
+
// sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
|
|
80
92
|
function broadcast(payload: unknown) {
|
|
81
93
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
82
94
|
for (const ws of clients) {
|
|
@@ -85,6 +97,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
// HMR feed — `canvas-hmr` reload signals. Safe to deliver to the segregated
|
|
101
|
+
// canvas origin, so both inspector (shell) AND canvas-hmr (canvas iframe)
|
|
102
|
+
// sockets receive it. T2 (9.1-A).
|
|
103
|
+
function broadcastHmr(payload: unknown) {
|
|
104
|
+
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
105
|
+
for (const ws of clients) {
|
|
106
|
+
if (ws.data.kind !== 'inspector' && ws.data.kind !== 'canvas-hmr') continue;
|
|
107
|
+
send(ws, msg);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
// Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
|
|
89
112
|
// state write; fs-watch.ts emits 'fs:*' on every save.
|
|
90
113
|
ctx.bus.on('selected', (sel) => broadcast({ type: 'selected', selected: sel }));
|
|
@@ -106,9 +129,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
106
129
|
// canvas iframes both subscribe.
|
|
107
130
|
ctx.bus.on('git-lifecycle', (payload: unknown) => broadcast({ type: 'git-lifecycle', payload }));
|
|
108
131
|
|
|
132
|
+
// Phase 9 Task 8 — hub-down offline mode. The linked-mode sync runtime emits
|
|
133
|
+
// 'sync:status' on every connection-state change (online / connecting /
|
|
134
|
+
// offline / offline-long, queued-op count, conflict notices). Browser tabs
|
|
135
|
+
// render the offline/synced/escalation banner from this. Solo mode never
|
|
136
|
+
// emits, so this is a no-op for unlinked projects.
|
|
137
|
+
ctx.bus.on('sync:status', (payload: unknown) => broadcast({ type: 'sync:status', payload }));
|
|
138
|
+
|
|
109
139
|
// HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
|
|
110
140
|
// The iframe-side client (in _shell.html) decides reload strategy from `mode`.
|
|
111
|
-
|
|
141
|
+
// Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
|
|
142
|
+
createHmrBroadcaster(ctx, (msg) => broadcastHmr(msg));
|
|
112
143
|
|
|
113
144
|
// Bind a connection to its room. Stored per-socket so close() can find the
|
|
114
145
|
// right room to disconnect from. Multiplexed via ws.data.id.
|
|
@@ -138,6 +169,11 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
138
169
|
await room.connect(conn);
|
|
139
170
|
return;
|
|
140
171
|
}
|
|
172
|
+
if (ws.data.kind === 'canvas-hmr') {
|
|
173
|
+
// HMR-only: join the broadcast set but get NO inspector snapshot.
|
|
174
|
+
clients.add(ws);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
141
177
|
clients.add(ws);
|
|
142
178
|
send(ws, { type: 'snapshot', state: inspect.state });
|
|
143
179
|
},
|
|
@@ -159,6 +195,9 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
159
195
|
clients.delete(ws);
|
|
160
196
|
},
|
|
161
197
|
async message(ws, raw) {
|
|
198
|
+
// HMR-only canvas-origin socket: never accepts inbound messages (the
|
|
199
|
+
// canvas iframe only listens for canvas-hmr; it never sends).
|
|
200
|
+
if (ws.data.kind === 'canvas-hmr') return;
|
|
162
201
|
if (ws.data.kind === 'collab') {
|
|
163
202
|
const binding = collabConns.get(ws.data.id);
|
|
164
203
|
if (!binding) return;
|