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:

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

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.