1
- import { ViewerContext } from "../App" ;
2
- import { Box , ScrollArea , Tooltip } from "@mantine/core" ;
3
1
import {
4
2
IconCaretDown ,
5
3
IconCaretRight ,
6
4
IconEye ,
7
5
IconEyeOff ,
6
+ IconPencil ,
7
+ IconDeviceFloppy ,
8
+ IconX ,
8
9
} from "@tabler/icons-react" ;
9
10
import React from "react" ;
10
- import { caretIcon , tableRow , tableWrapper } from "./SceneTreeTable.css" ;
11
+ import {
12
+ caretIcon ,
13
+ editIconWrapper ,
14
+ propsWrapper ,
15
+ tableRow ,
16
+ tableWrapper ,
17
+ } from "./SceneTreeTable.css" ;
11
18
import { useDisclosure } from "@mantine/hooks" ;
19
+ import { useForm } from "@mantine/form" ;
20
+ import { ViewerContext } from "../App" ;
21
+ import {
22
+ Box ,
23
+ Flex ,
24
+ ScrollArea ,
25
+ TextInput ,
26
+ Tooltip ,
27
+ ColorInput ,
28
+ } from "@mantine/core" ;
29
+
30
+ function EditNodeProps ( {
31
+ nodeName,
32
+ close,
33
+ } : {
34
+ nodeName : string ;
35
+ close : ( ) => void ;
36
+ } ) {
37
+ const viewer = React . useContext ( ViewerContext ) ! ;
38
+ const node = viewer . useSceneTree ( ( state ) => state . nodeFromName [ nodeName ] ) ;
39
+ const updateSceneNode = viewer . useSceneTree ( ( state ) => state . updateSceneNode ) ;
40
+
41
+ if ( node === undefined ) {
42
+ return null ;
43
+ }
44
+
45
+ // We'll use JSON, but add support for Infinity.
46
+ // We use infinity for point cloud rendering norms.
47
+ function stringify ( value : any ) {
48
+ if ( value == Number . POSITIVE_INFINITY ) {
49
+ return "Infinity" ;
50
+ } else {
51
+ return JSON . stringify ( value ) ;
52
+ }
53
+ }
54
+ function parse ( value : string ) {
55
+ if ( value === "Infinity" ) {
56
+ return Number . POSITIVE_INFINITY ;
57
+ } else {
58
+ return JSON . parse ( value ) ;
59
+ }
60
+ }
61
+
62
+ const props = node . message . props ;
63
+ console . log ( props ) ;
64
+ const initialValues = Object . fromEntries (
65
+ Object . entries ( props )
66
+ . filter ( ( [ , value ] ) => ! ( value instanceof Uint8Array ) )
67
+ . map ( ( [ key , value ] ) => [ key , stringify ( value ) ] ) ,
68
+ ) ;
69
+
70
+ const form = useForm ( {
71
+ initialValues : {
72
+ ...initialValues ,
73
+ } ,
74
+ validate : {
75
+ ...Object . fromEntries (
76
+ Object . keys ( initialValues ) . map ( ( key ) => [
77
+ key ,
78
+ ( value : string ) => {
79
+ try {
80
+ parse ( value ) ;
81
+ return null ;
82
+ } catch ( e ) {
83
+ return "Invalid JSON" ;
84
+ }
85
+ } ,
86
+ ] ) ,
87
+ ) ,
88
+ } ,
89
+ } ) ;
90
+
91
+ const handleSubmit = ( values : Record < string , string > ) => {
92
+ Object . entries ( values ) . forEach ( ( [ key , value ] ) => {
93
+ if ( value !== initialValues [ key ] ) {
94
+ try {
95
+ const parsedValue = parse ( value ) ;
96
+ updateSceneNode ( nodeName , { [ key ] : parsedValue } ) ;
97
+ // Update the form value to match the parsed value
98
+ form . setFieldValue ( key , stringify ( parsedValue ) ) ;
99
+ } catch ( e ) {
100
+ console . error ( "Failed to parse JSON:" , e ) ;
101
+ }
102
+ }
103
+ } ) ;
104
+ } ;
105
+
106
+ return (
107
+ < Box
108
+ className = { propsWrapper }
109
+ component = "form"
110
+ onSubmit = { form . onSubmit ( handleSubmit ) }
111
+ >
112
+ < Box
113
+ style = { {
114
+ position : "absolute" ,
115
+ top : "0.3em" ,
116
+ right : "0.4em" ,
117
+ } }
118
+ >
119
+ < Tooltip label = { "Close props" } >
120
+ < IconX
121
+ style = { {
122
+ cursor : "pointer" ,
123
+ width : "1em" ,
124
+ height : "1em" ,
125
+ display : "block" ,
126
+ color : "--mantine-color-error" ,
127
+ opacity : "0.7" ,
128
+ } }
129
+ onClick = { ( evt ) => {
130
+ evt . stopPropagation ( ) ;
131
+ close ( ) ;
132
+ } }
133
+ />
134
+ </ Tooltip >
135
+ </ Box >
136
+ { Object . entries ( props ) . map ( ( [ key , value ] ) => {
137
+ if ( value instanceof Uint8Array ) {
138
+ return null ;
139
+ }
140
+
141
+ const isDirty = form . values [ key ] !== initialValues [ key ] ;
142
+
143
+ return (
144
+ < Flex key = { key } align = "center" >
145
+ < Box size = "sm" fz = "xs" style = { { flexGrow : "1" } } >
146
+ { key . charAt ( 0 ) . toUpperCase ( ) + key . slice ( 1 ) . split ( "_" ) . join ( " " ) }
147
+ </ Box >
148
+ < Flex gap = "xs" w = "9em" >
149
+ { ( ( ) => {
150
+ // Check if this is a color property
151
+ try {
152
+ const parsedValue = parse ( form . values [ key ] ) ;
153
+ const isColorProp =
154
+ key . toLowerCase ( ) . includes ( "color" ) &&
155
+ Array . isArray ( parsedValue ) &&
156
+ parsedValue . length === 3 &&
157
+ parsedValue . every ( ( v ) => typeof v === "number" ) ;
158
+
159
+ if ( isColorProp ) {
160
+ // Convert RGB array [0-1] to hex color
161
+ const rgbToHex = ( r : number , g : number , b : number ) => {
162
+ const toHex = ( n : number ) => {
163
+ const hex = Math . round ( n ) . toString ( 16 ) ;
164
+ return hex . length === 1 ? "0" + hex : hex ;
165
+ } ;
166
+ return "#" + toHex ( r ) + toHex ( g ) + toHex ( b ) ;
167
+ } ;
168
+
169
+ // Convert hex color to RGB array [0-1]
170
+ const hexToRgb = ( hex : string ) => {
171
+ const r = parseInt ( hex . slice ( 1 , 3 ) , 16 ) ;
172
+ const g = parseInt ( hex . slice ( 3 , 5 ) , 16 ) ;
173
+ const b = parseInt ( hex . slice ( 5 , 7 ) , 16 ) ;
174
+ return [ r , g , b ] ;
175
+ } ;
176
+
177
+ return (
178
+ < ColorInput
179
+ size = "xs"
180
+ styles = { {
181
+ input : { height : "1.625rem" , minHeight : "1.625rem" } ,
182
+ // icon: { transform: "scale(0.8)" },
183
+ } }
184
+ w = "100%"
185
+ value = { rgbToHex (
186
+ parsedValue [ 0 ] ,
187
+ parsedValue [ 1 ] ,
188
+ parsedValue [ 2 ] ,
189
+ ) }
190
+ onChange = { ( hex ) => {
191
+ const rgb = hexToRgb ( hex ) ;
192
+ form . setFieldValue ( key , stringify ( rgb ) ) ;
193
+ form . onSubmit ( handleSubmit ) ( ) ;
194
+ } }
195
+ onKeyDown = { ( e ) => {
196
+ if ( e . key === "Enter" ) {
197
+ e . preventDefault ( ) ;
198
+ form . onSubmit ( handleSubmit ) ( ) ;
199
+ }
200
+ } }
201
+ />
202
+ ) ;
203
+ }
204
+ } catch ( e ) {
205
+ // If parsing fails, fall back to TextInput
206
+ }
207
+
208
+ // Default TextInput for non-color properties
209
+ return (
210
+ < TextInput
211
+ size = "xs"
212
+ styles = { {
213
+ input : {
214
+ height : "1.625rem" ,
215
+ minHeight : "1.625rem" ,
216
+ width : "100%" ,
217
+ } ,
218
+ // icon: { transform: "scale(0.8)" },
219
+ } }
220
+ w = "100%"
221
+ { ...form . getInputProps ( key ) }
222
+ onKeyDown = { ( e ) => {
223
+ if ( e . key === "Enter" ) {
224
+ e . preventDefault ( ) ;
225
+ form . onSubmit ( handleSubmit ) ( ) ;
226
+ }
227
+ } }
228
+ rightSection = {
229
+ < IconDeviceFloppy
230
+ style = { {
231
+ width : "1rem" ,
232
+ height : "1rem" ,
233
+ opacity : isDirty ? 1.0 : 0.3 ,
234
+ cursor : isDirty ? "pointer" : "default" ,
235
+ } }
236
+ onClick = { ( ) => {
237
+ if ( isDirty ) {
238
+ form . onSubmit ( handleSubmit ) ( ) ;
239
+ }
240
+ } }
241
+ />
242
+ }
243
+ />
244
+ ) ;
245
+ } ) ( ) }
246
+ </ Flex >
247
+ </ Flex >
248
+ ) ;
249
+ } ) }
250
+ < Box fz = "xs" opacity = "0.4" >
251
+ Changes can be overwritten by updates from the server.
252
+ </ Box >
253
+ </ Box >
254
+ ) ;
255
+ }
12
256
13
257
/* Table for seeing an overview of the scene tree, toggling visibility, etc. * */
14
258
export default function SceneTreeTable ( ) {
@@ -74,6 +318,9 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
74
318
const isVisibleEffective = isVisible && props . isParentVisible ;
75
319
const VisibleIcon = isVisible ? IconEye : IconEyeOff ;
76
320
321
+ const [ modalOpened , { open : openEditModal , close : closeEditModal } ] =
322
+ useDisclosure ( false ) ;
323
+
77
324
return (
78
325
< >
79
326
< Box
@@ -105,6 +352,7 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
105
352
opacity : isVisibleEffective ? 0.85 : 0.25 ,
106
353
width : "1.5em" ,
107
354
height : "1.5em" ,
355
+ display : "block" ,
108
356
} }
109
357
onClick = { ( evt ) => {
110
358
evt . stopPropagation ( ) ;
@@ -113,7 +361,7 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
113
361
/>
114
362
</ Tooltip >
115
363
</ Box >
116
- < Box >
364
+ < Box style = { { flexGrow : "1" } } >
117
365
{ props . nodeName
118
366
. split ( "/" )
119
367
. filter ( ( part ) => part . length > 0 )
@@ -128,7 +376,36 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
128
376
</ span >
129
377
) ) }
130
378
</ Box >
379
+ { ! modalOpened ? (
380
+ < Box
381
+ className = { editIconWrapper }
382
+ style = { {
383
+ width : "1.25em" ,
384
+ height : "1.25em" ,
385
+ display : "block" ,
386
+ transition : "opacity 0.2s" ,
387
+ } }
388
+ >
389
+ < Tooltip label = { "Local props" } >
390
+ < IconPencil
391
+ style = { {
392
+ cursor : "pointer" ,
393
+ width : "1.25em" ,
394
+ height : "1.25em" ,
395
+ display : "block" ,
396
+ } }
397
+ onClick = { ( evt ) => {
398
+ evt . stopPropagation ( ) ;
399
+ openEditModal ( ) ;
400
+ } }
401
+ />
402
+ </ Tooltip >
403
+ </ Box >
404
+ ) : null }
131
405
</ Box >
406
+ { modalOpened ? (
407
+ < EditNodeProps nodeName = { props . nodeName } close = { closeEditModal } />
408
+ ) : null }
132
409
{ expanded
133
410
? childrenName . map ( ( name ) => (
134
411
< SceneTreeTableRow
0 commit comments