Skip to content

Typed Element Components

Replace the single WhiteboardElementComponent (which stores all element data as a JSON blob in bindings["data"]) with per-element-type Components (ShapeComponent, TextComponent, PathComponent, etc.). Each element property becomes a real PropertyBinding — individually Static or Reference — so templates can bind individual properties. WhiteboardElementSchema and WhiteboardElementComponent are deleted.

Today, every whiteboard element is stored as a single ComponentPlacement with component_key: "element". The element’s type and all its properties are serialized into one JSON string inside bindings["data"]:

bindings: {
"data" → Static { value: PropertyValue::String(
'{"type":"shape","shapeType":"rect","fill":"solid","color":4294901760}'
)}
}

This has several problems:

  1. Templates can’t bind individual properties — the whole point of PropertyBinding::Reference is that a template can say “use the template’s accent color for this shape’s fill.” But with a single JSON blob, it’s all-or-nothing — you can’t reference a template property for just the color.

  2. Property system is bypassedWhiteboardElementComponent::properties() declares one property: "data" of type Json. The actual element properties (shape type, color, stroke width, etc.) are invisible to the property system. Property editors, AI describe, and validation can’t see them.

  3. Two redundant typesWhiteboardElementSchema (stored data without spatial info) and WhiteboardElement (compiled output with spatial info) mirror each other variant-for-variant. The schema exists only because the JSON blob needs a deserialization target.

  4. Unnecessary JSON round-trip — element data is serialized to JSON for storage in bindings, then deserialized back during compilation. With typed property bindings, the data is stored as PropertyValue directly — no serialization needed.

Each element type becomes its own Component with a unique component_key:

Componentcomponent_keyProperties
ShapeComponent"shape"shape_type, fill, color, stroke_color, stroke_width, start_arrow, end_arrow, rotation
PathComponent"path"points, properties (stroke color, width, etc.)
TextComponent"text"properties (text box styling). Content lives in LoroText containers (the Embedded Prose RFC).
FileComponent"file"file_type, url, thumbnail_url, file_name, rotation
UrlComponent"url"url_type, url, title, description, thumbnail_url
GeogebraComponent"geogebra"geogebra_type, material_id, commands
MaskComponent"mask"image_url, mask_path
NativeComponent"native"widget_type, dynamic properties

Each is registered via inventory::submit! with its component key.

pub struct ShapeComponent;
inventory::submit!(WhiteboardComponentEntry(&ShapeComponent));
impl Component for ShapeComponent {
type Metadata = WhiteboardChildMeta;
type Output = Vec<WhiteboardElement>;
fn properties(&self) -> Vec<PropertySchema> {
vec![
PropertySchema { key: "shape_type".into(), name: "Shape".into(),
property_type: PropertyType::Enum { values: vec!["rect", "circle", "triangle", ...] },
required: true, ..Default::default() },
PropertySchema { key: "fill".into(), name: "Fill".into(),
property_type: PropertyType::Enum { values: vec!["solid", "outline", "none"] },
..Default::default() },
PropertySchema { key: "color".into(), name: "Color".into(),
property_type: PropertyType::Color,
..Default::default() },
PropertySchema { key: "stroke_color".into(), name: "Stroke Color".into(),
property_type: PropertyType::Color,
..Default::default() },
PropertySchema { key: "stroke_width".into(), name: "Stroke Width".into(),
property_type: PropertyType::Number { min: Some(0.0), max: Some(50.0) },
..Default::default() },
PropertySchema { key: "rotation".into(), name: "Rotation".into(),
property_type: PropertyType::Number { min: None, max: None },
..Default::default() },
// arrows...
]
}
fn compile(&self, meta: &WhiteboardChildMeta, values: &HashMap<String, PropertyValue>)
-> Result<Vec<WhiteboardElement>, CompileError>
{
Ok(vec![WhiteboardElement::Shape(ShapeElement {
id: meta.id.clone(),
position: Offset { dx: meta.bounds.x, dy: meta.bounds.y },
size: Size { width: meta.bounds.width, height: meta.bounds.height },
shape_type: values.get("shape_type").try_into().unwrap_or_default(),
fill: values.get("fill").try_into().unwrap_or_default(),
color: values.get("color").try_into().unwrap_or_default(),
stroke_color: values.get("stroke_color").try_into().unwrap_or_default(),
stroke_width: values.get("stroke_width").and_then(|v| v.as_f64()).unwrap_or(2.0),
start_arrow: values.get("start_arrow").try_into().unwrap_or_default(),
end_arrow: values.get("end_arrow").try_into().unwrap_or_default(),
rotation: values.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0),
})])
}
}
impl WhiteboardComponent for ShapeComponent {
fn id(&self) -> &'static str { "shape" }
fn name(&self) -> &'static str { "Shape" }
fn description(&self) -> &'static str { "Rectangle, circle, triangle, line, and arrow shapes." }
fn icon(&self) -> &'static str { "shape" }
}

A shape placement now stores each property as a real binding:

ComponentPlacement {
id: "abc-123",
component_key: "shape",
position: WhiteboardPosition { x: 100.0, y: 200.0, width: 300.0, height: 150.0, z_index: 5 },
bindings: {
"shape_type" → Static { value: String("rect") },
"fill" → Static { value: String("solid") },
"color" → Reference { property_id: "accent_color" }, // template binding
"stroke_color" → Static { value: Number(0xFF000000) },
"stroke_width" → Static { value: Number(2.0) },
}
}

Individual properties can be Static (hardcoded) or Reference (resolved from parent template properties at compile time). Templates can bind any property independently.

  • WhiteboardElementSchema — deleted. No longer needed; element data lives in property bindings.
  • WhiteboardElementComponent — deleted. Replaced by per-type components.

The compiled output enum is unchanged:

pub enum WhiteboardElement {
Path(PathElement),
Shape(ShapeElement),
Text(TextElement),
File(FileElement),
Url(UrlElement),
Geogebra(GeogebraElement),
Mask(MaskElement),
Native(NativeElement),
}

Dart rendering is completely unaffected.

Intents that create elements (e.g. AddElement) currently take a WhiteboardElementSchema. They will instead take a component_key: String and bindings: HashMap<String, PropertyBinding>, or a typed builder per element type:

// Typed builders — each element type gets its own intent variant
AddShape { position: WhiteboardPosition, shape_type: ShapeType, color: Color, ... }
AddText { position: WhiteboardPosition, properties: TextBoxProperties }
AddPath { position: WhiteboardPosition, points: Vec<Point>, properties: PathElementProperties }
// etc.

Each typed builder constructs a ComponentPlacement with the correct component_key and property bindings internally. More ergonomic and discoverable than a generic AddElement { component_key, bindings } approach.

Interaction with the Embedded Prose RFC (Embedded Prose)

Section titled “Interaction with the Embedded Prose RFC (Embedded Prose)”

TextComponent has minimal properties (text box styling). The text content lives in LoroText containers (prose:{element_id}) inside the whiteboard’s LoroDoc per the Embedded Prose RFC — not in property bindings. TextComponent::compile() reads the LoroText container to render blocks.

  1. Define per-type ComponentsShapeComponent, PathComponent, TextComponent, FileComponent, UrlComponent, GeogebraComponent, MaskComponent. Each with properties() and compile(). Register via inventory. Verify: cargo check --workspace
  2. Update resolve_component — look up by new component keys ("shape", "text", etc.). Verify: cargo check --workspace
  3. Update WhiteboardIntent — change AddElement and related intents to use component_key + property bindings instead of WhiteboardElementSchema. Verify: cargo check --workspace
  4. Delete WhiteboardElementSchema — remove schema.rs, WhiteboardElementComponent, and all references. Verify: cargo test --workspace
  5. Delete group elements — remove VerticalGroup/HorizontalGroup from WhiteboardElement, delete group.rs, export support, and Dart rendering widgets. Verify: cargo test --workspace
  6. FRB + Dart — regenerate bindings. Update Dart code that constructs element intents. Verify: flutter analyze

Keep the JSON blob, add per-property overrides — allow both bindings["data"] (base) and individual property bindings (overrides). Template references go in per-property bindings, everything else stays in the blob. Rejected because it creates two sources of truth for the same data — which one wins on conflict?

Schema as Component — make WhiteboardElementSchema implement Component directly (each variant handles its own compilation). Rejected because it doesn’t solve the core problem: properties are still invisible to the property system if they’re fields on an enum variant rather than PropertyBinding entries.

Dynamic Component with runtime properties — one component that dynamically returns different properties() based on the element type stored in a "type" binding. Rejected because it makes properties() depend on runtime state, complicating the property system. Per-type components are simpler and fully static.

VerticalGroup and HorizontalGroup are removed entirely — type definitions, schema variants, export support, and Dart rendering widgets. They are defined but never created by any whiteboard intent or reducer logic. If layout grouping is needed in the future, it should be designed as a layout feature on placements, not an element type with compiled children as properties.

Typed builders. Each element type gets its own WhiteboardIntent variant (AddShape, AddText, AddPath, etc.) for ergonomics and discoverability. Each builder constructs the correct ComponentPlacement with component_key and property bindings internally.

Standard conventions:

  • ColorPropertyValue::String("#RRGGBB") or "#AARRGGBB" — standard web hex representation. Parse via FromStr, format via Display.
  • ShapeType (Circle, Rect, Triangle, Line) → PropertyValue::String("circle") — lowercase serde name.
  • ArrowType (None, Triangle, Line, Circle) → PropertyValue::String("none") — lowercase serde name.
  • Numeric types (f64) → PropertyValue::Number(...) directly.
  • General rule: enums as lowercase string, colors as web hex, numbers as numbers.

Deleted. VerticalGroup and HorizontalGroup are unused — no intent creates them, no reducer produces them. They don’t fit the per-component model (their “properties” are compiled child elements, not PropertyValues).

None — all questions resolved.