@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  useRef,
5
5
  useEffect,
6
6
  useMemo,
7
+ type CSSProperties,
7
8
  type MouseEvent,
8
9
  type ReactNode,
9
10
  } from "react";
@@ -78,10 +79,10 @@ import {
78
79
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
79
80
  STUDIO_MOTION_PANEL_ENABLED,
80
81
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
82
+ STUDIO_PREVIEW_SELECTION_ENABLED,
81
83
  STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
82
84
  } from "./components/editor/manualEditingAvailability";
83
85
  import {
84
- buildDefaultDomEditTextField,
85
86
  buildDomEditStylePatchOperation,
86
87
  buildDomEditTextPatchOperation,
87
88
  buildElementAgentPrompt,
@@ -91,9 +92,12 @@ import {
91
92
  findElementForTimelineElement,
92
93
  getDomEditLayerKey,
93
94
  getDomEditTargetKey,
95
+ isLargeRasterDomEditSelection,
94
96
  isTextEditableSelection,
97
+ resolveVisualDomEditSelectionTarget,
95
98
  serializeDomEditTextFields,
96
99
  resolveDomEditSelection,
100
+ type DomEditViewport,
97
101
  type DomEditLayerItem,
98
102
  type DomEditTextField,
99
103
  type DomEditSelection,
@@ -358,10 +362,64 @@ function isManualGeometryStyleProperty(property: string): boolean {
358
362
  return property === "left" || property === "top" || property === "width" || property === "height";
359
363
  }
360
364
 
365
+ interface PreviewLocalPointer {
366
+ x: number;
367
+ y: number;
368
+ viewport: DomEditViewport;
369
+ }
370
+
371
+ interface AgentModalAnchorPoint {
372
+ x: number;
373
+ y: number;
374
+ }
375
+
376
+ function resolvePreviewLocalPointer(
377
+ iframe: HTMLIFrameElement,
378
+ doc: Document,
379
+ win: Window,
380
+ clientX: number,
381
+ clientY: number,
382
+ ): PreviewLocalPointer | null {
383
+ const iframeRect = iframe.getBoundingClientRect();
384
+ const root =
385
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
386
+ const rootRect = root?.getBoundingClientRect();
387
+ const rootWidth = rootRect?.width || win.innerWidth;
388
+ const rootHeight = rootRect?.height || win.innerHeight;
389
+ if (!rootWidth || !rootHeight) return null;
390
+
391
+ const scaleX = iframeRect.width / rootWidth;
392
+ const scaleY = iframeRect.height / rootHeight;
393
+ return {
394
+ x: (clientX - iframeRect.left) / scaleX,
395
+ y: (clientY - iframeRect.top) / scaleY,
396
+ viewport: { width: rootWidth, height: rootHeight },
397
+ };
398
+ }
399
+
400
+ function getPreviewLocalPointer(
401
+ iframe: HTMLIFrameElement,
402
+ clientX: number,
403
+ clientY: number,
404
+ ): PreviewLocalPointer | null {
405
+ let doc: Document | null = null;
406
+ let win: Window | null = null;
407
+ try {
408
+ doc = iframe.contentDocument;
409
+ win = iframe.contentWindow;
410
+ } catch {
411
+ return null;
412
+ }
413
+ if (!doc || !win) return null;
414
+
415
+ return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
416
+ }
417
+
361
418
  function getPreviewTargetFromPointer(
362
419
  iframe: HTMLIFrameElement,
363
420
  clientX: number,
364
421
  clientY: number,
422
+ activeCompositionPath: string | null,
365
423
  ): HTMLElement | null {
366
424
  let doc: Document | null = null;
367
425
  let win: Window | null = null;
@@ -373,20 +431,35 @@ function getPreviewTargetFromPointer(
373
431
  }
374
432
  if (!doc || !win) return null;
375
433
 
376
- const iframeRect = iframe.getBoundingClientRect();
377
- const root =
378
- doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
379
- const rootRect = root?.getBoundingClientRect();
380
- const rootWidth = rootRect?.width || win.innerWidth;
381
- const rootHeight = rootRect?.height || win.innerHeight;
382
- if (!rootWidth || !rootHeight) return null;
434
+ const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
435
+ if (!localPointer) return null;
383
436
 
384
- const scaleX = iframeRect.width / rootWidth;
385
- const scaleY = iframeRect.height / rootHeight;
386
- const localX = (clientX - iframeRect.left) / scaleX;
387
- const localY = (clientY - iframeRect.top) / scaleY;
437
+ if (typeof doc.elementsFromPoint === "function") {
438
+ const visualTarget = resolveVisualDomEditSelectionTarget(
439
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
440
+ {
441
+ activeCompositionPath,
442
+ },
443
+ );
444
+ if (visualTarget) return visualTarget;
445
+ }
446
+
447
+ return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
448
+ }
388
449
 
389
- return getEventTargetElement(doc.elementFromPoint(localX, localY));
450
+ function buildRasterClickSelectionContext(
451
+ selection: DomEditSelection,
452
+ localPointer: PreviewLocalPointer,
453
+ ): string {
454
+ return [
455
+ "The user clicked a large raster/background element in the Studio preview.",
456
+ `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
457
+ localPointer.viewport.width,
458
+ )}x${Math.round(localPointer.viewport.height)} composition.`,
459
+ `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
460
+ "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
461
+ "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
462
+ ].join("\n");
390
463
  }
391
464
 
392
465
  function domEditSelectionsTargetSame(
@@ -592,17 +665,47 @@ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number |
592
665
 
593
666
  // ── Ask Agent Modal ──
594
667
 
668
+ function clampNumber(value: number, min: number, max: number): number {
669
+ if (max < min) return min;
670
+ return Math.min(Math.max(value, min), max);
671
+ }
672
+
673
+ function getAgentModalPositionStyle(
674
+ anchorPoint: AgentModalAnchorPoint | null,
675
+ ): CSSProperties | undefined {
676
+ if (!anchorPoint || typeof window === "undefined") return undefined;
677
+
678
+ const modalWidth = 480;
679
+ const estimatedModalHeight = 270;
680
+ const margin = 16;
681
+ const left = clampNumber(
682
+ anchorPoint.x,
683
+ margin + modalWidth / 2,
684
+ window.innerWidth - margin - modalWidth / 2,
685
+ );
686
+ const top = clampNumber(
687
+ anchorPoint.y + 12,
688
+ margin,
689
+ window.innerHeight - margin - estimatedModalHeight,
690
+ );
691
+
692
+ return { left, top, transform: "translateX(-50%)" };
693
+ }
694
+
595
695
  function AskAgentModal({
596
696
  selectionLabel,
697
+ anchorPoint = null,
597
698
  onSubmit,
598
699
  onClose,
599
700
  }: {
600
701
  selectionLabel: string;
702
+ anchorPoint?: AgentModalAnchorPoint | null;
601
703
  onSubmit: (instruction: string) => void;
602
704
  onClose: () => void;
603
705
  }) {
604
706
  const [value, setValue] = useState("");
605
707
  const inputRef = useRef<HTMLTextAreaElement>(null);
708
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
606
709
 
607
710
  useMountEffect(() => {
608
711
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -615,11 +718,18 @@ function AskAgentModal({
615
718
 
616
719
  return (
617
720
  <div
618
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
721
+ className={
722
+ anchorPoint
723
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
724
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
725
+ }
619
726
  onClick={onClose}
620
727
  >
621
728
  <div
622
- className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
729
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
730
+ anchorPoint ? "fixed" : ""
731
+ }`}
732
+ style={modalPositionStyle}
623
733
  onClick={(e) => e.stopPropagation()}
624
734
  >
625
735
  <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
@@ -774,13 +884,17 @@ export function StudioApp() {
774
884
  const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
775
885
  const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
776
886
  const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
887
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
888
+ string | undefined
889
+ >();
890
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
891
+ null,
892
+ );
777
893
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
778
894
  const [agentModalOpen, setAgentModalOpen] = useState(false);
779
895
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
780
896
  const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
781
- const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
782
- ReadonlySet<string>
783
- >(() => new Set());
897
+ const [compositionLoading, setCompositionLoading] = useState(true);
784
898
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
785
899
  const refreshPreviewDocumentVersion = useCallback(() => {
786
900
  setPreviewDocumentVersion((version) => version + 1);
@@ -1771,6 +1885,8 @@ export function StudioApp() {
1771
1885
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1772
1886
  ) => {
1773
1887
  setAgentPromptTagSnippet(undefined);
1888
+ setAgentPromptSelectionContext(undefined);
1889
+ setAgentModalAnchorPoint(null);
1774
1890
  setCopiedAgentPrompt(false);
1775
1891
  if (!selection) {
1776
1892
  domEditSelectionRef.current = null;
@@ -2296,13 +2412,13 @@ export function StudioApp() {
2296
2412
  (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2297
2413
  const iframe = previewIframeRef.current;
2298
2414
  if (!iframe || captionEditMode) return null;
2299
- const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2415
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2300
2416
  if (!target) return null;
2301
2417
  return buildDomSelectionFromTarget(target, {
2302
2418
  preferClipAncestor: options?.preferClipAncestor,
2303
2419
  });
2304
2420
  },
2305
- [buildDomSelectionFromTarget, captionEditMode],
2421
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2306
2422
  );
2307
2423
 
2308
2424
  const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
@@ -2398,8 +2514,21 @@ export function StudioApp() {
2398
2514
 
2399
2515
  const selection = buildDomSelectionForTimelineElement(element);
2400
2516
  if (selection) applyDomSelection(selection);
2517
+
2518
+ const key = getTimelineElementKey(element);
2519
+ if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2520
+ setInspectedTimelineElementId(key);
2521
+ setLeftCollapsed(false);
2522
+
2523
+ const iframe = previewIframeRef.current;
2524
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2525
+ seekStudioPreview(iframe, element.start);
2526
+ }
2527
+ } else {
2528
+ setInspectedTimelineElementId(null);
2529
+ }
2401
2530
  },
2402
- [applyDomSelection, buildDomSelectionForTimelineElement],
2531
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2403
2532
  );
2404
2533
 
2405
2534
  const handleTimelineElementInspect = useCallback(
@@ -2426,17 +2555,6 @@ export function StudioApp() {
2426
2555
  [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2427
2556
  );
2428
2557
 
2429
- const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
2430
- const key = getTimelineElementKey(element);
2431
- if (!key) return;
2432
- setThumbnailedTimelineElementIds((current) => {
2433
- const next = new Set(current);
2434
- if (next.has(key)) next.delete(key);
2435
- else next.add(key);
2436
- return next;
2437
- });
2438
- }, []);
2439
-
2440
2558
  const handleTimelineLayerSelect = useCallback(
2441
2559
  (layer: DomEditLayerItem) => {
2442
2560
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
@@ -2816,15 +2934,26 @@ export function StudioApp() {
2816
2934
  buildDomEditStylePatchOperation("background-size", "contain"),
2817
2935
  );
2818
2936
  }
2819
- await persistDomEditOperations(domEditSelection, operations, {
2820
- label: "Edit layer style",
2821
- skipRefresh: true,
2822
- prepareContent: importedFont
2823
- ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2824
- : undefined,
2825
- });
2937
+ try {
2938
+ await persistDomEditOperations(domEditSelection, operations, {
2939
+ label: "Edit layer style",
2940
+ skipRefresh: true,
2941
+ prepareContent: importedFont
2942
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2943
+ : undefined,
2944
+ });
2945
+ } catch (err) {
2946
+ console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
2947
+ }
2948
+ refreshDomEditSelectionFromPreview(domEditSelection);
2826
2949
  },
2827
- [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
2950
+ [
2951
+ activeCompPath,
2952
+ domEditSelection,
2953
+ persistDomEditOperations,
2954
+ refreshDomEditSelectionFromPreview,
2955
+ resolveImportedFontAsset,
2956
+ ],
2828
2957
  );
2829
2958
 
2830
2959
  const handleDomTextCommit = useCallback(
@@ -2978,51 +3107,11 @@ export function StudioApp() {
2978
3107
  [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
2979
3108
  );
2980
3109
 
2981
- const handleDomAddTextField = useCallback(
2982
- async (afterFieldKey?: string) => {
2983
- if (!domEditSelection) return null;
2984
- if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
2985
-
2986
- const insertionIndex = domEditSelection.textFields.findIndex(
2987
- (field) => field.key === afterFieldKey,
2988
- );
2989
- const baseField =
2990
- domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
2991
- domEditSelection.textFields[0];
2992
- const nextField = buildDefaultDomEditTextField(baseField);
2993
- const nextTextFields = [...domEditSelection.textFields];
2994
- nextTextFields.splice(
2995
- insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
2996
- 0,
2997
- nextField,
2998
- );
2999
-
3000
- await commitDomTextFields(domEditSelection, nextTextFields);
3001
- return nextField.key;
3002
- },
3003
- [commitDomTextFields, domEditSelection],
3004
- );
3005
-
3006
- const handleDomRemoveTextField = useCallback(
3007
- async (fieldKey: string) => {
3008
- if (!domEditSelection) return;
3009
- const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3010
- if (!field) return;
3011
-
3012
- if (field.source === "self") {
3013
- await handleDomTextCommit("", fieldKey);
3014
- return;
3015
- }
3016
-
3017
- const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
3018
- await commitDomTextFields(domEditSelection, nextTextFields);
3019
- },
3020
- [commitDomTextFields, domEditSelection, handleDomTextCommit],
3021
- );
3022
-
3023
3110
  const handleAskAgent = useCallback(() => {
3024
3111
  if (!domEditSelection) return;
3025
3112
  setAgentPromptTagSnippet(undefined);
3113
+ setAgentPromptSelectionContext(undefined);
3114
+ setAgentModalAnchorPoint(null);
3026
3115
  void preloadAgentPromptSnippet(domEditSelection);
3027
3116
  setAgentModalOpen(true);
3028
3117
  }, [domEditSelection, preloadAgentPromptSnippet]);
@@ -3037,6 +3126,7 @@ export function StudioApp() {
3037
3126
  selection: domEditSelection,
3038
3127
  currentTime,
3039
3128
  tagSnippet,
3129
+ selectionContext: agentPromptSelectionContext,
3040
3130
  userInstruction,
3041
3131
  sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3042
3132
  });
@@ -3048,11 +3138,21 @@ export function StudioApp() {
3048
3138
  }
3049
3139
 
3050
3140
  setAgentModalOpen(false);
3141
+ setAgentPromptSelectionContext(undefined);
3142
+ setAgentModalAnchorPoint(null);
3051
3143
  if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3052
3144
  setCopiedAgentPrompt(true);
3053
3145
  copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3054
3146
  },
3055
- [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
3147
+ [
3148
+ activeCompPath,
3149
+ agentPromptSelectionContext,
3150
+ agentPromptTagSnippet,
3151
+ currentTime,
3152
+ domEditSelection,
3153
+ projectDir,
3154
+ showToast,
3155
+ ],
3056
3156
  );
3057
3157
 
3058
3158
  const handlePreviewIframeRef = useCallback(
@@ -3070,9 +3170,9 @@ export function StudioApp() {
3070
3170
 
3071
3171
  const handlePreviewCanvasMouseDown = useCallback(
3072
3172
  (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3073
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
3173
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
3074
3174
  const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3075
- preferClipAncestor: options?.preferClipAncestor ?? true,
3175
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3076
3176
  });
3077
3177
  if (!nextSelection) {
3078
3178
  if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
@@ -3080,14 +3180,35 @@ export function StudioApp() {
3080
3180
  }
3081
3181
  e.preventDefault();
3082
3182
  e.stopPropagation();
3183
+ const localPointer = previewIframeRef.current
3184
+ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3185
+ : null;
3083
3186
  applyDomSelection(nextSelection, { additive: e.shiftKey });
3187
+ if (
3188
+ !e.shiftKey &&
3189
+ localPointer &&
3190
+ isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3191
+ ) {
3192
+ setAgentPromptSelectionContext(
3193
+ buildRasterClickSelectionContext(nextSelection, localPointer),
3194
+ );
3195
+ setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3196
+ void preloadAgentPromptSnippet(nextSelection);
3197
+ setAgentModalOpen(true);
3198
+ }
3084
3199
  },
3085
- [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
3200
+ [
3201
+ applyDomSelection,
3202
+ captionEditMode,
3203
+ compositionLoading,
3204
+ preloadAgentPromptSnippet,
3205
+ resolveDomSelectionFromPreviewPoint,
3206
+ ],
3086
3207
  );
3087
3208
 
3088
3209
  const handlePreviewCanvasPointerMove = useCallback(
3089
3210
  (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3090
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
3211
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
3091
3212
  updateDomEditHoverSelection(null);
3092
3213
  return null;
3093
3214
  }
@@ -3098,7 +3219,12 @@ export function StudioApp() {
3098
3219
  updateDomEditHoverSelection(nextSelection);
3099
3220
  return nextSelection;
3100
3221
  },
3101
- [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
3222
+ [
3223
+ captionEditMode,
3224
+ compositionLoading,
3225
+ resolveDomSelectionFromPreviewPoint,
3226
+ updateDomEditHoverSelection,
3227
+ ],
3102
3228
  );
3103
3229
 
3104
3230
  const handlePreviewCanvasPointerLeave = useCallback(() => {
@@ -3965,9 +4091,8 @@ export function StudioApp() {
3965
4091
  onInspectTimelineElement={handleTimelineElementInspect}
3966
4092
  inspectedTimelineElementId={inspectedTimelineElementId}
3967
4093
  timelineLayerChildCounts={timelineLayerChildCounts}
3968
- thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
3969
- onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
3970
4094
  onCompIdToSrcChange={setCompIdToSrc}
4095
+ onCompositionLoadingChange={setCompositionLoading}
3971
4096
  onCompositionChange={(compPath) => {
3972
4097
  // Sync activeCompPath when user drills down via timeline double-click
3973
4098
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
@@ -3984,7 +4109,7 @@ export function StudioApp() {
3984
4109
  iframeRef={previewIframeRef}
3985
4110
  activeCompositionPath={activeCompPath}
3986
4111
  hoverSelection={
3987
- STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
4112
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
3988
4113
  ? domEditHoverSelection
3989
4114
  : null
3990
4115
  }
@@ -4090,21 +4215,18 @@ export function StudioApp() {
4090
4215
  <div className="min-h-0 flex-1">
4091
4216
  {designPanelActive ? (
4092
4217
  <PropertyPanel
4093
- projectId={projectId}
4094
- assets={assets}
4095
4218
  element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4219
+ multiSelectCount={domEditGroupSelections.length}
4096
4220
  copiedAgentPrompt={copiedAgentPrompt}
4097
4221
  onClearSelection={clearDomSelection}
4098
4222
  onSetStyle={handleDomStyleCommit}
4099
4223
  onSetManualOffset={handleDomPathOffsetCommit}
4100
4224
  onSetManualSize={handleDomBoxSizeCommit}
4225
+ onSetRotation={handleDomRotationCommit}
4101
4226
  onSetText={handleDomTextCommit}
4102
4227
  onSetTextFieldStyle={handleDomTextFieldStyleCommit}
4103
- onAddTextField={handleDomAddTextField}
4104
- onRemoveTextField={handleDomRemoveTextField}
4105
4228
  onResetManualEdits={handleDomManualEditsReset}
4106
4229
  onAskAgent={handleAskAgent}
4107
- onImportAssets={handleImportFiles}
4108
4230
  fontAssets={fontAssets}
4109
4231
  onImportFonts={handleImportFonts}
4110
4232
  />
@@ -4122,9 +4244,9 @@ export function StudioApp() {
4122
4244
  projectId={projectId}
4123
4245
  onDelete={renderQueue.deleteRender}
4124
4246
  onClearCompleted={renderQueue.clearCompleted}
4125
- onStartRender={async (format, quality) => {
4247
+ onStartRender={async (format, quality, resolution, fps) => {
4126
4248
  await waitForPendingDomEditSaves();
4127
- await renderQueue.startRender(30, quality, format);
4249
+ await renderQueue.startRender({ fps, quality, format, resolution });
4128
4250
  }}
4129
4251
  isRendering={renderQueue.isRendering}
4130
4252
  />
@@ -4155,8 +4277,13 @@ export function StudioApp() {
4155
4277
  {agentModalOpen && domEditSelection && (
4156
4278
  <AskAgentModal
4157
4279
  selectionLabel={domEditSelection.label}
4280
+ anchorPoint={agentModalAnchorPoint}
4158
4281
  onSubmit={handleAgentModalSubmit}
4159
- onClose={() => setAgentModalOpen(false)}
4282
+ onClose={() => {
4283
+ setAgentModalOpen(false);
4284
+ setAgentPromptSelectionContext(undefined);
4285
+ setAgentModalAnchorPoint(null);
4286
+ }}
4160
4287
  />
4161
4288
  )}
4162
4289
 
@@ -4,8 +4,10 @@ import {
4
4
  buildStrokeWidthStyleUpdates,
5
5
  getClipPathInsetPx,
6
6
  getCssFilterFunctionPx,
7
+ getPropertyPanelVisibleSections,
7
8
  inferBoxShadowPreset,
8
9
  inferClipPathPreset,
10
+ isPropertyPanelMediaLikeSelection,
9
11
  normalizePanelPxValue,
10
12
  setCssFilterFunctionPx,
11
13
  } from "./PropertyPanel";
@@ -64,4 +66,51 @@ describe("PropertyPanel style helpers", () => {
64
66
  expect(buildStrokeStyleUpdates("none", "4px")).toEqual([["border-style", "none"]]);
65
67
  expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
66
68
  });
69
+
70
+ it("orders the simplified default inspector sections around high-confidence edits", () => {
71
+ expect(
72
+ getPropertyPanelVisibleSections({
73
+ hasSelection: true,
74
+ canEditStyles: true,
75
+ hasTextControls: true,
76
+ hasColorControls: true,
77
+ }),
78
+ ).toEqual(["Text", "Layout", "Colors", "Radius", "Shadow"]);
79
+
80
+ expect(
81
+ getPropertyPanelVisibleSections({
82
+ hasSelection: true,
83
+ canEditStyles: true,
84
+ hasTextControls: false,
85
+ hasColorControls: false,
86
+ }),
87
+ ).toEqual(["Layout", "Radius", "Shadow"]);
88
+ });
89
+
90
+ it("treats media tags and background-image layers as image-like controls", () => {
91
+ expect(
92
+ isPropertyPanelMediaLikeSelection({
93
+ tagName: "img",
94
+ styles: {},
95
+ }),
96
+ ).toBe(true);
97
+
98
+ expect(
99
+ isPropertyPanelMediaLikeSelection({
100
+ tagName: "div",
101
+ styles: {
102
+ "background-image": "url(/assets/studio.png)",
103
+ },
104
+ }),
105
+ ).toBe(true);
106
+
107
+ expect(
108
+ isPropertyPanelMediaLikeSelection({
109
+ tagName: "div",
110
+ styles: {
111
+ "background-image": "none",
112
+ },
113
+ }),
114
+ ).toBe(false);
115
+ });
67
116
  });