@cratis/scene
v0.157.3
Published
Cratis Scene: renders Studio prototypes as thumbnails and previews.
Downloads
17,578
Readme
Scene
TypeScript models for the Studio's low-code canvas: UIElement hierarchy, ExternalComponent, and the prototype surface component system.
Serialized prototype previews
PrototypeThumbnail accepts both live IComponent instances and raw UIElement data from JSON documents. At render time Scene uses the element's library-qualified _derivedTypeId (or componentName) to construct a registered component and copy its saved state, recursively hydrating element arrays in all named slots without changing the source document. Without a registered library, it renders a positioned, sized placeholder rather than an empty prototype. Library registration does not trigger a React rerender by itself; rerender the thumbnail after loading bundles to replace placeholders with actual components. Without an open action the thumbnail is a named, non-focusable preview; with one it remains an editor-opening button. The board/document reader does not load bundles or depend on Studio Core.
Layout preview DTOs
The six StudioLayout.* types (Stack, Row, Grid, Container, Wrap, and Canvas) are backend preview DTOs. They derive from the existing FrameworkElement/UIElement hierarchy and use the same on-wire ComponentName, inherited Properties, and Slots shape as ExternalComponent, so generic board previews can round-trip their nested children. They deliberately do not derive from ExternalComponent: the current derived-type converter treats a base with registered subclasses as a discriminator family and then cannot deserialize a generic externalComponent itself. This compatibility boundary avoids changing Fundamentals or breaking existing generic component, Dialog, and ConfirmDialog data.
A SceneDocument is the sole editable model. Layout DTOs are produced only for the legacy board-preview projection; they are not a second Scene model, editor, resolver, or persistence authority.
Component class — how it works
Component (in Component.ts) is the abstract base class for every prototype surface component (e.g. the 89 PrimeReact wrappers under Source/PrimeReact/). It extends ExternalComponent so instances are proper UIElement subtypes that flow through the command pipeline without any manual wire-format conversion.
Why extends ExternalComponent?
Commands that carry elements use UIElement[] as their type on both the C# and TypeScript sides. Before this design, Component was a separate class that implemented IComponent and had to be converted to a wire-format plain object before assignment (toWireElement). That conversion was a callsite smell that hid serialization failures and required every consumer to know about it.
Now Component extends ExternalComponent implements IComponent. An InputText instance IS a UIElement — pass it directly:
command.elements = updatedElements as unknown as typeof command.elements;The as unknown as cast is still needed because TypeScript sees UIElement[] (with id: Guid) while the editor's PrototypeSurfaceElement type has id: string. The runtime values are correct; only the declared types diverge at this boundary.
How serialization works
The Fundamentals JsonSerializer walks Object.getOwnPropertyNames(instance) and serializes each own property. The design of Component ensures only JSON-safe values appear as own properties:
| What | Own property? | Why |
|---|---|---|
| id | Yes — string accessor via Object.defineProperty | C# System.Text.Json parses a UUID string as Guid automatically |
| ZIndex, anchoring, width, height, name, properties, slots, componentName | Yes — class fields set in initialize() | All are plain values; safely serialized |
| _derivedTypeId | Yes — set explicitly in initialize() | Tells Fundamentals to deserialize as ExternalComponent on the server |
| toolbarIcon | No — prototype getter | React-icons components are regular functions with a prototype.constructor self-reference; serializing them causes infinite recursion. Being a prototype getter keeps them out of Object.getOwnPropertyNames entirely |
| RenderPrototype | No — abstract prototype method | Same reason; a method on the prototype is never an own property of the instance |
| label, position, size, children, type | No — prototype getters | Computed bridges; not serializable data |
The id bridge
SceneObject declares id: Guid (via @field(Guid)). IComponent declares id: string. The constructor replaces the class-field own property with a plain-string accessor:
let _id = '';
Object.defineProperty(this, 'id', {
get() { return _id; },
set(value: string) { _id = String(value ?? ''); },
enumerable: true,
configurable: true,
});The serializer reads the string UUID; C# parses it as Guid. Editor code that compares IDs uses string equality throughout.
The _derivedTypeId own property
DerivedType.get(constructor) uses Reflect.getOwnMetadata, which does not traverse the prototype chain. The @derivedType('externalComponent') decorator is on ExternalComponent, so DerivedType.get(InputText) returns undefined. Without the explicit own property the JSON output would have no _derivedTypeId and the C# deserializer would not know to construct an ExternalComponent.
initialize() sets it directly:
(this as unknown as Record<string, unknown>)['_derivedTypeId'] = 'externalComponent';The type key
After initialize(), componentName is updated to the full library-qualified key ('PrimeReact.InputText'). Before initialize() the subclass class field holds the short name ('InputText'). The type getter always returns the fully qualified key by splitting on . and re-joining with library:
get type(): string {
const cn = this.componentName ?? '';
const shortName = cn.split('.').pop() ?? cn;
return this.library ? `${this.library}.${shortName}` : shortName;
}Subclass authoring
Implement these members:
export default class InputText extends Component {
readonly componentName = 'InputText'; // short name; initialize() stores the full key
readonly category = 'Form';
readonly library = 'PrimeReact';
readonly initialSize = { width: 220, height: 42 };
readonly resizeBehavior = 'horizontal' as const;
// MUST be a getter — not a class field.
// Class fields are own instance properties and cause infinite recursion in Fundamentals' serializer.
override get toolbarIcon() { return FaKeyboard; }
RenderPrototype({ label }: PrototypeComponentRenderProps): ReactNode { ... }
}toolbarIcon must be an override get. A class field (readonly toolbarIcon = FaKeyboard) makes FaKeyboard an own instance property. Fundamentals' serializer then calls Object.getOwnPropertyNames(FaKeyboard) which includes prototype, whose constructor points back to FaKeyboard, causing unbounded recursion and a RangeError.
