diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5b3679c058..0c40f1bd9a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 96bba90af0..18ecd8dbe1 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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 | 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({

Select an element in the preview.

- 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.

); } 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({
onSetStyle("left", next)} + value={formatPxMetricValue(manualOffset.x)} + disabled={manualOffsetEditingDisabled} + onCommit={(next) => commitManualOffset("x", next)} /> onSetStyle("top", next)} + value={formatPxMetricValue(manualOffset.y)} + disabled={manualOffsetEditingDisabled} + onCommit={(next) => commitManualOffset("y", next)} /> onSetStyle("width", next)} + value={formatPxMetricValue(resolvedWidth)} + disabled={manualSizeEditingDisabled} + onCommit={(next) => commitManualSize("width", next)} /> onSetStyle("height", next)} + value={formatPxMetricValue(resolvedHeight)} + disabled={manualSizeEditingDisabled} + onCommit={(next) => commitManualSize("height", next)} />