mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
feat: add studio group repositioning
This commit is contained in:
committed by
Miguel Ángel
parent
8c6af5734e
commit
7a736eb1f8
+109
-11
@@ -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<RightPanelTab>("renders");
|
||||
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
||||
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
||||
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
||||
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
@@ -996,6 +1018,7 @@ export function StudioApp() {
|
||||
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
||||
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
||||
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
||||
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(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() {
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
assets={assets}
|
||||
element={domEditSelection}
|
||||
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<HTMLIFrameElement | null>;
|
||||
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> | void;
|
||||
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | 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<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
): 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<HTMLDivElement | null>(null);
|
||||
const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
|
||||
const [hoverRect, setHoverRect] = useState<OverlayRect | null>(null);
|
||||
const [groupOverlayItems, setGroupOverlayItems] = useState<GroupOverlayItem[]>([]);
|
||||
const gestureRef = useRef<GestureState | null>(null);
|
||||
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
||||
const blockedMoveRef = useRef<BlockedMoveState | null>(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<Map<string, HTMLElement>>(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<string>();
|
||||
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<HTMLElement>) => {
|
||||
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<HTMLElement>,
|
||||
@@ -549,10 +748,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>, {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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) => (
|
||||
<div
|
||||
key={item.key}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute rounded-xl border border-studio-accent/70 bg-studio-accent/[0.03]"
|
||||
style={{
|
||||
left: item.rect.left,
|
||||
top: item.rect.top,
|
||||
width: item.rect.width,
|
||||
height: item.rect.height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
data-dom-edit-selection-box="true"
|
||||
className="pointer-events-auto absolute rounded-xl border border-studio-accent bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
|
||||
style={{
|
||||
left: groupBounds.left,
|
||||
top: groupBounds.top,
|
||||
width: groupBounds.width,
|
||||
height: groupBounds.height,
|
||||
cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!allowCanvasMovement) return;
|
||||
if (e.shiftKey) return;
|
||||
startGroupDrag(e);
|
||||
}}
|
||||
onClick={handleBoxClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!hasGroupSelection && selection && overlayRect && (
|
||||
<>
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user