fix(studio): stale selection, rotation control, design panel polish

Fixes and improvements based on power-user testing feedback:

1. Fix stale selection after style edits — handleDomStyleCommit now
   calls refreshDomEditSelectionFromPreview after persisting, matching
   every other commit handler. Without this, the PropertyPanel showed
   frozen computedStyles after color/radius/shadow edits, making it
   look like editing "didn't work." Also adds error handling around
   the persist call.

2. Add rotation field to the Design panel Layout section — reads the
   current rotation angle from the manual edit manifest and commits
   via the existing handleDomRotationCommit handler.

3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now
   defaults to true so the Motion tab is discoverable without env vars.

4. Color controls only when element has color — fill color section now
   only shows when the element has an explicit non-transparent
   background-color. Text color shows only when the element has a
   color style. Prevents showing color pickers on elements where
   color edits have no visible effect.

5. Exclude canvas from selection — added "canvas" to
   DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the
   preview or listed in the layer panel.

6. Multi-selection feedback — shows "N elements selected" with
   guidance instead of the generic empty state when multiple elements
   are selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-05-10 18:40:52 +00:00
co-authored by Claude Opus 4.6
parent 25f60bfb9d
commit 52674dfd86
5 changed files with 73 additions and 20 deletions
+21 -8
View File
@@ -2933,15 +2933,26 @@ export function StudioApp() {
buildDomEditStylePatchOperation("background-size", "contain"),
);
}
await persistDomEditOperations(domEditSelection, operations, {
label: "Edit layer style",
skipRefresh: true,
prepareContent: importedFont
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
: undefined,
});
try {
await persistDomEditOperations(domEditSelection, operations, {
label: "Edit layer style",
skipRefresh: true,
prepareContent: importedFont
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
: undefined,
});
} catch (err) {
console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
}
refreshDomEditSelectionFromPreview(domEditSelection);
},
[activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
[
activeCompPath,
domEditSelection,
persistDomEditOperations,
refreshDomEditSelectionFromPreview,
resolveImportedFontAsset,
],
);
const handleDomTextCommit = useCallback(
@@ -4197,11 +4208,13 @@ export function StudioApp() {
{designPanelActive ? (
<PropertyPanel
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onSetStyle={handleDomStyleCommit}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetRotation={handleDomRotationCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onResetManualEdits={handleDomManualEditsReset}
@@ -11,6 +11,7 @@ import {
import { createPortal } from "react-dom";
import {
Eye,
Layers,
MessageSquare,
Move,
Palette,
@@ -28,7 +29,7 @@ import {
type ParsedColor,
} from "./colorValue";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
COMMON_LOCAL_FONT_FAMILIES,
googleFontStylesheetUrl,
@@ -39,11 +40,13 @@ import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingP
interface PropertyPanelProps {
element: DomEditSelection | null;
multiSelectCount?: number;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onResetManualEdits: (element: DomEditSelection) => void;
@@ -1568,11 +1571,13 @@ export function isPropertyPanelMediaLikeSelection(input: {
export const PropertyPanel = memo(function PropertyPanel({
element,
multiSelectCount = 0,
copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetManualOffset,
onSetManualSize,
onSetRotation,
onSetText,
onSetTextFieldStyle,
onResetManualEdits,
@@ -1597,12 +1602,28 @@ export const PropertyPanel = memo(function PropertyPanel({
if (!element) {
return (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
<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 element edits with safer geometry controls, color picking, and
cleaner grouped layer controls.
</p>
{multiSelectCount > 1 ? (
<>
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
{multiSelectCount} elements selected
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
Select a single element to edit its properties. Click an element in the preview or use
the timeline layer panel.
</p>
</>
) : (
<>
<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">
Click any element to inspect and edit it. Click deeper to select child elements.
</p>
</>
)}
</div>
);
}
@@ -1618,8 +1639,14 @@ export const PropertyPanel = memo(function PropertyPanel({
tagName: element.tagName,
styles,
});
const canShowFillColor = !mediaLikeSelection;
const canShowTextColor = !hasTextControls && !mediaLikeSelection;
const bgColor = styles["background-color"]?.trim();
const hasExplicitFillColor =
!mediaLikeSelection &&
Boolean(bgColor) &&
bgColor !== "rgba(0, 0, 0, 0)" &&
bgColor !== "transparent";
const canShowFillColor = hasExplicitFillColor;
const canShowTextColor = !mediaLikeSelection && Boolean(styles.color?.trim());
const visibleSections = getPropertyPanelVisibleSections({
hasSelection: true,
canEditStyles: showEditableSections,
@@ -1628,6 +1655,7 @@ export const PropertyPanel = memo(function PropertyPanel({
});
const manualOffset = readStudioPathOffset(element.element);
const manualSize = readStudioBoxSize(element.element);
const manualRotation = readStudioRotation(element.element);
const resolvedWidth =
manualSize.width > 0
? manualSize.width
@@ -1837,6 +1865,17 @@ export const PropertyPanel = memo(function PropertyPanel({
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("height", next)}
/>
<MetricField
label="Rotate"
value={`${formatNumericValue(manualRotation.angle)}°`}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => {
const parsed = parseFloat(next.replace("°", "").trim());
if (Number.isFinite(parsed)) {
onSetRotation(element, { angle: parsed % 360 });
}
}}
/>
</div>
</Section>
)}
@@ -398,6 +398,7 @@ function buildElementLabel(el: HTMLElement): string {
const DOM_LAYER_IGNORED_TAGS = new Set([
"base",
"br",
"canvas",
"link",
"meta",
"script",
@@ -16,13 +16,13 @@ describe("manual editing availability", () => {
vi.resetModules();
});
it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => {
it("enables inspector selection, motion panel, and manual editing by default", 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_MOTION_PANEL_ENABLED).toBe(true);
expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(true);
});
@@ -44,7 +44,7 @@ export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
env,
[STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"],
false,
true,
);
export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =