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"