From 503c66fa35b7160fd300a1081a2f555b713e5691 Mon Sep 17 00:00:00 2001 From: Guillaume Fontorbe Date: Wed, 24 Sep 2025 16:40:31 +0200 Subject: [PATCH 1/2] Add custom views recipe --- hugo/content/docs/recipes/custom-views.md | 365 ++++++++++++++++++++++ hugo/data/menu/main.yaml | 2 + 2 files changed, 367 insertions(+) create mode 100644 hugo/content/docs/recipes/custom-views.md diff --git a/hugo/content/docs/recipes/custom-views.md b/hugo/content/docs/recipes/custom-views.md new file mode 100644 index 0000000..bd37812 --- /dev/null +++ b/hugo/content/docs/recipes/custom-views.md @@ -0,0 +1,365 @@ +--- +title: 'Custom Views' +--- +{{< toc >}} + +Custom views are the foundation of how Sprotty renders your diagram elements. Each model element type is associated with a view that defines how it appears in the SVG DOM. This recipe covers everything you need to know about creating, configuring, and composing custom views. + +## Understanding Views + +Views are classes that implement the `IView` interface and transform model elements into SVG representations. Each view has a `render()` method that returns a virtual DOM node (`VNode`) describing the SVG elements to be rendered. + +### The View-Model Relationship + +Every model element has a `type` property that maps to exactly one view in the `ViewRegistry`. When Sprotty renders your diagram, it: + +1. Traverses the model tree +2. Looks up each element's view by its type +3. Calls the view's `render()` method +4. Builds the SVG DOM from the returned virtual nodes + +```typescript +// Model element with type +const nodeModel: SNode = { + id: 'node1', + type: 'node:custom', // This maps to a view + position: { x: 100, y: 50 }, + size: { width: 120, height: 80 } +}; +``` + +## Creating Your First Custom View + +Let's create a custom view step by step: + +### 1. Basic View Structure + +```typescript +import { injectable } from 'inversify'; +import { VNode } from 'snabbdom'; +import { IView, RenderingContext, SNodeImpl, IViewArgs } from 'sprotty'; + +@injectable() +export class CustomNodeView implements IView { + render(node: Readonly, context: RenderingContext, args?: IViewArgs): VNode | undefined { + // Check if the element should be rendered + if (!this.isVisible(node, context)) { + return undefined; + } + + // Return the SVG representation + return + + {context.renderChildren(node)} + ; + } + + // Helper method for visibility checking + protected isVisible(element: SNodeImpl, context: RenderingContext): boolean { + return !element.hidden && context.targetKind !== 'hidden'; + } +} +``` + +### 2. Key Components Explained + +**Injectable Decorator**: The `@injectable()` decorator is required for dependency injection. Never forget this! + +**Visibility Check**: Always check if an element should be rendered. This optimizes performance by skipping hidden or out-of-viewport elements. + +**Root Element**: The render method must return exactly one root element. Use `` (group) to wrap multiple SVG elements. + +**Position**: Set `x="0" y="0"` - the layout engine handles actual positioning. + +**Children Rendering**: Use `context.renderChildren(node)` to render child elements in their own views. + +## TSX Syntax and Conventions + +Sprotty uses TSX (TypeScript + JSX) for defining SVG elements. This allows you to write SVG declaratively with TypeScript expressions. + +### CSS Classes + +Use Sprotty's convenient class syntax for dynamic styling (which gets transformed into Snabbdom's class module format): + +```typescript +return 0} // Complex condition → class="error" (if condition is true) + class-large={node.size.width > 200} // Size-based styling → class="large" (if width > 200) +/>; +// Results in HTML: (assuming conditions are met) +``` + +### Attributes and Properties + +```typescript +return ; +``` + +### Handling Complex Content + +For complex content, use TypeScript expressions: + +```typescript +render(node: Readonly, context: RenderingContext): VNode { + const corners = this.calculateCorners(node); + const pathData = this.createPathFromCorners(corners); + + return + + {node.showLabel && {node.label}} + {context.renderChildren(node)} + ; +} +``` + +## View Registry Configuration + +Views must be registered in your dependency injection container to be used: + +### Basic Registration + +```typescript +import { ContainerModule } from 'inversify'; +import { configureModelElement, TYPES } from 'sprotty'; + +const diagramModule = new ContainerModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + + // Register model element with its view + configureModelElement(context, 'node:custom', SNodeImpl, CustomNodeView); + configureModelElement(context, 'edge:custom', SEdgeImpl, CustomEdgeView); + configureModelElement(context, 'label:custom', SLabelImpl, CustomLabelView); +}); +``` + +### Advanced Registration with Features + +```typescript +configureModelElement(context, 'node:interactive', InteractiveNode, InteractiveNodeView, { + enable: [selectFeature, moveFeature, hoverFeedback, popupFeature] +}); +``` + +## Rendering Context and Lifecycle + +The `RenderingContext` provides essential services and information during rendering: + +### Context Properties + +```typescript +render(node: Readonly, context: RenderingContext): VNode { + // Check rendering target (main, popup, hidden) + if (context.targetKind === 'popup') { + return this.renderPopupVersion(node); + } + + // Access the model root + const root = context.root; + + // Check if we're in hidden rendering (for bounds computation) + if (context.targetKind === 'hidden') { + return this.renderForBoundsComputation(node); + } + + return this.renderNormal(node, context); +} +``` + +### Rendering Children + +```typescript +// Render all children +{context.renderChildren(node)} + +// Render specific child +{context.renderElement(specificChild)} + +// Render children with filtering +{node.children + .filter(child => child.type === 'label') + .map(child => context.renderElement(child))} +``` + +### Context Services + +```typescript +render(node: Readonly, context: RenderingContext): VNode { + // Access view registry for manual view lookup + const labelView = context.viewRegistry.get('label:standard'); + + // Access parent arguments if available + const parentArgs = context.parentArgs; + + return /* ... */; +} +``` + +## View Composition Patterns + +### Extending Existing Views + +Build upon Sprotty's built-in views: + +```typescript +@injectable() +export class EnhancedNodeView extends RectangularNodeView { + override render(node: Readonly, context: RenderingContext): VNode | undefined { + const baseNode = super.render(node, context); + if (!baseNode) return undefined; + + // Add custom decorations + const decorations = this.renderDecorations(node); + + return + {baseNode} + {decorations} + ; + } + + protected renderDecorations(node: SNodeImpl): VNode[] { + const decorations: VNode[] = []; + + if (node.selected) { + decorations.push(); + } + + return decorations; + } +} +``` + +### Compositional Views + +Create reusable view components: + +```typescript +@injectable() +export class ComplexNodeView implements IView { + render(node: Readonly, context: RenderingContext): VNode { + return + {this.renderHeader(node)} + {this.renderBody(node, context)} + {this.renderFooter(node)} + ; + } + + protected renderHeader(node: ComplexNode): VNode { + return ; + } + + protected renderBody(node: ComplexNode, context: RenderingContext): VNode { + return + {context.renderChildren(node)} + ; + } + + protected renderFooter(node: ComplexNode): VNode { + return ; + } +} +``` + +### Conditional View Logic + +Handle different states and configurations: + +```typescript +@injectable() +export class StatefulNodeView implements IView { + render(node: Readonly, context: RenderingContext): VNode { + switch (node.state) { + case 'loading': + return this.renderLoadingState(node); + case 'error': + return this.renderErrorState(node); + case 'success': + return this.renderSuccessState(node, context); + default: + return this.renderDefaultState(node, context); + } + } + + protected renderLoadingState(node: StatefulNode): VNode { + return + + + Loading... + + ; + } + + // ... other state renderers +} +``` + +## Best Practices + +### Performance Optimization + +1. **Early Returns**: Always check visibility first +2. **Minimal Calculations**: Cache expensive computations +3. **Conditional Rendering**: Skip unnecessary elements + +```typescript +render(node: Readonly, context: RenderingContext): VNode | undefined { + // Early visibility check + if (!this.isVisible(node, context)) { + return undefined; + } + + // Cache expensive calculations + if (!this.cachedPath || this.isDirty(node)) { + this.cachedPath = this.calculateComplexPath(node); + this.markClean(node); + } + + return ; +} +``` + +### Separation of Concerns + +1. **Keep views focused**: One view per visual concept +2. **Delegate to children**: Let child views handle their own rendering +3. **Extract helpers**: Move complex calculations to separate methods + +### Error Handling + +```typescript +render(node: Readonly, context: RenderingContext): VNode | undefined { + try { + return this.renderContent(node, context); + } catch (error) { + console.error('Error rendering node:', node.id, error); + return this.renderErrorFallback(node); + } +} + +protected renderErrorFallback(node: SNodeImpl): VNode { + return ; +} +``` + +The key to mastering custom views is practice. Start simple, build incrementally, and always keep the separation between model and view clear. diff --git a/hugo/data/menu/main.yaml b/hugo/data/menu/main.yaml index 34cb6d9..81116e4 100644 --- a/hugo/data/menu/main.yaml +++ b/hugo/data/menu/main.yaml @@ -29,6 +29,8 @@ main: ref: "docs/concepts/best-practices" - name: Recipes sub: + - name: Custom Views + ref: "docs/recipes/custom-views" - name: SVG Rendering ref: "docs/recipes/svg-rendering" - name: Micro-layout From ba2f7243cefb93ca75856e31aa089eab8c055190 Mon Sep 17 00:00:00 2001 From: Guillaume Fontorbe Date: Tue, 14 Oct 2025 19:37:44 +0200 Subject: [PATCH 2/2] Add explanation about jsx pragma --- hugo/content/docs/recipes/custom-views.md | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/hugo/content/docs/recipes/custom-views.md b/hugo/content/docs/recipes/custom-views.md index bd37812..5656d3b 100644 --- a/hugo/content/docs/recipes/custom-views.md +++ b/hugo/content/docs/recipes/custom-views.md @@ -28,6 +28,58 @@ const nodeModel: SNode = { }; ``` +## Setting Up JSX for Custom Views + +Before you can create custom views using JSX/TSX syntax, you need to set up your TypeScript environment properly. Sprotty uses JSX to declaratively define SVG elements, but this requires specific configuration. + +### The JSX Pragma + +Every view file that uses JSX must start with a special pragma comment at the very top: + +```typescript +/** @jsx svg */ +import { svg } from 'sprotty/lib/lib/jsx'; +``` + +**The Pragma Comment**: `/** @jsx svg */` is a JSX pragma that tells the TypeScript compiler which function to use when transforming JSX expressions. Instead of using React's `createElement`, we use Sprotty's `svg` function. + +**The svg Import**: The `svg` function from `sprotty/lib/lib/jsx` is what actually transforms your JSX expressions into Snabbdom virtual DOM nodes (`VNode`). This is the core of how Sprotty renders SVG elements efficiently. + +### Why These Are Required + +When you write JSX like ``, the TypeScript compiler needs to transform it into function calls. The pragma tells TypeScript to transform it to `svg('rect', { width: 100, height: 50 })` instead of the default `React.createElement('rect', { width: 100, height: 50 })`. + +Without the pragma and import: + +- ❌ Your JSX won't compile +- ❌ You'll get "Cannot find name 'React'" errors +- ❌ The virtual DOM nodes won't be created correctly + +### TypeScript Configuration + +Your `tsconfig.json` must have JSX support enabled. Add or verify these settings: + +```json +{ + "compilerOptions": { + "jsx": "react", + "experimentalDecorators": true + } +} +``` + +- **`"jsx": "react"`**: Enables JSX transformation (despite the name, this works for non-React uses too) +- **`"experimentalDecorators": true`**: Required for the `@injectable()` decorator used in view classes + +### File Extension Requirement + +View files that use JSX **must** have the `.tsx` extension, not `.ts`: + +- ✅ `views.tsx` - Correct +- ❌ `views.ts` - Will not compile + +> 💡 **Pro Tip**: Always copy the pragma and import as the first two lines of any new view file. This is easy to forget and will cause confusing compilation errors! + ## Creating Your First Custom View Let's create a custom view step by step: @@ -35,6 +87,8 @@ Let's create a custom view step by step: ### 1. Basic View Structure ```typescript +/** @jsx svg */ +import { svg } from 'sprotty/lib/lib/jsx'; import { injectable } from 'inversify'; import { VNode } from 'snabbdom'; import { IView, RenderingContext, SNodeImpl, IViewArgs } from 'sprotty'; @@ -81,6 +135,8 @@ export class CustomNodeView implements IView { Sprotty uses TSX (TypeScript + JSX) for defining SVG elements. This allows you to write SVG declaratively with TypeScript expressions. +> 📖 **Note**: If you haven't already, make sure you've set up the JSX pragma and imports as explained in the [Setting Up JSX for Custom Views](#setting-up-jsx-for-custom-views) section above. Without these, the JSX syntax shown below won't work. + ### CSS Classes Use Sprotty's convenient class syntax for dynamic styling (which gets transformed into Snabbdom's class module format):