Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 101 additions & 48 deletions src/client/VZSidebar/VisualEditor/VisualEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { CheckboxWidget } from './CheckboxWidget';
import { TextInputWidget } from './TextInputWidget';
import { DropdownWidget } from './DropdownWidget';
import { ColorWidget } from './ColorWidget';
import {
getNestedProperty,
setNestedProperty,
} from './utils';

export const VisualEditor = () => {
const { files, submitOperation } =
Expand Down Expand Up @@ -66,11 +70,12 @@ export const VisualEditor = () => {
[property]: newValue,
}));

// Update config.json
const newConfigData = {
...configData,
[property]: newValue,
};
// Update config.json with support for nested properties
const newConfigData = setNestedProperty(
configData,
property,
newValue,
);

submitOperation((document: VizContent) => ({
...document,
Expand All @@ -97,11 +102,12 @@ export const VisualEditor = () => {
[property]: newValue,
}));

// Update config.json
const newConfigData = {
...configData,
[property]: newValue,
};
// Update config.json with support for nested properties
const newConfigData = setNestedProperty(
configData,
property,
newValue,
);

submitOperation((document: VizContent) => ({
...document,
Expand All @@ -128,11 +134,12 @@ export const VisualEditor = () => {
[property]: newValue,
}));

// Update config.json
const newConfigData = {
...configData,
[property]: newValue,
};
// Update config.json with support for nested properties
const newConfigData = setNestedProperty(
configData,
property,
newValue,
);

submitOperation((document: VizContent) => ({
...document,
Expand All @@ -156,11 +163,12 @@ export const VisualEditor = () => {
[property]: newValue,
}));

// Update config.json
const newConfigData = {
...configData,
[property]: newValue,
};
// Update config.json with support for nested properties
const newConfigData = setNestedProperty(
configData,
property,
newValue,
);

submitOperation((document: VizContent) => ({
...document,
Expand Down Expand Up @@ -224,9 +232,10 @@ export const VisualEditor = () => {
event.currentTarget.value,
);

// Get current hex color from config
// Get current hex color from config using nested property access
const currentHex =
configData[property] || '#000000';
getNestedProperty<string>(configData, property) ??
'#000000';

// Convert current hex to LCH
let hclFromRGB: HCLColor;
Expand Down Expand Up @@ -281,11 +290,12 @@ export const VisualEditor = () => {
[`${property}_l`]: newhcl[2],
}));

// Update config.json with hex value
const newConfigData = {
...configData,
[property]: newHex,
};
// Update config.json with hex value using nested property access
const newConfigData = setNestedProperty(
configData,
property,
newHex,
);

submitOperation((document: VizContent) => ({
...document,
Expand Down Expand Up @@ -320,23 +330,36 @@ export const VisualEditor = () => {
} = {};
visualEditorWidgets.forEach((widget) => {
if (widget.type === 'slider') {
newLocalValues[widget.property] =
configData[widget.property];
newLocalValues[widget.property] = getNestedProperty(
configData,
widget.property,
);
} else if (widget.type === 'checkbox') {
newLocalValues[widget.property] =
configData[widget.property];
newLocalValues[widget.property] = getNestedProperty(
configData,
widget.property,
);
} else if (widget.type === 'textInput') {
newLocalValues[widget.property] =
configData[widget.property];
newLocalValues[widget.property] = getNestedProperty(
configData,
widget.property,
);
} else if (widget.type === 'dropdown') {
newLocalValues[widget.property] =
configData[widget.property];
newLocalValues[widget.property] = getNestedProperty(
configData,
widget.property,
);
} else if (widget.type === 'color') {
newLocalValues[widget.property] =
configData[widget.property];
newLocalValues[widget.property] = getNestedProperty(
configData,
widget.property,
);

// Convert hex to LCH for internal state
const hexColor = configData[widget.property];
const hexColor = getNestedProperty<string>(
configData,
widget.property,
);

if (localValues[widget.property] !== hexColor) {
if (hexColor) {
Expand Down Expand Up @@ -414,8 +437,14 @@ export const VisualEditor = () => {
if (widgetConfig.type === 'slider') {
// Use local value if available, otherwise fall back to config value
const currentValue =
localValues[widgetConfig.property] ??
configData[widgetConfig.property];
(localValues[
widgetConfig.property
] as number) ??
getNestedProperty<number>(
configData,
widgetConfig.property,
) ??
widgetConfig.min;

return (
<SliderWidget
Expand All @@ -434,8 +463,14 @@ export const VisualEditor = () => {
} else if (widgetConfig.type === 'checkbox') {
// Use local value if available, otherwise fall back to config value
const currentValue =
localValues[widgetConfig.property] ??
configData[widgetConfig.property];
(localValues[
widgetConfig.property
] as boolean) ??
getNestedProperty<boolean>(
configData,
widgetConfig.property,
) ??
false;

return (
<CheckboxWidget
Expand All @@ -451,8 +486,14 @@ export const VisualEditor = () => {
} else if (widgetConfig.type === 'textInput') {
// Use local value if available, otherwise fall back to config value
const currentValue =
localValues[widgetConfig.property] ??
configData[widgetConfig.property];
(localValues[
widgetConfig.property
] as string) ??
getNestedProperty<string>(
configData,
widgetConfig.property,
) ??
'';

return (
<TextInputWidget
Expand All @@ -468,8 +509,15 @@ export const VisualEditor = () => {
} else if (widgetConfig.type === 'dropdown') {
// Use local value if available, otherwise fall back to config value
const currentValue =
localValues[widgetConfig.property] ??
configData[widgetConfig.property];
(localValues[
widgetConfig.property
] as string) ??
getNestedProperty<string>(
configData,
widgetConfig.property,
) ??
widgetConfig.options?.[0] ??
'';
const isOpen =
openDropdown === widgetConfig.property;

Expand All @@ -495,8 +543,13 @@ export const VisualEditor = () => {
} else if (widgetConfig.type === 'color') {
// Use local value if available, otherwise fall back to config value
const currentHex =
localValues[widgetConfig.property] ??
configData[widgetConfig.property] ??
(localValues[
widgetConfig.property
] as string) ??
getNestedProperty<string>(
configData,
widgetConfig.property,
) ??
'#000000';

// Convert hex to LCH for slider values
Expand Down
113 changes: 113 additions & 0 deletions src/client/VZSidebar/VisualEditor/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,116 @@ export const renderSliderBackground = (

ctx.putImageData(imageData, 0, 0);
};

// Helper function to get nested property value using dot notation
export const getNestedProperty = <T = unknown>(
obj: Record<string, unknown>,
path: string,
): T | undefined => {
// Validate path to prevent empty strings, consecutive dots, or leading/trailing dots
if (
!path ||
path.includes('..') ||
path.startsWith('.') ||
path.endsWith('.')
) {
return undefined;
}

const keys = path.split('.');
let value: unknown = obj;
for (const key of keys) {
// Skip empty keys that might result from splitting
if (!key) {
return undefined;
}
if (value === null || value === undefined) {
return undefined;
}
// Type guard to ensure value is an object before accessing property
if (typeof value !== 'object') {
return undefined;
}
value = (value as Record<string, unknown>)[key];
}
return value as T | undefined;
};

// Helper function to set nested property value using dot notation
export const setNestedProperty = <
T extends Record<string, unknown>,
>(
obj: T,
path: string,
value: unknown,
): T => {
// Validate path to prevent empty strings, consecutive dots, or leading/trailing dots
if (
!path ||
path.includes('..') ||
path.startsWith('.') ||
path.endsWith('.')
) {
throw new Error(
`Invalid property path "${path}" - path cannot be empty, contain consecutive dots, or have leading/trailing dots`,
);
}

const keys = path.split('.');

// Check for empty keys
if (keys.some((key) => !key)) {
throw new Error(
`Invalid property path "${path}" - path contains empty segments`,
);
}

const newObj = { ...obj };
let current: Record<string, unknown> = newObj;

// List of dangerous keys that could lead to prototype pollution
const dangerousKeys = [
'__proto__',
'constructor',
'prototype',
];

for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];

// Guard against prototype pollution
if (dangerousKeys.includes(key)) {
throw new Error(
`Cannot set property "${key}" - potential prototype pollution`,
);
}

// Create nested object if it doesn't exist or isn't a plain object
if (
current[key] == null ||
typeof current[key] !== 'object' ||
Array.isArray(current[key])
) {
current[key] = {};
} else {
// Clone the nested object to avoid mutation
current[key] = {
...(current[key] as Record<string, unknown>),
};
}
current = current[key] as Record<string, unknown>;
}

// Guard against prototype pollution for the final key
const finalKey = keys[keys.length - 1];
if (dangerousKeys.includes(finalKey)) {
throw new Error(
`Cannot set property "${finalKey}" - potential prototype pollution`,
);
}

// Set the final value
current[finalKey] = value;

return newObj;
};
1 change: 1 addition & 0 deletions src/client/VZSidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export const VZSidebar = ({
setIsVisualEditorOpen(true);
setIsAIChatOpen(false);
setIsSearchOpen(false);
setSidebarView(false); // Switch to files view width for visual editor
}}
>
<AdjustmentSVG />
Expand Down
14 changes: 10 additions & 4 deletions test/sampleDirectories/visualEditor/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@
"visualEditorWidgets": [
{
"type": "slider",
"label": "Point Radius (when not using size)",
"property": "Point Radius",
"label": "Point Radius",
"property": "pointRadius",
"min": 1,
"max": 30,
"step": 2.5
"max": 30
},
{
"type": "slider",
"label": "Left Margin",
"property": "margin.left",
"min": 0,
"max": 200
},
{
"type": "slider",
Expand Down