1- import { memo , useState , useCallback } from "react" ;
1+ import { memo , useCallback , useEffect , useRef , useState } from "react" ;
22import type { BlockParam } from "@hyperframes/core/registry" ;
3+ import { useFileManagerContextOptional } from "../../contexts/FileManagerContext" ;
4+ import { useStudioPlaybackContext } from "../../contexts/StudioContext" ;
35
46interface BlockParamsPanelProps {
57 blockName : string ;
@@ -9,23 +11,75 @@ interface BlockParamsPanelProps {
911 onClose : ( ) => void ;
1012}
1113
14+ type CommitState = { tone : "idle" | "saving" | "saved" | "error" ; message ?: string } ;
15+
1216export const BlockParamsPanel = memo ( function BlockParamsPanel ( {
1317 blockTitle,
1418 params,
15- compositionPath : _compositionPath ,
19+ compositionPath,
1620 onClose,
1721} : BlockParamsPanelProps ) {
22+ const fileManager = useFileManagerContextOptional ( ) ;
23+ const { setRefreshKey } = useStudioPlaybackContext ( ) ;
1824 const [ values , setValues ] = useState < Record < string , string > > ( ( ) => {
1925 const initial : Record < string , string > = { } ;
2026 for ( const p of params ) {
2127 initial [ p . key ] = p . default ;
2228 }
2329 return initial ;
2430 } ) ;
31+ // Last value actually written to the block file per param — the literal we
32+ // substitute when the next commit rewrites the file.
33+ const appliedRef = useRef < Record < string , string > > (
34+ Object . fromEntries ( params . map ( ( p ) => [ p . key , p . default ] ) ) ,
35+ ) ;
36+ const [ commitState , setCommitState ] = useState < CommitState > ( { tone : "idle" } ) ;
37+ const commitTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
38+
39+ useEffect (
40+ ( ) => ( ) => {
41+ if ( commitTimerRef . current ) clearTimeout ( commitTimerRef . current ) ;
42+ } ,
43+ [ ] ,
44+ ) ;
2545
26- const handleChange = useCallback ( ( key : string , value : string ) => {
27- setValues ( ( prev ) => ( { ...prev , [ key ] : value } ) ) ;
28- } , [ ] ) ;
46+ const commitParam = useCallback (
47+ async ( key : string , nextValue : string ) => {
48+ if ( ! fileManager ) return ;
49+ const previous = appliedRef . current [ key ] ;
50+ if ( previous === undefined || previous === nextValue || ! nextValue . trim ( ) ) return ;
51+ setCommitState ( { tone : "saving" } ) ;
52+ try {
53+ const content = await fileManager . readProjectFile ( compositionPath ) ;
54+ if ( ! content . includes ( previous ) ) {
55+ setCommitState ( {
56+ tone : "error" ,
57+ message : `Couldn't find the current value in ${ compositionPath } — it may have been edited by hand.` ,
58+ } ) ;
59+ return ;
60+ }
61+ const nextContent = content . split ( previous ) . join ( nextValue ) ;
62+ await fileManager . writeProjectFile ( compositionPath , nextContent ) ;
63+ appliedRef . current [ key ] = nextValue ;
64+ setCommitState ( { tone : "saved" } ) ;
65+ setRefreshKey ( ( k ) => k + 1 ) ;
66+ } catch {
67+ setCommitState ( { tone : "error" , message : "Couldn't save the block file. Retry?" } ) ;
68+ }
69+ } ,
70+ [ fileManager , compositionPath , setRefreshKey ] ,
71+ ) ;
72+
73+ const handleChange = useCallback (
74+ ( key : string , value : string ) => {
75+ setValues ( ( prev ) => ( { ...prev , [ key ] : value } ) ) ;
76+ if ( commitTimerRef . current ) clearTimeout ( commitTimerRef . current ) ;
77+ commitTimerRef . current = setTimeout ( ( ) => {
78+ void commitParam ( key , value ) ;
79+ } , 300 ) ;
80+ } ,
81+ [ commitParam ] ,
82+ ) ;
2983
3084 return (
3185 < div className = "flex flex-col h-full" >
@@ -34,7 +88,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
3488 < button
3589 type = "button"
3690 onClick = { onClose }
37- className = "text-neutral-500 hover:text-neutral-300 transition-colors"
91+ aria-label = "Close block parameters"
92+ className = "p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
3893 >
3994 < svg
4095 width = "14"
@@ -56,14 +111,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
56111 < div className = "text-[9px] font-medium text-neutral-500 uppercase tracking-wider" >
57112 Parameters
58113 </ div >
114+ { params . length === 0 && (
115+ < div className = "text-[10px] text-neutral-500" > This block has no editable parameters.</ div >
116+ ) }
117+ { ! fileManager && params . length > 0 && (
118+ < div className = "text-[10px] text-amber-400/90" >
119+ Block params can't be edited here — no project file access.
120+ </ div >
121+ ) }
59122 { params . map ( ( param ) => (
60123 < ParamControl
61124 key = { param . key }
62125 param = { param }
63126 value = { values [ param . key ] ?? param . default }
127+ disabled = { ! fileManager }
64128 onChange = { ( v ) => handleChange ( param . key , v ) }
65129 />
66130 ) ) }
131+ { commitState . tone === "saving" && (
132+ < div className = "text-[10px] text-neutral-500" role = "status" >
133+ Saving…
134+ </ div >
135+ ) }
136+ { commitState . tone === "saved" && (
137+ < div className = "text-[10px] text-emerald-500/90" role = "status" >
138+ Saved to { compositionPath }
139+ </ div >
140+ ) }
141+ { commitState . tone === "error" && (
142+ < div className = "text-[10px] text-red-400" role = "alert" >
143+ { commitState . message }
144+ </ div >
145+ ) }
67146 </ div >
68147 </ div >
69148 ) ;
@@ -72,10 +151,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
72151function ParamControl ( {
73152 param,
74153 value,
154+ disabled,
75155 onChange,
76156} : {
77157 param : BlockParam ;
78158 value : string ;
159+ disabled ?: boolean ;
79160 onChange : ( value : string ) => void ;
80161} ) {
81162 return (
@@ -87,14 +168,18 @@ function ParamControl({
87168 < input
88169 type = "color"
89170 value = { value }
171+ disabled = { disabled }
172+ aria-label = { `${ param . label } color` }
90173 onChange = { ( e ) => onChange ( e . target . value ) }
91- className = "w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
174+ className = "w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 "
92175 />
93176 < input
94177 type = "text"
95178 value = { value }
179+ disabled = { disabled }
180+ aria-label = { `${ param . label } value` }
96181 onChange = { ( e ) => onChange ( e . target . value ) }
97- className = "flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
182+ className = "flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50 "
98183 />
99184 </ div >
100185 ) }
@@ -107,8 +192,10 @@ function ParamControl({
107192 max = { param . max ?? 100 }
108193 step = { param . step ?? 1 }
109194 value = { value }
195+ disabled = { disabled }
196+ aria-label = { param . label }
110197 onChange = { ( e ) => onChange ( e . target . value ) }
111- className = "flex-1"
198+ className = "flex-1 disabled:cursor-not-allowed disabled:opacity-50 "
112199 />
113200 < span className = "text-[10px] text-neutral-400 w-8 text-right tabular-nums" > { value } </ span >
114201 </ div >
@@ -118,16 +205,20 @@ function ParamControl({
118205 < input
119206 type = "text"
120207 value = { value }
208+ disabled = { disabled }
209+ aria-label = { param . label }
121210 onChange = { ( e ) => onChange ( e . target . value ) }
122- className = "w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
211+ className = "w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50 "
123212 />
124213 ) }
125214
126215 { param . type === "select" && param . options && (
127216 < select
128217 value = { value }
218+ disabled = { disabled }
219+ aria-label = { param . label }
129220 onChange = { ( e ) => onChange ( e . target . value ) }
130- className = "w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
221+ className = "w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50 "
131222 >
132223 { param . options . map ( ( opt ) => (
133224 < option key = { opt . value } value = { opt . value } >
0 commit comments