diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5f734be5ef..398eab36ee 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(null); const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState(null); - const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState< - ReadonlySet - >(() => 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, 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, 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() {
{designPanelActive ? ( 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} /> diff --git a/packages/studio/src/components/editor/PropertyPanel.test.ts b/packages/studio/src/components/editor/PropertyPanel.test.ts index d3c2939faf..47301a21f6 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.ts +++ b/packages/studio/src/components/editor/PropertyPanel.test.ts @@ -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); + }); }); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index d464686517..581a45d324 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -11,34 +11,22 @@ import { import { createPortal } from "react-dom"; import { Eye, - Layers, MessageSquare, Move, Palette, - Plus, RotateCcw, Settings, - Square, Type, X, - Zap, } from "../../icons/SystemIcons"; import { formatCssColor, hsvToRgb, parseCssColor, rgbToHsv, - toColorPickerValue, toHexColor, type ParsedColor, } from "./colorValue"; -import { - buildDefaultGradientModel, - insertGradientStop, - parseGradient, - serializeGradient, - type GradientModel, -} from "./gradientValue"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits"; import { @@ -48,11 +36,8 @@ import { } from "./fontCatalog"; import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets"; import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel"; -import { IMAGE_EXT } from "../../utils/mediaTypes"; interface PropertyPanelProps { - projectId: string; - assets: string[]; element: DomEditSelection | null; copiedAgentPrompt: boolean; onClearSelection: () => void; @@ -61,11 +46,8 @@ interface PropertyPanelProps { 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; - onRemoveTextField: (fieldKey: string) => void; onResetManualEdits: (element: DomEditSelection) => void; onAskAgent: () => void; - onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; } @@ -221,18 +203,6 @@ function formatPxMetricValue(value: number): string { return `${formatNumericValue(value)}px`; } -function normalizeTextMetricValue(property: "letter-spacing" | "line-height", value: string) { - const trimmed = value.trim(); - if (!trimmed || trimmed === "normal") return trimmed || "normal"; - const token = parseNumericToken(trimmed); - if (!token) return trimmed; - if (property === "letter-spacing") { - return token.unit ? trimmed : `${formatNumericValue(token.value)}px`; - } - if (token.unit) return trimmed; - return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value); -} - function splitCssFunctions(value: string): string[] { const functions: string[] = []; let current = ""; @@ -336,23 +306,6 @@ export function buildStrokeStyleUpdates( return updates; } -function buildClipPathValue( - preset: "none" | "inset" | "circle" | "custom", - radiusValue: number, - fallback: string | undefined, -) { - if (preset === "custom") return fallback?.trim() || "none"; - if (preset === "circle") return "circle(50% at 50% 50%)"; - if (preset === "inset") { - return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`; - } - return "none"; -} - -function buildInsetClipPathValue(insetPx: number, radiusValue: number): string { - return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`; -} - function adjustNumericToken( value: string, direction: 1 | -1, @@ -366,123 +319,6 @@ function adjustNumericToken( return `${formatNumericValue(nextValue)}${token.unit}`; } -function formatColorToken(value: string): string { - const parsed = parseCssColor(value); - if (!parsed) return value; - const hex = toColorPickerValue(value).replace(/^#/, "").toUpperCase(); - return `${hex} / ${Math.round(parsed.alpha * 100)}%`; -} - -function extractBackgroundImageUrl(value: string | undefined): string { - if (!value) return ""; - const lowerValue = value.toLowerCase(); - const urlStart = lowerValue.indexOf("url("); - if (urlStart < 0) return ""; - - let index = urlStart + 4; - while ( - index < value.length && - (value[index] === " " || - value[index] === "\n" || - value[index] === "\r" || - value[index] === "\t" || - value[index] === "\f") - ) { - index += 1; - } - - const quote = value[index] === '"' || value[index] === "'" ? value[index] : null; - if (quote) { - index += 1; - const endQuote = value.indexOf(quote, index); - return endQuote >= index ? value.slice(index, endQuote) : ""; - } - - const endParen = value.indexOf(")", index); - if (endParen < index) return ""; - return value.slice(index, endParen).trim(); -} - -function normalizeProjectPath(value: string): string { - const trimmed = value.trim(); - const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed; - return decodeURIComponent(maybeUrl) - .replace(/\\/g, "/") - .replace(/^\.?\//, ""); -} - -function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string { - const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean); - const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean); - - fromParts.pop(); - - while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) { - fromParts.shift(); - targetParts.shift(); - } - - return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath; -} - -function toProjectRootAssetPath(assetPath: string): string { - return normalizeProjectPath(assetPath); -} - -function resolveSelectedAsset( - imageUrl: string, - sourceFile: string, - assets: string[], -): string | null { - const normalizedUrl = normalizeProjectPath(imageUrl); - if (!normalizedUrl) return null; - - for (const asset of assets) { - const normalizedAsset = normalizeProjectPath(asset); - const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset); - if ( - normalizedUrl === normalizedAsset || - normalizedUrl === relativeAsset || - normalizedUrl.endsWith(`/${normalizedAsset}`) || - normalizedUrl.endsWith(`/${relativeAsset}`) - ) { - return asset; - } - } - - return null; -} - -function collectSelectionColors(styles: Record) { - const candidates = [ - { source: "Fill", value: styles["background-color"] }, - { source: "Text", value: styles.color }, - ]; - - const deduped = new Map(); - - for (const candidate of candidates) { - if (!candidate.value) continue; - const parsed = parseCssColor(candidate.value); - if (!parsed || parsed.alpha <= 0) continue; - - const key = `${toColorPickerValue(candidate.value)}-${Math.round(parsed.alpha * 100)}`; - const existing = deduped.get(key); - if (existing) { - existing.sources.push(candidate.source); - continue; - } - - deduped.set(key, { - swatch: toColorPickerValue(candidate.value), - token: formatColorToken(candidate.value), - sources: [candidate.source], - }); - } - - return Array.from(deduped.values()); -} - function CommitField({ value, disabled, @@ -593,27 +429,6 @@ function MetricField({ ); } -function DetailField({ - label, - value, - disabled, - onCommit, -}: { - label: string; - value: string; - disabled?: boolean; - onCommit: (nextValue: string) => void; -}) { - return ( - - ); -} - function TextAreaField({ label, value, @@ -1226,75 +1041,6 @@ function FontFamilyField({ ); } -function getTextStyleValue( - field: DomEditSelection["textFields"][number], - inheritedStyles: Record, - property: string, - fallback: string, -): string { - return field.computedStyles[property] || inheritedStyles[property] || fallback; -} - -function AdvancedTextControls({ - field, - inheritedStyles, - disabled, - onCommit, -}: { - field: DomEditSelection["textFields"][number]; - inheritedStyles: Record; - disabled?: boolean; - onCommit: (property: string, value: string) => void; -}) { - return ( -
-
- - onCommit("line-height", normalizeTextMetricValue("line-height", next)) - } - /> - - onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next)) - } - /> -
-
- onCommit("text-align", next)} - options={["start", "left", "center", "right", "justify", "end"]} - /> - onCommit("text-transform", next)} - options={["none", "uppercase", "lowercase", "capitalize"]} - /> -
- onCommit("font-style", next)} - options={["normal", "italic", "oblique"]} - /> -
- ); -} - function ColorField({ label, value, @@ -1648,360 +1394,6 @@ function ColorSlider({ ); } -function ImageFillField({ - projectId, - sourceFile, - value, - assets, - disabled, - onCommit, - onImportAssets, -}: { - projectId: string; - sourceFile: string; - value: string; - assets: string[]; - disabled?: boolean; - onCommit: (nextValue: string) => void; - onImportAssets?: (files: FileList) => Promise; -}) { - const fileInputRef = useRef(null); - const [uploading, setUploading] = useState(false); - const imageAssets = useMemo(() => assets.filter((asset) => IMAGE_EXT.test(asset)), [assets]); - const selectedAsset = useMemo( - () => resolveSelectedAsset(value, sourceFile, imageAssets), - [imageAssets, sourceFile, value], - ); - const externalUrlValue = selectedAsset ? "" : value; - - const handleUpload = async (files: FileList | null) => { - if (!files?.length || !onImportAssets) return; - setUploading(true); - try { - const uploaded = await onImportAssets(files); - const nextImage = uploaded.find((asset) => IMAGE_EXT.test(asset)); - if (nextImage) { - onCommit(`url("${toProjectRootAssetPath(nextImage)}")`); - } - } finally { - setUploading(false); - } - }; - - return ( -
-
-
- Project asset - - { - await handleUpload(event.target.files); - event.target.value = ""; - }} - /> -
- {imageAssets.length > 0 ? ( -
- {selectedAsset && ( -
- {selectedAsset.split("/").pop() -
- )} -
- -
-
- ) : ( -
- No image assets yet. Upload one here and Studio will also add it to the Assets tab. -
- )} -
- - onCommit(next.trim() ? `url("${next.trim()}")` : "none")} - /> -
- ); -} - -function GradientField({ - value, - fallbackColor, - disabled, - onCommit, -}: { - value: string; - fallbackColor: string | undefined; - disabled?: boolean; - onCommit: (nextValue: string) => void; -}) { - const previewRef = useRef(null); - const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor); - - const commit = (next: GradientModel) => onCommit(serializeGradient(next)); - - const patch = (partial: Partial) => commit({ ...parsed, ...partial }); - - const updateStop = (index: number, partial: Partial) => { - const stops = parsed.stops.map((stop, stopIndex) => - stopIndex === index ? { ...stop, ...partial } : stop, - ); - commit({ ...parsed, stops }); - }; - - const addStop = (position?: number) => { - const nextGradient = - position != null - ? insertGradientStop(parsed, position) - : insertGradientStop( - parsed, - parsed.stops.at(-1)?.position != null - ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10) - : 100, - ); - commit(nextGradient); - }; - - const removeStop = (index: number) => { - if (parsed.stops.length <= 2) return; - commit({ ...parsed, stops: parsed.stops.filter((_, stopIndex) => stopIndex !== index) }); - }; - - const previewStyle = { - backgroundImage: serializeGradient(parsed), - }; - - return ( -
-
-
{ - if (disabled) return; - const rect = previewRef.current?.getBoundingClientRect(); - if (!rect || rect.width <= 0) return; - const position = ((event.clientX - rect.left) / rect.width) * 100; - addStop(position); - }} - > - {parsed.stops.map((stop, index) => ( -
- ))} -
-
- patch({ kind: next as GradientModel["kind"] })} - options={[ - { label: "Linear", value: "linear" }, - { label: "Radial", value: "radial" }, - { label: "Conic", value: "conic" }, - ]} - /> - - -
-
- - {(parsed.kind === "linear" || parsed.kind === "conic") && ( -
- {parsed.kind === "linear" ? "Angle" : "Start angle"} - `${Math.round(next)}°`} - onCommit={(next) => patch({ angle: next })} - /> -
- )} - - {parsed.kind === "radial" && ( -
- patch({ shape: next as GradientModel["shape"] })} - options={["ellipse", "circle"]} - /> - patch({ radialSize: next as GradientModel["radialSize"] })} - options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]} - /> -
- )} - - {(parsed.kind === "radial" || parsed.kind === "conic") && ( -
-
- Center X - `${Math.round(next)}%`} - onCommit={(next) => patch({ centerX: next })} - /> -
-
- Center Y - `${Math.round(next)}%`} - onCommit={(next) => patch({ centerY: next })} - /> -
-
- )} - -
-
- Stops - -
-
- {parsed.stops.map((stop, index) => ( -
- updateStop(index, { color: next })} - /> - - updateStop(index, { - position: Number.parseFloat(next.replace("%", "")) || 0, - }) - } - /> - -
- ))} -
-
-
- ); -} - function SliderControl({ value, min, @@ -2082,44 +1474,6 @@ function SliderControl({ ); } -function SegmentedControl({ - options, - value, - disabled, - onChange, -}: { - options: Array<{ label: string; value: string }>; - value: string; - disabled?: boolean; - onChange: (nextValue: string) => void; -}) { - return ( -
- {options.map((option) => { - const selected = option.value === value; - return ( - - ); - })} -
- ); -} - function SelectField({ label, value, @@ -2183,34 +1537,36 @@ function Section({ ); } -function SelectionColorRow({ - swatch, - token, - sources, -}: { - swatch: string; - token: string; - sources: string[]; -}) { - return ( -
-
-
-
{token}
-
- {sources.join(" · ")} -
-
-
- ); +export type PropertyPanelSectionId = "Text" | "Layout" | "Colors" | "Radius" | "Shadow"; + +export function getPropertyPanelVisibleSections(input: { + hasSelection: boolean; + canEditStyles: boolean; + hasTextControls: boolean; + hasColorControls: boolean; +}): PropertyPanelSectionId[] { + if (!input.hasSelection) return []; + + const sections: PropertyPanelSectionId[] = []; + if (input.hasTextControls) sections.push("Text"); + sections.push("Layout"); + if (input.canEditStyles) { + if (input.hasColorControls) sections.push("Colors"); + sections.push("Radius", "Shadow"); + } + return sections; +} + +export function isPropertyPanelMediaLikeSelection(input: { + tagName: string; + styles: Record; +}): boolean { + if (["img", "video", "audio", "canvas"].includes(input.tagName)) return true; + const backgroundImage = input.styles["background-image"]?.trim(); + return Boolean(backgroundImage && backgroundImage !== "none"); } export const PropertyPanel = memo(function PropertyPanel({ - projectId, - assets, element, copiedAgentPrompt, onClearSelection, @@ -2219,34 +1575,17 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetManualSize, onSetText, onSetTextFieldStyle, - onAddTextField, - onRemoveTextField, onResetManualEdits, onAskAgent, - onImportAssets, fontAssets = [], onImportFonts, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; - const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]); - const backgroundImage = styles["background-image"] ?? "none"; - const fillMode = - backgroundImage && backgroundImage !== "none" - ? backgroundImage.includes("gradient") - ? "Gradient" - : "Image" - : "Solid"; - const [preferredFillMode, setPreferredFillMode] = useState(fillMode); - const imageUrl = extractBackgroundImageUrl(backgroundImage); const [activeTextFieldKey, setActiveTextFieldKey] = useState( element?.textFields[0]?.key ?? null, ); const hasTextControls = element != null && isTextEditableSelection(element); - useEffect(() => { - setPreferredFillMode(fillMode); - }, [fillMode, element?.id, element?.selector, backgroundImage]); - useEffect(() => { const nextFields = element?.textFields ?? []; setActiveTextFieldKey((current) => { @@ -2271,24 +1610,22 @@ export const PropertyPanel = memo(function PropertyPanel({ const styleEditingDisabled = !element.capabilities.canEditStyles; 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 borderWidthValue = - parsePxMetricValue(styles["border-width"] ?? "") ?? - parsePxMetricValue(styles["border-top-width"] ?? "") ?? - 0; - const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none"; - const borderColorValue = - styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)"; const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]); - const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur"); - const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur"); - const clipPathValue = styles["clip-path"] || "none"; - const clipPathPreset = inferClipPathPreset(clipPathValue); - const clipInsetValue = getClipPathInsetPx(clipPathValue); const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; + const mediaLikeSelection = isPropertyPanelMediaLikeSelection({ + tagName: element.tagName, + styles, + }); + const canShowFillColor = !mediaLikeSelection; + const canShowTextColor = !hasTextControls && !mediaLikeSelection; + const visibleSections = getPropertyPanelVisibleSections({ + hasSelection: true, + canEditStyles: showEditableSections, + hasTextControls, + hasColorControls: canShowFillColor || canShowTextColor, + }); const manualOffset = readStudioPathOffset(element.element); const manualSize = readStudioBoxSize(element.element); const resolvedWidth = @@ -2328,20 +1665,6 @@ export const PropertyPanel = memo(function PropertyPanel({ }); }; - const handleFillModeChange = (nextMode: string) => { - setPreferredFillMode(nextMode); - if (nextMode === "Solid") { - onSetStyle("background-image", "none"); - return; - } - if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) { - onSetStyle( - "background-image", - serializeGradient(buildDefaultGradientModel(styles["background-color"])), - ); - } - }; - return (
@@ -2384,574 +1707,194 @@ export const PropertyPanel = memo(function PropertyPanel({
-
}> -
- commitManualOffset("x", next)} - /> - commitManualOffset("y", next)} - /> - commitManualSize("width", next)} - /> - commitManualSize("height", next)} - /> -
-
+ {visibleSections.includes("Text") && ( +
}> + {(() => { + const textFields = element.textFields; + const activeField = + textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0]; + if (!activeField) return null; - {showEditableSections && isFlex && ( -
}> -
- onSetStyle("flex-direction", next)} - options={[ - { label: "→ Row", value: "row" }, - { label: "↓ Column", value: "column" }, - ]} + return ( +
+ {textFields.length > 1 && ( +
+ Text layers + {textFields.map((field, index) => { + const active = field.key === activeField.key; + return ( + + ); + })} +
+ )} + +
+ onSetText(next, activeField.key)} + /> + + onSetTextFieldStyle(activeField.key, "color", next)} + /> + + onSetTextFieldStyle(activeField.key, "font-family", next)} + /> + +
+ onSetTextFieldStyle(activeField.key, "font-size", next)} + /> + + onSetTextFieldStyle(activeField.key, "font-weight", next) + } + /> +
+
+
+ ); + })()} +
+ )} + + {visibleSections.includes("Layout") && ( +
}> +
+ commitManualOffset("x", next)} /> -
- onSetStyle("justify-content", next)} - options={[ - "flex-start", - "center", - "space-between", - "space-around", - "space-evenly", - "flex-end", - ]} - /> - onSetStyle("align-items", next)} - options={["stretch", "flex-start", "center", "flex-end", "baseline"]} - /> -
- onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)} + commitManualOffset("y", next)} + /> + commitManualSize("width", next)} + /> + commitManualSize("height", next)} />
)} - {showEditableSections && ( - <> -
}> - `${formatNumericValue(next)}px`} - onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} - /> -
- -
}> -
-
- { - const normalized = normalizePanelPxValue(next, { - min: 0, - max: 200, - fallback: borderWidthValue, - }); - if (!normalized) return; - for (const [property, value] of buildStrokeWidthStyleUpdates( - normalized, - borderStyleValue, - )) { - await onSetStyle(property, value); - } - }} - /> - { - for (const [property, value] of buildStrokeStyleUpdates( - next, - formatPxMetricValue(borderWidthValue), - )) { - await onSetStyle(property, value); - } - }} - options={[ - "none", - "solid", - "dashed", - "dotted", - "double", - "hidden", - "groove", - "ridge", - "inset", - "outset", - ]} - /> -
+ {visibleSections.includes("Colors") && ( +
}> +
+ {canShowFillColor && ( onSetStyle("border-color", next)} + onCommit={(next) => onSetStyle("background-color", next)} /> -
-
- -
}> -
- { - if (next === "custom") return; - onSetStyle( - "box-shadow", - buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]), - ); - }} - options={["custom", "none", "soft", "lift", "glow"]} + onCommit={(next) => onSetStyle("color", next)} /> -
-
- Layer blur - `${formatNumericValue(next)}px`} - onCommit={(next) => - onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next)) - } - /> -
-
- Backdrop - `${formatNumericValue(next)}px`} - onCommit={(next) => - onSetStyle( - "backdrop-filter", - setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next), - ) - } - /> -
-
-
-
+ )} +
+
+ )} -
}> -
-
- onSetStyle("overflow", next)} - options={["visible", "hidden", "clip", "auto", "scroll"]} - /> - { - if (next === "custom") return; - onSetStyle( - "clip-path", - buildClipPathValue( - next as "none" | "inset" | "circle", - radiusValue, - clipPathValue, - ), - ); - }} - options={["custom", "none", "inset", "circle"]} - /> -
-
- Mask inset - `${formatNumericValue(next)}px`} - onCommit={(next) => - onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue)) - } - /> -
-
-
+ {visibleSections.includes("Radius") && ( +
}> + `${formatNumericValue(next)}px`} + onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} + /> +
+ )} -
}> -
- `${Math.round(next)}%`} - onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))} - /> - onSetStyle("mix-blend-mode", next)} - options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]} - /> -
-
- -
} - accessory={ -
- {preferredFillMode} -
- } - > -
- - {preferredFillMode === "Solid" ? ( - onSetStyle("background-color", next)} - /> - ) : preferredFillMode === "Gradient" ? ( - onSetStyle("background-image", next)} - /> - ) : ( - onSetStyle("background-image", next)} - onImportAssets={onImportAssets} - /> - )} - {!hasTextControls && ( - onSetStyle("color", next)} - /> - )} -
-
- - {hasTextControls && ( -
}> - {(() => { - const textFields = element.textFields; - const activeField = - textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0]; - if (!activeField) return null; - - if (textFields.length === 1) { - return ( -
-
-
- {formatTextFieldPreview(activeField.value) || "Text"} -
-
- {activeField.tagName} -
-
- - onSetText(next, activeField.key)} - /> - - onSetTextFieldStyle(activeField.key, "color", next)} - /> - -
- - onSetTextFieldStyle(activeField.key, "font-size", next) - } - /> - - onSetTextFieldStyle(activeField.key, "font-weight", next) - } - /> -
- - - onSetTextFieldStyle(activeField.key, "font-family", next) - } - /> - - - onSetTextFieldStyle(activeField.key, property, value) - } - /> -
- ); - } - - return ( -
-
-
- Text layers - -
-
- {textFields.map((field, index) => { - const active = field.key === activeField.key; - return ( - - ); - })} -
-
- -
-
-
-
- {formatTextFieldPreview(activeField.value) || "Text"} -
-
- {activeField.tagName} -
-
- -
- - onSetText(next, activeField.key)} - /> - - onSetTextFieldStyle(activeField.key, "color", next)} - /> - -
- - onSetTextFieldStyle(activeField.key, "font-size", next) - } - /> - - onSetTextFieldStyle(activeField.key, "font-weight", next) - } - /> -
- - - onSetTextFieldStyle(activeField.key, "font-family", next) - } - /> - - - onSetTextFieldStyle(activeField.key, property, value) - } - /> -
-
- ); - })()} -
- )} - - {selectionColors.length > 0 && ( -
}> -
- {selectionColors.map((entry) => ( - - ))} -
-
- )} - + {visibleSections.includes("Shadow") && ( +
}> + { + if (next === "custom") return; + onSetStyle( + "box-shadow", + buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]), + ); + }} + options={["custom", "none", "soft", "lift", "glow"]} + /> +
)}
diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts index 07b46c5c6f..330d109724 100644 --- a/packages/studio/src/components/editor/domEditing.test.ts +++ b/packages/studio/src/components/editor/domEditing.test.ts @@ -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>, +) { + 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(` +
+ Launch faster +
+ `); + 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(` + +
Empty
+
Visible
+ `); + 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(` + + + `); + 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(` +
+ Unlabeled copy +
+ `); + 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(` +
+ Launch faster +
+ `); + 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(` +
+
+

Core Conviction

+
+
+ `); + 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(`
diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index e253b042d2..f4ff9dabe9 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -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, + options: Pick, +): 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 = diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index 2ce70e923a..3d30fcbcaa 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -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); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 057a47a7b3..f31e151d97 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -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"; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 4e576aa5e5..efd1596fd0 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -55,8 +55,6 @@ interface NLELayoutProps { onInspectTimelineElement?: (element: TimelineElement) => void; inspectedTimelineElementId?: string | null; timelineLayerChildCounts?: ReadonlyMap; - thumbnailedTimelineElementIds?: ReadonlySet; - onToggleTimelineElementThumbnail?: (element: TimelineElement) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => 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} />
diff --git a/packages/studio/vite.studioMotion.ts b/packages/studio/vite.studioMotion.ts index d1d8ccb1f2..acf7066481 100644 --- a/packages/studio/vite.studioMotion.ts +++ b/packages/studio/vite.studioMotion.ts @@ -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"; diff --git a/qa-artifacts/studio-inspector-selection-ux/deep-text-leaf-selection.png b/qa-artifacts/studio-inspector-selection-ux/deep-text-leaf-selection.png new file mode 100644 index 0000000000..a9aa2ff059 Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/deep-text-leaf-selection.png differ diff --git a/qa-artifacts/studio-inspector-selection-ux/layer-list-escape-hatch.png b/qa-artifacts/studio-inspector-selection-ux/layer-list-escape-hatch.png new file mode 100644 index 0000000000..c6649b3424 Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/layer-list-escape-hatch.png differ diff --git a/qa-artifacts/studio-inspector-selection-ux/manual-drag-disabled.png b/qa-artifacts/studio-inspector-selection-ux/manual-drag-disabled.png new file mode 100644 index 0000000000..1e8a01283e Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/manual-drag-disabled.png differ diff --git a/qa-artifacts/studio-inspector-selection-ux/selection-flow.webm b/qa-artifacts/studio-inspector-selection-ux/selection-flow.webm new file mode 100644 index 0000000000..f4bac0c24d Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/selection-flow.webm differ diff --git a/qa-artifacts/studio-inspector-selection-ux/text-selection.png b/qa-artifacts/studio-inspector-selection-ux/text-selection.png new file mode 100644 index 0000000000..854f260503 Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/text-selection.png differ diff --git a/qa-artifacts/studio-inspector-selection-ux/timeline-default-thumbnails.png b/qa-artifacts/studio-inspector-selection-ux/timeline-default-thumbnails.png new file mode 100644 index 0000000000..1e8a01283e Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/timeline-default-thumbnails.png differ diff --git a/qa-artifacts/studio-inspector-selection-ux/video-selection.png b/qa-artifacts/studio-inspector-selection-ux/video-selection.png new file mode 100644 index 0000000000..185d26abe9 Binary files /dev/null and b/qa-artifacts/studio-inspector-selection-ux/video-selection.png differ