Files
hyperframes/packages/studio/src/components/editor/DomEditOverlay.tsx
T
Miguel Ángel f19d6fd471 feat: CLI observability + fix studio save failures on JS-created elements (#1091)
* feat(core): add probeElementInSource for source-existence checks

* feat(core): add probe-element endpoint for source-existence checks

* feat(studio): gate editing capabilities on source existence

* fix(studio): enrich save_failure telemetry with target details

* feat(studio): async selection resolution with source probe

Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").

Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
  `probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
  when `projectId` is supplied and the element has a stable id/selector.
  `existsInSource: false` flows into `resolveDomEditCapabilities`, which
  disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
  `resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
  helpers to eliminate repeated boilerplate across remove/patch/probe handlers.

Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
  `resolveDomSelectionFromPreviewPoint`,
  `buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
  `refreshDomEditSelectionFromPreview`, and
  `refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
  forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
  `buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
  with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
  `handlePreviewCanvasPointerMove` made async (React ignores handler return
  values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
  converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
  `handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
  return type widened to `Promise<DomEditSelection | null>`; pointer-down
  handler falls back to `hoverSelectionRef.current` (always populated by a
  prior hover) instead of awaiting the async move callback inline.

Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
  files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
  not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
  made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
  and `hoverSelection` pre-seeded so pointer-down test works with the new
  hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
  `Promise.resolve()`; seek/selection hydration test made async with
  `await act(async () => { await Promise.resolve(); })` to flush microtasks.

* feat(cli): add global error handlers for crash telemetry

Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.

* feat(cli): track per-command success/failure and duration

* test(core): add integration test for JS-created element probe scenario

* fix: address PR review feedback

- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc

* fix(cli): restore stack_trace in cli_error telemetry

* fix(cli): use captured module refs in exit handlers instead of dead import()
2026-05-27 01:44:31 -04:00

393 lines
14 KiB
TypeScript

import { memo, useMemo, useRef, type RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import {
type BlockedMoveState,
type FocusableDomEditOverlay,
type GestureState,
type GroupGestureState,
focusDomEditOverlayElement,
} from "./domEditOverlayGestures";
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
// Re-exports for external consumers — preserving existing import paths.
export {
filterNestedDomEditGroupItems,
resolveDomEditCoordinateScale,
resolveDomEditGroupOverlayRect,
} from "./domEditOverlayGeometry";
export {
focusDomEditOverlayElement,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
export interface DomEditGroupPathOffsetCommit {
selection: DomEditSelection;
next: { x: number; y: number };
}
interface DomEditOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
activeCompositionPath: string | null;
selection: DomEditSelection | null;
groupSelections?: DomEditSelection[];
hoverSelection: DomEditSelection | null;
allowCanvasMovement?: boolean;
onCanvasMouseDown: (
event: React.MouseEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
) => void;
onCanvasPointerMove: (
event: React.PointerEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
onCanvasPointerLeave: () => void;
onSelectionChange: (
selection: DomEditSelection,
options?: { revealPanel?: boolean; additive?: boolean },
) => void;
onBlockedMove: (selection: DomEditSelection) => void;
onManualDragStart?: () => 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 },
) => Promise<void> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
}
export const DomEditOverlay = memo(function DomEditOverlay({
iframeRef,
activeCompositionPath,
selection,
groupSelections = [],
hoverSelection,
allowCanvasMovement = true,
onCanvasMouseDown,
onCanvasPointerMove,
onCanvasPointerLeave,
onSelectionChange,
onBlockedMove,
onManualDragStart,
onPathOffsetCommit,
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const gestureRef = useRef<GestureState | null>(null);
const groupGestureRef = useRef<GroupGestureState | null>(null);
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
const suppressNextBoxClickRef = useRef(false);
const suppressNextBoxMouseDownRef = useRef(false);
const suppressNextOverlayMouseDownRef = useRef(false);
const rafPausedRef = useRef(false);
const selectionRef = useRef(selection);
selectionRef.current = selection;
const activeCompositionPathRef = useRef(activeCompositionPath);
activeCompositionPathRef.current = activeCompositionPath;
const groupSelectionsRef = useRef(groupSelections);
groupSelectionsRef.current = groupSelections;
const hoverSelectionRef = useRef(hoverSelection);
hoverSelectionRef.current = hoverSelection;
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);
onRotationCommitRef.current = onRotationCommit;
const onBlockedMoveRef = useRef(onBlockedMove);
onBlockedMoveRef.current = onBlockedMove;
const onManualDragStartRef = useRef(onManualDragStart);
onManualDragStartRef.current = onManualDragStart;
const onCanvasPointerMoveRef = useRef(onCanvasPointerMove);
onCanvasPointerMoveRef.current = onCanvasPointerMove;
const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave);
onCanvasPointerLeaveRef.current = onCanvasPointerLeave;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const {
overlayRect,
overlayRectRef,
setOverlayRect,
hoverRect,
groupOverlayItems,
groupOverlayItemsRef,
setGroupOverlayItems,
} = useDomEditOverlayRects({
iframeRef,
overlayRef,
selectionRef,
activeCompositionPathRef,
groupSelectionsRef,
hoverSelectionRef,
rafPausedRef,
});
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
boxRef,
selectionRef,
overlayRectRef,
groupOverlayItemsRef,
gestureRef,
groupGestureRef,
blockedMoveRef,
rafPausedRef,
suppressNextBoxClickRef,
setOverlayRect,
setGroupOverlayItems,
onBlockedMoveRef,
onManualDragStartRef,
onPathOffsetCommitRef,
onGroupPathOffsetCommitRef,
onBoxSizeCommitRef,
onRotationCommitRef,
onCanvasPointerMoveRef,
onCanvasMouseDown,
});
const selectionKey = useMemo(() => {
if (!selection) return "none";
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${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 handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (suppressNextOverlayMouseDownRef.current) {
suppressNextOverlayMouseDownRef.current = false;
suppressNextBoxMouseDownRef.current = false;
suppressNextBoxClickRef.current = false;
event.preventDefault();
event.stopPropagation();
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
onCanvasMouseDown(event, { preferClipAncestor: false });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
suppressNextBoxClickRef.current = true;
}
};
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) {
// Use the already-updated hover selection rather than re-resolving async
const candidate = hoverSelectionRef.current;
if (!candidate) return;
event.preventDefault();
event.stopPropagation();
suppressNextOverlayMouseDownRef.current = true;
suppressNextBoxMouseDownRef.current = true;
suppressNextBoxClickRef.current = true;
onSelectionChangeRef.current(candidate, { additive: true });
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
const candidate = hoverSelectionRef.current;
if (!candidate?.capabilities.canApplyManualOffset) return;
const overlayEl = overlayRef.current;
const iframe = iframeRef.current;
const candidateRect =
overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
if (!candidateRect) return;
suppressNextOverlayMouseDownRef.current = true;
selectionRef.current = candidate;
setOverlayRect(candidateRect);
const didStartGesture = gestures.startGesture("drag", event, {
selection: candidate,
rect: candidateRect,
});
if (!didStartGesture) {
suppressNextOverlayMouseDownRef.current = false;
return;
}
onSelectionChangeRef.current(candidate);
};
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (gestureRef.current || groupGestureRef.current) return;
if (suppressNextBoxClickRef.current) {
suppressNextBoxClickRef.current = false;
event.stopPropagation();
return;
}
onCanvasMouseDown(event, { preferClipAncestor: false });
};
const suppressBoxMouseDown = (e: React.MouseEvent) => {
if (!suppressNextBoxMouseDownRef.current) return;
suppressNextBoxMouseDownRef.current = false;
e.preventDefault();
e.stopPropagation();
};
return (
<div
ref={overlayRef}
className="absolute inset-0 z-10 pointer-events-auto outline-none"
tabIndex={-1}
aria-label="Composition canvas"
onPointerDownCapture={(event) =>
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
}
onPointerDown={handleOverlayPointerDown}
onMouseDown={handleOverlayMouseDown}
onPointerMove={gestures.onPointerMove}
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
onPointerUp={gestures.onPointerUp}
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
>
{hoverSelection && hoverRect && (
<div
aria-hidden="true"
data-dom-edit-hover-box="true"
className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
}}
/>
)}
{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 || !groupCanMove || e.shiftKey) return;
gestures.startGroupDrag(e);
}}
onMouseDown={suppressBoxMouseDown}
onClick={handleBoxClick}
/>
</>
)}
{!hasGroupSelection && selection && overlayRect && (
<>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
<div
className="pointer-events-none absolute"
style={{
left: overlayRect.left + overlayRect.width / 2,
top: overlayRect.top - 34,
width: 28,
height: 34,
transform: "translateX(-50%)",
}}
>
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
<button
type="button"
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
style={{ cursor: "grab", touchAction: "none" }}
title="Rotate"
aria-label="Rotate selection"
onPointerDown={(e) => {
e.stopPropagation();
gestures.startGesture("rotate", e);
}}
/>
</div>
)}
<div
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
? "move"
: "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || e.shiftKey) return;
if (selection.capabilities.canApplyManualOffset) {
gestures.startGesture("drag", e);
return;
}
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
blockedMoveRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
notified: false,
};
}}
onMouseDown={suppressBoxMouseDown}
onClick={handleBoxClick}
>
{allowCanvasMovement && selection.capabilities.canApplyManualSize && (
<div
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
style={{ cursor: "se-resize", touchAction: "none" }}
onPointerDown={(e) => {
e.stopPropagation();
gestures.startGesture("resize", e);
}}
/>
)}
</div>
</>
)}
</div>
);
});