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"