The Inspector Problem
Select an object. A panel appears showing its properties. Change a number in the panel. The object updates.
This is two-way binding. It sounds simple. It isn't.
First Attempt: Direct DOM Manipulation
function showProperties(object) {
document.getElementById('prop-x').value = object.x;
document.getElementById('prop-y').value = object.y;
document.getElementById('prop-width').value = object.width;
// ... 20 more properties
}
function updateFromPanel() {
selectedObject.x = parseFloat(document.getElementById('prop-x').value);
selectedObject.y = parseFloat(document.getElementById('prop-y').value);
// ... 20 more properties
redraw();
}
Problems:
- Every property needs manual wiring
- No type checking
- No validation
- Adding a property means editing two functions
We had 47 properties across all object types. This approach collapsed under its own weight.
The Property Descriptor Pattern
Instead of hardcoding each property, describe them:
const propertyDescriptors = {
Rectangle: [
{ key: 'x', type: 'number', label: 'X Position' },
{ key: 'y', type: 'number', label: 'Y Position' },
{ key: 'width', type: 'number', label: 'Width', min: 1 },
{ key: 'height', type: 'number', label: 'Height', min: 1 },
{ key: 'cornerRadius', type: 'number', label: 'Corner Radius', min: 0 },
{ key: 'fill', type: 'color', label: 'Fill Color' },
{ key: 'stroke', type: 'color', label: 'Stroke Color' },
{ key: 'strokeWidth', type: 'number', label: 'Stroke Width', min: 0 }
],
Star: [
{ key: 'x', type: 'number', label: 'X Position' },
{ key: 'y', type: 'number', label: 'Y Position' },
{ key: 'pointCount', type: 'integer', label: 'Points', min: 3, max: 24 },
{ key: 'innerRadius', type: 'number', label: 'Inner Radius', min: 0 },
{ key: 'outerRadius', type: 'number', label: 'Outer Radius', min: 1 }
]
};
Now the panel generates itself from descriptors.
var cfg = {
"id": "inspector1",
"type": "inspector-demo",
"bindings": {
"x": "object.x",
"y": "object.y",
"width": "object.width",
"height": "object.height",
"fill": "object.fill"
},
"width": 500,
"height": 280
}
Try changing values. Watch the preview update.
Generating UI From Descriptors
function buildPropertyPanel(object, descriptors) {
const panel = document.createElement('div');
for (const desc of descriptors) {
const row = createPropertyRow(desc, object);
panel.appendChild(row);
}
return panel;
}
function createPropertyRow(desc, object) {
const row = document.createElement('div');
row.className = 'property-row';
const label = document.createElement('label');
label.textContent = desc.label;
const input = createInputForType(desc.type, desc);
input.value = object[desc.key];
input.addEventListener('change', () => {
const value = parseInputValue(input, desc.type);
if (validateValue(value, desc)) {
object[desc.key] = value;
onPropertyChange(object, desc.key, value);
}
});
row.appendChild(label);
row.appendChild(input);
return row;
}
Add a property to the descriptor. UI generates automatically.
Type-Specific Inputs
Different property types need different controls:
function createInputForType(type, desc) {
switch (type) {
case 'number':
case 'integer':
return createNumberInput(desc);
case 'color':
return createColorPicker(desc);
case 'boolean':
return createCheckbox(desc);
case 'enum':
return createDropdown(desc.options);
case 'angle':
return createAngleInput(desc);
default:
return createTextInput(desc);
}
}
Numbers get spinners. Colors get pickers. Enums get dropdowns. The descriptor drives everything.
Two-Way Binding
The UI should update when the object changes externally (e.g., dragging on canvas).
class PropertyBinding {
constructor(object, key, input, onChange) {
this.object = object;
this.key = key;
this.input = input;
this.onChange = onChange;
// UI → Object
input.addEventListener('input', () => {
this.object[this.key] = this.parseValue();
this.onChange();
});
// Object → UI (using getter/setter)
this.value = object[key];
Object.defineProperty(object, key, {
get: () => this.value,
set: (v) => {
this.value = v;
this.input.value = v;
}
});
}
}
Now object.x = 100 updates both the canvas and the input field.
Validation and Constraints
Properties have constraints. Width can't be negative. Point count must be an integer between 3 and 24.
function validateAndApply(object, desc, rawValue) {
let value = rawValue;
// Type coercion
if (desc.type === 'integer') {
value = Math.round(value);
}
// Clamp to range
if (desc.min !== undefined) {
value = Math.max(desc.min, value);
}
if (desc.max !== undefined) {
value = Math.min(desc.max, value);
}
// Apply
object[desc.key] = value;
return value;
}
The input might show "2.7" but the object gets "3" for an integer property.
Multi-Selection
Select two rectangles. What shows in the panel?
If both have width 100: show "100". If one is 100 and one is 150: show "—" (mixed).
function getPropertyValue(objects, key) {
const values = objects.map(o => o[key]);
const first = values[0];
if (values.every(v => v === first)) {
return { value: first, mixed: false };
}
return { value: null, mixed: true };
}
function setPropertyValue(objects, key, value) {
for (const obj of objects) {
obj[key] = value;
}
}
Change a mixed property: all selected objects get the new value.
Property Groups
Properties organize into collapsible groups:
const propertyGroups = {
Transform: ['x', 'y', 'rotation', 'scaleX', 'scaleY'],
Size: ['width', 'height'],
Appearance: ['fill', 'stroke', 'strokeWidth', 'opacity'],
Effects: ['blur', 'shadow']
};
The panel renders groups as sections with headers. Users expand what they need.
Undo Integration
Property changes need undo support:
input.addEventListener('change', () => {
const oldValue = object[desc.key];
const newValue = parseInputValue(input);
undoStack.push({
type: 'property',
object: object.id,
key: desc.key,
oldValue,
newValue,
undo() { object[desc.key] = oldValue; },
redo() { object[desc.key] = newValue; }
});
object[desc.key] = newValue;
});
Ctrl+Z reverts property changes. Users expect this.
Performance: Batching Updates
Dragging a slider generates many change events. Redrawing the canvas on each one is wasteful.
let updateScheduled = false;
function scheduleUpdate() {
if (updateScheduled) return;
updateScheduled = true;
requestAnimationFrame(() => {
redrawCanvas();
updateScheduled = false;
});
}
Batch updates into the next frame. Slider feels smooth. Canvas redraws once.
Summary
- Descriptors define properties, UI generates from them
- Type-specific inputs: numbers, colors, enums, booleans
- Two-way binding keeps UI and objects in sync
- Validation enforces constraints
- Multi-selection shows mixed values, applies to all
- Undo integration is essential
The inspector is the bridge between user intent and object state. Make it solid, and the whole editor feels professional.
This completes the fundamentals. From here: blend modes, gradients, effects, boolean operations. But you have the foundation.
Build the basics right. Everything else follows.