Skip to content

Commit 67c38f9

Browse files
committed
feat(studio): disable manual positioning JSON by default, add toggle
Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting)
1 parent e8068ea commit 67c38f9

9 files changed

Lines changed: 157 additions & 27 deletions

File tree

‎packages/studio/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ export function StudioApp() {
192192
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
193193
reloadPreview,
194194
setRefreshKey,
195+
manualEditsEnabled: manifestPersistence.manualEditsEnabled,
196+
setManualEditsEnabled: manifestPersistence.setManualEditsEnabled,
195197
});
196198

197199
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;

‎packages/studio/src/components/StudioPreviewArea.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function StudioPreviewArea({
7575
domEditHoverSelection,
7676
domEditSelection,
7777
domEditGroupSelections,
78+
manualEditsEnabled,
7879
handleTimelineElementSelect,
7980
handlePreviewCanvasMouseDown,
8081
handlePreviewCanvasPointerMove,
@@ -133,7 +134,7 @@ export function StudioPreviewArea({
133134
}
134135
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
135136
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
136-
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
137+
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && manualEditsEnabled}
137138
onCanvasMouseDown={handlePreviewCanvasMouseDown}
138139
onCanvasPointerMove={handlePreviewCanvasPointerMove}
139140
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}

‎packages/studio/src/components/StudioRightPanel.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export function StudioRightPanel({
6464
handleDomMotionCommit,
6565
handleDomMotionClear,
6666
applyDomSelection,
67+
manualEditsEnabled,
68+
setManualEditsEnabled,
6769
} = useDomEditContext();
6870

6971
const { assets, fontAssets, handleImportFiles, handleImportFonts } = useFileManagerContext();
@@ -180,6 +182,8 @@ export function StudioRightPanel({
180182
onImportFonts={handleImportFonts}
181183
activeCompositionPath={activeCompPath}
182184
onSelectLayer={handleSelectLayer}
185+
manualEditsEnabled={manualEditsEnabled}
186+
onSetManualEditsEnabled={setManualEditsEnabled}
183187
/>
184188
) : motionPanelActive ? (
185189
<MotionPanel

‎packages/studio/src/components/editor/PropertyPanel.tsx‎

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ interface PropertyPanelProps {
5353
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
5454
activeCompositionPath?: string | null;
5555
onSelectLayer?: (layer: DomEditLayerItem) => void;
56+
manualEditsEnabled?: boolean;
57+
onSetManualEditsEnabled?: (enabled: boolean) => void;
5658
}
5759

5860
/* ------------------------------------------------------------------ */
@@ -115,6 +117,42 @@ function LayerTree({
115117
);
116118
}
117119

120+
/* ------------------------------------------------------------------ */
121+
/* ManualPositioningToggle */
122+
/* ------------------------------------------------------------------ */
123+
124+
function ManualPositioningToggle({
125+
enabled,
126+
onToggle,
127+
}: {
128+
enabled: boolean;
129+
onToggle: (enabled: boolean) => void;
130+
}) {
131+
return (
132+
<div className="flex-shrink-0 border-t border-neutral-800 px-4 py-3">
133+
<div className="flex items-center justify-between">
134+
<span className="text-[11px] text-neutral-400">Manual positioning</span>
135+
<button
136+
type="button"
137+
role="switch"
138+
aria-checked={enabled}
139+
onClick={() => onToggle(!enabled)}
140+
title={enabled ? "Disable drag-to-reposition" : "Enable drag-to-reposition"}
141+
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors focus-visible:outline-none ${
142+
enabled ? "bg-studio-accent" : "bg-neutral-700"
143+
}`}
144+
>
145+
<span
146+
className={`pointer-events-none mt-0.5 inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
147+
enabled ? "translate-x-[18px]" : "translate-x-[2px]"
148+
}`}
149+
/>
150+
</button>
151+
</div>
152+
</div>
153+
);
154+
}
155+
118156
/* ------------------------------------------------------------------ */
119157
/* PropertyPanel */
120158
/* ------------------------------------------------------------------ */
@@ -141,41 +179,52 @@ export const PropertyPanel = memo(function PropertyPanel({
141179
onImportFonts,
142180
activeCompositionPath = null,
143181
onSelectLayer,
182+
manualEditsEnabled = true,
183+
onSetManualEditsEnabled,
144184
}: PropertyPanelProps) {
145185
const styles = element?.computedStyles ?? EMPTY_STYLES;
146186

147187
if (!element) {
148188
return (
149-
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
150-
{multiSelectCount > 1 ? (
151-
<>
152-
<Layers size={18} className="mb-3 text-neutral-600" />
153-
<p className="text-sm font-medium text-neutral-200">
154-
{multiSelectCount} elements selected
155-
</p>
156-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
157-
Select a single element to edit its properties. Click an element in the preview or use
158-
the timeline layer panel.
159-
</p>
160-
</>
161-
) : (
162-
<>
163-
<Eye size={18} className="mb-3 text-neutral-600" />
164-
<p className="text-sm font-medium text-neutral-200">
165-
Select an element in the preview.
166-
</p>
167-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
168-
The inspector is tuned for element edits with safer geometry controls, color picking,
169-
and cleaner grouped layer controls.
170-
</p>
171-
</>
189+
<div className="flex h-full flex-col bg-neutral-900">
190+
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
191+
{multiSelectCount > 1 ? (
192+
<>
193+
<Layers size={18} className="mb-3 text-neutral-600" />
194+
<p className="text-sm font-medium text-neutral-200">
195+
{multiSelectCount} elements selected
196+
</p>
197+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
198+
Select a single element to edit its properties. Click an element in the preview or
199+
use the timeline layer panel.
200+
</p>
201+
</>
202+
) : (
203+
<>
204+
<Eye size={18} className="mb-3 text-neutral-600" />
205+
<p className="text-sm font-medium text-neutral-200">
206+
Select an element in the preview.
207+
</p>
208+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
209+
The inspector is tuned for element edits with safer geometry controls, color
210+
picking, and cleaner grouped layer controls.
211+
</p>
212+
</>
213+
)}
214+
</div>
215+
{onSetManualEditsEnabled && (
216+
<ManualPositioningToggle
217+
enabled={manualEditsEnabled}
218+
onToggle={onSetManualEditsEnabled}
219+
/>
172220
)}
173221
</div>
174222
);
175223
}
176224

177-
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
178-
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
225+
const manualOffsetEditingDisabled =
226+
!element.capabilities.canApplyManualOffset || !manualEditsEnabled;
227+
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize || !manualEditsEnabled;
179228
const sourceLabel = element.id ? `#${element.id}` : element.selector;
180229
const showEditableSections = element.capabilities.canEditStyles;
181230
const manualOffset = readStudioPathOffset(element.element);
@@ -318,6 +367,7 @@ export const PropertyPanel = memo(function PropertyPanel({
318367
<MetricField
319368
label="R"
320369
value={`${manualRotation.angle}°`}
370+
disabled={!manualEditsEnabled}
321371
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
322372
/>
323373
</div>
@@ -342,6 +392,9 @@ export const PropertyPanel = memo(function PropertyPanel({
342392
/>
343393
)}
344394
</div>
395+
{onSetManualEditsEnabled && (
396+
<ManualPositioningToggle enabled={manualEditsEnabled} onToggle={onSetManualEditsEnabled} />
397+
)}
345398
</div>
346399
);
347400
});

‎packages/studio/src/components/editor/manualEditsParsing.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,10 @@ export function parseStudioManualEditManifest(content: string): StudioManualEdit
126126
if (!parsed || typeof parsed !== "object") return emptyStudioManualEditManifest();
127127
const edits = (parsed as { edits?: unknown }).edits;
128128
if (!Array.isArray(edits)) return emptyStudioManualEditManifest();
129+
const record = parsed as Record<string, unknown>;
129130
return {
130131
version: 1,
132+
enabled: record.enabled === true,
131133
edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null),
132134
};
133135
} catch {

‎packages/studio/src/components/editor/manualEditsTypes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | Studio
7575

7676
export interface StudioManualEditManifest {
7777
version: 1;
78+
enabled?: boolean;
7879
edits: StudioManualEdit[];
7980
}
8081

‎packages/studio/src/contexts/DomEditContext.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export function DomEditProvider({
5050
setAgentModalOpen,
5151
setAgentPromptSelectionContext,
5252
setAgentModalAnchorPoint,
53+
manualEditsEnabled,
54+
setManualEditsEnabled,
5355
},
5456
children,
5557
}: {
@@ -95,6 +97,8 @@ export function DomEditProvider({
9597
setAgentModalOpen,
9698
setAgentPromptSelectionContext,
9799
setAgentModalAnchorPoint,
100+
manualEditsEnabled,
101+
setManualEditsEnabled,
98102
}),
99103
[
100104
domEditSelection,
@@ -134,6 +138,8 @@ export function DomEditProvider({
134138
setAgentModalOpen,
135139
setAgentPromptSelectionContext,
136140
setAgentModalAnchorPoint,
141+
manualEditsEnabled,
142+
setManualEditsEnabled,
137143
],
138144
);
139145
return <DomEditContext value={stable}>{children}</DomEditContext>;

‎packages/studio/src/hooks/useDomEditSession.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface UseDomEditSessionParams {
6666
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
6767
reloadPreview: () => void;
6868
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
69+
manualEditsEnabled: boolean;
70+
setManualEditsEnabled: (enabled: boolean) => void;
6971
}
7072

7173
// ── Hook ──
@@ -105,6 +107,8 @@ export function useDomEditSession({
105107
syncPreviewHistoryHotkey,
106108
reloadPreview,
107109
setRefreshKey: _setRefreshKey,
110+
manualEditsEnabled,
111+
setManualEditsEnabled,
108112
}: UseDomEditSessionParams) {
109113
void _setRefreshKey;
110114
// ── Selection (delegated to useDomSelection) ──
@@ -339,5 +343,7 @@ export function useDomEditSession({
339343
setAgentModalOpen,
340344
setAgentPromptSelectionContext,
341345
setAgentModalAnchorPoint,
346+
manualEditsEnabled,
347+
setManualEditsEnabled,
342348
};
343349
}

‎packages/studio/src/hooks/useManifestPersistence.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function useManifestPersistence({
5555
activeCompPathRef,
5656
}: UseManifestPersistenceParams) {
5757
const [, setStudioMotionRevision] = useState(0);
58+
const [manualEditsEnabled, setManualEditsEnabledState] = useState(false);
5859

5960
const domEditSaveTimestampRef = useRef(0);
6061
const domTextCommitVersionRef = useRef(0);
@@ -169,7 +170,9 @@ export function useManifestPersistence({
169170
return;
170171
}
171172
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
172-
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
173+
const parsed = parseStudioManualEditManifest(content);
174+
studioManualEditManifestRef.current = parsed;
175+
setManualEditsEnabledState(parsed.enabled ?? false);
173176
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
174177
}
175178
applyCurrentStudioManualEditsToPreview(iframe);
@@ -435,6 +438,7 @@ export function useManifestPersistence({
435438
studioManualEditProjectRef.current = projectId;
436439
if (!previousProjectId || previousProjectId === projectId) return;
437440
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
441+
setManualEditsEnabledState(false);
438442
studioManualEditRevisionRef.current += 1;
439443
studioMotionManifestRef.current = emptyStudioMotionManifest();
440444
studioMotionRevisionRef.current += 1;
@@ -481,6 +485,55 @@ export function useManifestPersistence({
481485
return () => es.close();
482486
});
483487

488+
const setManualEditsEnabled = useCallback(
489+
(enabled: boolean) => {
490+
const previousManifest = studioManualEditManifestRef.current;
491+
const nextManifest = { ...previousManifest, enabled };
492+
studioManualEditManifestRef.current = nextManifest;
493+
studioManualEditRevisionRef.current += 1;
494+
setManualEditsEnabledState(enabled);
495+
496+
const save = async () => {
497+
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
498+
const diskManifest = parseStudioManualEditManifest(originalContent);
499+
const nextDiskManifest = { ...diskManifest, enabled };
500+
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
501+
if (nextDiskContent === originalContent) return;
502+
503+
const pid = projectIdRef.current;
504+
if (!pid) throw new Error("No active project");
505+
domEditSaveTimestampRef.current = Date.now();
506+
await saveProjectFilesWithHistory({
507+
projectId: pid,
508+
label: enabled ? "Enable manual positioning" : "Disable manual positioning",
509+
kind: "manual",
510+
coalesceKey: "manual-edits-enabled",
511+
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
512+
readFile: async () => originalContent,
513+
writeFile: writeProjectFile,
514+
recordEdit,
515+
});
516+
domEditSaveTimestampRef.current = Date.now();
517+
};
518+
519+
void queueDomEditSave(save).catch((error) => {
520+
studioManualEditManifestRef.current = previousManifest;
521+
studioManualEditRevisionRef.current += 1;
522+
setManualEditsEnabledState(previousManifest.enabled ?? false);
523+
const message = error instanceof Error ? error.message : "Failed to save setting";
524+
showToast(message);
525+
});
526+
},
527+
[
528+
queueDomEditSave,
529+
readOptionalProjectFile,
530+
writeProjectFile,
531+
recordEdit,
532+
showToast,
533+
domEditSaveTimestampRef,
534+
],
535+
);
536+
484537
return {
485538
domEditSaveTimestampRef,
486539
domTextCommitVersionRef,
@@ -501,5 +554,7 @@ export function useManifestPersistence({
501554
commitStudioManualEditManifestOptimistically,
502555
commitStudioMotionManifestOptimistically,
503556
syncHistoryPreviewAfterApply,
557+
manualEditsEnabled,
558+
setManualEditsEnabled,
504559
};
505560
}

0 commit comments

Comments
 (0)