From 7a736eb1f89e981e381c2e87af552ff2f2c7ec8b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 2 May 2026 01:57:34 -0700 Subject: [PATCH] feat: add studio group repositioning --- packages/studio/src/App.tsx | 120 ++++++- .../components/editor/DomEditOverlay.test.ts | 24 ++ .../src/components/editor/DomEditOverlay.tsx | 317 +++++++++++++++++- 3 files changed, 445 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad51200be9..1bea84d92c 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -70,7 +70,10 @@ import { importedFontFaceCss, type ImportedFontAsset, } from "./components/editor/fontAssets"; -import { DomEditOverlay } from "./components/editor/DomEditOverlay"; +import { + DomEditOverlay, + type DomEditGroupPathOffsetCommit, +} from "./components/editor/DomEditOverlay"; import { buildDefaultDomEditTextField, buildDomEditStylePatchOperation, @@ -359,6 +362,24 @@ function domEditSelectionsTargetSame( return getDomEditTargetKey(a) === getDomEditTargetKey(b); } +function domEditSelectionInGroup( + group: DomEditSelection[], + selection: DomEditSelection | null, +): boolean { + if (!selection) return false; + return group.some((entry) => domEditSelectionsTargetSame(entry, selection)); +} + +function toggleDomEditGroupSelection( + group: DomEditSelection[], + selection: DomEditSelection, +): DomEditSelection[] { + if (domEditSelectionInGroup(group, selection)) { + return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection)); + } + return [...group, selection]; +} + // ── Ask Agent Modal ── function AskAgentModal({ @@ -540,6 +561,7 @@ export function StudioApp() { const [rightCollapsed, setRightCollapsed] = useState(true); const [rightPanelTab, setRightPanelTab] = useState("renders"); const [domEditSelection, setDomEditSelection] = useState(null); + const [domEditGroupSelections, setDomEditGroupSelections] = useState([]); const [domEditHoverSelection, setDomEditHoverSelection] = useState(null); const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState(); const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false); @@ -996,6 +1018,7 @@ export function StudioApp() { const consoleErrorsRef = useRef([]); const copiedAgentTimerRef = useRef | null>(null); const domEditSelectionRef = useRef(domEditSelection); + const domEditGroupSelectionsRef = useRef(domEditGroupSelections); const domEditHoverSelectionRef = useRef(domEditHoverSelection); const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); @@ -1056,6 +1079,7 @@ export function StudioApp() { }); projectIdRef.current = projectId; domEditSelectionRef.current = domEditSelection; + domEditGroupSelectionsRef.current = domEditGroupSelections; domEditHoverSelectionRef.current = domEditHoverSelection; // eslint-disable-next-line no-restricted-syntax @@ -1530,16 +1554,51 @@ export function StudioApp() { ); const applyDomSelection = useCallback( - (selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => { - setDomEditSelection(selection); + ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean; additive?: boolean }, + ) => { setAgentPromptTagSnippet(undefined); setCopiedAgentPrompt(false); - if (selection) { + if (!selection) { + domEditSelectionRef.current = null; + domEditGroupSelectionsRef.current = []; + setDomEditSelection(null); + setDomEditGroupSelections([]); + setSelectedTimelineElementId(null); + return; + } + + const currentGroup = domEditGroupSelectionsRef.current; + const isAdditiveSelection = Boolean(options?.additive); + const wasInGroup = domEditSelectionInGroup(currentGroup, selection); + const nextGroup = isAdditiveSelection + ? toggleDomEditGroupSelection(currentGroup, selection) + : [selection]; + const currentSelection = domEditSelectionRef.current; + const nextSelection = + isAdditiveSelection && wasInGroup + ? domEditSelectionsTargetSame(currentSelection, selection) + ? (nextGroup[0] ?? null) + : domEditSelectionInGroup(nextGroup, currentSelection) + ? currentSelection + : (nextGroup[0] ?? null) + : selection; + + domEditSelectionRef.current = nextSelection; + domEditGroupSelectionsRef.current = nextGroup; + setDomEditSelection(nextSelection); + setDomEditGroupSelections(nextGroup); + + if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); setRightPanelTab("design"); } - const nextSelectedTimelineId = findMatchingTimelineElementId(selection, timelineElements); + const nextSelectedTimelineId = findMatchingTimelineElementId( + nextSelection, + timelineElements, + ); setSelectedTimelineElementId(nextSelectedTimelineId); return; } @@ -1995,6 +2054,29 @@ export function StudioApp() { [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], ); + const handleDomGroupPathOffsetCommit = useCallback( + (updates: DomEditGroupPathOffsetCommit[]) => { + if (updates.length === 0) return; + const coalesceKey = updates + .map((update) => getDomEditTargetKey(update.selection)) + .sort() + .join(":"); + commitStudioManualEditManifestOptimistically( + (manifest) => + updates.reduce( + (nextManifest, update) => + upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next), + manifest, + ), + { + label: `Move ${updates.length} layers`, + coalesceKey: `group-path-offset:${coalesceKey}`, + }, + ); + }, + [commitStudioManualEditManifestOptimistically], + ); + const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { commitStudioManualEditManifestOptimistically( @@ -2331,12 +2413,12 @@ export function StudioApp() { preferClipAncestor: options?.preferClipAncestor ?? true, }); if (!nextSelection) { - applyDomSelection(null, { revealPanel: false }); + if (!e.shiftKey) applyDomSelection(null, { revealPanel: false }); return; } e.preventDefault(); e.stopPropagation(); - applyDomSelection(nextSelection); + applyDomSelection(nextSelection, { additive: e.shiftKey }); }, [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint], ); @@ -2374,10 +2456,22 @@ export function StudioApp() { // eslint-disable-next-line no-restricted-syntax useEffect(() => { if (!domEditHoverSelection) return; - if (!domEditSelection) return; - if (!domEditSelectionsTargetSame(domEditHoverSelection, domEditSelection)) return; + const hoverMatchesSelection = domEditSelectionsTargetSame( + domEditHoverSelection, + domEditSelection, + ); + const hoverMatchesGroup = domEditSelectionInGroup( + domEditGroupSelections, + domEditHoverSelection, + ); + if (!hoverMatchesSelection && !hoverMatchesGroup) return; updateDomEditHoverSelection(null); - }, [domEditHoverSelection, domEditSelection, updateDomEditHoverSelection]); + }, [ + domEditGroupSelections, + domEditHoverSelection, + domEditSelection, + updateDomEditHoverSelection, + ]); // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -3155,6 +3249,9 @@ export function StudioApp() { selection={ !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null } + groupSelections={ + !rightCollapsed && rightPanelTab === "design" ? domEditGroupSelections : [] + } allowCanvasMovement onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} @@ -3162,6 +3259,7 @@ export function StudioApp() { onSelectionChange={applyDomSelection} onBlockedMove={handleBlockedDomMove} onPathOffsetCommit={handleDomPathOffsetCommit} + onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit} onBoxSizeCommit={handleDomBoxSizeCommit} onRotationCommit={handleDomRotationCommit} /> @@ -3238,7 +3336,7 @@ export function StudioApp() { 1 ? null : domEditSelection} copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onSetStyle={handleDomStyleCommit} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 98e7d15b42..fa8d30a366 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -3,6 +3,7 @@ import { focusDomEditOverlayElement, hasDomEditRotationChanged, resolveDomEditCoordinateScale, + resolveDomEditGroupOverlayRect, resolveDomEditPathOffsetGesture, resolveDomEditResizeGesture, resolveDomEditRotationGesture, @@ -67,6 +68,29 @@ describe("resolveDomEditPathOffsetGesture", () => { }); }); +describe("resolveDomEditGroupOverlayRect", () => { + it("returns a bounding box that contains every selected element", () => { + expect( + resolveDomEditGroupOverlayRect([ + { left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 }, + { left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 }, + { left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 }, + ]), + ).toEqual({ + left: 20, + top: 10, + width: 160, + height: 120, + editScaleX: 1, + editScaleY: 1, + }); + }); + + it("returns null for an empty group", () => { + expect(resolveDomEditGroupOverlayRect([])).toBeNull(); + }); +}); + describe("resolveDomEditResizeGesture", () => { it("resizes width and height independently by default", () => { expect( diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index d8361fb653..bbeaf97240 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -34,9 +34,21 @@ interface OverlayRect { editScaleY: number; } +interface GroupOverlayItem { + key: string; + selection: DomEditSelection; + rect: OverlayRect; +} + +export interface DomEditGroupPathOffsetCommit { + selection: DomEditSelection; + next: { x: number; y: number }; +} + interface DomEditOverlayProps { iframeRef: RefObject; selection: DomEditSelection | null; + groupSelections?: DomEditSelection[]; hoverSelection: DomEditSelection | null; allowCanvasMovement?: boolean; onCanvasMouseDown: ( @@ -48,12 +60,16 @@ interface DomEditOverlayProps { options?: { preferClipAncestor?: boolean }, ) => DomEditSelection | null; onCanvasPointerLeave: () => void; - onSelectionChange: (selection: DomEditSelection, options?: { revealPanel?: boolean }) => void; + onSelectionChange: ( + selection: DomEditSelection, + options?: { revealPanel?: boolean; additive?: boolean }, + ) => void; onBlockedMove: (selection: DomEditSelection) => void; onPathOffsetCommit: ( selection: DomEditSelection, next: { x: number; y: number }, ) => Promise | void; + onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise | void; onBoxSizeCommit: ( selection: DomEditSelection, next: { width: number; height: number }, @@ -167,6 +183,41 @@ function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean { ); } +function groupOverlayItemsEqual(a: GroupOverlayItem[], b: GroupOverlayItem[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every((item, index) => { + const other = b[index]; + return Boolean(other && item.key === other.key && rectsEqual(item.rect, other.rect)); + }); +} + +export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRect | null { + const first = rects[0]; + if (!first) return null; + + let left = first.left; + let top = first.top; + let right = first.left + first.width; + let bottom = first.top + first.height; + + for (const rect of rects.slice(1)) { + left = Math.min(left, rect.left); + top = Math.min(top, rect.top); + right = Math.max(right, rect.left + rect.width); + bottom = Math.max(bottom, rect.top + rect.height); + } + + return { + left, + top, + width: right - left, + height: bottom - top, + editScaleX: 1, + editScaleY: 1, + }; +} + function selectionCacheKey( selection: Pick, ): string { @@ -304,6 +355,24 @@ interface GestureState { manualEditDragToken?: string; } +interface GroupGestureMember { + key: string; + selection: DomEditSelection; + initialPathOffset: StudioPathOffsetSnapshot; + actualOffsetX: number; + actualOffsetY: number; + editScaleX: number; + editScaleY: number; + manualEditDragToken?: string; +} + +interface GroupGestureState { + startX: number; + startY: number; + originItems: GroupOverlayItem[]; + members: GroupGestureMember[]; +} + interface BlockedMoveState { pointerId: number; startX: number; @@ -318,6 +387,7 @@ type ResolvedElementRef = { export const DomEditOverlay = memo(function DomEditOverlay({ iframeRef, selection, + groupSelections = [], hoverSelection, allowCanvasMovement = true, onCanvasMouseDown, @@ -326,6 +396,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onSelectionChange, onBlockedMove, onPathOffsetCommit, + onGroupPathOffsetCommit, onBoxSizeCommit, onRotationCommit, }: DomEditOverlayProps) { @@ -333,24 +404,33 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const boxRef = useRef(null); const [overlayRect, setOverlayRect] = useState(null); const [hoverRect, setHoverRect] = useState(null); + const [groupOverlayItems, setGroupOverlayItems] = useState([]); const gestureRef = useRef(null); + const groupGestureRef = useRef(null); const blockedMoveRef = useRef(null); const suppressNextBoxClickRef = useRef(false); const suppressNextOverlayMouseDownRef = useRef(false); const rafPausedRef = useRef(false); const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); + const resolvedGroupElementRef = useRef>(new Map()); const selectionRef = useRef(selection); selectionRef.current = selection; + const groupSelectionsRef = useRef(groupSelections); + groupSelectionsRef.current = groupSelections; const hoverSelectionRef = useRef(hoverSelection); hoverSelectionRef.current = hoverSelection; const overlayRectRef = useRef(overlayRect); overlayRectRef.current = overlayRect; const hoverRectRef = useRef(hoverRect); hoverRectRef.current = hoverRect; + const groupOverlayItemsRef = useRef(groupOverlayItems); + groupOverlayItemsRef.current = groupOverlayItems; const onPathOffsetCommitRef = useRef(onPathOffsetCommit); onPathOffsetCommitRef.current = onPathOffsetCommit; + const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit); + onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit; const onBoxSizeCommitRef = useRef(onBoxSizeCommit); onBoxSizeCommitRef.current = onBoxSizeCommit; const onRotationCommitRef = useRef(onRotationCommit); @@ -386,6 +466,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({ hoverRectRef.current = next; setHoverRect(next); }; + const clearGroupOverlayItems = () => { + if (groupOverlayItemsRef.current.length === 0) return; + groupOverlayItemsRef.current = []; + setGroupOverlayItems([]); + }; + const setNextGroupOverlayItems = (next: GroupOverlayItem[]) => { + if (groupOverlayItemsEqual(groupOverlayItemsRef.current, next)) return; + groupOverlayItemsRef.current = next; + setGroupOverlayItems(next); + }; const resolveElement = (doc: Document, sel: DomEditSelection, cacheRef: ResolvedElementRef) => { const key = selectionCacheKey(sel); const cached = cacheRef.current; @@ -401,6 +491,19 @@ export const DomEditOverlay = memo(function DomEditOverlay({ cacheRef.current = next ? { key, element: next } : null; return next; }; + const resolveGroupElement = (doc: Document, sel: DomEditSelection) => { + const key = selectionCacheKey(sel); + const cached = resolvedGroupElementRef.current.get(key); + if (cached?.isConnected && cached.ownerDocument === doc) return cached; + + const next = findElementForSelection(doc, sel, sel.sourceFile); + if (next) { + resolvedGroupElementRef.current.set(key, next); + } else { + resolvedGroupElementRef.current.delete(key); + } + return next; + }; const update = () => { frame = requestAnimationFrame(update); @@ -412,8 +515,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (!iframe || !overlayEl) { resolvedElementRef.current = null; resolvedHoverElementRef.current = null; + resolvedGroupElementRef.current.clear(); clearOverlayRect(); clearHoverRect(); + clearGroupOverlayItems(); return; } @@ -421,8 +526,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (!doc) { resolvedElementRef.current = null; resolvedHoverElementRef.current = null; + resolvedGroupElementRef.current.clear(); clearOverlayRect(); clearHoverRect(); + clearGroupOverlayItems(); return; } @@ -438,11 +545,34 @@ export const DomEditOverlay = memo(function DomEditOverlay({ clearOverlayRect(); } + const group = groupSelectionsRef.current; + if (group.length > 0) { + const nextGroupItems: GroupOverlayItem[] = []; + const liveGroupKeys = new Set(); + for (const groupSelection of group) { + const key = selectionCacheKey(groupSelection); + liveGroupKeys.add(key); + const el = resolveGroupElement(doc, groupSelection); + const rect = el ? toOverlayRect(overlayEl, iframe, el) : null; + if (rect) nextGroupItems.push({ key, selection: groupSelection, rect }); + } + for (const key of resolvedGroupElementRef.current.keys()) { + if (!liveGroupKeys.has(key)) resolvedGroupElementRef.current.delete(key); + } + setNextGroupOverlayItems(nextGroupItems); + } else { + resolvedGroupElementRef.current.clear(); + clearGroupOverlayItems(); + } + const hoverSel = hoverSelectionRef.current; const hoverMatchesSelection = Boolean( sel && hoverSel && selectionCacheKey(sel) === selectionCacheKey(hoverSel), ); - if (!hoverSel || hoverMatchesSelection) { + const hoverMatchesGroup = Boolean( + hoverSel && group.some((entry) => selectionCacheKey(entry) === selectionCacheKey(hoverSel)), + ); + if (!hoverSel || hoverMatchesSelection || hoverMatchesGroup) { resolvedHoverElementRef.current = null; clearHoverRect(); return; @@ -467,6 +597,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({ selection.selectorIndex ?? 0 }`; }, [selection]); + const groupBounds = useMemo( + () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)), + [groupOverlayItems], + ); + const hasGroupSelection = groupSelections.length > 1; + const groupCanMove = + hasGroupSelection && + groupOverlayItems.length > 1 && + groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset); const setDraftOverlayRect = (next: OverlayRect) => { if (rectsEqual(overlayRectRef.current, next)) return; @@ -485,6 +624,66 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }); }; + const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => { + if (groupOverlayItemsEqual(groupOverlayItemsRef.current, next)) return; + groupOverlayItemsRef.current = next; + setGroupOverlayItems(next); + }; + + const restoreGroupGestureOverlayItems = (g: GroupGestureState) => { + setDraftGroupOverlayItems(g.originItems); + }; + + const startGroupDrag = (e: React.PointerEvent) => { + const items = groupOverlayItemsRef.current; + if (items.length <= 1) return false; + + const blockedSelection = items.find( + (item) => !item.selection.capabilities.canApplyManualOffset, + )?.selection; + if (blockedSelection) { + e.preventDefault(); + e.stopPropagation(); + onBlockedMoveRef.current(blockedSelection); + return false; + } + + const members = items.map((item) => { + const offset = readStudioPathOffset(item.selection.element); + return { + key: item.key, + selection: item.selection, + initialPathOffset: captureStudioPathOffset(item.selection.element), + actualOffsetX: offset.x, + actualOffsetY: offset.y, + editScaleX: item.rect.editScaleX, + editScaleY: item.rect.editScaleY, + manualEditDragToken: beginStudioManualEditGesture(item.selection.element), + }; + }); + + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + + rafPausedRef.current = true; + groupGestureRef.current = { + startX: e.clientX, + startY: e.clientY, + originItems: items, + members, + }; + return true; + }; + + const restoreGroupPathOffsets = (g: GroupGestureState) => { + for (const member of g.members) { + restoreStudioPathOffset(member.selection.element, member.initialPathOffset); + endStudioManualEditGesture(member.selection.element, member.manualEditDragToken); + } + restoreGroupGestureOverlayItems(g); + }; + const startGesture = ( kind: GestureKind, e: React.PointerEvent, @@ -549,10 +748,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const onPointerMove = (e: React.PointerEvent) => { const g = gestureRef.current; + const groupG = groupGestureRef.current; const sel = g?.selection ?? selectionRef.current; const box = boxRef.current; const blockedMove = blockedMoveRef.current; - if (!blockedMove && !g) { + if (!blockedMove && !g && !groupG) { onCanvasPointerMoveRef.current(e, { preferClipAncestor: false }); } @@ -567,6 +767,35 @@ export const DomEditOverlay = memo(function DomEditOverlay({ return; } + if (groupG) { + const dx = e.clientX - groupG.startX; + const dy = e.clientY - groupG.startY; + setDraftGroupOverlayItems( + groupG.originItems.map((item) => ({ + ...item, + rect: { + ...item.rect, + left: item.rect.left + dx, + top: item.rect.top + dy, + }, + })), + ); + for (const member of groupG.members) { + applyStudioPathOffsetDraft( + member.selection.element, + resolveDomEditPathOffsetGesture({ + actualOffsetX: member.actualOffsetX, + actualOffsetY: member.actualOffsetY, + scaleX: member.editScaleX, + scaleY: member.editScaleY, + dx, + dy, + }), + ); + } + return; + } + if (!g || !sel) return; const dx = e.clientX - g.startX; @@ -642,9 +871,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const onPointerUp = (e: React.PointerEvent) => { const g = gestureRef.current; + const groupG = groupGestureRef.current; const sel = g?.selection ?? selectionRef.current; const box = boxRef.current; blockedMoveRef.current = null; + + if (groupG) { + groupGestureRef.current = null; + rafPausedRef.current = false; + + const movedDistance = Math.hypot(e.clientX - groupG.startX, e.clientY - groupG.startY); + if (movedDistance < BLOCKED_MOVE_THRESHOLD_PX) { + restoreGroupPathOffsets(groupG); + suppressNextBoxClickRef.current = true; + onCanvasMouseDown(e as unknown as React.MouseEvent, { + preferClipAncestor: false, + }); + return; + } + + const updates = groupG.members.map((member) => { + const finalOffset = readStudioPathOffset(member.selection.element); + applyStudioPathOffset(member.selection.element, finalOffset); + return { selection: member.selection, next: finalOffset }; + }); + void Promise.resolve(onGroupPathOffsetCommitRef.current(updates)) + .catch(() => { + for (const member of groupG.members) { + if ( + member.manualEditDragToken && + isStudioManualEditGestureCurrent(member.selection.element, member.manualEditDragToken) + ) { + restoreStudioPathOffset(member.selection.element, member.initialPathOffset); + } + } + }) + .finally(() => { + for (const member of groupG.members) { + endStudioManualEditGesture(member.selection.element, member.manualEditDragToken); + } + }); + return; + } + if (!g || !sel) { gestureRef.current = null; rafPausedRef.current = false; @@ -761,6 +1030,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const handleOverlayPointerDown = (event: React.PointerEvent) => { if (!allowCanvasMovement || event.button !== 0) return; + if (event.shiftKey) return; const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; @@ -797,7 +1067,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ // This lets you click a child element even when a parent is selected, because // the click coordinates are forwarded to the iframe's element picker. const handleBoxClick = (event: React.MouseEvent) => { - if (gestureRef.current) return; + if (gestureRef.current || groupGestureRef.current) return; if (suppressNextBoxClickRef.current) { suppressNextBoxClickRef.current = false; event.stopPropagation(); @@ -807,6 +1077,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }; const clearPointerState = () => { + const groupG = groupGestureRef.current; + if (groupG) restoreGroupPathOffsets(groupG); const g = gestureRef.current; const sel = g?.selection ?? selectionRef.current; if (g?.mode === "path-offset" && sel) { @@ -824,6 +1096,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ endStudioManualEditGesture(sel.element, g.manualEditDragToken); } blockedMoveRef.current = null; + groupGestureRef.current = null; gestureRef.current = null; rafPausedRef.current = false; }; @@ -855,7 +1128,41 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }} /> )} - {selection && overlayRect && ( + {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && ( + <> + {groupOverlayItems.map((item) => ( +