Nobody using a visual editor cares whether line height and letter spacing happen to use the same Blazor component. They do notice when one supports multi-selection, live preview and template variables while the other behaves differently for no apparent reason.
That kind of inconsistency is easy to introduce. HCTI's browser-based template editor has text, images, panels and other blocks with about 180 addressable properties between them. Adding one is not just adding a field to a model. It may also need a sidebar label, grouping and ordering, an appropriate input, mixed-selection behavior, preview and commit handling, change tracking, CSS output, validation and tests.
We could have maintained all of that through a large registry and a careful checklist. Instead, we made the strongly typed C# model the starting point and used Roslyn source generators to produce the repetitive metadata and routing around it. Normal Blazor components still own the controls and layout. They receive enough consistent information that LineHeight or FitMode does not need its own one-off implementation.
If you do not normally work in C#, a Roslyn source generator is a program that runs as part of compilation (while the app is building). It inspects the C# and adds more C# to the same build. The browser receives ordinary compiled code; it does not run the generator or scan these attributes at startup.
This post follows a property from its attributes to a generated descriptor and then into the Blazor sidebar. It also covers the shared input workflow, the places where composite controls are still useful and how block components receive a common rendering contract.
Related Reading
The architecture overview maps the surrounding editor, while the change tracking and preview post follows what happens after one of these generated controls applies a value.
Model Design
The root element of a template is a TemplateModel (and they say naming things is hard!). It holds the basic template metadata, canvas settings, discovered variables and a flat collection of blocks.
Each block has a stable numeric ID. ID 0 belongs to the canvas, and every real block gets an ID that stays with it when it moves, changes layers or enters a panel. TemplateModel implements ITemplateNodeResolver, giving the rest of the system one way to find the canvas or a block without caring about its concrete type.
Each block type is a class inheriting from TemplateBlockBase. Source generation fills in much of the editing and rendering code around these classes, driven by attributes on the editable properties. We'll get into those below.
// Shortened for readability.
public sealed partial class TemplateModel : ITemplateNodeResolver
{
private uint _nextBlockId = 1;
public string Name { get; set; } = "New Template";
public string? Description { get; set; }
public TemplateCanvasSettings Canvas { get; set; } = new();
public IReadOnlyList<TemplateBlockBase> Blocks => _blocks;
private List<TemplateBlockBase> _blocks = [];
private Dictionary<uint, TemplateBlockBase> _blockLookup = [];
public uint MakeNextBlockId() => Interlocked.Increment(ref _nextBlockId);
private void UpdateBlockLookup() =>
_blockLookup = _blocks.ToDictionary(block => block.Id);
public bool TryGetNode(uint id, out ITemplateNode? node)
{
node = id == 0 ? Canvas : _blockLookup.GetValueOrDefault(id);
return node is not null;
}
}
Properties
Many properties apply to every block and live on TemplateBlockBase. Others belong to a specific block type or an intermediate base such as TextBlockBase.
Some properties are plain scalars, but many have their own types. Templatable values add another wrapper, and almost everything that accepts user input has parsing or validation attached to it.
Editable properties can also carry attributes describing how they appear in the sidebar, whether they can be edited in a particular context, how they participate in change tracking and how they map to rendering. I did not want adding something like TextDecorationSkipInk to mean touching the model, the sidebar, the property registry, the change tracker, the renderer and three other places I forgot about until a test fails.
The shapes vary quite a bit. Here is a shortened cross-section without the serialization and generation attributes:
// Shortened for readability.
public readonly record struct TemplateCssSize :
IParsable<TemplateCssSize>,
IUtf8SpanFormattable
{
public float Value { get; init; }
public CssSizeUnit Unit { get; init; }
public static TemplateCssSize Parse(string value, IFormatProvider? provider);
public static bool TryParse(
string? value,
IFormatProvider? provider,
out TemplateCssSize result);
public bool TryFormat(
Span<byte> destination,
out int bytesWritten,
ReadOnlySpan<char> format,
IFormatProvider? provider);
}
public readonly record struct Min0CssSize
{
public float Value
{
get;
init => field = Math.Max(0f, value);
}
public CssSizeUnit Unit { get; init; }
}
Structured Properties
The model accepts user input and eventually emits CSS, so primitive values are often too permissive. A float does not tell you whether negative values are allowed, whether a unit is required or whether 0 has a special meaning. Small structs give those rules one place to live, along with parsing and formatting.
The size types implement IParsable<T> and IUtf8SpanFormattable. Inputs can use the standard .NET parsing pattern instead of maintaining a separate parser for each control. At the other end, CSS generation can write the same value directly into a UTF-8 span without first allocating an intermediate string. The rules for accepting 12px and producing 12px stay with the type that knows what a valid size is.
For example, we have 4 size types: TemplateCssSize, Min0CssSize, MaybeUnitlessMin0CssSize and GridTrackSize. They share a numeric value and unit, but enforce different rules. Min0CssSize clamps negative values. MaybeUnitlessMin0CssSize can distinguish 1.2 from 1.2px. GridTrackSize supports the units and sizing behavior that make sense inside a grid. We also have Ushort1, a 1-indexed ushort, because grid tracks should not accept zero.
Complex Properties
A background looks like one property in the sidebar, but its model includes a mode, solid color, gradient settings and pattern settings. A gradient adds its own type, angle and list of color stops. Text underlines have color, style, thickness, offset and skipping rules.
Those underline settings could all have been top-level properties such as UnderlineColor and UnderlineThickness. Grouping them under one Underline property makes the block model, generated sidebar and CSS code easier to work with. I tend to call these nested models Value types, hence names like TextUnderlineValue and TemplateBackgroundFillValue. That is just our naming convention; unlike the small structs above, many of them are classes.
Keeping that structure in the model gives validation and CSS generation something concrete to work with. It also preserves mode-specific settings. Switching a background from a gradient to a solid color does not require flattening the gradient into strings or throwing its stops away.
Collection Properties
There are 2 primary collection shapes in the model: lists and dictionaries. Shadow layers and extra CSS entries are lists because users can add more than one and their order matters.
Dictionaries are currently used most heavily for grid track settings. We do not know a user's eventual grid size, and most rows and columns use their defaults anyway. RowOverrides and ColumnOverrides therefore start empty and only store tracks the user has customized. If a user changes the background or size of row 4, that one Ushort1 key gets an entry in the panel's RowOverrides dictionary. Rendering, validation and change tracking can work with the exceptions instead of carrying a fully populated model for every possible track.
Panels
Panels could have had a large impact on the model, but we did not let them. The block collection stays flat, and every block has 3 panel-related properties:
ParentPanelIdidentifies the block's parent, with0meaning the root canvas.FlexItemholds the settings for how the block participates in a flex panel, such as basis, grow, shrink and alignment.GridItemholds the equivalent grid-specific settings, including row, column, spans and alignment.
FlexItem and GridItem are inert unless the block is inside their respective panel type. Moving a block between the canvas, flex, grid and free-positioned panels does not require changing its model shape or throwing those settings away. The parent determines which set of properties matters.
Building the Editor UI
The editor itself follows a fairly conventional application layout: blocks and actions on the left, the canvas in the middle and properties on the right. Modals, context menus, selection controls and stylesheets sit alongside that main layout rather than being owned by any one block.
Blazor builds that UI from Razor components: .razor files containing HTML-like markup, component parameters and C# code. The real EditorHost has quite a bit more in it, but its broad shape looks like this:
@* Shortened for readability. *@
<EditorLeftSidebar />
<section class="editor-stage">
<EditorHostToolbar />
<div class="editor-canvas">
<RenderingBlockTree />
<HtmlEditorCanvas />
<div data-te-interaction-preview-layer="1">
@* Drag, drop and alignment previews. *@
</div>
</div>
</section>
<EditorPropertiesSidebar />
<ModalHost />
<ContextMenuHost />
<SelectionChrome />
<CentralizedStylesheet />
Blazor owns this application shell, the property controls, selection state and the block components on the canvas. The TypeScript interaction runtime has an intentionally narrower role. It temporarily takes over direct manipulation, then commits the result back to Blazor.
From Property to Input
The right sidebar is not handwritten for every block type. Source generation turns the attributes on editable model properties into descriptors containing their label, group, value type, changed path, visibility rules and other editor metadata.
Line height follows the normal path from a model property to a standard editor control:
// Shortened for readability.
[TemplateDefinition(
FriendlyName = "Line height",
GroupKey = "typography",
GroupLabel = "Typography",
DisplayType = PropertyDisplayType.CssSize,
Order = 41,
InlineRowKey = "text-spacing",
InsetLabel = "LINE HEIGHT",
NumberMin = 0,
NumberStep = 0.1)]
[CssDefinition(CssStyleKey = "line-height")]
public TemplateProperty_Templatable<MaybeUnitlessMin0CssSize> LineHeight
{ get; set; } = new MaybeUnitlessMin0CssSize(0.95f, true);
Because this happens during compilation, the browser does not use reflection to assemble descriptors. The generated getter safely narrows a selected block to Tracking_TextBlockBase, the constant addresses connect the control and its templating options to change tracking and the render-version getter lets the sidebar tell whether this particular value changed.
PropertySidebarItem can then choose SizeInput from the descriptor's value and display types. The value type tells it that negatives are not allowed and that unitless values are valid. The descriptor supplies the label, grouping, row placement, templating metadata and changed path. None of that has to be restated in a line-height-specific Razor component.
The sidebar groups those descriptors, filters them for the current selection and hands each visible one to PropertySidebarItem:
@* Shortened for readability. *@
@foreach (var group in PropertyGroups)
{
<section>
@foreach (var row in group.Properties)
{
@foreach (var descriptor in row)
{
if (!IsDescriptorHiddenForCurrentSelection(descriptor))
{
<PropertySidebarItem
Descriptor="descriptor"
SelectedBlock="SelectedBlock" />
}
}
}
</section>
}
PropertySidebarItem selects an input from the descriptor. Booleans become switches, enums become either dropdowns or toolbars, CSS sizes become size inputs and complex value types such as backgrounds or underlines get a purpose-built composite input. This dispatch is type-based and centralized, so block components do not need to know anything about the sidebar.
Adding a normal property therefore does not mean adding another control directly to a text, image or panel sidebar. Most of the editor-facing information is declared on the model, generated into a descriptor and consumed by the existing input system.
A property can still opt into bespoke UI when it needs it. Most properties do not. Fix mixed-selection behavior in the shared size input, for example, and line height, letter spacing, width and every other size control receive the same fix.
Inputs as Editing Workflows
An editor input has more responsibility than its HTML control suggests. A numeric field may contain temporarily invalid text while someone is typing. It may represent several selected blocks with different current values. Dragging its spinner should preview continuously, but the completed gesture should produce one undoable change. Templated values may also lock the input depending on their current mode.
The shared input base handles that plumbing. Individual inputs provide parsing, validation and the small piece of logic needed to apply their value. The common path takes care of selection, eligibility, preview notification and transactions.
There are 2 ways for an input to customize that path. Optional Func<> hooks can be supplied when a particular use of a control needs to reject a block or make a related change. The input implementation itself can override the methods that validate and apply its value.
// Flattened from the input configuration for readability.
Func<ITrackedNode, bool>? EligibilityCheck { get; }
Func<ITrackedNode, Task<bool>>? BeforeApplyPreview { get; }
Func<ITrackedNode, Task<bool>>? BeforeCommit { get; }
A normal input can use the generated generic apply methods unchanged. Numeric and composite inputs override them when their value needs more specific handling. Separately, an input inside the underline control can supply the two Before... hooks to enable underlining before it previews or commits a new color. The shared base does not need any underline-specific logic to support that.
// Shortened for readability.
foreach (var block in ViewContext.NodesForEditing())
{
if (EligibilityCheck?.Invoke(block) == false)
{
continue;
}
if (BeforeApplyPreview is not null)
{
changed |= await BeforeApplyPreview(block);
}
changed |= ActuallyApplyPreviewValue(block, next);
}
if (changed)
{
ViewContext.NotifyPreviewChanged();
}
That distinction is used by plain fields, color pickers, keyboard nudging and larger composite controls. The input does not need to know how a changed path reaches a particular block type, and the model does not need to know which control initiated the edit.
Composite Inputs
Structured model properties can have structured controls. An underline is one property in the generated sidebar, while TextUnderlineInput composes the enabled state, color, style, thickness, offset and skip-ink controls used to edit it. Each child control receives an address beneath the underline's base address.
@* Simplified example. *@
@* Inherits the shared selection, preview and commit behavior. *@
@inherits _TemplateEditorCompositeInputBase<Tracking_Proxy_TextUnderlineValue>
<_IsEnabledWrapper
IsEnabled="isEnabledAcrossSelection"
IsEnabledChanged="HandleEnableAsync"
Label="@Label">
<TemplateColorInput
Value="Value.DisplayColor.Value"
PathId="ColorPath"
InputOptions="ColorInputOptions"
ParentTemplateMode="Value.Color.RequirementMode" />
<CompactEnumSelect
Value="@((byte)Value.Style)"
ValueChanged="HandleStyleChangedAsync" />
<SizeInput
Value="@(new TemplateCssSize(Value.DisplayThickness.Value, Value.DisplayThickness.Unit))"
PathId="ThicknessPath"
InputOptions="ThicknessInputOptions" />
</_IsEnabledWrapper>
@code {
private InputBaseOptions? colorInputOptions;
private InputBaseOptions? thicknessInputOptions;
private InputBaseOptions ColorInputOptions => colorInputOptions ??= new()
{
// A selected block may not have an underline yet. Enable it before
// previewing or committing the new color on that block.
BeforeApplyPreview = EnsureUnderlineEnabledPreviewAsync,
BeforeCommit = EnsureUnderlineEnabledCommitAsync
};
private InputBaseOptions ThicknessInputOptions => thicknessInputOptions ??= new()
{
BeforeApplyPreview = EnsureUnderlineEnabledPreviewAsync,
BeforeCommit = EnsureUnderlineEnabledCommitAsync
};
private ChangedPathAddress ColorPath =>
BaseAddress.Child(ChangedPathId.Color);
private ChangedPathAddress ThicknessPath =>
BaseAddress.Child(ChangedPathId.Thickness);
private Task<bool> EnsureUnderlineEnabledPreviewAsync(ITrackedNode block) =>
Task.FromResult(block.ApplyTrackedPreviewValue(
BaseAddress.Child(ChangedPathId.IsEnabled), true));
private Task<bool> EnsureUnderlineEnabledCommitAsync(ITrackedNode block) =>
Task.FromResult(block.ApplyTrackedValue(
ChangeTracker,
BaseAddress.Child(ChangedPathId.IsEnabled),
true));
}
Those hooks run for each block being edited. If 3 text blocks are selected and only 2 currently have underlines, changing the color first previews or enables the underline on the third block, then applies the color to all 3. The enable and color writes still go through the same preview or tracked commit path. Background modes use the same pattern when a color or gradient control needs to enable the background and select the right mode before applying its own value.
Rendering Blocks
RenderingBlockTree walks the root blocks, gives each one a stable component key based on its block ID and dispatches to the appropriate component for its concrete type. Panel components recursively render their children, even though the stored block collection remains flat.
// Shortened for readability.
foreach (var block in ViewContext.RootBlocks)
{
builder.OpenComponent<RenderingBlockNode>(2);
builder.SetKey(block.Id);
builder.AddComponentParameter(3, nameof(RenderingBlockNode.Block), block);
builder.CloseComponent();
}
// Inside RenderingBlockNode:
switch (Block)
{
case Tracking_TextBlockData text:
OpenBlockComponent<BlazorTextBox>(builder, text);
break;
case Tracking_ImageBlockData image:
OpenBlockComponent<BlazorImageBlock>(builder, image);
break;
case Tracking_GridBasePanelBlockData grid:
OpenBlockComponent<BlazorGridPanelBlock>(builder, grid);
break;
}
The individual components are deliberately small. An image block inherits the common block component and provides the markup that belongs inside its frame:
@inherits TemplateComponentBase<Tracking_ImageBlockData, ImageBlockData>
@RenderBlockFrame(@<text>
<img src="@EffectiveSourceUrl"
data-te-inner="@Block.Id"
data-te-target="@LayoutTarget.BlockInner.CssValue"
data-te-target-id="@Block.Id"
loading="lazy"
draggable="false" />
</text>)
RenderBlockFrame supplies the structure and attributes shared by every block. That includes the stable block ID, parent panel ID, current version, locking and aspect-ratio flags, minimum dimensions and whether the interaction runtime may drag or resize it. A new block component gets that contract by inheriting the base instead of reproducing a long list of data-* attributes.
The frame also marks the different elements that CSS may target:
// Shortened from TemplateComponentBase.RenderBlockFrame.
builder.OpenElement(0, "div");
builder.AddAttribute(1, "data-te-block-id", Block.Id);
builder.AddAttribute(2, "data-te-parent-panel-id", Block.ParentPanelId);
builder.AddAttribute(3, "data-te-locked", IsLocked ? "1" : "0");
builder.AddAttribute(4, "data-te-can-drag", CanRuntimeDrag ? "1" : "0");
builder.AddAttribute(5, "data-te-can-resize", CanRuntimeResize ? "1" : "0");
AppendLayoutTargetAttributes(builder, LayoutTarget.BlockOuter);
builder.OpenElement(10, "div");
AppendLayoutTargetAttributes(builder, LayoutTarget.BlockSurface);
builder.AddContent(11, childContent);
builder.CloseElement();
builder.CloseElement();
One Attribute Contract for Two Renderers
The editor is not the only place that needs those layout targets. The server renderer must produce equivalent attributes even though it writes HTML directly rather than building a Blazor render tree. ILayoutTargetAttributeWriter is the small interface between the model and both output systems:
public interface ILayoutTargetAttributeWriter
{
void Marker(ReadOnlySpan<char> key, int sequence = 0);
void StringAttr(
ReadOnlySpan<char> key,
ReadOnlySpan<char> value,
bool trim = false,
int sequence = 0);
void FormattableAttr<T>(
ReadOnlySpan<char> key,
T value,
int sequence = 0)
where T : ISpanFormattable;
}
The model writes attributes against that interface. It knows that a block outer, surface or structured-panel wrapper needs a target name and block ID. For grid children it can also add the panel ID, row, column and resolved grid area.
// Shortened for readability.
public virtual void WriteLayoutTargetAttributes<TWriter>(
LayoutTarget target,
PanelBlockData? parentPanel,
ref TWriter writer)
where TWriter : ILayoutTargetAttributeWriter, allows ref struct
{
writer.StringAttr("data-te-target", target.CssValue);
writer.FormattableAttr("data-te-target-id", Id);
if (target == LayoutTarget.StructuredPanelChildWrapper)
{
writer.Marker("data-te-structured-parent");
if (parentPanel is GridBasePanelBlockData grid)
{
GridItem.ResolveArea(
grid.GridLayout,
out var column,
out var row,
out _,
out _);
writer.FormattableAttr("data-te-grid-panel-id", grid.Id);
writer.FormattableAttr("data-te-grid-column", column);
writer.FormattableAttr("data-te-grid-row", row);
}
}
}
In the editor, RenderTreeLayoutTargetAttributeWriter translates those calls into RenderTreeBuilder.AddAttribute. On the server, HtmlBuilder implements the same interface and writes directly to the HTML output. The interface accepts spans, formattable values and ref struct implementations so the server path can stay in UTF-8 buffers without turning every attribute into an intermediate string.
This gives the editor and final renderer the same understanding of BlockOuter, BlockSurface, BlockInner and panel child wrappers without forcing them through the same rendering technology. It also means a grid-layout attribute cannot quietly exist in the editor but disappear from the final HTML, or vice versa.
The block component is still only one layer of what appears on the canvas. Selection chrome, interaction previews and generated stylesheets are rendered separately. A color preview can therefore update the relevant stylesheet without asking the block's markup to render again. Version signatures and ShouldRender keep those updates contained.
Underlines, backgrounds and shadows still have custom components because they need more coordination than a normal scalar property. Everything else follows the generated descriptor into the shared input and rendering code.
Conclusion
Source generation has worked well here because we keep its job narrow. It reads the model declarations and emits descriptors, typed accessors, tracking paths, render tokens and routing code. It does not decide what a good color picker or underline editor should look like. That remains normal Blazor code.
We are on our third iteration of the source generators behind this editor. The first two were excellent prototypes. They proved that the model-driven approach could cover change tracking, previews, CSS and the property sidebar. They were also becoming code I did not want to maintain for the next five years.
Some of that came from the real complexity of the problem, and some of it was AI slop. AI made it very easy to add another helper, generic path or clever abstraction that looked reasonable in one file. Across the whole generator, those additions produced too much code and made the generator itself hard to follow. We replaced substantial parts of it instead of preserving every early decision.
The current version is much stricter about what gets generated and why. AI was useful while we explored the shape of the system and wrote boilerplate. Once that shape settled, I rewrote the generator around code I could actually follow and extend.
For a normal property, the model declaration is enough to get it into the right sidebar group with the right control and changed path. The shared input components handle mixed selections, previews and commits the same way they do everywhere else. Adding line height should not require somebody to remember all of those pieces individually.
Generating C# also means many mistakes fail the build. Tests, analyzers and generator diagnostics cover the ones that do not. When something slips through, we can trace it from the property declaration to the generated descriptor, the Blazor input, the tracked value and the rendered output.
Tip
Commit generated code once you're happy with it. When a small generator edit unexpectedly adds 2,000 lines or a new routing branch, the diff makes that obvious.
Customers should never know (or care) which parts were generated. They will notice that size inputs behave like other size inputs, previews work in the same places and multi-selection does not change the rules. That consistency is what we wanted from the system.
Template Editor Engineering Series
| Post | Description |
|---|---|
| Building a Visual Template Editor in Blazor WASM | A high-level tour of the decisions, architecture, and shared systems behind the editor. |
| Building Consistent Blazor UX with Source Generators | Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators. |
| Undo, Redo, and Live Preview - No Diffs Needed | Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state. |
| Why Our Blazor App Still Uses TypeScript | A practical split between Blazor-owned application state and TypeScript-owned browser behavior. |
| Beyond Text Replacement: Building Typed Template Variables | Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties. |
| Building a System to Generate Complex CSS | Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers. |
| Simplifying Complexity: MemoryPack to the Rescue | MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server. |
| Productionizing Our Blazor WebAssembly Editor | Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor. |
