diff --git a/poster-board/PosterBoard/demo-poster-board.json b/poster-board/PosterBoard/demo-poster-board.json index 8c5e5c4..6048686 100644 --- a/poster-board/PosterBoard/demo-poster-board.json +++ b/poster-board/PosterBoard/demo-poster-board.json @@ -4,6 +4,45 @@ "attributes": { "cardInfo": { "name": "Demo Poster Board" + }, + "frameSettings": [ + { + "cardIndex": 3, + "x": 460, + "y": 340 + }, + { + "cardIndex": 4, + "x": 680, + "y": 90 + } + ] + }, + "relationships": { + "cards.0": { + "links": { + "self": "../../4b6602-wine-cellar-card-definition/WineBottle/011f0239-9a66-405f-a874-32bb4146ee7b" + } + }, + "cards.1": { + "links": { + "self": "../../d29736-tier-list/TierList/my-language-ranking" + } + }, + "cards.2": { + "links": { + "self": "../../blog-app/games/Wordle/play" + } + }, + "cards.3": { + "links": { + "self": "../../673fb6-blackjack-cardgame-definition/Blackjack/be0fd885-7672-4379-a001-ea8611da2ec2" + } + }, + "cards.4": { + "links": { + "self": "../../4b6602-wine-cellar-card-definition/WineBottle/7bc1c7b6-f051-4a45-aaba-8f41b62eed0d" + } } }, "meta": { diff --git a/poster-board/poster-board.gts b/poster-board/poster-board.gts index 587c07e..7eedd8d 100644 --- a/poster-board/poster-board.gts +++ b/poster-board/poster-board.gts @@ -1,11 +1,59 @@ -import { CardDef, Component } from 'https://cardstack.com/base/card-api'; +import { + CardDef, + Component, + FieldDef, + field, + contains, + containsMany, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; import { tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; +import { get } from '@ember/helper'; import { on } from '@ember/modifier'; import Modifier from 'ember-modifier'; +import { FittedCardContainer } from '@cardstack/boxel-ui/components'; +import { fittedFormatById } from '@cardstack/boxel-ui/helpers'; import LayoutDashboardIcon from '@cardstack/boxel-icons/layout-dashboard'; import { RigState, SurfaceRig, type PanSession } from './rig'; +// Tiles use the shared cardsgrid-tile fitted size so boards show cards at a +// size their fitted views are designed for. FittedCardContainer applies the +// dimensions; these constants drive the grid placement math. +const cardsgridTile = fittedFormatById.get('cardsgrid-tile')!; +const TILE_WIDTH = cardsgridTile.width; +const TILE_HEIGHT = cardsgridTile.height; +const TILE_GAP = 32; +const GRID_COLUMNS = 4; +// Breathing room between the world origin and the default grid (~--boxel-sp-xs) +const GRID_PADDING = 10; + +interface TilePlacement { + index: number; + x: number; + y: number; +} + +// Cards without a persisted frame setting flow into a fixed grid. +function defaultPlacement(index: number): TilePlacement { + return { + index, + x: GRID_PADDING + (index % GRID_COLUMNS) * (TILE_WIDTH + TILE_GAP), + y: + GRID_PADDING + + Math.floor(index / GRID_COLUMNS) * (TILE_HEIGHT + TILE_GAP), + }; +} + +export class FrameSettingsField extends FieldDef { + static displayName = 'Frame Settings'; + + @field cardIndex = contains(NumberField); + @field x = contains(NumberField); + @field y = contains(NumberField); +} + interface OnInsertSignature { Element: HTMLElement; Args: { @@ -43,6 +91,30 @@ class Isolated extends Component { return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); } + // ── Tile placement ───────────────────────────────────── + + get tilePlacements(): TilePlacement[] { + let cards = this.args.model?.cards ?? []; + let settings = this.args.model?.frameSettings ?? []; + return cards.map((_card, index) => { + let setting = settings.find((s) => Number(s.cardIndex) === index); + // Number() guards against non-numeric values in hand-edited JSON + let x = Number(setting?.x); + let y = Number(setting?.y); + if (setting && Number.isFinite(x) && Number.isFinite(y)) { + return { index, x, y }; + } + return defaultPlacement(index); + }); + } + + get hasCards() { + return this.tilePlacements.length > 0; + } + + tileStyle = (tile: TilePlacement) => + htmlSafe(`left: ${tile.x}px; top: ${tile.y}px;`); + // ── Wheel ────────────────────────────────────────────── handleWheel = (event: Event) => { @@ -159,11 +231,25 @@ class Isolated extends Component { >
-
-

<@fields.cardTitle />

-

Scroll to pan · ⌘ or Ctrl + scroll - to zoom · Drag to pan

-
+ {{#each this.tilePlacements key='index' as |tile|}} + + {{#let (get @fields.cards tile.index) as |LinkedCard|}} + + {{/let}} + + {{/each}} + {{#unless this.hasCards}} +
+

<@fields.cardTitle />

+

Scroll to pan · ⌘ or Ctrl + scroll + to zoom · Drag to pan

+
+ {{/unless}}
{ will-change: transform; } + .poster-board-tile { + position: absolute; + } + .poster-board-grid { position: absolute; inset: calc(var(--pb-grid-extent) / -2); @@ -329,5 +419,8 @@ export class PosterBoard extends CardDef { static icon = LayoutDashboardIcon; static prefersWideFormat = true; + @field cards = linksToMany(() => CardDef); + @field frameSettings = containsMany(FrameSettingsField); + static isolated = Isolated; } diff --git a/poster-board/poster-board.test.gts b/poster-board/poster-board.test.gts index d3e2220..d895afe 100644 --- a/poster-board/poster-board.test.gts +++ b/poster-board/poster-board.test.gts @@ -4,11 +4,15 @@ import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { setupBaseRealm } from '@cardstack/host/tests/helpers/base-realm'; +import { saveCard, testRealmURL } from '@cardstack/host/tests/helpers'; +import { + CardDef, + setupBaseRealm, +} from '@cardstack/host/tests/helpers/base-realm'; import { renderCard } from '@cardstack/host/tests/helpers/render-component'; import { setupRenderingTest } from '@cardstack/host/tests/helpers/setup'; -import { PosterBoard } from './poster-board'; +import { FrameSettingsField, PosterBoard } from './poster-board'; async function renderPosterBoard() { let loader = getService('loader-service').loader; @@ -84,5 +88,46 @@ export function runTests() { .dom('[data-test-zoom-level]') .hasText('100%', 'zoom stays at 100% after momentum delay elapses'); }); + + test('poster-board renders linked cards at persisted and grid-default positions', async function (assert) { + let loader = getService('loader-service').loader; + + class Note extends CardDef { + static displayName = 'Note'; + } + loader.shimModule(`${testRealmURL}note`, { Note }); + + let note1 = new Note(); + let note2 = new Note(); + await saveCard(note1, `${testRealmURL}Note/1`, loader); + await saveCard(note2, `${testRealmURL}Note/2`, loader); + + let board = new PosterBoard({ + cards: [note1, note2], + frameSettings: [ + new FrameSettingsField({ cardIndex: 1, x: 500, y: 120 }), + ], + }); + await renderCard(loader, board, 'isolated'); + + assert + .dom('[data-test-poster-board-tile]') + .exists({ count: 2 }, 'a tile renders per linked card'); + assert + .dom('[data-test-poster-board-tile="0"]') + .hasStyle( + { left: '10px', top: '10px' }, + 'card without settings lands at its padded grid-default slot', + ); + assert + .dom('[data-test-poster-board-tile="1"]') + .hasStyle( + { left: '500px', top: '120px' }, + 'card with frame settings renders at its persisted position', + ); + assert + .dom('[data-test-poster-board] h1') + .doesNotExist('hint header is hidden when the board has cards'); + }); }); }