Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions poster-board/PosterBoard/demo-poster-board.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
105 changes: 99 additions & 6 deletions poster-board/poster-board.gts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -43,6 +91,30 @@ class Isolated extends Component<typeof PosterBoard> {
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) => {
Expand Down Expand Up @@ -159,11 +231,25 @@ class Isolated extends Component<typeof PosterBoard> {
>
<div class='poster-board-plane' style={{this.planeStyle}}>
<div class='poster-board-grid' aria-hidden='true'></div>
<header class='poster-board-hint'>
<h1 class='poster-board-hint-title'><@fields.cardTitle /></h1>
<p class='poster-board-hint-line'>Scroll to pan · ⌘ or Ctrl + scroll
to zoom · Drag to pan</p>
</header>
{{#each this.tilePlacements key='index' as |tile|}}
<FittedCardContainer
@size='cardsgrid-tile'
@style={{this.tileStyle tile}}
class='poster-board-tile'
data-test-poster-board-tile={{tile.index}}
>
{{#let (get @fields.cards tile.index) as |LinkedCard|}}
<LinkedCard @format='fitted' />
{{/let}}
</FittedCardContainer>
{{/each}}
{{#unless this.hasCards}}
<header class='poster-board-hint'>
<h1 class='poster-board-hint-title'><@fields.cardTitle /></h1>
<p class='poster-board-hint-line'>Scroll to pan · ⌘ or Ctrl + scroll
to zoom · Drag to pan</p>
</header>
{{/unless}}
</div>

<div
Expand Down Expand Up @@ -229,6 +315,10 @@ class Isolated extends Component<typeof PosterBoard> {
will-change: transform;
}

.poster-board-tile {
position: absolute;
}

.poster-board-grid {
position: absolute;
inset: calc(var(--pb-grid-extent) / -2);
Expand Down Expand Up @@ -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;
}
49 changes: 47 additions & 2 deletions poster-board/poster-board.test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});
}
Loading