vzcode 2.4.0 → 2.6.0

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.
@@ -4,6 +4,7 @@ import {
4
4
  useState,
5
5
  useRef,
6
6
  useEffect,
7
+ useMemo,
7
8
  } from 'react';
8
9
  import { VZCodeContext } from '../VZCodeContext';
9
10
  import { VizContent, VizFileId } from '@vizhub/viz-types';
@@ -35,14 +36,19 @@ const formatValue = (
35
36
  };
36
37
 
37
38
  export const VisualEditor = () => {
38
- const { files, submitOperation, iframeRef } =
39
+ const { files, submitOperation } =
39
40
  useContext(VZCodeContext);
40
41
 
41
- // Local state to track slider values during user interaction
42
+ // Local state to track widget values during user interaction
42
43
  const [localValues, setLocalValues] = useState<{
43
- [key: string]: number;
44
+ [key: string]: number | boolean | string;
44
45
  }>({});
45
46
 
47
+ // State to track which dropdown is open
48
+ const [openDropdown, setOpenDropdown] = useState<
49
+ string | null
50
+ >(null);
51
+
46
52
  let configFileId: VizFileId | null = null;
47
53
  for (const fileId in files) {
48
54
  if (files[fileId].name === CONFIG_FILE_NAME) {
@@ -51,47 +57,13 @@ export const VisualEditor = () => {
51
57
  }
52
58
  }
53
59
 
54
- if (configFileId === null) {
55
- return (
56
- <EmptyState>
57
- To begin using the visual editor, create a
58
- config.json.
59
- </EmptyState>
60
- );
61
- }
62
-
63
- let configData;
64
-
65
- try {
66
- configData = JSON.parse(files[configFileId].text);
67
- } catch (error) {
68
- return (
69
- <EmptyState>
70
- Your config.json file is not valid json.
71
- </EmptyState>
72
- );
73
- }
74
-
75
- if (!('visualEditorWidgets' in configData)) {
76
- return (
77
- <EmptyState>
78
- To begin using the visual editor, make sure your
79
- config.json has a key called "visualEditorWidgets",
80
- whose value is the config for the visual editor.
81
- </EmptyState>
82
- );
83
- }
84
-
85
- if (!Array.isArray(configData.visualEditorWidgets)) {
86
- return (
87
- <EmptyState>
88
- Your config.json file has "visualEditorWidgets" but
89
- it is not an array. Please make sure
90
- "visualEditorWidgets" is an array of widget
91
- configurations.
92
- </EmptyState>
93
- );
94
- }
60
+ const configData = useMemo(() => {
61
+ try {
62
+ return JSON.parse(files[configFileId].text);
63
+ } catch (error) {
64
+ return null;
65
+ }
66
+ }, [files?.[configFileId]?.text]);
95
67
 
96
68
  const onSliderChange = useCallback(
97
69
  (property: string) =>
@@ -126,97 +98,211 @@ export const VisualEditor = () => {
126
98
  [configData, files, configFileId, setLocalValues],
127
99
  );
128
100
 
101
+ const onCheckboxChange = useCallback(
102
+ (property: string) =>
103
+ (event: React.ChangeEvent<HTMLInputElement>) => {
104
+ const newValue = event.currentTarget.checked;
105
+
106
+ // Update local state immediately for responsive UI
107
+ setLocalValues((prev) => ({
108
+ ...prev,
109
+ [property]: newValue,
110
+ }));
111
+
112
+ // Update config.json
113
+ const newConfigData = {
114
+ ...configData,
115
+ [property]: newValue,
116
+ };
117
+
118
+ submitOperation((document: VizContent) => ({
119
+ ...document,
120
+ files: {
121
+ ...files,
122
+ [configFileId]: {
123
+ name: 'config.json',
124
+ text: JSON.stringify(newConfigData, null, 2),
125
+ },
126
+ },
127
+ }));
128
+ },
129
+ [configData, files, configFileId, setLocalValues],
130
+ );
131
+
132
+ const onTextInputChange = useCallback(
133
+ (property: string) =>
134
+ (event: React.ChangeEvent<HTMLInputElement>) => {
135
+ const newValue = event.currentTarget.value;
136
+
137
+ // Update local state immediately for responsive UI
138
+ setLocalValues((prev) => ({
139
+ ...prev,
140
+ [property]: newValue,
141
+ }));
142
+
143
+ // Update config.json
144
+ const newConfigData = {
145
+ ...configData,
146
+ [property]: newValue,
147
+ };
148
+
149
+ submitOperation((document: VizContent) => ({
150
+ ...document,
151
+ files: {
152
+ ...files,
153
+ [configFileId]: {
154
+ name: 'config.json',
155
+ text: JSON.stringify(newConfigData, null, 2),
156
+ },
157
+ },
158
+ }));
159
+ },
160
+ [configData, files, configFileId, setLocalValues],
161
+ );
162
+
163
+ const onDropdownChange = useCallback(
164
+ (property: string, newValue: string) => {
165
+ // Update local state immediately for responsive UI
166
+ setLocalValues((prev) => ({
167
+ ...prev,
168
+ [property]: newValue,
169
+ }));
170
+
171
+ // Update config.json
172
+ const newConfigData = {
173
+ ...configData,
174
+ [property]: newValue,
175
+ };
176
+
177
+ submitOperation((document: VizContent) => ({
178
+ ...document,
179
+ files: {
180
+ ...files,
181
+ [configFileId]: {
182
+ name: 'config.json',
183
+ text: JSON.stringify(newConfigData, null, 2),
184
+ },
185
+ },
186
+ }));
187
+ },
188
+ [configData, files, configFileId, setLocalValues],
189
+ );
190
+
191
+ // Custom dropdown handlers
192
+ const handleDropdownToggle = useCallback(
193
+ (property: string) => {
194
+ setOpenDropdown((prev) =>
195
+ prev === property ? null : property,
196
+ );
197
+ },
198
+ [],
199
+ );
200
+
201
+ const handleDropdownOptionClick = useCallback(
202
+ (property: string, value: string) => {
203
+ onDropdownChange(property, value);
204
+ setOpenDropdown(null);
205
+ },
206
+ [onDropdownChange],
207
+ );
208
+
209
+ // Close dropdown when clicking outside
210
+ useEffect(() => {
211
+ const handleClickOutside = (event: MouseEvent) => {
212
+ if (openDropdown) {
213
+ const target = event.target as Element;
214
+ if (!target.closest('.visual-editor-dropdown')) {
215
+ setOpenDropdown(null);
216
+ }
217
+ }
218
+ };
219
+
220
+ document.addEventListener(
221
+ 'mousedown',
222
+ handleClickOutside,
223
+ );
224
+ return () => {
225
+ document.removeEventListener(
226
+ 'mousedown',
227
+ handleClickOutside,
228
+ );
229
+ };
230
+ }, [openDropdown]);
231
+
129
232
  const visualEditorWidgets: VisualEditorConfigEntry[] =
130
- configData.visualEditorWidgets;
233
+ configData?.visualEditorWidgets ?? [];
131
234
 
132
235
  // Sync local values with config data when it changes (including remote updates)
133
236
  useEffect(() => {
134
- const newLocalValues: { [key: string]: number } = {};
237
+ const newLocalValues: {
238
+ [key: string]: number | boolean | string;
239
+ } = {};
135
240
  visualEditorWidgets.forEach((widget) => {
136
- if (widget.type === 'number') {
241
+ if (widget.type === 'slider') {
242
+ newLocalValues[widget.property] =
243
+ configData[widget.property];
244
+ } else if (widget.type === 'checkbox') {
245
+ newLocalValues[widget.property] =
246
+ configData[widget.property];
247
+ } else if (widget.type === 'textInput') {
248
+ newLocalValues[widget.property] =
249
+ configData[widget.property];
250
+ } else if (widget.type === 'dropdown') {
137
251
  newLocalValues[widget.property] =
138
252
  configData[widget.property];
139
253
  }
140
254
  });
141
255
  setLocalValues(newLocalValues);
142
- }, [configData, visualEditorWidgets]);
143
-
144
- // Track previous config state to detect changes from any source (remote clients, text editor, etc.)
145
- const previousConfigRef = useRef<any>(null);
146
-
147
- // Detect config.json changes and send updates to iframe
148
- useEffect(() => {
149
- if (!configFileId || !files || !files[configFileId]) {
150
- return;
151
- }
256
+ }, [configData]);
152
257
 
153
- let newConfigData;
154
- try {
155
- newConfigData = JSON.parse(files[configFileId].text);
156
- } catch (error) {
157
- // If config is invalid JSON, we can't process changes
158
- return;
159
- }
160
-
161
- const previousConfig = previousConfigRef.current;
162
-
163
- // Update the ref with the new config
164
- previousConfigRef.current = newConfigData;
165
-
166
- // Skip processing if this is the first time or if there's no previous config
167
- if (!previousConfig) {
168
- return;
169
- }
258
+ if (configFileId === null) {
259
+ return (
260
+ <EmptyState>
261
+ To begin using the visual editor, create a
262
+ config.json.
263
+ </EmptyState>
264
+ );
265
+ }
170
266
 
171
- // Find changed top-level properties
172
- const changedProperties: { [key: string]: any } = {};
173
-
174
- // Check all properties in the new config
175
- for (const key in newConfigData) {
176
- if (newConfigData[key] !== previousConfig[key]) {
177
- // Deep comparison for objects to detect actual changes
178
- if (
179
- typeof newConfigData[key] === 'object' &&
180
- typeof previousConfig[key] === 'object'
181
- ) {
182
- if (
183
- JSON.stringify(newConfigData[key]) !==
184
- JSON.stringify(previousConfig[key])
185
- ) {
186
- changedProperties[key] = newConfigData[key];
187
- }
188
- } else {
189
- changedProperties[key] = newConfigData[key];
190
- }
191
- }
192
- }
267
+ if (!configData) {
268
+ return (
269
+ <EmptyState>
270
+ Your config.json file is not valid json.
271
+ </EmptyState>
272
+ );
273
+ }
193
274
 
194
- // Check for deleted properties (properties that existed before but don't exist now)
195
- for (const key in previousConfig) {
196
- if (!(key in newConfigData)) {
197
- changedProperties[key] = undefined;
198
- }
199
- }
275
+ if (
276
+ configData &&
277
+ !('visualEditorWidgets' in configData)
278
+ ) {
279
+ return (
280
+ <EmptyState>
281
+ To begin using the visual editor, make sure your
282
+ config.json has a key called "visualEditorWidgets",
283
+ whose value is the config for the visual editor.
284
+ </EmptyState>
285
+ );
286
+ }
200
287
 
201
- // Send changed properties to iframe if any changes were detected
202
- if (Object.keys(changedProperties).length > 0) {
203
- try {
204
- iframeRef.current.contentWindow.postMessage(
205
- changedProperties,
206
- );
207
- } catch (error) {
208
- console.error(
209
- 'Failed to send config changes to iframe:',
210
- error,
211
- );
212
- }
213
- }
214
- }, [files, configFileId, iframeRef]);
288
+ if (
289
+ configData &&
290
+ !Array.isArray(configData.visualEditorWidgets)
291
+ ) {
292
+ return (
293
+ <EmptyState>
294
+ Your config.json file has "visualEditorWidgets" but
295
+ it is not an array. Please make sure
296
+ "visualEditorWidgets" is an array of widget
297
+ configurations.
298
+ </EmptyState>
299
+ );
300
+ }
215
301
 
216
302
  return (
217
303
  <div className="visual-editor">
218
304
  {visualEditorWidgets.map((widgetConfig, index) => {
219
- if (widgetConfig.type === 'number') {
305
+ if (widgetConfig.type === 'slider') {
220
306
  // Use local value if available, otherwise fall back to config value
221
307
  const currentValue =
222
308
  localValues[widgetConfig.property] ??
@@ -254,7 +340,7 @@ export const VisualEditor = () => {
254
340
  className="slider-input"
255
341
  min={widgetConfig.min}
256
342
  max={widgetConfig.max}
257
- step="any"
343
+ step={widgetConfig.step}
258
344
  value={currentValue}
259
345
  onChange={onSliderChange(
260
346
  widgetConfig.property,
@@ -275,6 +361,169 @@ export const VisualEditor = () => {
275
361
  </div>
276
362
  </div>
277
363
  );
364
+ } else if (widgetConfig.type === 'checkbox') {
365
+ // Use local value if available, otherwise fall back to config value
366
+ const currentValue =
367
+ localValues[widgetConfig.property] ??
368
+ configData[widgetConfig.property];
369
+
370
+ return (
371
+ <div
372
+ key={widgetConfig.property}
373
+ className="visual-editor-checkbox"
374
+ >
375
+ <div className="checkbox-header">
376
+ <label
377
+ htmlFor={widgetConfig.property}
378
+ className="checkbox-label"
379
+ >
380
+ {widgetConfig.label}
381
+ </label>
382
+ <span className="checkbox-value">
383
+ {currentValue ? 'On' : 'Off'}
384
+ </span>
385
+ </div>
386
+ <div className="checkbox-container">
387
+ <input
388
+ type="checkbox"
389
+ id={widgetConfig.property}
390
+ className="checkbox-input"
391
+ checked={currentValue}
392
+ onChange={onCheckboxChange(
393
+ widgetConfig.property,
394
+ )}
395
+ />
396
+ <div className="checkbox-visual">
397
+ <div
398
+ className={`checkbox-indicator ${
399
+ currentValue ? 'checked' : ''
400
+ }`}
401
+ />
402
+ </div>
403
+ </div>
404
+ </div>
405
+ );
406
+ } else if (widgetConfig.type === 'textInput') {
407
+ // Use local value if available, otherwise fall back to config value
408
+ const currentValue =
409
+ localValues[widgetConfig.property] ??
410
+ configData[widgetConfig.property];
411
+
412
+ return (
413
+ <div
414
+ key={widgetConfig.property}
415
+ className="visual-editor-text-input"
416
+ >
417
+ <div className="text-input-header">
418
+ <label
419
+ htmlFor={widgetConfig.property}
420
+ className="text-input-label"
421
+ >
422
+ {widgetConfig.label}
423
+ </label>
424
+ </div>
425
+ <div className="text-input-container">
426
+ <input
427
+ type="text"
428
+ id={widgetConfig.property}
429
+ className="text-input-field"
430
+ value={currentValue || ''}
431
+ onChange={onTextInputChange(
432
+ widgetConfig.property,
433
+ )}
434
+ />
435
+ </div>
436
+ </div>
437
+ );
438
+ } else if (widgetConfig.type === 'dropdown') {
439
+ // Use local value if available, otherwise fall back to config value
440
+ const currentValue =
441
+ localValues[widgetConfig.property] ??
442
+ configData[widgetConfig.property];
443
+ const isOpen =
444
+ openDropdown === widgetConfig.property;
445
+
446
+ return (
447
+ <div
448
+ key={widgetConfig.property}
449
+ className="visual-editor-dropdown"
450
+ >
451
+ <div className="dropdown-header">
452
+ <label
453
+ htmlFor={widgetConfig.property}
454
+ className="dropdown-label"
455
+ >
456
+ {widgetConfig.label}
457
+ </label>
458
+ {/* <span className="dropdown-value">
459
+ {currentValue}
460
+ </span> */}
461
+ </div>
462
+ <div className="dropdown-container">
463
+ <button
464
+ type="button"
465
+ className={`dropdown-button ${
466
+ isOpen ? 'open' : ''
467
+ }`}
468
+ onClick={() =>
469
+ handleDropdownToggle(
470
+ widgetConfig.property,
471
+ )
472
+ }
473
+ aria-expanded={isOpen}
474
+ aria-haspopup="listbox"
475
+ >
476
+ <span className="dropdown-button-text">
477
+ {currentValue}
478
+ </span>
479
+ <div
480
+ className={`dropdown-arrow ${
481
+ isOpen ? 'open' : ''
482
+ }`}
483
+ >
484
+ <svg
485
+ width="12"
486
+ height="8"
487
+ viewBox="0 0 12 8"
488
+ fill="none"
489
+ xmlns="http://www.w3.org/2000/svg"
490
+ >
491
+ <path
492
+ d="M1 1.5L6 6.5L11 1.5"
493
+ stroke="currentColor"
494
+ strokeWidth="2"
495
+ strokeLinecap="round"
496
+ strokeLinejoin="round"
497
+ />
498
+ </svg>
499
+ </div>
500
+ </button>
501
+ {isOpen && (
502
+ <div className="dropdown-options">
503
+ {widgetConfig.options.map((option) => (
504
+ <button
505
+ key={option}
506
+ type="button"
507
+ className={`dropdown-option ${
508
+ option === currentValue
509
+ ? 'selected'
510
+ : ''
511
+ }`}
512
+ onClick={() =>
513
+ handleDropdownOptionClick(
514
+ widgetConfig.property,
515
+ option,
516
+ )
517
+ }
518
+ >
519
+ {option}
520
+ </button>
521
+ ))}
522
+ </div>
523
+ )}
524
+ </div>
525
+ </div>
526
+ );
278
527
  }
279
528
  })}
280
529
  </div>