mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): element groups — studio UI (#1759)
* feat(studio): element groups — source mutations Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements routes) that the studio group feature is built on. Studio UI lands in the next PR. * fix(studio): hoverable group interior + non-sticky drill-in Two group selection bugs with animated members: 1) Empty space inside a group's overlay didn't hover/select the group. Members animated outside the wrapper's static box (110px box vs 340px member union), so elementsFromPoint hit only the full-bleed background there. Add a member-union hit-test fallback: a point inside a group's live member bounds resolves to that group (innermost wins). 2) After drilling into a group and selecting a child, nothing else was selectable — out-of-scope resolved to null. Make drill-in non-sticky: interacting outside the drilled group re-resolves normally and exits the drill-in, so a later click on the group selects it as a unit again.
This commit is contained in:
@@ -258,6 +258,8 @@ export function StudioApp() {
|
||||
onResetKeyframes: () => resetKeyframesRef.current(),
|
||||
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
||||
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
|
||||
onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
|
||||
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
|
||||
activeCompPath,
|
||||
forceReloadSdkSession: sdkHandle.forceReload,
|
||||
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
|
||||
|
||||
@@ -93,6 +93,7 @@ export function StudioRightPanel({
|
||||
domEditGroupSelections,
|
||||
copiedAgentPrompt,
|
||||
clearDomSelection,
|
||||
handleUngroupSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomAttributeLiveCommit,
|
||||
@@ -241,6 +242,7 @@ export function StudioRightPanel({
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onUngroup={handleUngroupSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetAttributeLive={handleDomAttributeLiveCommit}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
import { MarqueeOverlay } from "./MarqueeOverlay";
|
||||
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { collectDomEditLayerItems } from "./domEditingLayers";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
import {
|
||||
@@ -248,7 +248,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const elMap = new Map<string, HTMLElement>();
|
||||
for (const item of items) {
|
||||
if (!isElementComputedVisible(item.element)) continue;
|
||||
const r = toOverlayRect(overlay, iframe, item.element);
|
||||
// Groups use their members' union (where they actually render), so a group
|
||||
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
||||
// wrapper box.
|
||||
const r = groupAwareOverlayRect(overlay, iframe, item.element);
|
||||
if (!r) continue;
|
||||
// Any edge crossing the composition border → gray-zone indicator (the
|
||||
// in-canvas portion is clipped away below, so only the sliver shows).
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { X } from "../../icons/SystemIcons";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
|
||||
export function InspectorHeaderActions({
|
||||
element,
|
||||
copied,
|
||||
onCopy,
|
||||
onClear,
|
||||
onUngroup,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onClear: () => void;
|
||||
onUngroup?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{onUngroup && element.dataAttributes["hf-group"] != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUngroup}
|
||||
title="Ungroup (⌘⇧G)"
|
||||
className="flex h-6 items-center rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
|
||||
>
|
||||
Ungroup
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
||||
copied
|
||||
? "text-studio-accent"
|
||||
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
|
||||
}`}
|
||||
title={copied ? "Copied!" : "Copy element info to clipboard"}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClear}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,9 +59,11 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const {
|
||||
domEditSelection,
|
||||
activeGroupElement,
|
||||
applyDomSelection,
|
||||
updateDomEditHoverSelection,
|
||||
handleDomZIndexReorderCommit,
|
||||
setActiveGroupElement,
|
||||
} = useDomEditContext();
|
||||
|
||||
const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
|
||||
@@ -86,12 +88,16 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
||||
if (!root) return;
|
||||
|
||||
// A preview reload detaches the drilled-into wrapper; exit drill-in if so.
|
||||
if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null);
|
||||
|
||||
const items = collectDomEditLayerItems(root, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
activeGroupElement,
|
||||
});
|
||||
setLayers(sortLayersByZIndex(items));
|
||||
}, [previewIframeRef, activeCompPath, isMasterView]);
|
||||
}, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]);
|
||||
|
||||
useEffect(() => {
|
||||
collectLayers();
|
||||
@@ -135,9 +141,10 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
preferClipAncestor: false,
|
||||
activeGroupElement,
|
||||
});
|
||||
},
|
||||
[activeCompPath, isMasterView, previewIframeRef],
|
||||
[activeCompPath, isMasterView, previewIframeRef, activeGroupElement],
|
||||
);
|
||||
|
||||
const seekToLayer = useCallback(
|
||||
@@ -183,6 +190,19 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
[resolveSelection, applyDomSelection, seekToLayer],
|
||||
);
|
||||
|
||||
// Double-click a group row → drill into it; any other row → select it.
|
||||
const handleLayerDoubleClick = useCallback(
|
||||
async (layer: DomEditLayerItem) => {
|
||||
const selection = await resolveSelection(layer);
|
||||
if (selection?.element.hasAttribute("data-hf-group")) {
|
||||
setActiveGroupElement(selection.element);
|
||||
} else {
|
||||
await handleSelectLayer(layer);
|
||||
}
|
||||
},
|
||||
[resolveSelection, setActiveGroupElement, handleSelectLayer],
|
||||
);
|
||||
|
||||
const handleLayerHover = useCallback(
|
||||
async (layer: DomEditLayerItem | null) => {
|
||||
if (!layer) {
|
||||
@@ -271,6 +291,18 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
onPointerUp={handleContainerPointerUp}
|
||||
onPointerCancel={handleContainerPointerUp}
|
||||
>
|
||||
{activeGroupElement && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveGroupElement(null)}
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
<span className="truncate">
|
||||
{activeGroupElement.getAttribute("data-hf-group") || "Group"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{visibleLayers.map((layer, index) => {
|
||||
const selected = layer.key === selectedKey;
|
||||
const isDragged = layer.key === dragKey;
|
||||
@@ -286,6 +318,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !dragKey && handleSelectLayer(layer)}
|
||||
onDoubleClick={() => !dragKey && handleLayerDoubleClick(layer)}
|
||||
onPointerDown={(e) => handleRowPointerDown(index, e)}
|
||||
onPointerEnter={() => !dragKey && handleLayerHover(layer)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
||||
import { Eye, Layers, Move } from "../../icons/SystemIcons";
|
||||
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
multiSelectCount = 0,
|
||||
copiedAgentPrompt: _copiedAgentPrompt,
|
||||
onClearSelection,
|
||||
onUngroup,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
@@ -295,38 +297,13 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyElementInfo}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
||||
clipboardCopied
|
||||
? "text-studio-accent"
|
||||
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
|
||||
}`}
|
||||
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClearSelection}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
|
||||
|
||||
export interface CubePose {
|
||||
@@ -22,80 +22,18 @@ const SENSITIVITY = 0.6; // degrees per pixel of drag
|
||||
* Presentational only: emits a live draft pose while dragging and a final pose
|
||||
* on release — the parent owns live-previewing and committing to GSAP props.
|
||||
*/
|
||||
// transformPerspective (px) is inversely related to effect strength, with 0 = off.
|
||||
// Map a 0..1 slider strength to px and to the cube's weak-perspective projection.
|
||||
const STRONG_PX = 200;
|
||||
const WEAK_PX = 1600;
|
||||
const PX_RANGE = WEAK_PX - STRONG_PX;
|
||||
const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE));
|
||||
const pxToStrength = (px: number) =>
|
||||
px <= 0
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE));
|
||||
// transformPerspective (px) drives the cube's weak-perspective projection;
|
||||
// 0 = off → flattest (largest projection distance).
|
||||
const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
|
||||
|
||||
/** Horizontal "perspective strength" slider — left = none, right = dramatic. */
|
||||
function PerspectiveSlider({
|
||||
value,
|
||||
onDraft,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onDraft?: (px: number) => void;
|
||||
onCommit: (px: number) => void;
|
||||
}) {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const strength = pxToStrength(value);
|
||||
const fromEvent = (clientX: number) => {
|
||||
const r = trackRef.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return 0;
|
||||
return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width)));
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 pb-1.5 pt-1">
|
||||
<span className="text-[8px] font-medium uppercase tracking-wide text-neutral-600">Persp</span>
|
||||
<div
|
||||
ref={trackRef}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
onDraft?.(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (draggingRef.current) onDraft?.(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
onCommit(fromEvent(e.clientX));
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
draggingRef.current = false;
|
||||
}}
|
||||
className="relative h-3 flex-1 cursor-ew-resize touch-none"
|
||||
>
|
||||
<div className="absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-neutral-700" />
|
||||
<div
|
||||
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-[#5ff0bf]"
|
||||
style={{ width: `${strength * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-900 bg-[#5ff0bf]"
|
||||
style={{ left: `${strength * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Transform3DCube({
|
||||
pose,
|
||||
perspective = 0,
|
||||
z = 0,
|
||||
onPoseDraft,
|
||||
onPoseCommit,
|
||||
onPerspectiveDraft,
|
||||
onPerspectiveCommit,
|
||||
onDepthDraft,
|
||||
onDepthCommit,
|
||||
onRecenter,
|
||||
onKeyframe,
|
||||
keyframed,
|
||||
@@ -103,13 +41,16 @@ export function Transform3DCube({
|
||||
pose: CubePose;
|
||||
/** Element's transformPerspective (px); drives the cube's foreshortening. */
|
||||
perspective?: number;
|
||||
/** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
|
||||
z?: number;
|
||||
/** Fires on every drag move with the in-progress pose (parent live-previews). */
|
||||
onPoseDraft?: (pose: CubePose) => void;
|
||||
/** Fires once on pointer release with the final pose (commit). */
|
||||
onPoseCommit: (pose: CubePose) => void;
|
||||
/** Live + committed perspective (px) from the in-cube slider. */
|
||||
onPerspectiveDraft?: (px: number) => void;
|
||||
onPerspectiveCommit?: (px: number) => void;
|
||||
/** Live depth (translateZ px) during a scroll; parent live-previews it. */
|
||||
onDepthDraft?: (z: number) => void;
|
||||
/** Committed depth (translateZ px) once a scroll burst settles. */
|
||||
onDepthCommit?: (z: number) => void;
|
||||
/** Reset to identity orientation. */
|
||||
onRecenter?: () => void;
|
||||
/** Toggle keyframing the 3D transform (convert the static set → keyframes). */
|
||||
@@ -118,16 +59,63 @@ export function Transform3DCube({
|
||||
keyframed?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<CubePose | null>(null);
|
||||
const [depthDraft, setDepthDraft] = useState<number | null>(null);
|
||||
const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
|
||||
const shown = draft ?? pose;
|
||||
const shownZ = depthDraft ?? z;
|
||||
|
||||
// Scroll over the cube to push the element along Z (depth) — matches the
|
||||
// studio's "scroll = z depth" gesture-recording convention. A non-passive
|
||||
// listener is required so preventDefault can stop the panel from scrolling.
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const depthRef = useRef({ z, onDepthDraft, onDepthCommit });
|
||||
depthRef.current = { z, onDepthDraft, onDepthCommit };
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
let pending: number | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current;
|
||||
if (!commit) return;
|
||||
e.preventDefault();
|
||||
// ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
|
||||
// it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
|
||||
pending = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
|
||||
draft?.(pending);
|
||||
setDepthDraft(pending); // live-scale the cube while scrolling
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (pending != null) commit(pending);
|
||||
pending = null;
|
||||
setDepthDraft(null); // fall back to the committed z prop
|
||||
}, 160);
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Depth feedback: the cube scales like the element would — translateZ(z) under
|
||||
// a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
|
||||
// farther (z<0) smaller. Fall back to the default lens so depth always reads in
|
||||
// the gizmo even before a perspective is set.
|
||||
const lens = perspective > 0 ? perspective : 800;
|
||||
const depthScale = Math.max(0.4, Math.min(2.2, lens / (lens - shownZ)));
|
||||
const projOpts = {
|
||||
cx: CX,
|
||||
cy: CY,
|
||||
r: RADIUS,
|
||||
r: RADIUS * depthScale,
|
||||
persp: pxToProjPersp(perspective),
|
||||
};
|
||||
const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
|
||||
const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts);
|
||||
// The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
|
||||
// and RotateZ act in planes that contain Y, so they read inverted in the gizmo
|
||||
// unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the
|
||||
// cube's orientation a true mirror of the element.
|
||||
const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
||||
const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
@@ -140,10 +128,13 @@ export function Transform3DCube({
|
||||
if (!d) return;
|
||||
const dx = e.clientX - d.x;
|
||||
const dy = e.clientY - d.y;
|
||||
// dy→rotationX and shift dx→rotationZ are negated to match the projection's
|
||||
// sign flip (above), so the cube's response to a drag is unchanged while the
|
||||
// element now rotates in lock-step with it.
|
||||
const next: CubePose = e.shiftKey
|
||||
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) }
|
||||
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) }
|
||||
: {
|
||||
rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY),
|
||||
rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY),
|
||||
rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
|
||||
rotationZ: d.pose.rotationZ,
|
||||
};
|
||||
@@ -161,6 +152,7 @@ export function Transform3DCube({
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
|
||||
@@ -169,7 +161,7 @@ export function Transform3DCube({
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
role="slider"
|
||||
aria-label="Drag to rotate in 3D; hold Shift to roll"
|
||||
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
|
||||
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
||||
shown.rotationY,
|
||||
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
||||
@@ -301,13 +293,6 @@ export function Transform3DCube({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onPerspectiveCommit && (
|
||||
<PerspectiveSlider
|
||||
value={perspective}
|
||||
onDraft={onPerspectiveDraft}
|
||||
onCommit={onPerspectiveCommit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,34 @@ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRec
|
||||
};
|
||||
}
|
||||
|
||||
// A group's overlay box encompasses its members' actual rendered bounds, not just
|
||||
// the wrapper's own box — so members moved or transformed out of the wrapper still
|
||||
// sit inside the box. Used by the selection, hover, and off-canvas overlays so they
|
||||
// all agree on where a group is.
|
||||
export function groupAwareOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
el: HTMLElement,
|
||||
): OverlayRect | null {
|
||||
const rect = toOverlayRect(overlayEl, iframe, el);
|
||||
if (!rect || !el.hasAttribute("data-hf-group")) return rect;
|
||||
// Union the MEMBERS' rendered rects — where the content actually is — not the
|
||||
// wrapper's own box. The wrapper is invisible and its box can sit apart from the
|
||||
// members once they've been moved/transformed, which would otherwise drag the
|
||||
// group's bounds (and its off-canvas marker) off to a stale position.
|
||||
const rects: OverlayRect[] = [];
|
||||
for (const child of Array.from(el.children)) {
|
||||
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
|
||||
if (childRect) rects.push(childRect);
|
||||
}
|
||||
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
|
||||
if (!union) return rect; // empty group → fall back to the wrapper box
|
||||
// resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's
|
||||
// real edit (display) scale, which the drag uses to convert pointer→offset — a
|
||||
// reset-to-1 makes the group move at ~display-scale speed and lag the cursor.
|
||||
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
|
||||
}
|
||||
|
||||
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
|
||||
return items.filter(
|
||||
(item) => !items.some((other) => other !== item && other.element.contains(item.element)),
|
||||
|
||||
@@ -250,7 +250,7 @@ export function querySelectorAllSafely(doc: Document, selector: string): Element
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeIdentifier(value: string): string {
|
||||
function humanizeIdentifier(value: string): string {
|
||||
return (
|
||||
value
|
||||
.replace(/\.html$/i, "")
|
||||
@@ -270,10 +270,16 @@ export function buildStableSelector(el: HTMLElement): string | undefined {
|
||||
const compositionId = el.getAttribute("data-composition-id");
|
||||
if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
|
||||
|
||||
// Group wrappers carry no id/class; their data-hf-group value is the unique,
|
||||
// stable handle the source mutations write — use it so the wrapper is
|
||||
// selectable, patchable (move/scale), and addressable for ungroup.
|
||||
const group = el.getAttribute("data-hf-group");
|
||||
if (group) return `[data-hf-group="${escapeCssString(group)}"]`;
|
||||
|
||||
return getPreferredClassSelector(el);
|
||||
}
|
||||
|
||||
export function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
||||
function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
||||
const classes = Array.from(el.classList)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
@@ -283,6 +289,34 @@ export function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
||||
return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function buildElementLabel(el: HTMLElement): string {
|
||||
const compositionId = el.getAttribute("data-composition-id");
|
||||
if (compositionId && compositionId !== "main") {
|
||||
return humanizeIdentifier(compositionId);
|
||||
}
|
||||
|
||||
const compositionSrc =
|
||||
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
||||
if (compositionSrc) {
|
||||
return humanizeIdentifier(compositionSrc);
|
||||
}
|
||||
|
||||
const group = el.getAttribute("data-hf-group");
|
||||
if (group) return group;
|
||||
|
||||
if (el.id) return humanizeIdentifier(el.id);
|
||||
|
||||
const preferredClass = getPreferredClassSelector(el);
|
||||
if (preferredClass) {
|
||||
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
||||
}
|
||||
|
||||
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
||||
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
export function getSelectorIndex(
|
||||
doc: Document,
|
||||
el: HTMLElement,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { isHtmlElement } from "./domEditingDom";
|
||||
|
||||
// `data-hf-group` selection semantics: a group wrapper is selected as one unit
|
||||
// until the user drills into it; once drilled in, clicks resolve to its children
|
||||
// (or to the next nested group inside it). One level of drill-in at a time keeps
|
||||
// nested groups navigable.
|
||||
|
||||
export type GroupCapture =
|
||||
| { kind: "unit"; element: HTMLElement } // select this group wrapper as one unit
|
||||
| { kind: "child" } // resolve the clicked element normally
|
||||
| { kind: "out-of-scope" }; // clicked outside the drilled-into group → select nothing
|
||||
|
||||
// Layer-tree roots: the drilled-into group's element children, else the doc root.
|
||||
export function groupScopedLayerRoots(
|
||||
root: HTMLElement,
|
||||
activeGroupElement: HTMLElement | null,
|
||||
): HTMLElement[] {
|
||||
const els = activeGroupElement?.isConnected ? Array.from(activeGroupElement.children) : [root];
|
||||
return els.filter(isHtmlElement);
|
||||
}
|
||||
|
||||
export function resolveGroupCapture(
|
||||
startEl: HTMLElement,
|
||||
activeGroupElement: HTMLElement | null,
|
||||
): GroupCapture {
|
||||
const groups: HTMLElement[] = [];
|
||||
for (let n: HTMLElement | null = startEl; n; n = n.parentElement) {
|
||||
if (n.hasAttribute("data-hf-group")) groups.push(n);
|
||||
}
|
||||
if (!activeGroupElement) {
|
||||
const outermost = groups[groups.length - 1];
|
||||
return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
|
||||
}
|
||||
const idx = groups.indexOf(activeGroupElement);
|
||||
if (idx === -1) return { kind: "out-of-scope" };
|
||||
const nestedInside = groups[idx - 1];
|
||||
return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
|
||||
import {
|
||||
collectDomEditLayerItems,
|
||||
resolveDomEditSelection,
|
||||
buildDomEditPatchTarget,
|
||||
readHfId,
|
||||
} from "./domEditingLayers";
|
||||
|
||||
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
|
||||
|
||||
@@ -76,3 +81,92 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
|
||||
expect(selection?.hfId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditSelection — data-hf-group capture", () => {
|
||||
// <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
|
||||
// <span id="child"/></div></div></div>
|
||||
function buildNestedGroups() {
|
||||
const parent = document.createElement("div");
|
||||
parent.id = "parent";
|
||||
const outer = document.createElement("div");
|
||||
outer.setAttribute("data-hf-group", "Group 1");
|
||||
const inner = document.createElement("div");
|
||||
inner.setAttribute("data-hf-group", "Group 2");
|
||||
const child = document.createElement("span");
|
||||
child.id = "child";
|
||||
inner.appendChild(child);
|
||||
outer.appendChild(inner);
|
||||
parent.appendChild(outer);
|
||||
document.body.appendChild(parent);
|
||||
return { parent, outer, inner, child };
|
||||
}
|
||||
|
||||
it("selects the outermost group as a unit when clicking a child (not drilled in)", async () => {
|
||||
const { parent, outer, child } = buildNestedGroups();
|
||||
const selection = await resolveDomEditSelection(child, opts);
|
||||
document.body.removeChild(parent);
|
||||
|
||||
expect(selection?.element).toBe(outer);
|
||||
expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
|
||||
});
|
||||
|
||||
it("selects the next nested group when drilled into the outer group", async () => {
|
||||
const { parent, outer, inner, child } = buildNestedGroups();
|
||||
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });
|
||||
document.body.removeChild(parent);
|
||||
|
||||
expect(selection?.element).toBe(inner);
|
||||
expect(selection?.selector).toBe('[data-hf-group="Group 2"]');
|
||||
});
|
||||
|
||||
it("selects the child when drilled all the way into the innermost group", async () => {
|
||||
const { parent, inner, child } = buildNestedGroups();
|
||||
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: inner });
|
||||
document.body.removeChild(parent);
|
||||
|
||||
expect(selection?.element).toBe(child);
|
||||
expect(selection?.id).toBe("child");
|
||||
});
|
||||
|
||||
it("layer tree is scoped to the group's members when drilled in", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
const group = document.createElement("div");
|
||||
group.setAttribute("data-hf-group", "Group 1");
|
||||
const inside = document.createElement("div");
|
||||
inside.id = "inside";
|
||||
const outside = document.createElement("div");
|
||||
outside.id = "outside";
|
||||
group.appendChild(inside);
|
||||
root.appendChild(group);
|
||||
root.appendChild(outside);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const opts2 = { activeCompositionPath: "index.html", isMasterView: true };
|
||||
const full = collectDomEditLayerItems(root, opts2).map((i) => i.id);
|
||||
const scoped = collectDomEditLayerItems(root, { ...opts2, activeGroupElement: group }).map(
|
||||
(i) => i.id,
|
||||
);
|
||||
document.body.removeChild(root);
|
||||
|
||||
expect(full).toContain("outside");
|
||||
expect(scoped).toContain("inside");
|
||||
expect(scoped).not.toContain("outside");
|
||||
});
|
||||
|
||||
it("returns null when clicking outside the group the user is drilled into", async () => {
|
||||
const { parent, inner } = buildNestedGroups();
|
||||
const outside = document.createElement("div");
|
||||
outside.id = "outside";
|
||||
document.body.appendChild(outside);
|
||||
|
||||
const selection = await resolveDomEditSelection(outside, {
|
||||
...opts,
|
||||
activeGroupElement: inner,
|
||||
});
|
||||
document.body.removeChild(parent);
|
||||
document.body.removeChild(outside);
|
||||
|
||||
expect(selection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* for dom editing.
|
||||
*/
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
|
||||
import type {
|
||||
DomEditCapabilities,
|
||||
DomEditContextOptions,
|
||||
@@ -11,15 +12,14 @@ import type {
|
||||
DomEditTextField,
|
||||
} from "./domEditingTypes";
|
||||
import {
|
||||
buildElementLabel,
|
||||
buildStableSelector,
|
||||
findClosestByAttribute,
|
||||
getCuratedComputedStyles,
|
||||
getDataAttributes,
|
||||
getInlineStyles,
|
||||
getPreferredClassSelector,
|
||||
getSelectorIndex,
|
||||
getSourceFileForElement,
|
||||
humanizeIdentifier,
|
||||
isHtmlElement,
|
||||
isIdentityTransform,
|
||||
isTextBearingTag,
|
||||
@@ -275,31 +275,6 @@ export function resolveDomEditCapabilities(args: {
|
||||
|
||||
// ─── Element label ────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function buildElementLabel(el: HTMLElement): string {
|
||||
const compositionId = el.getAttribute("data-composition-id");
|
||||
if (compositionId && compositionId !== "main") {
|
||||
return humanizeIdentifier(compositionId);
|
||||
}
|
||||
|
||||
const compositionSrc =
|
||||
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
||||
if (compositionSrc) {
|
||||
return humanizeIdentifier(compositionSrc);
|
||||
}
|
||||
|
||||
if (el.id) return humanizeIdentifier(el.id);
|
||||
|
||||
const preferredClass = getPreferredClassSelector(el);
|
||||
if (preferredClass) {
|
||||
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
||||
}
|
||||
|
||||
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
||||
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
// ─── Source probe ────────────────────────────────────────────────────────────
|
||||
|
||||
async function probeSourceElement(
|
||||
@@ -334,7 +309,15 @@ export async function resolveDomEditSelection(
|
||||
if (!startEl) return null;
|
||||
const doc = startEl.ownerDocument;
|
||||
|
||||
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
|
||||
let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
|
||||
if (capture.kind === "out-of-scope") {
|
||||
// Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
|
||||
// exits it and resolves the target normally, rather than selecting nothing
|
||||
// (which felt like "can't select anything" once you'd drilled in).
|
||||
capture = resolveGroupCapture(startEl, null);
|
||||
}
|
||||
let current: HTMLElement | null =
|
||||
capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options);
|
||||
while (current && current !== doc.body && current !== doc.documentElement) {
|
||||
const selector = buildStableSelector(current);
|
||||
const hfId = readHfId(current);
|
||||
@@ -501,7 +484,8 @@ export function collectDomEditLayerItems(
|
||||
}
|
||||
};
|
||||
|
||||
visit(root, 0);
|
||||
// Drilled into a group → show only its members; otherwise the whole tree.
|
||||
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export interface DomEditContextOptions {
|
||||
activeCompositionPath: string | null;
|
||||
isMasterView: boolean;
|
||||
preferClipAncestor?: boolean;
|
||||
/** The group wrapper the user has drilled into (null = top level). Selection
|
||||
* resolution treats groups as a unit unless drilled into one. */
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface DomEditViewport {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { Transform3DCube, type CubePose } from "./Transform3DCube";
|
||||
|
||||
// Default perspective (px) applied when depth is first set, so translateZ is
|
||||
// visible. ~800px is a moderate lens — closer = stronger foreshortening.
|
||||
const DEFAULT_DEPTH_PERSPECTIVE = 800;
|
||||
|
||||
type KeyframeEntry = Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
@@ -123,18 +127,36 @@ function Cube3dControl({
|
||||
<Transform3DCube
|
||||
pose={pose}
|
||||
perspective={gsapRuntimeValues.transformPerspective ?? 0}
|
||||
z={gsapRuntimeValues.z ?? 0}
|
||||
onPoseDraft={livePreview}
|
||||
onPoseCommit={commitPose}
|
||||
onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
|
||||
onPerspectiveCommit={(px) =>
|
||||
void onCommitAnimatedProperty(element, "transformPerspective", px)
|
||||
onDepthDraft={(z) =>
|
||||
onLivePreviewProps?.(
|
||||
element,
|
||||
gsapRuntimeValues.transformPerspective
|
||||
? { z }
|
||||
: { z, transformPerspective: DEFAULT_DEPTH_PERSPECTIVE },
|
||||
)
|
||||
}
|
||||
onDepthCommit={(z) => {
|
||||
// translateZ is invisible without a perspective lens — apply a sensible
|
||||
// default the first time depth is set so scrolling visibly moves the
|
||||
// element. The user can still fine-tune via the Perspective field.
|
||||
if (!gsapRuntimeValues.transformPerspective) {
|
||||
void onCommitAnimatedProperty(
|
||||
element,
|
||||
"transformPerspective",
|
||||
DEFAULT_DEPTH_PERSPECTIVE,
|
||||
);
|
||||
}
|
||||
void onCommitAnimatedProperty(element, "z", z);
|
||||
}}
|
||||
onRecenter={recenter}
|
||||
onKeyframe={onKeyframe}
|
||||
keyframed={keyframed}
|
||||
/>
|
||||
<p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
|
||||
Drag to tilt · Shift-drag to roll
|
||||
Drag to tilt · Shift-drag to roll · Scroll for depth
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface PropertyPanelProps {
|
||||
multiSelectCount?: number;
|
||||
copiedAgentPrompt: boolean;
|
||||
onClearSelection: () => void;
|
||||
/** Dissolve the selected data-hf-group wrapper (shown only for group selections). */
|
||||
onUngroup?: () => void;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ResolvedElementRef,
|
||||
groupOverlayItemsEqual,
|
||||
isElementVisibleForOverlay,
|
||||
groupAwareOverlayRect,
|
||||
rectsEqual,
|
||||
resolveElementForOverlay,
|
||||
selectionCacheKey,
|
||||
@@ -155,7 +156,7 @@ export function useDomEditOverlayRects({
|
||||
// backgroundless full-bleed scene above a subcomposition), which would wrongly
|
||||
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
|
||||
if (el && isElementVisibleForOverlay(el)) {
|
||||
const nextRect = toOverlayRect(overlayEl, iframe, el);
|
||||
const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
|
||||
setOverlayRect(nextRect);
|
||||
const descendants = el.querySelectorAll("*");
|
||||
if (descendants.length > 0 && descendants.length <= 60) {
|
||||
@@ -196,9 +197,13 @@ export function useDomEditOverlayRects({
|
||||
const liveGroupKeys = new Set<string>();
|
||||
for (const groupSelection of group) {
|
||||
const key = selectionCacheKey(groupSelection);
|
||||
// Members of the same group collapse to one selection under select-as-unit,
|
||||
// so a multi-select can hold the same group twice — dedupe by key to avoid
|
||||
// duplicate React keys (and a doubled overlay box).
|
||||
if (liveGroupKeys.has(key)) continue;
|
||||
liveGroupKeys.add(key);
|
||||
const el = resolveGroupElement(doc, groupSelection);
|
||||
const rect = el ? toOverlayRect(overlayEl, iframe, el) : null;
|
||||
const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
|
||||
if (el && rect)
|
||||
nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
|
||||
}
|
||||
@@ -235,7 +240,7 @@ export function useDomEditOverlayRects({
|
||||
return;
|
||||
}
|
||||
|
||||
setHoverRect(toOverlayRect(overlayEl, iframe, hoverEl));
|
||||
setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(update);
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface DomEditActionsValue extends Pick<
|
||||
| "handleBlockedDomMove"
|
||||
| "handleDomManualDragStart"
|
||||
| "handleDomEditElementDelete"
|
||||
| "handleGroupSelection"
|
||||
| "handleUngroupSelection"
|
||||
| "setActiveGroupElement"
|
||||
| "buildDomSelectionFromTarget"
|
||||
| "buildDomSelectionForTimelineElement"
|
||||
| "updateDomEditHoverSelection"
|
||||
@@ -72,6 +75,7 @@ export interface DomEditSelectionValue extends Pick<
|
||||
| "domEditSelection"
|
||||
| "domEditGroupSelections"
|
||||
| "domEditHoverSelection"
|
||||
| "activeGroupElement"
|
||||
| "domEditSelectionRef"
|
||||
| "selectedGsapAnimations"
|
||||
| "gsapMultipleTimelines"
|
||||
@@ -138,6 +142,10 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
handleGroupSelection,
|
||||
handleUngroupSelection,
|
||||
setActiveGroupElement,
|
||||
activeGroupElement,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
@@ -216,6 +224,9 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
handleGroupSelection,
|
||||
handleUngroupSelection,
|
||||
setActiveGroupElement,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
@@ -277,6 +288,9 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
handleGroupSelection,
|
||||
handleUngroupSelection,
|
||||
setActiveGroupElement,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
@@ -319,6 +333,7 @@ export function DomEditProvider({
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
activeGroupElement,
|
||||
domEditSelectionRef,
|
||||
selectedGsapAnimations,
|
||||
gsapMultipleTimelines,
|
||||
@@ -332,6 +347,7 @@ export function DomEditProvider({
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
activeGroupElement,
|
||||
domEditSelectionRef,
|
||||
selectedGsapAnimations,
|
||||
gsapMultipleTimelines,
|
||||
|
||||
@@ -117,6 +117,10 @@ interface UseAppHotkeysParams {
|
||||
onDeleteSelectedKeyframes: () => void;
|
||||
onAfterUndoRedo?: () => void;
|
||||
onToggleRecording?: () => void;
|
||||
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
|
||||
onGroupSelection?: () => void;
|
||||
/** Ungroup the selected group wrapper (⌘⇧G). */
|
||||
onUngroupSelection?: () => void;
|
||||
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
||||
activeCompPath?: string | null;
|
||||
/**
|
||||
@@ -142,6 +146,8 @@ interface HotkeyCallbacks {
|
||||
onResetKeyframes: () => boolean;
|
||||
onDeleteSelectedKeyframes: () => void;
|
||||
onToggleRecording?: () => void;
|
||||
onGroupSelection?: () => void;
|
||||
onUngroupSelection?: () => void;
|
||||
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
@@ -169,6 +175,13 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) cb.onUngroupSelection?.();
|
||||
else cb.onGroupSelection?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
|
||||
if (key === "c") {
|
||||
if (cb.handleCopy()) event.preventDefault();
|
||||
@@ -310,6 +323,8 @@ export function useAppHotkeys({
|
||||
onDeleteSelectedKeyframes,
|
||||
onAfterUndoRedo,
|
||||
onToggleRecording,
|
||||
onGroupSelection,
|
||||
onUngroupSelection,
|
||||
activeCompPath,
|
||||
forceReloadSdkSession,
|
||||
}: UseAppHotkeysParams) {
|
||||
@@ -403,6 +418,8 @@ export function useAppHotkeys({
|
||||
onResetKeyframes,
|
||||
onDeleteSelectedKeyframes,
|
||||
onToggleRecording,
|
||||
onGroupSelection,
|
||||
onUngroupSelection,
|
||||
leftSidebarRef,
|
||||
domEditSelectionRef,
|
||||
showToast,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||
import { useDomEditCommits } from "./useDomEditCommits";
|
||||
import { useGroupCommits } from "./useGroupCommits";
|
||||
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
||||
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
||||
import { useDomEditWiring } from "./useDomEditWiring";
|
||||
@@ -114,7 +115,10 @@ export function useDomEditSession({
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
activeGroupElement,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
setActiveGroupElement,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
@@ -279,6 +283,42 @@ export function useDomEditSession({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// ── Element groups (wrap selected elements in a data-hf-group div) ──
|
||||
|
||||
const { groupSelection, ungroupSelection } = useGroupCommits({
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
const handleGroupSelection = useCallback(() => {
|
||||
const group = domEditGroupSelectionsRef.current;
|
||||
const single = domEditSelectionRef.current;
|
||||
const members = group.length > 0 ? group : single ? [single] : [];
|
||||
if (members.length < 2) {
|
||||
showToast("Select at least 2 elements to group", "info");
|
||||
return;
|
||||
}
|
||||
void groupSelection(members);
|
||||
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
|
||||
|
||||
const handleUngroupSelection = useCallback(() => {
|
||||
const sel = domEditSelectionRef.current;
|
||||
if (!sel?.element.hasAttribute("data-hf-group")) {
|
||||
showToast("Select a group to ungroup", "info");
|
||||
return;
|
||||
}
|
||||
// Dissolving the group exits any drill-in (the wrapper is about to vanish).
|
||||
setActiveGroupElement(null);
|
||||
void ungroupSelection(sel);
|
||||
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
|
||||
|
||||
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
||||
|
||||
const {
|
||||
@@ -360,6 +400,7 @@ export function useDomEditSession({
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
resolveAllDomSelectionsFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
setActiveGroupElement,
|
||||
onClickToSource,
|
||||
});
|
||||
|
||||
@@ -435,6 +476,7 @@ export function useDomEditSession({
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
activeGroupElement,
|
||||
agentModalOpen,
|
||||
agentModalAnchorPoint,
|
||||
copiedAgentPrompt,
|
||||
@@ -467,6 +509,9 @@ export function useDomEditSession({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
handleGroupSelection,
|
||||
handleUngroupSelection,
|
||||
setActiveGroupElement,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
|
||||
@@ -48,13 +48,16 @@ export interface UseDomSelectionReturn {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditGroupSelections: DomEditSelection[];
|
||||
domEditHoverSelection: DomEditSelection | null;
|
||||
activeGroupElement: HTMLElement | null;
|
||||
// Refs
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
|
||||
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
||||
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
|
||||
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
|
||||
setActiveGroupElement: (el: HTMLElement | null) => void;
|
||||
// Callbacks
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
@@ -67,12 +70,20 @@ export interface UseDomSelectionReturn {
|
||||
clearDomSelection: () => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
options?: {
|
||||
preferClipAncestor?: boolean;
|
||||
skipSourceProbe?: boolean;
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
},
|
||||
) => Promise<DomEditSelection | null>;
|
||||
resolveDomSelectionFromPreviewPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
options?: {
|
||||
preferClipAncestor?: boolean;
|
||||
skipSourceProbe?: boolean;
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
},
|
||||
) => Promise<DomEditSelection | null>;
|
||||
resolveAllDomSelectionsFromPreviewPoint: (
|
||||
clientX: number,
|
||||
@@ -110,17 +121,21 @@ export function useDomSelection({
|
||||
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
||||
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
||||
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
||||
// The data-hf-group wrapper the user has drilled into (null = top level).
|
||||
const [activeGroupElement, setActiveGroupElementState] = useState<HTMLElement | null>(null);
|
||||
|
||||
// ── Refs ──
|
||||
|
||||
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
||||
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
||||
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
||||
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
|
||||
|
||||
// Keep refs in sync with state
|
||||
domEditSelectionRef.current = domEditSelection;
|
||||
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
||||
domEditHoverSelectionRef.current = domEditHoverSelection;
|
||||
activeGroupElementRef.current = activeGroupElement;
|
||||
|
||||
// ── Callbacks ──
|
||||
|
||||
@@ -178,6 +193,14 @@ export function useDomSelection({
|
||||
setDomEditSelection(nextSelection);
|
||||
setDomEditGroupSelections(nextGroup);
|
||||
|
||||
// Selecting something outside the drilled-into group exits the drill-in, so
|
||||
// a later click on the group selects it as a unit again (non-sticky drill-in).
|
||||
const activeGroup = activeGroupElementRef.current;
|
||||
if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) {
|
||||
activeGroupElementRef.current = null;
|
||||
setActiveGroupElementState(null);
|
||||
}
|
||||
|
||||
if (nextSelection) {
|
||||
if (options?.revealPanel !== false) {
|
||||
setRightCollapsed(false);
|
||||
@@ -203,16 +226,36 @@ export function useDomSelection({
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
}, [applyDomSelection]);
|
||||
|
||||
// Drill into / out of a group. Changing scope clears the current selection so
|
||||
// the user isn't left with an out-of-scope element selected.
|
||||
const setActiveGroupElement = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setActiveGroupElementState(el);
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
},
|
||||
[applyDomSelection],
|
||||
);
|
||||
|
||||
const buildDomSelectionFromTarget = useCallback(
|
||||
(
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
|
||||
options?: {
|
||||
preferClipAncestor?: boolean;
|
||||
skipSourceProbe?: boolean;
|
||||
// Override the drill-in scope (used by canvas double-click to resolve the
|
||||
// child inside a group before the activeGroupElement state has re-rendered).
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
},
|
||||
) => {
|
||||
return resolveDomEditSelection(target, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
preferClipAncestor: options?.preferClipAncestor,
|
||||
skipSourceProbe: options?.skipSourceProbe,
|
||||
activeGroupElement:
|
||||
options && "activeGroupElement" in options
|
||||
? options.activeGroupElement
|
||||
: activeGroupElementRef.current,
|
||||
projectId,
|
||||
});
|
||||
},
|
||||
@@ -224,7 +267,11 @@ export function useDomSelection({
|
||||
async (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
|
||||
options?: {
|
||||
preferClipAncestor?: boolean;
|
||||
skipSourceProbe?: boolean;
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
},
|
||||
) => {
|
||||
const iframe = previewIframeRef.current;
|
||||
if (!iframe || captionEditMode) return null;
|
||||
@@ -235,10 +282,19 @@ export function useDomSelection({
|
||||
}
|
||||
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
||||
if (!target) return null;
|
||||
return buildDomSelectionFromTarget(target, {
|
||||
preferClipAncestor: options?.preferClipAncestor,
|
||||
skipSourceProbe: options?.skipSourceProbe,
|
||||
});
|
||||
return buildDomSelectionFromTarget(
|
||||
target,
|
||||
options && "activeGroupElement" in options
|
||||
? {
|
||||
preferClipAncestor: options.preferClipAncestor,
|
||||
skipSourceProbe: options.skipSourceProbe,
|
||||
activeGroupElement: options.activeGroupElement,
|
||||
}
|
||||
: {
|
||||
preferClipAncestor: options?.preferClipAncestor,
|
||||
skipSourceProbe: options?.skipSourceProbe,
|
||||
},
|
||||
);
|
||||
},
|
||||
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
||||
);
|
||||
@@ -445,7 +501,12 @@ export function useDomSelection({
|
||||
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
|
||||
}
|
||||
} else {
|
||||
nextGroup = selections;
|
||||
// Dedupe by target: under select-as-unit several marquee'd members collapse
|
||||
// to the same group, which must count as one selection, not many duplicates.
|
||||
nextGroup = [];
|
||||
for (const s of selections) {
|
||||
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
|
||||
}
|
||||
}
|
||||
const nextSelection = additive && current ? current : selections[0];
|
||||
domEditSelectionRef.current = nextSelection;
|
||||
@@ -478,13 +539,16 @@ export function useDomSelection({
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
activeGroupElement,
|
||||
// Refs
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
domEditHoverSelectionRef,
|
||||
activeGroupElementRef,
|
||||
// State setters
|
||||
setDomEditSelection,
|
||||
setDomEditGroupSelections,
|
||||
setActiveGroupElement,
|
||||
// Callbacks
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
|
||||
@@ -20,13 +20,19 @@ export interface UsePreviewInteractionParams {
|
||||
resolveDomSelectionFromPreviewPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
|
||||
options?: {
|
||||
preferClipAncestor?: boolean;
|
||||
skipSourceProbe?: boolean;
|
||||
activeGroupElement?: HTMLElement | null;
|
||||
},
|
||||
) => Promise<DomEditSelection | null>;
|
||||
resolveAllDomSelectionsFromPreviewPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => Promise<DomEditSelection[]>;
|
||||
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
||||
/** Drill into a group (double-click on the canvas) so its children become selectable. */
|
||||
setActiveGroupElement: (el: HTMLElement | null) => void;
|
||||
|
||||
onClickToSource?: (selection: DomEditSelection) => void;
|
||||
}
|
||||
@@ -53,6 +59,7 @@ export function usePreviewInteraction({
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
resolveAllDomSelectionsFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
setActiveGroupElement,
|
||||
onClickToSource,
|
||||
}: UsePreviewInteractionParams) {
|
||||
const cycleRef = useRef<ClickCycleState | null>(null);
|
||||
@@ -62,6 +69,24 @@ export function usePreviewInteraction({
|
||||
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
||||
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
||||
|
||||
// Double-click a group → drill into it and select the child under the
|
||||
// pointer (resolve with the group as the explicit drill-in scope, since the
|
||||
// activeGroupElement state hasn't re-rendered yet within this handler).
|
||||
if (e.detail >= 2 && !e.shiftKey) {
|
||||
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
|
||||
if (hit?.element.hasAttribute("data-hf-group")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cycleRef.current = null;
|
||||
setActiveGroupElement(hit.element);
|
||||
const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
||||
activeGroupElement: hit.element,
|
||||
});
|
||||
applyDomSelection(child ?? hit);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const prev = cycleRef.current;
|
||||
const dx = prev ? e.clientX - prev.x : Infinity;
|
||||
@@ -125,6 +150,7 @@ export function usePreviewInteraction({
|
||||
onClickToSource,
|
||||
resolveAllDomSelectionsFromPreviewPoint,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
setActiveGroupElement,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ const SHORTCUT_SECTIONS = [
|
||||
{ key: "⌘V", label: "Paste element" },
|
||||
{ key: "⌘X", label: "Cut element" },
|
||||
{ key: "S", label: "Split clip at playhead" },
|
||||
{ key: "⌘G", label: "Group elements" },
|
||||
{ key: "⌘⇧G", label: "Ungroup" },
|
||||
{ key: "Del", label: "Delete selected element" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -81,6 +81,37 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Animated group members can move outside their wrapper's static layout box, so
|
||||
// the empty space inside a group's *visual* bounds (the member-union the overlay
|
||||
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
|
||||
// point falls within a group's live member-union rect, return that wrapper.
|
||||
// Innermost (smallest-area) group wins for nested groups.
|
||||
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
|
||||
let best: HTMLElement | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
|
||||
let left = Infinity;
|
||||
let top = Infinity;
|
||||
let right = -Infinity;
|
||||
let bottom = -Infinity;
|
||||
for (const member of Array.from(group.children)) {
|
||||
const r = member.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
left = Math.min(left, r.left);
|
||||
top = Math.min(top, r.top);
|
||||
right = Math.max(right, r.right);
|
||||
bottom = Math.max(bottom, r.bottom);
|
||||
}
|
||||
if (right < left || x < left || x > right || y < top || y > bottom) continue;
|
||||
const area = (right - left) * (bottom - top);
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
best = group;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function getPreviewTargetFromPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
@@ -113,6 +144,12 @@ export function getPreviewTargetFromPointer(
|
||||
if (visualTarget) return visualTarget;
|
||||
}
|
||||
|
||||
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
||||
// back to the group whose member-union contains the point, so the whole group
|
||||
// area is hoverable/selectable, not just where a member currently sits.
|
||||
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
||||
if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
|
||||
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
||||
if (!isElementComputedVisible(fallback)) return null;
|
||||
|
||||
Reference in New Issue
Block a user