feat(studio): simplify inspector selection ux

This commit is contained in:
Miguel Ángel
2026-05-08 21:40:12 -07:00
parent 1781f6bf1f
commit 5ddf0285f5
16 changed files with 529 additions and 1351 deletions
+19 -70
View File
@@ -78,10 +78,10 @@ import {
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
STUDIO_MOTION_PANEL_ENABLED,
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
STUDIO_PREVIEW_SELECTION_ENABLED,
STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
} from "./components/editor/manualEditingAvailability";
import {
buildDefaultDomEditTextField,
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
buildElementAgentPrompt,
@@ -92,6 +92,7 @@ import {
getDomEditLayerKey,
getDomEditTargetKey,
isTextEditableSelection,
resolveVisualDomEditSelectionTarget,
serializeDomEditTextFields,
resolveDomEditSelection,
type DomEditLayerItem,
@@ -362,6 +363,7 @@ function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
clientY: number,
activeCompositionPath: string | null,
): HTMLElement | null {
let doc: Document | null = null;
let win: Window | null = null;
@@ -386,6 +388,16 @@ function getPreviewTargetFromPointer(
const localX = (clientX - iframeRect.left) / scaleX;
const localY = (clientY - iframeRect.top) / scaleY;
if (typeof doc.elementsFromPoint === "function") {
const visualTarget = resolveVisualDomEditSelectionTarget(
doc.elementsFromPoint(localX, localY),
{
activeCompositionPath,
},
);
if (visualTarget) return visualTarget;
}
return getEventTargetElement(doc.elementFromPoint(localX, localY));
}
@@ -778,9 +790,6 @@ export function StudioApp() {
const [agentModalOpen, setAgentModalOpen] = useState(false);
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
ReadonlySet<string>
>(() => new Set());
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
const refreshPreviewDocumentVersion = useCallback(() => {
setPreviewDocumentVersion((version) => version + 1);
@@ -2296,13 +2305,13 @@ export function StudioApp() {
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
const iframe = previewIframeRef.current;
if (!iframe || captionEditMode) return null;
const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
if (!target) return null;
return buildDomSelectionFromTarget(target, {
preferClipAncestor: options?.preferClipAncestor,
});
},
[buildDomSelectionFromTarget, captionEditMode],
[activeCompPath, buildDomSelectionFromTarget, captionEditMode],
);
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
@@ -2439,17 +2448,6 @@ export function StudioApp() {
[applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
);
const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
const key = getTimelineElementKey(element);
if (!key) return;
setThumbnailedTimelineElementIds((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const handleTimelineLayerSelect = useCallback(
(layer: DomEditLayerItem) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
@@ -2991,48 +2989,6 @@ export function StudioApp() {
[commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
);
const handleDomAddTextField = useCallback(
async (afterFieldKey?: string) => {
if (!domEditSelection) return null;
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
const insertionIndex = domEditSelection.textFields.findIndex(
(field) => field.key === afterFieldKey,
);
const baseField =
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
domEditSelection.textFields[0];
const nextField = buildDefaultDomEditTextField(baseField);
const nextTextFields = [...domEditSelection.textFields];
nextTextFields.splice(
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
0,
nextField,
);
await commitDomTextFields(domEditSelection, nextTextFields);
return nextField.key;
},
[commitDomTextFields, domEditSelection],
);
const handleDomRemoveTextField = useCallback(
async (fieldKey: string) => {
if (!domEditSelection) return;
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
if (!field) return;
if (field.source === "self") {
await handleDomTextCommit("", fieldKey);
return;
}
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
await commitDomTextFields(domEditSelection, nextTextFields);
},
[commitDomTextFields, domEditSelection, handleDomTextCommit],
);
const handleAskAgent = useCallback(() => {
if (!domEditSelection) return;
setAgentPromptTagSnippet(undefined);
@@ -3083,9 +3039,9 @@ export function StudioApp() {
const handlePreviewCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode) return;
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? true,
preferClipAncestor: options?.preferClipAncestor ?? false,
});
if (!nextSelection) {
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
@@ -3100,7 +3056,7 @@ export function StudioApp() {
const handlePreviewCanvasPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode) {
updateDomEditHoverSelection(null);
return null;
}
@@ -3978,8 +3934,6 @@ export function StudioApp() {
onInspectTimelineElement={handleTimelineElementInspect}
inspectedTimelineElementId={inspectedTimelineElementId}
timelineLayerChildCounts={timelineLayerChildCounts}
thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
@@ -3997,7 +3951,7 @@ export function StudioApp() {
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
? domEditHoverSelection
: null
}
@@ -4103,8 +4057,6 @@ export function StudioApp() {
<div className="min-h-0 flex-1">
{designPanelActive ? (
<PropertyPanel
projectId={projectId}
assets={assets}
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
@@ -4113,11 +4065,8 @@ export function StudioApp() {
onSetManualSize={handleDomBoxSizeCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
onRemoveTextField={handleDomRemoveTextField}
onResetManualEdits={handleDomManualEditsReset}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
/>
@@ -4,8 +4,10 @@ import {
buildStrokeWidthStyleUpdates,
getClipPathInsetPx,
getCssFilterFunctionPx,
getPropertyPanelVisibleSections,
inferBoxShadowPreset,
inferClipPathPreset,
isPropertyPanelMediaLikeSelection,
normalizePanelPxValue,
setCssFilterFunctionPx,
} from "./PropertyPanel";
@@ -64,4 +66,51 @@ describe("PropertyPanel style helpers", () => {
expect(buildStrokeStyleUpdates("none", "4px")).toEqual([["border-style", "none"]]);
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
});
it("orders the simplified default inspector sections around high-confidence edits", () => {
expect(
getPropertyPanelVisibleSections({
hasSelection: true,
canEditStyles: true,
hasTextControls: true,
hasColorControls: true,
}),
).toEqual(["Text", "Layout", "Colors", "Radius", "Shadow"]);
expect(
getPropertyPanelVisibleSections({
hasSelection: true,
canEditStyles: true,
hasTextControls: false,
hasColorControls: false,
}),
).toEqual(["Layout", "Radius", "Shadow"]);
});
it("treats media tags and background-image layers as image-like controls", () => {
expect(
isPropertyPanelMediaLikeSelection({
tagName: "img",
styles: {},
}),
).toBe(true);
expect(
isPropertyPanelMediaLikeSelection({
tagName: "div",
styles: {
"background-image": "url(/assets/studio.png)",
},
}),
).toBe(true);
expect(
isPropertyPanelMediaLikeSelection({
tagName: "div",
styles: {
"background-image": "none",
},
}),
).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import {
getDomEditNonEditableReason,
getDomEditTargetKey,
isTextEditableSelection,
resolveVisualDomEditSelectionTarget,
serializeDomEditTextFields,
type DomEditSelection,
resolveDomEditCapabilities,
@@ -23,6 +24,30 @@ function createDocument(markup: string): Document {
return window.document;
}
function setElementRect(
element: HTMLElement,
rect: Partial<Pick<DOMRect, "left" | "top" | "width" | "height">>,
) {
const left = rect.left ?? 0;
const top = rect.top ?? 0;
const width = rect.width ?? 100;
const height = rect.height ?? 40;
Object.defineProperty(element, "getBoundingClientRect", {
configurable: true,
value: () => ({
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => null,
}),
});
}
describe("resolveDomEditCapabilities", () => {
it("marks absolute px-positioned layers as movable and resizable", () => {
expect(
@@ -199,6 +224,104 @@ describe("resolveDomEditCapabilities", () => {
});
});
describe("resolveVisualDomEditSelectionTarget", () => {
it("prefers the visible leaf under the pointer over an oversized container", () => {
const document = createDocument(`
<section id="container" class="hero-shell">
<span id="headline" class="headline">Launch faster</span>
</section>
`);
const container = document.getElementById("container") as HTMLElement;
const headline = document.getElementById("headline") as HTMLElement;
setElementRect(container, { width: 900, height: 520 });
setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 });
expect(
resolveVisualDomEditSelectionTarget([container, headline], {
activeCompositionPath: "index.html",
}),
).toBe(headline);
});
it("skips hidden and zero-size elements before picking a rendered candidate", () => {
const document = createDocument(`
<div id="hidden" style="display: none">Hidden</div>
<div id="empty">Empty</div>
<div id="visible">Visible</div>
`);
const hidden = document.getElementById("hidden") as HTMLElement;
const empty = document.getElementById("empty") as HTMLElement;
const visible = document.getElementById("visible") as HTMLElement;
setElementRect(hidden, { width: 120, height: 32 });
setElementRect(empty, { width: 0, height: 0 });
setElementRect(visible, { width: 120, height: 32 });
expect(
resolveVisualDomEditSelectionTarget([hidden, empty, visible], {
activeCompositionPath: "index.html",
}),
).toBe(visible);
});
it("skips transparent elements that still report a box", () => {
const document = createDocument(`
<button id="transparent" style="opacity: 0">Transparent</button>
<button id="visible">Visible</button>
`);
const transparent = document.getElementById("transparent") as HTMLElement;
const visible = document.getElementById("visible") as HTMLElement;
setElementRect(transparent, { width: 120, height: 32 });
setElementRect(visible, { width: 120, height: 32 });
expect(
resolveVisualDomEditSelectionTarget([transparent, visible], {
activeCompositionPath: "index.html",
}),
).toBe(visible);
});
it("falls back to the nearest stable editable ancestor when a visual child has no target", () => {
const document = createDocument(`
<section id="card">
<span>Unlabeled copy</span>
</section>
`);
const card = document.getElementById("card") as HTMLElement;
const span = card.querySelector("span") as HTMLElement;
setElementRect(card, { width: 400, height: 200 });
setElementRect(span, { left: 40, top: 40, width: 140, height: 28 });
expect(
resolveVisualDomEditSelectionTarget([span, card], {
activeCompositionPath: "index.html",
}),
).toBe(card);
});
it("keeps explicit layer selection able to target containers", () => {
const document = createDocument(`
<section id="container" class="hero-shell">
<span id="headline" class="headline">Launch faster</span>
</section>
`);
const container = document.getElementById("container") as HTMLElement;
const headline = document.getElementById("headline") as HTMLElement;
setElementRect(container, { width: 900, height: 520 });
setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 });
const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], {
activeCompositionPath: "index.html",
});
const explicitSelection = resolveDomEditSelection(container, {
activeCompositionPath: "index.html",
isMasterView: false,
});
expect(visualTarget).toBe(headline);
expect(explicitSelection?.id).toBe("container");
});
});
describe("resolveDomEditSelection", () => {
it("keeps composition host transforms disabled in master view", () => {
expect(
@@ -643,6 +766,36 @@ describe("resolveDomEditSelection", () => {
).toBe(root);
});
it("normalizes preview URLs when resolving master timeline composition clips", () => {
const document = createDocument(`
<div data-composition-id="main">
<div
id="slide-1"
data-composition-id="slide-core-conviction"
data-composition-src="compositions/slide-01-core-conviction.html"
>
<h1>Core Conviction</h1>
</div>
</div>
`);
const slide = document.getElementById("slide-1") as HTMLElement;
expect(
findElementForTimelineElement(
document,
{
id: "slide-1",
compositionSrc:
"http://127.0.0.1:5176/api/projects/apple-presentation-download/preview/compositions/slide-01-core-conviction.html",
},
{
activeCompositionPath: null,
isMasterView: true,
},
),
).toBe(slide);
});
it("does not fall back to the root composition when an explicit timeline selector misses", () => {
const document = createDocument(`
<div data-composition-id="hook">
@@ -291,6 +291,27 @@ function escapeCssString(value: string): string {
.replace(/\f/g, "\\c ");
}
function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
let pathname = trimmed;
try {
pathname = new URL(trimmed, "http://studio.local").pathname;
} catch {
pathname = trimmed;
}
for (const marker of ["/preview/comp/", "/preview/"]) {
const markerIndex = pathname.indexOf(marker);
if (markerIndex < 0) continue;
const sourcePath = pathname.slice(markerIndex + marker.length).replace(/^\/+/, "");
return sourcePath || trimmed;
}
return trimmed;
}
function querySelectorAllSafely(doc: Document, selector: string): Element[] {
try {
return Array.from(doc.querySelectorAll(selector));
@@ -416,6 +437,66 @@ function getDomLayerPatchTarget(
};
}
function getElementDepth(el: HTMLElement): number {
let depth = 0;
let current = el.parentElement;
while (current) {
depth += 1;
current = current.parentElement;
}
return depth;
}
function hasRenderedBox(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) return false;
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
if (!computed) return true;
if (computed.display === "none" || computed.visibility === "hidden") return false;
const opacity = Number.parseFloat(computed.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
return true;
}
function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
const tagName = el.tagName.toLowerCase();
const rect = el.getBoundingClientRect();
const area = Math.max(1, rect.width * rect.height);
const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
const visualLeafBonus =
isEditableTextLeaf(el) || ["img", "video", "canvas", "svg"].includes(tagName) ? 2_000 : 0;
return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
}
export function resolveVisualDomEditSelectionTarget(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement | null {
let best: { element: HTMLElement; score: number } | null = null;
let pointerStackIndex = 0;
for (const entry of elementsFromPoint) {
if (!isHtmlElement(entry)) {
pointerStackIndex += 1;
continue;
}
if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
const score = getVisualElementScore(entry, pointerStackIndex);
if (!best || score > best.score) {
best = { element: entry, score };
}
}
pointerStackIndex += 1;
}
return best?.element ?? null;
}
function getDirectLayerChildren(el: HTMLElement, options: DomEditContextOptions): HTMLElement[] {
return Array.from(el.children).filter(
(child): child is HTMLElement =>
@@ -848,9 +929,14 @@ export function findElementForTimelineElement(
options: TimelineElementDomTargetOptions,
): HTMLElement | null {
const elementId = typeof element.id === "string" ? element.id : "";
const compositionSource = element.compositionSrc ?? options.compIdToSrc?.get(elementId);
const compositionSource =
normalizeTimelineCompositionSource(element.compositionSrc) ??
options.compIdToSrc?.get(elementId);
const sourceFile =
compositionSource ?? element.sourceFile ?? options.activeCompositionPath ?? "index.html";
compositionSource ??
normalizeTimelineCompositionSource(element.sourceFile) ??
options.activeCompositionPath ??
"index.html";
const escapedElementId = escapeCssString(elementId);
const escapedCompositionSource = compositionSource ? escapeCssString(compositionSource) : null;
const selector =
@@ -16,10 +16,11 @@ describe("manual editing availability", () => {
vi.resetModules();
});
it("enables inspector layers by default while motion and manual editing stay opt-in", async () => {
it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false);
expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(true);
@@ -51,6 +52,7 @@ describe("manual editing availability", () => {
});
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(false);
expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(false);
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(false);
});
@@ -55,6 +55,8 @@ export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
true,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
export const STUDIO_MANUAL_EDITING_ENABLED = STUDIO_PREVIEW_MANUAL_EDITING_ENABLED;
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -55,8 +55,6 @@ interface NLELayoutProps {
onInspectTimelineElement?: (element: TimelineElement) => void;
inspectedTimelineElementId?: string | null;
timelineLayerChildCounts?: ReadonlyMap<string, number>;
thumbnailedTimelineElementIds?: ReadonlySet<string>;
onToggleTimelineElementThumbnail?: (element: TimelineElement) => void;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
/** Whether the timeline panel is visible (default: true) */
@@ -94,8 +92,6 @@ export const NLELayout = memo(function NLELayout({
onInspectTimelineElement,
inspectedTimelineElementId,
timelineLayerChildCounts,
thumbnailedTimelineElementIds,
onToggleTimelineElementThumbnail,
onCompIdToSrcChange,
timelineVisible,
onToggleTimeline,
@@ -453,8 +449,6 @@ export const NLELayout = memo(function NLELayout({
onInspectElement={onInspectTimelineElement}
inspectedElementId={inspectedTimelineElementId}
layerChildCounts={timelineLayerChildCounts}
thumbnailedElementIds={thumbnailedTimelineElementIds}
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
disabled={timelineDisabled}
/>
</div>
+2 -2
View File
@@ -3,11 +3,11 @@ import { join } from "node:path";
import {
createStudioManualEditsRenderBodyScript,
type StudioManualEditsRenderScriptOptions,
} from "@hyperframes/core/studio-api/manual-edits-render-script";
} from "../core/src/studio-api/helpers/manualEditsRenderScript";
import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "@hyperframes/core/studio-api/studio-motion-render-script";
} from "../core/src/studio-api/helpers/studioMotionRenderScript";
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB