-
Notifications
You must be signed in to change notification settings - Fork 12
PosterBoard step 2: render cards at grid positions #5560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3d9f743
4cce5d2
b9afbdd
685dc51
67373cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,67 @@ | ||
| import { CardDef, Component } from 'https://cardstack.com/base/card-api'; | ||
| import { | ||
| CardDef, | ||
| Component, | ||
| FieldDef, | ||
| field, | ||
| contains, | ||
| containsMany, | ||
| getRelationshipMembershipState, | ||
| linksToMany, | ||
| } from '@cardstack/base/card-api'; | ||
| import NumberField from '@cardstack/base/number'; | ||
| import { tracked } from '@glimmer/tracking'; | ||
| import { htmlSafe } from '@ember/template'; | ||
| import { on } from '@ember/modifier'; | ||
| import Modifier from 'ember-modifier'; | ||
| import { | ||
| searchEntryWireQueryFromQuery, | ||
| type RenderableSearchEntryLike, | ||
| type SearchEntryWireQuery, | ||
| } from '@cardstack/runtime-common'; | ||
| import { | ||
| BrokenLinkTemplate, | ||
| 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 +99,103 @@ class Isolated extends Component<typeof PosterBoard> { | |
| return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); | ||
| } | ||
|
|
||
| // ── Tile placement ───────────────────────────────────── | ||
|
|
||
| // The linked cards' reference URLs, read from the relationship membership | ||
| // state — a pure read that never triggers the lazy link load, so the board | ||
| // renders tiles without ever fetching the linked instances. Index-aligned | ||
| // with the cards slots; only a `not-set` slot lacks a reference. | ||
| get linkedRefs(): (string | undefined)[] { | ||
| let owner = this.args.model as unknown as PosterBoard | undefined; | ||
| if (!owner) { | ||
| return []; | ||
| } | ||
| let { membership } = getRelationshipMembershipState(owner, 'cards'); | ||
| return (membership ?? []).map((slot) => slot.reference); | ||
| } | ||
|
|
||
| get tilePlacements(): TilePlacement[] { | ||
| let settings = this.args.model?.frameSettings ?? []; | ||
| return this.linkedRefs.map((_ref, 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; | ||
| } | ||
|
|
||
| // Tiles render as prerendered fitted HTML addressed by the linked cards' | ||
| // URLs. `fitted` is bound through `htmlQuery` — a bare `eq.format` would be | ||
| // read as an `item.` field path and rejected. Instance index rows key on | ||
| // the `.json` file URL, and `scope: 'cards'` drops each card's dual-indexed | ||
| // file row. Undefined (no linked cards) leaves the search idle. | ||
| get tilesQuery(): SearchEntryWireQuery | undefined { | ||
| let refs = this.linkedRefs.filter( | ||
| (ref): ref is string => ref !== undefined, | ||
| ); | ||
| if (refs.length === 0) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| ...searchEntryWireQueryFromQuery({}, { scope: 'cards' }), | ||
| cardUrls: refs.map((ref) => `${ref}.json`), | ||
| filter: { eq: { htmlQuery: { eq: { format: 'fitted' } } } }, | ||
| }; | ||
| } | ||
|
|
||
| // Results come back in engine order, not linked order, so each tile finds | ||
| // its own entry by reference URL (`entry.id` is the extensionless card id). | ||
| entryFor = ( | ||
| index: number, | ||
| entries: RenderableSearchEntryLike[], | ||
| ): RenderableSearchEntryLike | undefined => { | ||
| let ref = this.linkedRefs[index]; | ||
| return ref ? entries.find((entry) => entry.id === ref) : undefined; | ||
| }; | ||
|
|
||
| // Empty string (a `not-set` slot) is falsy, so the template's `{{#if ref}}` | ||
| // guard skips the placeholder for slots with nothing to point at — glint | ||
| // doesn't narrow in templates, so the fallback keeps the type `string`. | ||
| refAt = (index: number): string => this.linkedRefs[index] ?? ''; | ||
|
|
||
| // Terminal failures (error / not-found) per cards slot, index-aligned with | ||
| // tilePlacements. Since the board never loads its links, membership | ||
| // normally reports `not-loaded`; broken kinds surface here when the links | ||
| // were loaded elsewhere (e.g. the edit format), bringing the real errorDoc | ||
| // with them. Slots whose entry never arrives fall back to the synthesized | ||
| // not-found placeholder below. | ||
| brokenSlotAt = (index: number) => { | ||
| let owner = this.args.model as unknown as PosterBoard | undefined; | ||
| if (!owner) { | ||
| return undefined; | ||
| } | ||
| let { membership } = getRelationshipMembershipState(owner, 'cards'); | ||
| let rel = (membership ?? [])[index]; | ||
| return rel && (rel.kind === 'error' || rel.kind === 'not-found') | ||
| ? rel | ||
| : undefined; | ||
| }; | ||
|
Comment on lines
+175
to
+185
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Non-blocking (cleanup). To be fair to the design: Glimmer's per-reference caching keeps this off the pan/zoom hot path — these getters don't read rig state, so a pan frame doesn't recompute them — so it isn't a per-frame cost. It's the same array rebuilt several times per membership change, and it splits "read the cards' membership" across two call sites that must stay in sync. Consider one source of truth, e.g. Generated by Claude Code |
||
|
|
||
| // A card can lack an index entry entirely (deleted target, unsaved link, a | ||
| // reference outside the searched realms) — the wire document simply omits | ||
| // it, leaving no errorDoc to thread through. | ||
| missingEntryErrorDoc = { | ||
| status: 404, | ||
| title: 'Not Found', | ||
| message: 'This card has no entry in the search index', | ||
| }; | ||
|
|
||
| tileStyle = (tile: TilePlacement) => | ||
| htmlSafe(`left: ${tile.x}px; top: ${tile.y}px;`); | ||
|
|
||
| // ── Wheel ────────────────────────────────────────────── | ||
|
|
||
| handleWheel = (event: Event) => { | ||
|
|
@@ -63,7 +216,10 @@ class Isolated extends Component<typeof PosterBoard> { | |
| return; | ||
| } | ||
| const target = event.target as HTMLElement; | ||
| if (target.closest('[data-poster-board-hud]')) { | ||
| // Pointers that start on the HUD or inside a card tile are not pans: | ||
| // capturing them would break the tile's own focus/selection behavior | ||
| // (and tile pointerdown becomes drag-to-move in step 3) | ||
| if (target.closest('[data-poster-board-hud], [data-poster-board-tile]')) { | ||
| return; | ||
| } | ||
| this.panSession = this.surfaceRig.startPan(event.clientX, event.clientY); | ||
|
|
@@ -168,25 +324,78 @@ class Isolated extends Component<typeof PosterBoard> { | |
| <div | ||
| class='poster-board-root' | ||
| style={{this.rootStyle}} | ||
| role='application' | ||
| aria-label='Poster board canvas' | ||
| tabindex='0' | ||
| {{OnInsert this.handleInserted}} | ||
| {{on 'wheel' this.handleWheel}} | ||
| {{on 'pointerdown' this.handlePointerDown}} | ||
| {{on 'pointermove' this.handlePointerMove}} | ||
| {{on 'pointerup' this.handlePointerUp}} | ||
| {{on 'pointercancel' this.handlePointerUp}} | ||
| {{on 'keydown' this.handleKeyDown}} | ||
| role='application' | ||
| aria-label='Poster board canvas' | ||
| tabindex='0' | ||
| data-test-poster-board | ||
| > | ||
| <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 or drag to pan · Pinch or | ||
| Shift + / Shift - to zoom</p> | ||
| </header> | ||
| {{#let (component @context.searchResultsComponent) as |SearchResults|}} | ||
| {{! Overlays default on: each tile registers with the operator-mode | ||
| overlay layer, giving it the standard hover chrome (type chip, | ||
| options menu, selection, click-to-open) anchored to the tile. }} | ||
| <SearchResults @query={{this.tilesQuery}} @mode='none' as |results|> | ||
| {{#each this.tilePlacements key='index' as |tile|}} | ||
| <FittedCardContainer | ||
| @size='cardsgrid-tile' | ||
| @style={{this.tileStyle tile}} | ||
| class='poster-board-tile' | ||
| data-poster-board-tile | ||
| data-test-poster-board-tile={{tile.index}} | ||
| > | ||
| {{#let (this.brokenSlotAt tile.index) as |broken|}} | ||
| {{#if broken}} | ||
| <BrokenLinkTemplate | ||
| @brokenUrl={{broken.reference}} | ||
| @errorDoc={{broken.errorDoc}} | ||
| @state={{broken.kind}} | ||
| @format='fitted' | ||
| data-test-poster-board-broken-tile={{tile.index}} | ||
| /> | ||
| {{else}} | ||
| {{#let | ||
| (this.entryFor tile.index results.entries) | ||
| as |entry| | ||
| }} | ||
| {{#if entry}} | ||
| <entry.component class='poster-board-tile-card' /> | ||
| {{else}} | ||
| {{#unless results.isLoading}} | ||
| {{#let (this.refAt tile.index) as |ref|}} | ||
| {{#if ref}} | ||
| <BrokenLinkTemplate | ||
| @brokenUrl={{ref}} | ||
| @errorDoc={{this.missingEntryErrorDoc}} | ||
| @state='not-found' | ||
| @format='fitted' | ||
| data-test-poster-board-broken-tile={{tile.index}} | ||
| /> | ||
| {{/if}} | ||
| {{/let}} | ||
| {{/unless}} | ||
| {{/if}} | ||
| {{/let}} | ||
| {{/if}} | ||
| {{/let}} | ||
| </FittedCardContainer> | ||
| {{/each}} | ||
| </SearchResults> | ||
| {{/let}} | ||
| {{#unless this.hasCards}} | ||
| <header class='poster-board-hint'> | ||
| <h1 class='poster-board-hint-title'><@fields.cardTitle /></h1> | ||
| <p class='poster-board-hint-line'>Scroll or drag to pan · Pinch or | ||
| Shift + / Shift - to zoom</p> | ||
| </header> | ||
| {{/unless}} | ||
| </div> | ||
|
|
||
| <div | ||
|
|
@@ -261,6 +470,24 @@ class Isolated extends Component<typeof PosterBoard> { | |
| will-change: transform; | ||
| } | ||
|
|
||
| .poster-board-tile { | ||
| position: absolute; | ||
| } | ||
|
|
||
| /* Tile content is display-only — the overlay layer owns hover/click. | ||
| The overlay binds its listeners to the card's root element, so that | ||
| element must keep receiving pointer events; only its descendants opt | ||
| out, so links/buttons in the card never intercept a click and text | ||
| never starts a selection. `user-select` resolves through the parent, | ||
| so setting it once on the root covers the subtree. */ | ||
| .poster-board-tile-card { | ||
| user-select: none; | ||
| } | ||
|
|
||
| .poster-board-tile-card :deep(*) { | ||
| pointer-events: none; | ||
| } | ||
|
|
||
| .poster-board-grid { | ||
| position: absolute; | ||
| inset: calc(var(--pb-grid-extent) / -2); | ||
|
|
@@ -361,5 +588,8 @@ export class PosterBoard extends CardDef { | |
| static icon = LayoutDashboardIcon; | ||
| static prefersWideFormat = true; | ||
|
|
||
| @field cards = linksToMany(() => CardDef); | ||
| @field frameSettings = containsMany(FrameSettingsField); | ||
|
|
||
| static isolated = Isolated; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Non-blocking (design note, relevant to step 3). Frame settings are matched to cards by array index (
Number(s.cardIndex) === index, whereindexis the position in thecardslinksToMany). That means removing or reordering a linked card silently reassigns every persisted position after it to a different card — the settings don't move with the card they were authored for.Step 3 (drag-to-move) will start writing real positions into these records, so the index-keyed model becomes load-bearing exactly when a mismatch is most visible (a user drags card A, later removes an earlier card, and card A's saved position now belongs to card B). Worth considering keying each
FrameSettingsFieldby the linked card's reference/id instead of its slot index, so a position tracks its card across insert/remove/reorder. Not this PR's blocker — flagging now because step 3 builds directly on this shape.Generated by Claude Code