From 0a997cd791930bad4db794c7e2e02a3b522980e2 Mon Sep 17 00:00:00 2001 From: Einar Date: Fri, 4 Sep 2026 08:53:33 +0200 Subject: [PATCH] Bring Canvas back, and give it the extent that identifies it Canvas was removed on the argument that FreeformArrangement already says what absolute placement is. It does not say the same thing: a FreeformArrangement arranges a layout *slot's* content with one variant per size class, while a Canvas places elements inside a single coordinate space in the element tree. They sit at different levels, and dropping the panel left the tree with nowhere for a free-placement design surface to land. The name is not a choice either - `@cratis/components` ships `Canvas`, and that is the surface a Cratis application actually draws one on. A designer placing components on a canvas and the model recording where they landed should not need two words for the same thing. The reason it could be dropped without anything noticing was real, though: the old Canvas declared no properties, so nothing could tell it from a plain Panel in a model that carries no discriminator. It now declares its `Extent` - the coordinate space a `Canvas.Left`/`Canvas.Top` pair is measured against - which is both a genuine canvas property and what `isCanvas` recognizes it by. `panelLayout` renders it: the canvas becomes the positioned ancestor its children are placed against, sized to its extent, and a child naming any of the four edges is placed absolutely. A child naming none stays in flow rather than being pinned to the origin. Verified: 5 .NET projects / 0 warnings / 111 specs, 931 JS specs (up from 923), lint and build clean, five Storybooks build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EGM8pbSNm2nS8KoKCpKNui --- Source/DotNET/Model/Elements/Panels/Canvas.cs | 39 +++++++++++++++++++ .../when_telling_the_panels_apart.ts | 11 ++++-- Source/JavaScript/engine/panelKind.ts | 6 ++- .../model/elements/panels/Canvas.ts | 21 ++++++++++ .../JavaScript/model/elements/panels/index.ts | 1 + .../for_panelLayout/when_arranging_a_panel.ts | 25 +++++++++++- .../JavaScript/react/renderer/panelLayout.ts | 23 ++++++++++- scene-model-shape.json | 3 ++ 8 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 Source/DotNET/Model/Elements/Panels/Canvas.cs create mode 100644 Source/JavaScript/model/elements/panels/Canvas.ts diff --git a/Source/DotNET/Model/Elements/Panels/Canvas.cs b/Source/DotNET/Model/Elements/Panels/Canvas.cs new file mode 100644 index 0000000..78a9e87 --- /dev/null +++ b/Source/DotNET/Model/Elements/Panels/Canvas.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Elements.Panels; + +/// +/// Places its children at absolute coordinates within a coordinate space of its own. A child states where +/// it sits through the Canvas.Left, Canvas.Top, Canvas.Right and Canvas.Bottom +/// keys of its , which is how an attached property is carried in a +/// model that has no attached properties of its own. +/// +/// +/// +/// Named for @cratis/components' Canvas, which is the surface a Cratis application actually +/// draws one on. The vocabulary is shared deliberately: a designer placing components on a canvas and the +/// model recording where they landed should not need two words for the same thing. +/// +/// +/// This is a panel in the element tree, and is not the same concept as a +/// - that arranges a layout slot's content, with one +/// placement variant per size class. A canvas places elements inside a single coordinate space, and is +/// what a free-placement design surface serializes to. +/// +/// +public record Canvas : Panel +{ + /// + /// Gets the size of the coordinate space children are placed in - the design surface a + /// Canvas.Left/Canvas.Top pair is measured against. + /// + /// + /// Either dimension left unspecified means the renderer decides, exactly as elsewhere. This is also + /// what tells a canvas apart from a plain : the model carries no discriminator, + /// so a panel is recognized by the property it alone declares. + /// + public Size Extent { get; init; } = new(); +} diff --git a/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts b/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts index 81cac86..840a1d6 100644 --- a/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts +++ b/Source/JavaScript/engine/for_panelKind/when_telling_the_panels_apart.ts @@ -1,8 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DockPanel, Grid, HorizontalAlignment, Orientation, Panel, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; -import { isDockPanel, isGrid, isStackPanel, isWrapPanel } from '../index'; +import { Canvas, DockPanel, Grid, HorizontalAlignment, Orientation, Panel, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; +import { isCanvas, isDockPanel, isGrid, isStackPanel, isWrapPanel } from '../index'; const base = { id: 'panel', @@ -21,6 +21,7 @@ const base = { children: [], }; +const canvas: Canvas = { ...base, extent: { width: 1280, height: 800 } }; const grid: Grid = { ...base, rows: [], columns: [] }; const dock: DockPanel = { ...base, lastChildFill: true }; const stack: StackPanel = { ...base, orientation: Orientation.Vertical, spacing: 0 }; @@ -28,6 +29,7 @@ const wrap: WrapPanel = { ...base, orientation: Orientation.Horizontal }; const plain: Panel = { ...base }; describe('when telling the panels apart', () => { + it('should recognize a canvas by its extent', () => isCanvas(canvas).should.be.true); it('should recognize a grid by its tracks', () => isGrid(grid).should.be.true); it('should recognize a dock panel by its fill flag', () => isDockPanel(dock).should.be.true); it('should recognize a stack panel by its spacing', () => isStackPanel(stack).should.be.true); @@ -36,7 +38,10 @@ describe('when telling the panels apart', () => { it('should not mistake a stack panel for a wrap panel', () => isWrapPanel(stack).should.be.false); it('should not mistake a wrap panel for a stack panel', () => isStackPanel(wrap).should.be.false); it('should not mistake a grid for a dock panel', () => isDockPanel(grid).should.be.false); + it('should not mistake a canvas for a grouping panel', () => isCanvas(plain).should.be.false); + it('should leave a canvas unclaimed by the arranging guards', () => + [isGrid(canvas), isDockPanel(canvas), isStackPanel(canvas), isWrapPanel(canvas)].should.have.members([false, false, false, false])); it('should leave a plain panel unclaimed by every guard', () => - [isGrid(plain), isDockPanel(plain), isStackPanel(plain), isWrapPanel(plain)].should.have.members([false, false, false, false])); + [isCanvas(plain), isGrid(plain), isDockPanel(plain), isStackPanel(plain), isWrapPanel(plain)].should.have.members([false, false, false, false, false])); }); diff --git a/Source/JavaScript/engine/panelKind.ts b/Source/JavaScript/engine/panelKind.ts index a8dd278..3b238b8 100644 --- a/Source/JavaScript/engine/panelKind.ts +++ b/Source/JavaScript/engine/panelKind.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DockPanel, Grid, Panel, StackPanel, WrapPanel } from '@cratis/scene.model'; +import { Canvas, DockPanel, Grid, Panel, StackPanel, WrapPanel } from '@cratis/scene.model'; /** * Type guards telling the concrete panels apart, by the property each one alone declares - the same way @@ -13,6 +13,10 @@ import { DockPanel, Grid, Panel, StackPanel, WrapPanel } from '@cratis/scene.mod * whatever way its platform considers neutral. */ +export function isCanvas(panel: Panel): panel is Canvas { + return 'extent' in panel; +} + export function isGrid(panel: Panel): panel is Grid { return 'rows' in panel && 'columns' in panel; } diff --git a/Source/JavaScript/model/elements/panels/Canvas.ts b/Source/JavaScript/model/elements/panels/Canvas.ts new file mode 100644 index 0000000..ce79d98 --- /dev/null +++ b/Source/JavaScript/model/elements/panels/Canvas.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Panel } from '../Panel'; +import { Size } from '../../common'; + +/** + * Places its children at absolute coordinates within a coordinate space of its own. A child states where + * it sits through the `Canvas.Left`, `Canvas.Top`, `Canvas.Right` and `Canvas.Bottom` keys of its + * `properties`, which is how an attached property is carried in a model that has no attached properties + * of its own. + * + * Named for `@cratis/components`' `Canvas`, which is the surface a Cratis application actually draws one + * on. This is a panel in the element tree, and is not the same concept as a `FreeformArrangement` - that + * arranges a *layout slot's* content with one placement variant per size class. + */ +export interface Canvas extends Panel { + extent: Size; +} + +export const CanvasPropertyNames: (keyof Canvas)[] = ['extent']; diff --git a/Source/JavaScript/model/elements/panels/index.ts b/Source/JavaScript/model/elements/panels/index.ts index a5a6f81..9f75788 100644 --- a/Source/JavaScript/model/elements/panels/index.ts +++ b/Source/JavaScript/model/elements/panels/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +export * from './Canvas'; export * from './StackPanel'; export * from './DockPanel'; export * from './WrapPanel'; diff --git a/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts b/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts index 09f4d29..687ae09 100644 --- a/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts +++ b/Source/JavaScript/react/for_panelLayout/when_arranging_a_panel.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ColumnDefinition, Dock, DockPanel, Grid, GridUnitType, HorizontalAlignment, Orientation, RowDefinition, SceneElement, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; +import { Canvas, ColumnDefinition, Dock, DockPanel, Grid, GridUnitType, HorizontalAlignment, Orientation, RowDefinition, SceneElement, StackPanel, VerticalAlignment, Visibility, WrapPanel } from '@cratis/scene.model'; import { childStyle, panelStyle } from '../renderer/panelLayout'; const base = { @@ -28,6 +28,29 @@ const row = (over: Partial = {}): RowDefinition => ({ height: sta const column = (over: Partial = {}): ColumnDefinition => ({ width: star(1), minimumWidth: 0, maximumWidth: Number.POSITIVE_INFINITY, ...over }); describe('when arranging a panel', () => { + it('should make a canvas the coordinate space its children are placed against', () => { + const panel: Canvas = { ...base, extent: {} }; + panelStyle(panel)!.position!.should.equal('relative'); + }); + + it('should size a canvas to its extent', () => { + const panel: Canvas = { ...base, extent: { width: 1280, height: 800 } }; + const style = panelStyle(panel)!; + [style.width, style.height].should.have.members([1280, 800]); + }); + + it('should place a canvas child at the edges it names', () => { + const panel: Canvas = { ...base, extent: {} }; + const style = childStyle(panel, child({ 'Canvas.Left': 40, 'Canvas.Top': 24 }), 0)!; + style.position!.should.equal('absolute'); + [style.left, style.top].should.have.members([40, 24]); + }); + + it('should leave a canvas child that names no edge in flow', () => { + const panel: Canvas = { ...base, extent: {} }; + (childStyle(panel, child(), 0) === undefined).should.be.true; + }); + it('should lay a stack panel out along its orientation', () => { const panel: StackPanel = { ...base, orientation: Orientation.Horizontal, spacing: 8 }; panelStyle(panel)!.flexDirection!.should.equal('row'); diff --git a/Source/JavaScript/react/renderer/panelLayout.ts b/Source/JavaScript/react/renderer/panelLayout.ts index d424339..16f1f7b 100644 --- a/Source/JavaScript/react/renderer/panelLayout.ts +++ b/Source/JavaScript/react/renderer/panelLayout.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { CSSProperties } from 'react'; -import { isDockPanel, isGrid, isStackPanel, isWrapPanel } from '@cratis/scene.engine'; +import { isCanvas, isDockPanel, isGrid, isStackPanel, isWrapPanel } from '@cratis/scene.engine'; import { Dock, GridUnitType, Orientation } from '@cratis/scene.model'; import type { ColumnDefinition, GridLength, Panel, RowDefinition, SceneElement } from '@cratis/scene.model'; @@ -39,6 +39,16 @@ const number = (value: unknown): number | undefined => (typeof value === 'number /** The CSS that arranges a panel's own children. */ export function panelStyle(panel: Panel): CSSProperties | undefined { + if (isCanvas(panel)) { + // Absolutely placed children need a positioned ancestor to be placed against, and the canvas is + // that ancestor rather than whatever happens to be above it. + return { + position: 'relative', + width: panel.extent?.width ?? undefined, + height: panel.extent?.height ?? undefined, + }; + } + if (isGrid(panel)) { return { display: 'grid', @@ -77,6 +87,17 @@ export function panelStyle(panel: Panel): CSSProperties | undefined { export function childStyle(panel: Panel, child: SceneElement, index: number): CSSProperties | undefined { const properties = child.properties ?? {}; + if (isCanvas(panel)) { + const left = number(properties['Canvas.Left']); + const top = number(properties['Canvas.Top']); + const right = number(properties['Canvas.Right']); + const bottom = number(properties['Canvas.Bottom']); + // A child that names no edge is not placed by the canvas at all, so it keeps whatever flow the + // renderer would otherwise have given it rather than being pinned to the origin. + if (left === undefined && top === undefined && right === undefined && bottom === undefined) return undefined; + return { position: 'absolute', left, top, right, bottom }; + } + if (isGrid(panel)) { const row = number(properties['Grid.Row']); const column = number(properties['Grid.Column']); diff --git a/scene-model-shape.json b/scene-model-shape.json index 222ecc1..67d7eae 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -56,6 +56,9 @@ "Panel": [ "children" ], + "Canvas": [ + "extent" + ], "ItemsControl": [ "itemsSource", "itemTemplate"