Typed Element Components
Summary
Section titled “Summary”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.
Motivation
Section titled “Motivation”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:
-
Templates can’t bind individual properties — the whole point of
PropertyBinding::Referenceis 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. -
Property system is bypassed —
WhiteboardElementComponent::properties()declares one property:"data"of typeJson. 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. -
Two redundant types —
WhiteboardElementSchema(stored data without spatial info) andWhiteboardElement(compiled output with spatial info) mirror each other variant-for-variant. The schema exists only because the JSON blob needs a deserialization target. -
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
PropertyValuedirectly — no serialization needed.
Design
Section titled “Design”Per-element-type Components
Section titled “Per-element-type Components”Each element type becomes its own Component with a unique component_key:
| Component | component_key | Properties |
|---|---|---|
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.
Component implementation pattern
Section titled “Component implementation pattern”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" }}Storage — real property bindings
Section titled “Storage — real property bindings”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.
Deleted types
Section titled “Deleted types”WhiteboardElementSchema— deleted. No longer needed; element data lives in property bindings.WhiteboardElementComponent— deleted. Replaced by per-type components.
WhiteboardElement output — unchanged
Section titled “WhiteboardElement output — unchanged”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.
WhiteboardIntent changes
Section titled “WhiteboardIntent changes”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 variantAddShape { 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.
Implementation Plan
Section titled “Implementation Plan”- Define per-type Components —
ShapeComponent,PathComponent,TextComponent,FileComponent,UrlComponent,GeogebraComponent,MaskComponent. Each withproperties()andcompile(). Register via inventory. Verify:cargo check --workspace - Update resolve_component — look up by new component keys (
"shape","text", etc.). Verify:cargo check --workspace - Update WhiteboardIntent — change
AddElementand related intents to usecomponent_key+ property bindings instead ofWhiteboardElementSchema. Verify:cargo check --workspace - Delete WhiteboardElementSchema — remove
schema.rs,WhiteboardElementComponent, and all references. Verify:cargo test --workspace - Delete group elements — remove
VerticalGroup/HorizontalGroupfromWhiteboardElement, deletegroup.rs, export support, and Dart rendering widgets. Verify:cargo test --workspace - FRB + Dart — regenerate bindings. Update Dart code that constructs element intents. Verify:
flutter analyze
Alternatives Considered
Section titled “Alternatives Considered”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.
Delete group elements
Section titled “Delete group elements”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.
Resolved Questions
Section titled “Resolved Questions”Typed builders vs generic AddElement
Section titled “Typed builders vs generic AddElement”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.
PropertyValue conversion
Section titled “PropertyValue conversion”Standard conventions:
Color→PropertyValue::String("#RRGGBB")or"#AARRGGBB"— standard web hex representation. Parse viaFromStr, format viaDisplay.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.
Group elements
Section titled “Group elements”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).
Unresolved Questions
Section titled “Unresolved Questions”None — all questions resolved.