Files
hyperframes/packages/studio/src/hooks/useDomEditPreviewSync.ts
T
Miguel ÁngelandClaude Opus 4.8 8ae010bf51 feat(studio): marquee multi-selection + off-canvas indicators (#1693)
* chore(studio): remove all console.* calls from studio package

* chore(studio): address review — remove dead stubs, restore consent notice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): immediateRender for set tweens + array timeline normalization

- Set tweens now emit immediateRender:true so they render on page load
  without requiring the runtime to seek past position 0
- Runtime IIFE normalizes array timelines (window.__timelines = [tl])
  to keyed objects, and auto-adds data-start on root elements
- Drag teardown clears translate:none to prevent #1673 fly-off
- Position-only set tweens hidden from timeline diamonds (3 cache paths)
- Parser: ease-only keyframe update preserves existing properties

* fix(runtime): address review — restore perf gate, debug surface, scrub restore

- Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on
  restack — avoids ~2400 wasted per-tick seeks on video-heavy renders.
- Restore the console.debug body + docstring bullet of swallow() in
  diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an
  empty if-block.
- Rebind: after the progress-cycle set() kick, seek to state.currentTime via
  totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload
  restore keeps the playhead.
- Array __timelines normalization + data-start default now resolve the root
  via a shared findRootCompositionEl() that honors data-root="true" first
  (matches resolveRootCompositionElement, which now delegates to it).
- Ease-only keyframe update leaves a primitive (non-object) keyframe value
  untouched instead of wiping it to {}; add a preservation unit test.
- Document the boundDuration<=0 progress(1) kick + restore the STATIC-case
  comment in gsapRuntimeBridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): marquee multi-selection + off-canvas indicators

- Click+drag on empty canvas draws dashed selection rectangle
- SAT/OBB intersection handles rotated/scaled/skewed elements
- Shift+marquee adds to existing selection
- Click on empty canvas deselects
- Off-canvas elements show dashed outline indicators (clickable)
- Dashed border only shows outside canvas, solid inside (clip-path)
- 12 geometry unit tests

* feat(studio): address review — group-aware off-canvas indicators + fixes

- Off-canvas indicator suppression now skips every selected element (primary
  AND marquee group members), not just the primary, so group members no longer
  render a doubled overlay (group rect + dashed indicator).
- Drop selection from the off-canvas layout effect deps; the selected-element
  filter runs at render time. Avoids re-walking geometry on each selection change.
- applyMarqueeSelection now honors STUDIO_INSPECTOR_PANELS_ENABLED.
- Restore the stale-selection clear in useDomEditPreviewSync when the selected
  element no longer resolves after a re-sync. Drag-release stays handled by
  suppressNextBoxClickRef.
- Off-canvas indicator is keyboard-accessible; canvas cursor driven by marquee
  rect state, not a render-time ref read.
- Rename partiallyOutside -> extendsOutsideComp + comment the clip-path hit-test.
- Extract OffCanvasIndicators into its own component (DomEditOverlay was already
  over the 600-LOC cap on this branch; extraction brings it under).
- Declare onUpdateKeyframeEase on PropertyPanelProps so this branch typechecks
  standalone (handler + wiring already here; only the type had leaked upstack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:53:18 -04:00

135 lines
4.9 KiB
TypeScript

/**
* Side effects for syncing the DOM edit selection with the preview iframe on
* load/refresh, and for auto-revealing source in the Code tab.
* Extracted from useDomEditSession to keep file sizes under the 600-line limit.
*/
import { useEffect, useRef } from "react";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { PatchTarget } from "../utils/sourcePatcher";
interface UseDomEditPreviewSyncParams {
previewIframe: HTMLIFrameElement | null;
activeCompPath: string | null;
captionEditMode: boolean;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; preserveGroup?: boolean },
) => void;
buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
refreshPreviewDocumentVersion: () => void;
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
getSidebarTab?: () => SidebarTab;
gsapCacheVersion?: number;
}
export function useDomEditPreviewSync({
previewIframe,
activeCompPath,
captionEditMode,
domEditSelectionRef,
domEditSelection,
applyDomSelection,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
openSourceForSelection,
getSidebarTab,
gsapCacheVersion,
}: UseDomEditPreviewSyncParams): void {
// Sync selection from preview document on load / refresh
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!previewIframe) return;
// fallow-ignore-next-line complexity
const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current;
if (!currentSelection) return;
let doc: Document | null = null;
try {
doc = previewIframe.contentDocument;
} catch {
return;
}
if (!doc) return;
reapplyPositionEditsAfterSeek(doc);
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
if (!nextElement) {
// The selected element no longer resolves in the (re-synced) document
// — comp/hot reload, activeCompPath swap, or post-save replacement.
// Clear so overlay geometry isn't computed on a stale, detached node.
// (Drag-release-in-gray-zone is handled separately by
// suppressNextBoxClickRef; the dragged element still resolves here.)
applyDomSelection(null, { revealPanel: false });
return;
}
const nextSelection = await buildDomSelectionFromTarget(nextElement);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
};
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
};
previewIframe.addEventListener("load", handleLoad);
return () => {
previewIframe.removeEventListener("load", handleLoad);
};
}, [
activeCompPath,
applyDomSelection,
buildDomSelectionFromTarget,
captionEditMode,
domEditSelectionRef,
previewIframe,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
gsapCacheVersion,
]);
// Auto-reveal source when an element is selected while the Code tab is active.
// Use a ref for the callback so the effect only fires on selection changes,
// not when openSourceForSelection is recreated due to editingFile content updates.
const openSourceRef = useRef(openSourceForSelection);
openSourceRef.current = openSourceForSelection;
useEffect(
// fallow-ignore-next-line complexity
() => {
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
if (!domEditSelection.sourceFile) return;
if (getSidebarTab() !== "code") return;
openSourceRef.current(domEditSelection.sourceFile, {
id: domEditSelection.id,
selector: domEditSelection.selector,
selectorIndex: domEditSelection.selectorIndex,
});
},
[domEditSelection, getSidebarTab],
);
}