fix(studio): route inspector geometry through manual edits

This commit is contained in:
Vance Ingalls
2026-05-04 14:44:30 -07:00
committed by Miguel Ángel
parent d17288fc70
commit 62b83a0e2b
2 changed files with 120 additions and 100 deletions
+50 -84
View File
@@ -239,10 +239,7 @@ function normalizeDomEditStyleValue(property: string, value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
if (
["left", "top", "width", "height", "border-radius", "font-size"].includes(property) &&
/^-?\d+(\.\d+)?$/.test(trimmed)
) {
if (["border-radius", "font-size"].includes(property) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
return `${trimmed}px`;
}
@@ -253,27 +250,6 @@ function isImageBackgroundValue(value: string): boolean {
return /^url\(/i.test(value.trim());
}
function shouldDetachOppositeEdges(selection: DomEditSelection): boolean {
return Boolean(
selection.inlineStyles.inset || selection.inlineStyles.right || selection.inlineStyles.bottom,
);
}
function buildOppositeEdgePatchOperations(
selection: DomEditSelection,
dimension: "width" | "height" | "both",
): PatchOperation[] {
if (!shouldDetachOppositeEdges(selection)) return [];
const operations: PatchOperation[] = [];
if (dimension === "width" || dimension === "both") {
operations.push({ type: "inline-style", property: "right", value: "auto" });
}
if (dimension === "height" || dimension === "both") {
operations.push({ type: "inline-style", property: "bottom", value: "auto" });
}
return operations;
}
function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
if (!target || typeof target !== "object") return null;
const maybeNode = target as {
@@ -339,12 +315,8 @@ function findMatchingTimelineElementId(
return null;
}
function isMoveStyleProperty(property: string): boolean {
return property === "left" || property === "top";
}
function isResizeStyleProperty(property: string): boolean {
return property === "width" || property === "height";
function isManualGeometryStyleProperty(property: string): boolean {
return property === "left" || property === "top" || property === "width" || property === "height";
}
function getPreviewTargetFromPointer(
@@ -1987,45 +1959,6 @@ export function StudioApp() {
[activeCompPath, editHistory.recordEdit, writeProjectFile],
);
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
{
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
},
);
},
[commitStudioManualEditManifestOptimistically],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
{
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
},
);
},
[commitStudioManualEditManifestOptimistically],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
{
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
},
);
},
[commitStudioManualEditManifestOptimistically],
);
const refreshDomEditSelectionFromPreview = useCallback(
(selection: DomEditSelection) => {
const iframe = previewIframeRef.current;
@@ -2048,6 +1981,48 @@ export function StudioApp() {
[applyDomSelection, buildDomSelectionFromTarget],
);
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
{
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
{
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
{
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
);
const handleDomManualEditsReset = useCallback(
(selection: DomEditSelection) => {
commitStudioManualEditManifestOptimistically(
@@ -2070,11 +2045,8 @@ export function StudioApp() {
const handleDomStyleCommit = useCallback(
async (property: string, value: string) => {
if (!domEditSelection) return;
const isMoveStyle = isMoveStyleProperty(property);
const isResizeStyle = isResizeStyleProperty(property);
if (isMoveStyle && !domEditSelection.capabilities.canMove) return;
if (isResizeStyle && !domEditSelection.capabilities.canResize) return;
if (!isMoveStyle && !isResizeStyle && !domEditSelection.capabilities.canEditStyles) return;
if (isManualGeometryStyleProperty(property)) return;
if (!domEditSelection.capabilities.canEditStyles) return;
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
const iframe = previewIframeRef.current;
const doc = iframe?.contentDocument;
@@ -2086,10 +2058,6 @@ export function StudioApp() {
injectPreviewGoogleFont(doc, value);
if (importedFont) injectPreviewImportedFont(doc, importedFont);
}
if (shouldDetachOppositeEdges(domEditSelection)) {
if (property === "width") el.style.right = "auto";
if (property === "height") el.style.bottom = "auto";
}
if (property === "background-image" && isImageBackgroundValue(value)) {
el.style.setProperty("background-position", "center");
el.style.setProperty("background-repeat", "no-repeat");
@@ -2100,11 +2068,7 @@ export function StudioApp() {
const operations: PatchOperation[] = [
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
];
if (property === "width") {
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "width"));
} else if (property === "height") {
operations.push(...buildOppositeEdgePatchOperations(domEditSelection, "height"));
} else if (property === "background-image" && isImageBackgroundValue(value)) {
if (property === "background-image" && isImageBackgroundValue(value)) {
operations.push(
buildDomEditStylePatchOperation("background-position", "center"),
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
@@ -3278,6 +3242,8 @@ export function StudioApp() {
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onSetStyle={handleDomStyleCommit}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
@@ -38,6 +38,7 @@ import {
type GradientModel,
} from "./gradientValue";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
import {
COMMON_LOCAL_FONT_FAMILIES,
googleFontStylesheetUrl,
@@ -54,6 +55,8 @@ interface PropertyPanelProps {
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
@@ -171,6 +174,17 @@ function parseNumericToken(value: string | undefined): ParsedNumericToken | null
};
}
function parsePxMetricValue(value: string): number | null {
const token = parseNumericToken(value);
if (!token) return null;
if (token.unit && token.unit.toLowerCase() !== "px") return null;
return token.value;
}
function formatPxMetricValue(value: number): string {
return `${formatNumericValue(value)}px`;
}
function adjustNumericToken(
value: string,
direction: 1 | -1,
@@ -1962,6 +1976,8 @@ export const PropertyPanel = memo(function PropertyPanel({
copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetManualOffset,
onSetManualSize,
onSetText,
onSetTextFieldStyle,
onAddTextField,
@@ -2006,22 +2022,60 @@ export const PropertyPanel = memo(function PropertyPanel({
<Eye size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
and cleaner grouped layer controls.
The inspector is tuned for element edits with safer geometry controls, color picking, and
cleaner grouped layer controls.
</p>
</div>
);
}
const styleEditingDisabled = !element.capabilities.canEditStyles;
const moveEditingDisabled = !element.capabilities.canMove;
const resizeEditingDisabled = !element.capabilities.canResize;
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
const clipContent = ["hidden", "clip"].includes((styles.overflow ?? "").trim());
const sourceLabel = element.id ? `#${element.id}` : element.selector;
const showEditableSections = element.capabilities.canEditStyles;
const manualOffset = readStudioPathOffset(element.element);
const manualSize = readStudioBoxSize(element.element);
const resolvedWidth =
manualSize.width > 0
? manualSize.width
: (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
const resolvedHeight =
manualSize.height > 0
? manualSize.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
const current = readStudioPathOffset(element.element);
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
});
};
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
const current = readStudioBoxSize(element.element);
const width =
current.width > 0
? current.width
: (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
const height =
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
});
};
const handleFillModeChange = (nextMode: string) => {
setPreferredFillMode(nextMode);
@@ -2083,27 +2137,27 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className={RESPONSIVE_GRID}>
<MetricField
label="X"
value={styles.left ?? "auto"}
disabled={moveEditingDisabled}
onCommit={(next) => onSetStyle("left", next)}
value={formatPxMetricValue(manualOffset.x)}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("x", next)}
/>
<MetricField
label="Y"
value={styles.top ?? "auto"}
disabled={moveEditingDisabled}
onCommit={(next) => onSetStyle("top", next)}
value={formatPxMetricValue(manualOffset.y)}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("y", next)}
/>
<MetricField
label="W"
value={styles.width ?? "auto"}
disabled={resizeEditingDisabled}
onCommit={(next) => onSetStyle("width", next)}
value={formatPxMetricValue(resolvedWidth)}
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("width", next)}
/>
<MetricField
label="H"
value={styles.height ?? "auto"}
disabled={resizeEditingDisabled}
onCommit={(next) => onSetStyle("height", next)}
value={formatPxMetricValue(resolvedHeight)}
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("height", next)}
/>
</div>
</Section>