diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 76f4048c1..5bbaaf29e 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -334,6 +334,7 @@ export function StudioApp() { queueDomEditSave: previewPersistence.queueDomEditSave, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, + updateEditingFileContent: fileManager.updateEditingFileContent, domEditSaveTimestampRef, editHistory: { recordEdit: editHistory.recordEdit }, fileTree: fileManager.fileTree, diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 1a7cf8c28..9384950ab 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -171,10 +171,10 @@ export function StudioHeader({ void handleUndo(); }} disabled={!editHistory.canUndo} - className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ + className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${ editHistory.canUndo - ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800" - : "border-neutral-900 text-neutral-700" + ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800" + : "text-neutral-700 cursor-default" }`} title={ editHistory.undoLabel @@ -192,10 +192,10 @@ export function StudioHeader({ void handleRedo(); }} disabled={!editHistory.canRedo} - className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ + className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${ editHistory.canRedo - ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800" - : "border-neutral-900 text-neutral-700" + ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800" + : "text-neutral-700 cursor-default" }`} title={ editHistory.redoLabel @@ -215,7 +215,7 @@ export function StudioHeader({ }} onFocus={refreshCaptureFrameTime} onPointerDown={refreshCaptureFrameTime} - className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800" + className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800" title="Capture current frame" aria-label="Capture current frame" > diff --git a/packages/studio/src/components/editor/BorderRadiusEditor.tsx b/packages/studio/src/components/editor/BorderRadiusEditor.tsx new file mode 100644 index 000000000..c4f697a5f --- /dev/null +++ b/packages/studio/src/components/editor/BorderRadiusEditor.tsx @@ -0,0 +1,209 @@ +import { useCallback, useState } from "react"; +import { MetricField } from "./propertyPanelPrimitives"; +import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; + +type Corner = "tl" | "tr" | "br" | "bl"; + +interface BorderRadiusEditorProps { + tl: number; + tr: number; + br: number; + bl: number; + disabled?: boolean; + onCommit: (corner: Corner | "all", value: number) => void; +} + +const PREVIEW_W = 72; +const PREVIEW_H = 52; +const MAX_RADIUS = 26; + +function clampRadius(v: number): number { + return Math.max(0, Math.min(MAX_RADIUS, v)); +} + +function scaleRadius(v: number, maxPx: number): number { + if (maxPx <= 0) return 0; + return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS)); +} + +export function BorderRadiusEditor({ + tl, + tr, + br, + bl, + disabled, + onCommit, +}: BorderRadiusEditorProps) { + const uniform = tl === tr && tr === br && br === bl; + const [linked, setLinked] = useState(uniform); + + const maxVal = Math.max(tl, tr, br, bl, 1); + const sTL = scaleRadius(tl, maxVal); + const sTR = scaleRadius(tr, maxVal); + const sBR = scaleRadius(br, maxVal); + const sBL = scaleRadius(bl, maxVal); + + const handleCornerCommit = useCallback( + (corner: Corner, raw: string) => { + const v = parseNumericValue(raw) ?? 0; + if (linked) { + onCommit("all", v); + } else { + onCommit(corner, v); + } + }, + [linked, onCommit], + ); + + const handleToggleLinked = useCallback(() => { + if (!linked && !uniform) { + onCommit("all", tl); + } + setLinked((l) => !l); + }, [linked, uniform, tl, onCommit]); + + const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL); + + return ( +
+
+ + + + + + + + + +
+ + {linked ? ( + handleCornerCommit("tl", next)} + /> + ) : ( +
+ handleCornerCommit("tl", next)} + /> + handleCornerCommit("tr", next)} + /> + handleCornerCommit("bl", next)} + /> + handleCornerCommit("br", next)} + /> +
+ )} +
+ ); +} + +function buildRoundedRectPath( + w: number, + h: number, + tl: number, + tr: number, + br: number, + bl: number, +): string { + return [ + `M ${tl} 0`, + `L ${w - tr} 0`, + `Q ${w} 0 ${w} ${tr}`, + `L ${w} ${h - br}`, + `Q ${w} ${h} ${w - br} ${h}`, + `L ${bl} ${h}`, + `Q 0 ${h} 0 ${h - bl}`, + `L 0 ${tl}`, + `Q 0 0 ${tl} 0`, + "Z", + ].join(" "); +} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index aafee19da..551d734be 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -217,6 +217,34 @@ export const PropertyPanel = memo(function PropertyPanel({ } })(); + const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => { + if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) { + const hasBRProp = gsapAnimations.some( + (a) => + "borderRadius" in a.properties || + a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties), + ); + if (!hasBRProp) return null; + } + const iframe = previewIframeRef?.current; + const selector = element.id ? `#${element.id}` : element.selector; + if (!iframe?.contentDocument || !selector) return null; + try { + const el = iframe.contentDocument.querySelector(selector); + if (!el) return null; + const cs = iframe.contentWindow!.getComputedStyle(el); + const parse = (v: string) => Number.parseFloat(v) || 0; + return { + tl: parse(cs.borderTopLeftRadius), + tr: parse(cs.borderTopRightRadius), + br: parse(cs.borderBottomRightRadius), + bl: parse(cs.borderBottomLeftRadius), + }; + } catch { + return null; + } + })(); + const displayX = gsapRuntimeValues?.x ?? manualOffset.x; const displayY = gsapRuntimeValues?.y ?? manualOffset.y; const displayW = gsapRuntimeValues?.width ?? resolvedWidth; @@ -544,6 +572,7 @@ export const PropertyPanel = memo(function PropertyPanel({ assets={assets} onSetStyle={onSetStyle} onImportAssets={onImportAssets} + gsapBorderRadius={gsapBorderRadius} /> )} diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index 96f69ff8c..b26a3f2d1 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -29,6 +29,10 @@ export const CURATED_STYLE_PROPERTIES = [ "opacity", "mix-blend-mode", "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", "border-width", "border-style", "border-color", diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index 5702fe764..a755824d5 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -33,6 +33,7 @@ import { } from "./propertyPanelPrimitives"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; +import { BorderRadiusEditor } from "./BorderRadiusEditor"; export function StyleSections({ projectId, @@ -41,6 +42,7 @@ export function StyleSections({ assets, onSetStyle, onImportAssets, + gsapBorderRadius, }: { projectId: string; element: DomEditSelection; @@ -48,10 +50,19 @@ export function StyleSections({ assets: string[]; onSetStyle: (prop: string, value: string) => void | Promise; onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; }) { const styleEditingDisabled = !element.capabilities.canEditStyles; const isFlex = styles.display === "flex" || styles.display === "inline-flex"; const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const radiusTL = + gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; + const radiusTR = + gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; + const radiusBR = + gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; + const radiusBL = + gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); const borderWidthValue = parsePxMetricValue(styles["border-width"] ?? "") ?? @@ -155,15 +166,26 @@ export function StyleSections({ {hasVisualBackground && (
} defaultCollapsed> - `${formatNumericValue(next)}px`} - onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} + onCommit={(corner, value) => { + const px = `${formatNumericValue(value)}px`; + if (corner === "all") { + onSetStyle("border-radius", px); + } else { + const prop = { + tl: "border-top-left-radius", + tr: "border-top-right-radius", + br: "border-bottom-right-radius", + bl: "border-bottom-left-radius", + }[corner]; + onSetStyle(prop, px); + } + }} />
)} diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index f634eb8a1..51fe9308b 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -26,6 +26,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, @@ -64,6 +65,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, @@ -96,6 +98,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 1ee449de8..cf788ec4f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -59,6 +59,7 @@ export interface UseDomEditSessionParams { queueDomEditSave: (save: () => Promise) => Promise; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; + updateEditingFileContent: (path: string, content: string) => void; domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; fileTree: string[]; @@ -100,6 +101,7 @@ export function useDomEditSession({ queueDomEditSave, readProjectFile: _readProjectFile, writeProjectFile, + updateEditingFileContent, domEditSaveTimestampRef, editHistory, fileTree, @@ -224,6 +226,18 @@ export function useDomEditSession({ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe + // reload via refreshKey but don't go through commitMutation, so the cache + // would otherwise retain stale keyframe entries). + const prevRefreshKeyRef = useRef(refreshKey); + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (refreshKey !== prevRefreshKeyRef.current) { + prevRefreshKeyRef.current = refreshKey; + bumpGsapCache(); + } + }, [refreshKey, bumpGsapCache]); + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; usePopulateKeyframeCacheForFile( @@ -268,6 +282,7 @@ export function useDomEditSession({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate: bumpGsapCache, + onFileContentChanged: updateEditingFileContent, }); // ── Commit handlers (delegated to useDomEditCommits) ── diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index caef9eeb9..f977d8bd9 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -108,6 +108,12 @@ export function useFileManager({ } }, []); + const updateEditingFileContent = useCallback((path: string, content: string) => { + if (editingPathRef.current === path) { + setEditingFile({ path, content }); + } + }, []); + const readOptionalProjectFile = useCallback(async (path: string): Promise => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -460,6 +466,7 @@ export function useFileManager({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, // Click-to-source revealSourceOffset, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 4c5908f4a..fbc8acd86 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -108,6 +108,7 @@ interface GsapScriptCommitsParams { domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; onCacheInvalidate: () => void; + onFileContentChanged?: (path: string, content: string) => void; } const DEBOUNCE_MS = 150; @@ -121,6 +122,7 @@ export function useGsapScriptCommits({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, + onFileContentChanged, }: GsapScriptCommitsParams) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; @@ -164,14 +166,28 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (result.after != null) { + onFileContentChanged?.(targetPath, result.after); + } + if (result.parsed?.animations) { const { setKeyframeCache } = usePlayerStore.getState(); + const idsWithKeyframes = new Set(); for (const anim of result.parsed.animations) { - if (!anim.keyframes) continue; const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; if (!id) continue; - setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); - if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + if (anim.keyframes) { + idsWithKeyframes.add(id); + setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + } + } + const targetId = + (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? + selection.id; + if (targetId && !idsWithKeyframes.has(targetId)) { + setKeyframeCache(`${targetPath}#${targetId}`, undefined); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined); } } @@ -195,6 +211,7 @@ export function useGsapScriptCommits({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, + onFileContentChanged, ], ); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index b2c2d5280..f47b806cf 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -224,7 +224,14 @@ export function usePopulateKeyframeCacheForFile( const sf = sourceFile; fetchParsedAnimations(projectId, sf).then((parsed) => { if (!parsed) return; - const { setKeyframeCache } = usePlayerStore.getState(); + const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); + const sfPrefix = `${sf}#`; + const fallbackPrefix = "index.html#"; + for (const key of keyframeCache.keys()) { + if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) { + setKeyframeCache(key, undefined); + } + } for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id || !anim.keyframes) continue;