Skip to content

Commit 441f841

Browse files
miguel-heygenclaude
andcommitted
feat(studio): address review — group-aware off-canvas indicators + fixes
- Off-canvas indicator suppression now skips every selected element (primary AND marquee group members), not just the primary, so group members no longer render a doubled overlay (group rect + dashed indicator). - Drop selection from the off-canvas layout effect deps; the selected-element filter runs at render time. Avoids re-walking geometry on each selection change. - applyMarqueeSelection now honors STUDIO_INSPECTOR_PANELS_ENABLED. - Restore the stale-selection clear in useDomEditPreviewSync when the selected element no longer resolves after a re-sync. Drag-release stays handled by suppressNextBoxClickRef. - Off-canvas indicator is keyboard-accessible; canvas cursor driven by marquee rect state, not a render-time ref read. - Rename partiallyOutside -> extendsOutsideComp + comment the clip-path hit-test. - Extract OffCanvasIndicators into its own component (DomEditOverlay was already over the 600-LOC cap on this branch; extraction brings it under). - Declare onUpdateKeyframeEase on PropertyPanelProps so this branch typechecks standalone (handler + wiring already here; only the type had leaked upstack). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d2e9e1 commit 441f841

5 files changed

Lines changed: 148 additions & 53 deletions

File tree

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
focusDomEditOverlayElement,
1515
} from "./domEditOverlayGestures";
1616
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
17+
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
1718
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
1819
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
1920
import { GridOverlay } from "./GridOverlay";
@@ -224,9 +225,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
224225
// Off-canvas element indicators — dashed outlines for elements positioned
225226
// outside the composition bounds so users can find them.
226227
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
227-
const [offCanvasRects, setOffCanvasRects] = useState<
228-
{ key: string; left: number; top: number; width: number; height: number }[]
229-
>([]);
228+
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
230229
useEffect(() => {
231230
const iframe = iframeRef.current;
232231
const overlay = overlayRef.current;
@@ -248,19 +247,24 @@ export const DomEditOverlay = memo(function DomEditOverlay({
248247
if (!isElementComputedVisible(item.element)) continue;
249248
const r = toOverlayRect(overlay, iframe, item.element);
250249
if (!r) continue;
251-
const partiallyOutside =
250+
// Any edge crossing the composition border → gray-zone indicator (the
251+
// in-canvas portion is clipped away below, so only the sliver shows).
252+
const extendsOutsideComp =
252253
r.left < compRect.left ||
253254
r.left + r.width > compRect.left + compRect.width ||
254255
r.top < compRect.top ||
255256
r.top + r.height > compRect.top + compRect.height;
256-
if (partiallyOutside) {
257+
if (extendsOutsideComp) {
257258
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
258259
elMap.set(item.key, item.element);
259260
}
260261
}
261262
offCanvasElementsRef.current = elMap;
262263
setOffCanvasRects(rects);
263-
}, [iframeRef, compRect, activeCompositionPath, selection]);
264+
// Positions depend on layout, not selection — the selected-element
265+
// suppression is a render-time filter, so selection/groupSelections stay
266+
// out of the deps to avoid re-walking geometry on each selection change.
267+
}, [iframeRef, compRect, activeCompositionPath]);
264268

265269
const gestures = createDomEditOverlayGestureHandlers({
266270
overlayRef,
@@ -407,7 +411,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
407411
className="absolute inset-0 z-10 pointer-events-auto outline-none"
408412
tabIndex={-1}
409413
aria-label="Composition canvas"
410-
style={marquee.marqueeRef.current?.pastThreshold ? { cursor: "crosshair" } : undefined}
414+
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
415+
style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
411416
onPointerDownCapture={(event) =>
412417
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
413418
}
@@ -554,52 +559,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
554559
}}
555560
/>
556561
))}
557-
{offCanvasRects
558-
.filter((r) => {
559-
const selEl = selection?.element;
560-
return !selEl || offCanvasElementsRef.current.get(r.key) !== selEl;
561-
})
562-
.map((r) => {
563-
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
564-
const cL = Math.max(0, compRect.left - r.left);
565-
const cT = Math.max(0, compRect.top - r.top);
566-
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
567-
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
568-
const hasInside = cL < cR && cT < cB;
569-
const clipOutside = hasInside
570-
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
571-
: undefined;
572-
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
573-
const handleClick = async (e: React.MouseEvent) => {
574-
e.stopPropagation();
575-
const el = offCanvasElementsRef.current.get(r.key);
576-
if (!el) return;
577-
const { resolveDomEditSelection } = await import("./domEditingLayers");
578-
const acp = activeCompositionPathRef.current ?? "index.html";
579-
const sel = await resolveDomEditSelection(el, {
580-
activeCompositionPath: acp,
581-
isMasterView: !acp || acp === "index.html",
582-
skipSourceProbe: true,
583-
});
584-
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
585-
};
586-
return (
587-
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
588-
{/* Dashed layer — clipped to exclude canvas area */}
589-
<div
590-
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
591-
style={clipOutside ? { clipPath: clipOutside } : undefined}
592-
title={`Off-canvas: ${r.key} — click to select`}
593-
onClick={handleClick}
594-
/>
595-
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
596-
<div
597-
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
598-
style={{ clipPath: clipInside }}
599-
/>
600-
</div>
601-
);
602-
})}
562+
<OffCanvasIndicators
563+
rects={offCanvasRects}
564+
elements={offCanvasElementsRef}
565+
compRect={compRect}
566+
selection={selection}
567+
groupSelections={groupSelections}
568+
activeCompositionPathRef={activeCompositionPathRef}
569+
onSelectionChangeRef={onSelectionChangeRef}
570+
/>
603571
{marquee.marqueeRect && (
604572
<div
605573
aria-hidden="true"
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import React from "react";
2+
import { type DomEditSelection } from "./domEditing";
3+
4+
export interface OffCanvasRect {
5+
key: string;
6+
left: number;
7+
top: number;
8+
width: number;
9+
height: number;
10+
}
11+
12+
interface OffCanvasIndicatorsProps {
13+
rects: OffCanvasRect[];
14+
elements: React.MutableRefObject<Map<string, HTMLElement>>;
15+
compRect: { left: number; top: number; width: number; height: number };
16+
selection: DomEditSelection | null;
17+
groupSelections: DomEditSelection[];
18+
activeCompositionPathRef: React.MutableRefObject<string | null>;
19+
onSelectionChangeRef: React.MutableRefObject<
20+
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
21+
>;
22+
}
23+
24+
/**
25+
* Dashed teal indicators for elements whose bounds extend past the composition
26+
* (the "gray zone"). The in-canvas portion is clipped away so only the
27+
* protruding sliver is dashed; the inside portion gets a solid outline.
28+
* Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
29+
*/
30+
export function OffCanvasIndicators({
31+
rects,
32+
elements,
33+
compRect,
34+
selection,
35+
groupSelections,
36+
activeCompositionPathRef,
37+
onSelectionChangeRef,
38+
}: OffCanvasIndicatorsProps): React.ReactElement {
39+
return (
40+
<>
41+
{rects
42+
.filter((r) => {
43+
// Suppress the indicator for any currently-selected element (primary
44+
// OR a marquee group member) — those already render a selection box.
45+
const el = elements.current.get(r.key);
46+
if (!el) return true;
47+
if (selection?.element === el) return false;
48+
return !groupSelections.some((g) => g.element === el);
49+
})
50+
.map((r) => {
51+
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
52+
const cL = Math.max(0, compRect.left - r.left);
53+
const cT = Math.max(0, compRect.top - r.top);
54+
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
55+
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
56+
const hasInside = cL < cR && cT < cB;
57+
const clipOutside = hasInside
58+
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
59+
: undefined;
60+
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
61+
const selectOffCanvas = async () => {
62+
const el = elements.current.get(r.key);
63+
if (!el) return;
64+
const { resolveDomEditSelection } = await import("./domEditingLayers");
65+
const acp = activeCompositionPathRef.current ?? "index.html";
66+
const sel = await resolveDomEditSelection(el, {
67+
activeCompositionPath: acp,
68+
isMasterView: !acp || acp === "index.html",
69+
skipSourceProbe: true,
70+
});
71+
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
72+
};
73+
const handleClick = (e: React.MouseEvent) => {
74+
e.stopPropagation();
75+
void selectOffCanvas();
76+
};
77+
return (
78+
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
79+
{/* Dashed layer — clipped to exclude canvas area.
80+
Note: clip-path is visual only — hit-testing still covers the
81+
full bounding rect, so clicking the in-canvas portion selects
82+
via this handler. That's acceptable: it resolves the same
83+
element the normal canvas path would, just with
84+
skipSourceProbe (the element is already known here). */}
85+
<div
86+
role="button"
87+
tabIndex={0}
88+
aria-label={`Select off-canvas element ${r.key}`}
89+
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
90+
style={clipOutside ? { clipPath: clipOutside } : undefined}
91+
title={`Off-canvas: ${r.key} — click to select`}
92+
onClick={handleClick}
93+
onKeyDown={(e) => {
94+
if (e.key === "Enter" || e.key === " ") {
95+
e.preventDefault();
96+
e.stopPropagation();
97+
void selectOffCanvas();
98+
}
99+
}}
100+
/>
101+
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
102+
<div
103+
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
104+
style={{ clipPath: clipInside }}
105+
/>
106+
</div>
107+
);
108+
})}
109+
</>
110+
);
111+
}

packages/studio/src/components/editor/propertyPanelHelpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface PropertyPanelProps {
6666
value: number | string,
6767
) => void;
6868
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
69+
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
6970
onConvertToKeyframes?: (animationId: string) => void;
7071
onCommitAnimatedProperty?: (
7172
selection: DomEditSelection,

packages/studio/src/hooks/useDomEditPreviewSync.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export function useDomEditPreviewSync({
6868

6969
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
7070
if (!nextElement) {
71+
// The selected element no longer resolves in the (re-synced) document
72+
// — comp/hot reload, activeCompPath swap, or post-save replacement.
73+
// Clear so overlay geometry isn't computed on a stale, detached node.
74+
// (Drag-release-in-gray-zone is handled separately by
75+
// suppressNextBoxClickRef; the dragged element still resolves here.)
76+
applyDomSelection(null, { revealPanel: false });
7177
return;
7278
}
7379

packages/studio/src/hooks/useDomSelection.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,15 @@ export function useDomSelection({
423423
const applyMarqueeSelection = useCallback(
424424
// fallow-ignore-next-line complexity
425425
(selections: DomEditSelection[], additive: boolean) => {
426+
// Honor the inspector-panels kill switch like applyDomSelection does, so
427+
// marquee can't land selections while the inspector UI is suppressed.
428+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
429+
domEditSelectionRef.current = null;
430+
domEditGroupSelectionsRef.current = [];
431+
setDomEditSelection(null);
432+
setDomEditGroupSelections([]);
433+
return;
434+
}
426435
if (selections.length === 0) {
427436
if (!additive) applyDomSelection(null, { revealPanel: false });
428437
return;

0 commit comments

Comments
 (0)