@1agh/maude 0.40.0 → 0.41.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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* @scope apps/studio/comments-overlay.tsx
|
|
4
4
|
* @purpose Renders DS-styled comment pins (Phase 6 Task 2), the in-place
|
|
5
5
|
* composer bubble (Task 3), and the thread popover (Task 4)
|
|
6
|
-
* inside the canvas iframe. Sibling to `annotations-layer
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* inside the canvas iframe. Sibling to `annotations-layer`, but
|
|
7
|
+
* NOT portaled into `.dc-world` — renders as a screen-coord
|
|
8
|
+
* `position: fixed` layer instead (DDR-034; see "Pin position
|
|
9
|
+
* math" below).
|
|
9
10
|
*
|
|
10
11
|
* Data flow (Phase 6 Task 2 — pins only; composer + thread land in Task 3/4):
|
|
11
12
|
* 1. Shell (`client/app.jsx`) pushes `{ dgn: 'comments-set', comments }`
|
|
@@ -23,9 +24,23 @@
|
|
|
23
24
|
* gain a `comments-filter` channel in Task 6; until then the overlay always
|
|
24
25
|
* hides resolved pins. Plan-aligned.
|
|
25
26
|
*
|
|
26
|
-
* Pin position math — see `
|
|
27
|
-
*
|
|
28
|
-
* plane
|
|
27
|
+
* Pin position math — see `resolveCommentTarget` below. Screen coords come
|
|
28
|
+
* straight from `getBoundingClientRect()` on the live target; CSS zoom on the
|
|
29
|
+
* world plane is already baked into that rect, so no zoom math is needed here.
|
|
30
|
+
*
|
|
31
|
+
* Target resolution + orphan cleanup — a canvas rewrite (`/design:edit`
|
|
32
|
+
* regenerating JSX) renumbers `data-cd-id` (DDR-019's documented AST-position
|
|
33
|
+
* trade-off), which can silently reanchor a comment to the wrong element or to
|
|
34
|
+
* nothing. `resolveCommentTarget` tries the stored selector first, falls back
|
|
35
|
+
* to a structural match via `resolveByDomPath` (dom-selection.ts) when the
|
|
36
|
+
* direct hit is missing or looks like the wrong element (tag mismatch), and —
|
|
37
|
+
* per DDR-034's deferred future-work item — `CommentPin` auto-deletes a
|
|
38
|
+
* comment whose target stays unresolvable past a short grace window.
|
|
39
|
+
*
|
|
40
|
+
* Popup placement — `CommentComposer` / `CommentThread` pick a side
|
|
41
|
+
* (left/right, above/below the anchor) that actually fits the viewport via
|
|
42
|
+
* `placeNearPoint`, instead of always growing down-right (which used to clip
|
|
43
|
+
* off-screen near the canvas edge).
|
|
29
44
|
*
|
|
30
45
|
* The legacy vanilla-JS `#dgn-pin-layer` injected by `inspect.ts` is hidden
|
|
31
46
|
* on mount to avoid double-pins inside TSX canvases. The legacy layer still
|
|
@@ -34,6 +49,7 @@
|
|
|
34
49
|
|
|
35
50
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
36
51
|
|
|
52
|
+
import { resolveByDomPath } from './dom-selection.ts';
|
|
37
53
|
import { useCollab } from './use-collab.tsx';
|
|
38
54
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
39
55
|
|
|
@@ -92,8 +108,15 @@ export interface OverlayComment {
|
|
|
92
108
|
author?: string;
|
|
93
109
|
thread?: OverlayReply[];
|
|
94
110
|
mentions?: string[];
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
/** Target's tag/classes/ancestor-path at creation time — unused for
|
|
112
|
+
* rendering, but the structural-fallback ingredients `resolveCommentTarget`
|
|
113
|
+
* reaches for when the stored `selector` no longer identifies the right
|
|
114
|
+
* element (see file header). Absent on legacy comments. */
|
|
115
|
+
tag?: string;
|
|
116
|
+
classes?: string;
|
|
117
|
+
dom_path?: string[];
|
|
118
|
+
// html_excerpt unused at overlay layer; kept off the type to keep the
|
|
119
|
+
// surface tight.
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -141,32 +164,92 @@ function deriveFile(): string | null {
|
|
|
141
164
|
// halo chrome at z-index 5) instead of being portaled into `.dc-world` where
|
|
142
165
|
// it would lose the stacking battle.
|
|
143
166
|
|
|
144
|
-
|
|
145
|
-
selector: string
|
|
146
|
-
index?: number
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
export interface TargetRef {
|
|
168
|
+
selector: string;
|
|
169
|
+
index?: number;
|
|
170
|
+
tag?: string;
|
|
171
|
+
classes?: string;
|
|
172
|
+
dom_path?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a comment (or in-progress selection)'s live target element. Tries
|
|
177
|
+
* the stored `data-cd-id` selector first — cheap, correct in the common case.
|
|
178
|
+
* A tag mismatch against what was captured at creation time is the tell that
|
|
179
|
+
* `data-cd-id` renumbered onto an unrelated element (DDR-019); when that
|
|
180
|
+
* happens, or the selector matches nothing at all, fall back to a structural
|
|
181
|
+
* match via `resolveByDomPath`.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveCommentTarget(target: TargetRef): HTMLElement | null {
|
|
184
|
+
if (!target.selector) return null;
|
|
154
185
|
let el: HTMLElement | null = null;
|
|
155
186
|
try {
|
|
156
187
|
// index disambiguates a component repeated within one artboard (querySelector
|
|
157
188
|
// alone would always grab the first match). Absent/0 → first.
|
|
158
|
-
const all = document.querySelectorAll(selector);
|
|
159
|
-
const i = index && index > 0 && index < all.length ? index : 0;
|
|
189
|
+
const all = document.querySelectorAll(target.selector);
|
|
190
|
+
const i = target.index && target.index > 0 && target.index < all.length ? target.index : 0;
|
|
160
191
|
el = (all[i] ?? all[0] ?? null) as HTMLElement | null;
|
|
161
192
|
} catch {
|
|
162
|
-
|
|
193
|
+
el = null;
|
|
194
|
+
}
|
|
195
|
+
if (el && target.tag && el.tagName.toLowerCase() !== target.tag.toLowerCase()) {
|
|
196
|
+
el = null;
|
|
163
197
|
}
|
|
198
|
+
if (!el && target.dom_path?.length) {
|
|
199
|
+
const artboardId = target.selector.match(/data-dc-screen="([^"]+)"/)?.[1];
|
|
200
|
+
el = resolveByDomPath(document, {
|
|
201
|
+
artboardId,
|
|
202
|
+
tag: target.tag,
|
|
203
|
+
classes: target.classes,
|
|
204
|
+
dom_path: target.dom_path,
|
|
205
|
+
}) as HTMLElement | null;
|
|
206
|
+
}
|
|
207
|
+
return el;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function screenRectFor(target: TargetRef): {
|
|
211
|
+
x: number;
|
|
212
|
+
y: number;
|
|
213
|
+
w: number;
|
|
214
|
+
h: number;
|
|
215
|
+
} | null {
|
|
216
|
+
const el = resolveCommentTarget(target);
|
|
164
217
|
if (!el?.isConnected) return null;
|
|
165
218
|
const r = el.getBoundingClientRect();
|
|
166
219
|
if (r.width === 0 && r.height === 0) return null;
|
|
167
220
|
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
168
221
|
}
|
|
169
222
|
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// Edge-aware popup placement — picks a side (left/right, above/below the
|
|
225
|
+
// anchor point) that actually fits the viewport, falling back to an inward
|
|
226
|
+
// clamp for the rare case where no side fully fits (tiny viewport). Mirrors
|
|
227
|
+
// the pattern context-menu.tsx already uses for the same problem, but flips
|
|
228
|
+
// axes instead of only clamping, per the explicit ask: always open toward
|
|
229
|
+
// whichever side has room, not just "shifted back into view".
|
|
230
|
+
export function placeNearPoint(
|
|
231
|
+
point: { x: number; y: number },
|
|
232
|
+
size: { w: number; h: number }
|
|
233
|
+
): { x: number; y: number } {
|
|
234
|
+
const margin = 8;
|
|
235
|
+
const vw = typeof window !== 'undefined' ? window.innerWidth : point.x + size.w;
|
|
236
|
+
const vh = typeof window !== 'undefined' ? window.innerHeight : point.y + size.h;
|
|
237
|
+
|
|
238
|
+
let x = point.x;
|
|
239
|
+
if (x + size.w + margin > vw) {
|
|
240
|
+
const flipped = point.x - size.w;
|
|
241
|
+
x = flipped >= margin ? flipped : Math.max(margin, vw - size.w - margin);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let y = point.y;
|
|
245
|
+
if (y + size.h + margin > vh) {
|
|
246
|
+
const flipped = point.y - size.h;
|
|
247
|
+
y = flipped >= margin ? flipped : Math.max(margin, vh - size.h - margin);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { x, y };
|
|
251
|
+
}
|
|
252
|
+
|
|
170
253
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
254
|
// Public component — mounted from canvas-shell.tsx alongside ToolPalette /
|
|
172
255
|
// AnnotationsLayer / SnapGuideOverlay.
|
|
@@ -453,6 +536,7 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
453
536
|
sequence={n}
|
|
454
537
|
focused={focusedId === c.id}
|
|
455
538
|
onClick={handlePinClick}
|
|
539
|
+
onOrphaned={handleDelete}
|
|
456
540
|
/>
|
|
457
541
|
);
|
|
458
542
|
})}
|
|
@@ -736,19 +820,28 @@ function MentionAwareTextarea({
|
|
|
736
820
|
// reflow, font load). Falls back to the stored `bounds` when the target is
|
|
737
821
|
// gone from the DOM.
|
|
738
822
|
|
|
823
|
+
// How long a pin is allowed to stay unresolvable (no live target AND no
|
|
824
|
+
// structural-fallback match) before its comment is presumed orphaned and
|
|
825
|
+
// auto-deleted. Long enough to ride out a canvas HMR remount; short enough
|
|
826
|
+
// that a genuinely deleted element's comment doesn't linger.
|
|
827
|
+
const ORPHAN_GRACE_MS = 3000;
|
|
828
|
+
|
|
739
829
|
function CommentPin({
|
|
740
830
|
comment,
|
|
741
831
|
sequence,
|
|
742
832
|
focused,
|
|
743
833
|
onClick,
|
|
834
|
+
onOrphaned,
|
|
744
835
|
}: {
|
|
745
836
|
comment: OverlayComment;
|
|
746
837
|
sequence: number;
|
|
747
838
|
focused: boolean;
|
|
748
839
|
onClick: (id: string) => void;
|
|
840
|
+
onOrphaned: (id: string) => void;
|
|
749
841
|
}) {
|
|
750
842
|
const ref = useRef<HTMLButtonElement | null>(null);
|
|
751
843
|
const rafRef = useRef<number | null>(null);
|
|
844
|
+
const unresolvedSinceRef = useRef<number | null>(null);
|
|
752
845
|
|
|
753
846
|
useEffect(() => {
|
|
754
847
|
const tick = () => {
|
|
@@ -756,17 +849,27 @@ function CommentPin({
|
|
|
756
849
|
const pin = ref.current;
|
|
757
850
|
if (!pin) return;
|
|
758
851
|
|
|
759
|
-
// Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
852
|
+
// Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
|
|
853
|
+
// (resolveCommentTarget tries the stored selector, then a structural
|
|
854
|
+
// fallback). Falls back to stored bounds (a screen-coord capture at
|
|
855
|
+
// create time) when neither resolves — better than vanishing entirely.
|
|
856
|
+
let pos = screenRectFor(comment);
|
|
857
|
+
if (pos) {
|
|
858
|
+
unresolvedSinceRef.current = null;
|
|
859
|
+
} else {
|
|
860
|
+
if (unresolvedSinceRef.current == null) {
|
|
861
|
+
unresolvedSinceRef.current = Date.now();
|
|
862
|
+
} else if (Date.now() - unresolvedSinceRef.current > ORPHAN_GRACE_MS) {
|
|
863
|
+
onOrphaned(comment.id);
|
|
864
|
+
}
|
|
865
|
+
if (comment.bounds) {
|
|
866
|
+
pos = {
|
|
867
|
+
x: comment.bounds.x,
|
|
868
|
+
y: comment.bounds.y,
|
|
869
|
+
w: comment.bounds.w,
|
|
870
|
+
h: comment.bounds.h,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
770
873
|
}
|
|
771
874
|
if (!pos) {
|
|
772
875
|
pin.style.display = 'none';
|
|
@@ -786,7 +889,7 @@ function CommentPin({
|
|
|
786
889
|
return () => {
|
|
787
890
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
788
891
|
};
|
|
789
|
-
}, [comment
|
|
892
|
+
}, [comment, onOrphaned]);
|
|
790
893
|
|
|
791
894
|
const author = comment.author?.trim() || 'unknown';
|
|
792
895
|
const label = `Comment ${sequence} by ${author}`;
|
|
@@ -835,15 +938,17 @@ function CommentComposer({
|
|
|
835
938
|
|
|
836
939
|
// Live anchor — composer tracks the target element via rAF so pan/zoom
|
|
837
940
|
// while typing keeps the card glued to its anchor. Writes directly to the
|
|
838
|
-
// DOM so we don't re-render every frame.
|
|
941
|
+
// DOM so we don't re-render every frame. `placeNearPoint` picks whichever
|
|
942
|
+
// side actually fits the viewport instead of always growing down-right.
|
|
839
943
|
useEffect(() => {
|
|
840
944
|
const tick = () => {
|
|
841
945
|
rafRef.current = null;
|
|
842
946
|
const node = cardRef.current;
|
|
843
947
|
if (!node) return;
|
|
844
948
|
const anchor = computeAnchor(state);
|
|
845
|
-
|
|
846
|
-
node.style.
|
|
949
|
+
const placed = placeNearPoint(anchor, { w: node.offsetWidth, h: node.offsetHeight });
|
|
950
|
+
node.style.left = `${Math.round(placed.x)}px`;
|
|
951
|
+
node.style.top = `${Math.round(placed.y)}px`;
|
|
847
952
|
rafRef.current = requestAnimationFrame(tick);
|
|
848
953
|
};
|
|
849
954
|
rafRef.current = requestAnimationFrame(tick);
|
|
@@ -960,15 +1065,17 @@ function CommentThread({
|
|
|
960
1065
|
|
|
961
1066
|
// Live anchor — popover tracks the pin via rAF so it stays glued to its
|
|
962
1067
|
// target through pan / zoom (FigJam parity). Writing to the dialog style
|
|
963
|
-
// directly avoids re-rendering every frame.
|
|
1068
|
+
// directly avoids re-rendering every frame. `placeNearPoint` picks whichever
|
|
1069
|
+
// side actually fits the viewport instead of always growing down-right.
|
|
964
1070
|
useEffect(() => {
|
|
965
1071
|
const tick = () => {
|
|
966
1072
|
rafRef.current = null;
|
|
967
1073
|
const node = dialogRef.current;
|
|
968
1074
|
if (!node) return;
|
|
969
1075
|
const anchor = computeThreadAnchor(comment);
|
|
970
|
-
|
|
971
|
-
node.style.
|
|
1076
|
+
const placed = placeNearPoint(anchor, { w: node.offsetWidth, h: node.offsetHeight });
|
|
1077
|
+
node.style.left = `${Math.round(placed.x)}px`;
|
|
1078
|
+
node.style.top = `${Math.round(placed.y)}px`;
|
|
972
1079
|
rafRef.current = requestAnimationFrame(tick);
|
|
973
1080
|
};
|
|
974
1081
|
rafRef.current = requestAnimationFrame(tick);
|
|
@@ -1180,7 +1287,7 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
|
|
|
1180
1287
|
// Resolve target's live screen rect; popover drops below the pin with small
|
|
1181
1288
|
// breathing room. Stored bounds (capture-time screen coords) are the
|
|
1182
1289
|
// last-resort fallback for orphaned pins.
|
|
1183
|
-
const rect = comment.selector ? screenRectFor(comment
|
|
1290
|
+
const rect = comment.selector ? screenRectFor(comment) : null;
|
|
1184
1291
|
if (rect) {
|
|
1185
1292
|
// Pin sits at (rect.right - 12, rect.top - 12). Place popover at the same
|
|
1186
1293
|
// x for visual continuity, 16px below the top so it clears the pin.
|
|
@@ -1205,7 +1312,7 @@ function computeAnchor(state: ComposerState): { x: number; y: number } {
|
|
|
1205
1312
|
return { x: state.clientX, y: state.clientY + 8 };
|
|
1206
1313
|
}
|
|
1207
1314
|
if (state.selection.selector) {
|
|
1208
|
-
const rect = screenRectFor(state.selection
|
|
1315
|
+
const rect = screenRectFor(state.selection);
|
|
1209
1316
|
if (rect) {
|
|
1210
1317
|
return { x: rect.x, y: rect.y + rect.h + 8 };
|
|
1211
1318
|
}
|
|
@@ -60,8 +60,14 @@ export interface MenuItem {
|
|
|
60
60
|
* chrome`). When present the row opens a submenu on hover / ArrowRight /
|
|
61
61
|
* click and `onSelect` on THIS item is not invoked — only the chosen leaf's
|
|
62
62
|
* `onSelect` fires. A disabled leaf carries `disabledHint` for its title.
|
|
63
|
+
*
|
|
64
|
+
* A FUNCTION form is resolved lazily by `MenuItemRow` at render time (not
|
|
65
|
+
* memoized at registry-build time) — for a per-click computed flyout like
|
|
66
|
+
* "Select layer" (every stamped element stacked under the cursor, which
|
|
67
|
+
* depends on `target.clientX/clientY` and can't be known when the static
|
|
68
|
+
* registry is built).
|
|
63
69
|
*/
|
|
64
|
-
submenu?: MenuItem[];
|
|
70
|
+
submenu?: MenuItem[] | ((target: ContextTarget) => MenuItem[]);
|
|
65
71
|
/** Hover/title hint shown when the item is `disabled` (a11y affordance). */
|
|
66
72
|
disabledHint?: string;
|
|
67
73
|
onSelect: (target: ContextTarget) => void;
|
|
@@ -209,8 +215,8 @@ const MENU_CSS = `
|
|
|
209
215
|
}
|
|
210
216
|
.dc-context-menu .dc-menu-item.is-destructive:hover,
|
|
211
217
|
.dc-context-menu .dc-menu-item.is-destructive:focus-visible {
|
|
212
|
-
background:
|
|
213
|
-
color:
|
|
218
|
+
background: #c0392b;
|
|
219
|
+
color: #fff;
|
|
214
220
|
}
|
|
215
221
|
.dc-context-menu .dc-menu-shortcut {
|
|
216
222
|
color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55));
|
|
@@ -402,7 +408,11 @@ function MenuItemRow({
|
|
|
402
408
|
const flyoutRef = useRef<HTMLDivElement>(null);
|
|
403
409
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
404
410
|
|
|
405
|
-
|
|
411
|
+
// Resolve a function-form submenu against THIS click's target (e.g.
|
|
412
|
+
// "Select layer" computing candidates from `target.clientX/clientY`).
|
|
413
|
+
const submenuItems = typeof item.submenu === 'function' ? item.submenu(target) : item.submenu;
|
|
414
|
+
|
|
415
|
+
if (!submenuItems || submenuItems.length === 0) {
|
|
406
416
|
return (
|
|
407
417
|
<button
|
|
408
418
|
type="button"
|
|
@@ -482,7 +492,7 @@ function MenuItemRow({
|
|
|
482
492
|
onMouseEnter={cancelClose}
|
|
483
493
|
onMouseLeave={scheduleClose}
|
|
484
494
|
>
|
|
485
|
-
{
|
|
495
|
+
{submenuItems.map((sub) => (
|
|
486
496
|
<button
|
|
487
497
|
key={sub.id}
|
|
488
498
|
type="button"
|