Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ type GsapMutationRequest =
| {
type: "split-into-property-groups";
animationId: string;
}
| {
type: "delete-all-for-selector";
targetSelector: string;
};

// ── GSAP mutation executor ──────────────────────────────────────────────────
Expand Down Expand Up @@ -528,6 +532,17 @@ async function executeGsapMutation(
}
return removeAnimationFromScript(block.scriptText, body.animationId);
}
case "delete-all-for-selector": {
const parsed = parseGsapScript(block.scriptText);
const matching = parsed.animations.filter((a) => a.targetSelector === body.targetSelector);
if (matching.length === 0) return block.scriptText;
stripStudioEditsFromTarget(block.document, body.targetSelector);
let script = block.scriptText;
for (const anim of matching.reverse()) {
script = removeAnimationFromScript(script, anim.id);
}
return script;
}
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
Expand Down
34 changes: 27 additions & 7 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function StudioPreviewArea({
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
buildDomSelectionFromTarget,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomPathOffsetCommit,
Expand All @@ -119,7 +120,7 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
} = useDomEditContext();

const [snapPrefs, setSnapPrefs] = useState(() => {
Expand Down Expand Up @@ -153,10 +154,9 @@ export function StudioPreviewArea({
onRazorSplit={handleRazorSplit}
onRazorSplitAll={handleRazorSplitAll}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
for (const anim of selectedGsapAnimations) {
handleGsapDeleteAnimation(anim.id);
}
onDeleteAllKeyframes={(elId) => {
const rawId = elId.includes("#") ? elId.split("#").pop()! : elId;
handleGsapDeleteAllForElement(`#${rawId}`);
}}
onDeleteKeyframe={(_elId, pct) => {
const cacheKey = domEditSelection?.id ?? "";
Expand Down Expand Up @@ -185,11 +185,23 @@ export function StudioPreviewArea({
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
const kf = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - tweenOldPct) < 0.2,
);
if (!kf) return;
const tweenStart = anim.resolvedStart ?? 0;
const tweenDur = anim.duration ?? 1;
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
const tweenNewPct =
tweenDur > 0
? Math.max(
0,
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
)
: 0;
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, newPct, prop, val);
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
}
}}
onToggleKeyframeAtPlayhead={(el) => {
Expand Down Expand Up @@ -279,6 +291,14 @@ export function StudioPreviewArea({
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
onSelectElementById={async (id) => {
const iframe = previewIframeRef.current;
const el = iframe?.contentDocument?.getElementById(id);
if (!el) return null;
const sel = await buildDomSelectionFromTarget(el);
if (sel) applyDomSelection(sel, { revealPanel: true });
return sel;
}}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
Expand Down
21 changes: 0 additions & 21 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,6 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";

function AutoKeyframeToggle() {
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
return (
<Tooltip label={enabled ? "Auto-keyframe ON" : "Auto-keyframe OFF"}>
<button
type="button"
onClick={() => usePlayerStore.getState().setAutoKeyframeEnabled(!enabled)}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
enabled ? "text-red-400" : "text-neutral-600 hover:text-neutral-400"
}`}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
{enabled && <circle cx="7" cy="7" r="3" fill="currentColor" />}
</svg>
</button>
</Tooltip>
);
}

interface DomEditSessionSlice extends EnableKeyframesSession {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
Expand Down Expand Up @@ -169,7 +149,6 @@ export function TimelineToolbar({
</svg>
</button>
</Tooltip>
<AutoKeyframeToggle />
</>
)}
{onSplitElement &&
Expand Down
79 changes: 79 additions & 0 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { useOffScreenIndicators } from "./useOffScreenIndicators";

// Re-exports for external consumers — preserving existing import paths.
export {
Expand Down Expand Up @@ -54,6 +55,7 @@ interface DomEditOverlayProps {
) => void;
onBlockedMove: (selection: DomEditSelection) => void;
onManualDragStart?: () => void;
onSelectElementById?: (id: string) => Promise<DomEditSelection | null>;
onPathOffsetCommit: (
selection: DomEditSelection,
next: { x: number; y: number },
Expand Down Expand Up @@ -83,6 +85,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
gridVisible = false,
gridSpacing = 50,
onManualDragStart,
onSelectElementById,
onPathOffsetCommit,
onGroupPathOffsetCommit,
onBoxSizeCommit,
Expand Down Expand Up @@ -212,6 +215,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return () => cancelAnimationFrame(frame);
});

const offScreenIndicators = useOffScreenIndicators({ iframeRef, overlayRef, compRect });

const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
Expand Down Expand Up @@ -263,6 +268,22 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
// Don't re-resolve selection when clicking outside the composition bounds —
// the iframe can't resolve elements there, so it would clear the selection.
if (selection && compRect.width > 0) {
const overlayEl = overlayRef.current;
if (overlayEl) {
const overlayRect = overlayEl.getBoundingClientRect();
const clickX = event.clientX - overlayRect.left;
const clickY = event.clientY - overlayRect.top;
const outsideComp =
clickX < compRect.left ||
clickX > compRect.left + compRect.width ||
clickY < compRect.top ||
clickY > compRect.top + compRect.height;
if (outsideComp) return;
}
}
onCanvasMouseDown(event, { preferClipAncestor: false });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
Expand Down Expand Up @@ -500,6 +521,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}}
/>
))}
{offScreenIndicators.length > 0 &&
compRect.width > 0 &&
offScreenIndicators.map((ind) => {
const isSelected = selection?.id === ind.elementId;
return (
<div
key={`offscreen-${ind.key}`}
className={`absolute rounded-sm ${isSelected ? "pointer-events-none" : "cursor-grab"}`}
style={{
left: ind.left,
top: ind.top,
width: ind.width,
height: ind.height,
border: `1.5px dashed var(--panel-accent, #34d399)`,
opacity: isSelected ? 0.3 : 0.5,
zIndex: isSelected ? 1 : 5,
}}
onPointerDown={
isSelected
? undefined
: (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
let deltaX = 0;
let deltaY = 0;
let moved = false;
const onMove = (me: PointerEvent) => {
deltaX = me.clientX - startX;
deltaY = me.clientY - startY;
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) moved = true;
if (moved) {
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
};
const onUp = async (ue: PointerEvent) => {
el.releasePointerCapture(ue.pointerId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.style.transform = "";
const sel = await onSelectElementById?.(ind.elementId);
if (moved && sel && onPathOffsetCommit) {
const scale = compRect.scaleX || 1;
onPathOffsetCommit(sel, { x: deltaX / scale, y: deltaY / scale });
}
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
}
}
title={isSelected ? undefined : `Drag #${ind.elementId}`}
/>
);
})}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
false,
true,
);

export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
Expand Down
Loading
Loading