From ff5dc9b9f99c43e31a0aaeb568e1ded6133eee1d Mon Sep 17 00:00:00 2001 From: Akib Date: Mon, 14 Sep 2026 10:26:20 +0600 Subject: [PATCH] feat(demo): code and config views on every example, and drop picsum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StackBlitz starter pulled its images from picsum.photos, and they were not rendering. Verified in a real Chromium against the built starter: 13 failed image requests before, zero after. The fix is what the demo already did — generate the image as an inline SVG data URI. An external image host makes the example depend on someone else's uptime, rate limits and CORS policy, and when it is slow the first thing a visitor sees is an empty grid. Data URIs render instantly, work offline, and still exercise the decode path the grid waits on. Every demo example now sits in a shared frame with a Config toggle, a Code toggle and a copy button. The preview is deliberately never hidden: swapping it out for a tab would take the grid to display:none, where it measures zero and lays out again on the way back — a flicker caused by the demo, in the one place a visitor is judging whether the layout is steady. The panels open above a preview that stays mounted, so the code and the result are readable together. Checked in Chromium across all five examples: both toggles, the copy button against real clipboard contents, and item counts unchanged after toggling (24, 22, 20, 15, 600). No console errors. --- examples/stackblitz/src/main.ts | 27 ++- projects/demo/src/app/app.css | 15 +- projects/demo/src/app/example-frame.ts | 149 ++++++++++++ projects/demo/src/app/examples/dashboard.ts | 169 ++++++++------ projects/demo/src/app/examples/dynamic.ts | 75 +++--- projects/demo/src/app/examples/gallery.ts | 214 ++++++++++-------- projects/demo/src/app/examples/performance.ts | 178 ++++++++------- projects/demo/src/app/examples/spans.ts | 152 +++++++------ 8 files changed, 627 insertions(+), 352 deletions(-) create mode 100644 projects/demo/src/app/example-frame.ts diff --git a/examples/stackblitz/src/main.ts b/examples/stackblitz/src/main.ts index 07169fe..bcc0d9a 100644 --- a/examples/stackblitz/src/main.ts +++ b/examples/stackblitz/src/main.ts @@ -22,11 +22,36 @@ interface Photo { /** Deterministic sizes, so the grid looks the same every time it boots. */ const HEIGHTS = [300, 520, 260, 440, 310, 580, 350, 420, 280, 500, 330, 460]; +/** + * A generated image, as a data URI. + * + * Deliberately not a photo service. An external image host makes the example + * depend on someone else's uptime, rate limits and CORS policy — and when it is + * slow, the very first thing a visitor sees is an empty grid. These render + * instantly, work offline, and still exercise the decode path the grid waits on. + */ +function artwork(id: number, width: number, height: number): string { + const hue = (id * 47) % 360; + const svg = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + function makePhoto(id: number): Photo { const height = HEIGHTS[id % HEIGHTS.length]; return { id, - url: `https://picsum.photos/seed/masonry-${id}/400/${height}`, + url: artwork(id, 400, height), title: `Photo ${id}`, width: 400, height, diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index 53dfbef..080607e 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -1,19 +1,12 @@ -:host { - display: block; - max-width: 1600px; - margin: 0 auto; - padding: 32px 24px 96px; -} - .masthead { display: flex; flex-wrap: wrap; gap: 20px; - align-items: flex-end; + align-items: center; justify-content: space-between; - padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--line); + padding: 10px 10px; } .masthead h1 { @@ -52,3 +45,7 @@ nav a.active { color: var(--accent); font-weight: 600; } + +main { + padding: 20px 10px; +} diff --git a/projects/demo/src/app/example-frame.ts b/projects/demo/src/app/example-frame.ts new file mode 100644 index 0000000..662b83a --- /dev/null +++ b/projects/demo/src/app/example-frame.ts @@ -0,0 +1,149 @@ +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; + +type Panel = 'none' | 'config' | 'code'; + +/** + * The shell every example sits in: a description, a toggle for the controls, a + * toggle for the source, and a copy button. + * + * The preview is never hidden. Tabs that swap the grid out would take it to + * `display: none`, where it measures zero and has to lay out again on the way + * back — a flicker caused entirely by the demo, in the one place a visitor is + * judging whether the layout is steady. So the panels open *above* a preview + * that stays mounted, which also lets you read the code and watch the result at + * the same time. + */ +@Component({ + selector: 'demo-example', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ +
+ + + + @if (panel() === 'code') { + + } +
+ + +
+ +
+ + @if (panel() === 'code') { +
{{ code() }}
+ } + + + `, + styles: ` + :host { + display: block; + } + + .toolbar { + display: flex; + gap: 6px; + align-items: center; + margin: 0 0 14px; + } + + .toolbar button { + padding: 5px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--muted); + font: inherit; + font-size: 0.82rem; + cursor: pointer; + } + + .toolbar button:hover { + color: var(--text); + } + + .toolbar button.on { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; + } + + .toolbar .copy { + margin-left: auto; + } + + .code { + margin: 0 0 18px; + padding: 14px 16px; + max-height: 420px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + font-size: 0.8rem; + line-height: 1.55; + tab-size: 2; + } + + .controls { + margin-bottom: 18px; + } + `, +}) +export class DemoExample { + /** The snippet shown under "Code", and what the copy button puts on the clipboard. */ + readonly code = input.required(); + + readonly panel = signal('none'); + readonly copied = signal(false); + + toggle(which: Panel): void { + this.panel.update((current) => (current === which ? 'none' : which)); + } + + async copy(): Promise { + try { + await navigator.clipboard.writeText(this.code()); + } catch { + // Clipboard access is refused in some embedded contexts (an iframe + // without permission, or plain http). Fall back to a selection the + // visitor can copy with the keyboard rather than failing silently. + const area = document.createElement('textarea'); + area.value = this.code(); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.append(area); + area.select(); + try { + document.execCommand('copy'); + } finally { + area.remove(); + } + } + this.copied.set(true); + setTimeout(() => this.copied.set(false), 1600); + } +} diff --git a/projects/demo/src/app/examples/dashboard.ts b/projects/demo/src/app/examples/dashboard.ts index 8a72966..9036efe 100644 --- a/projects/demo/src/app/examples/dashboard.ts +++ b/projects/demo/src/app/examples/dashboard.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { WIDGETS } from './dashboard-data'; @@ -19,91 +20,111 @@ import { DashboardWidget } from './dashboard-widget'; */ @Component({ selector: 'dashboard-example', - imports: [NG_MASONRY_GRID, DashboardWidget], + imports: [NG_MASONRY_GRID, DashboardWidget, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Twenty tiles, each a static size: height in pixels, width in whole columns via - masonryColSpan. No body is rendered up front — every one sits behind - @defer (on viewport) and loads as its placeholder scrolls into view. Because - the tile already owns its height, the swap costs no relayout: the grid positioned the board - from the skeletons, and the real content lands in exactly the same box. -

+ +

+ Twenty tiles, each a static size: height in pixels, width in whole columns via + masonryColSpan. No body is rendered up front — every one sits behind + @defer (on viewport) and loads as its placeholder scrolls into view. + Because the tile already owns its height, the swap costs no relayout: the grid positioned + the board from the skeletons, and the real content lands in exactly the same box. +

-

- Tiles are wider than article cards, so this board also redefines what the breakpoint names - mean — breakpoints: { sm: 620, md: 980, … } — while - columns still reads as { xs: 1, sm: 2, md: 3, … }. The - override merges over the defaults and is scoped to this grid. -

+

+ Tiles are wider than article cards, so this board also redefines what the breakpoint names + mean — breakpoints: { sm: 620, md: 980, … } — while + columns still reads as { xs: 1, sm: 2, md: 3, … }. The + override merges over the defaults and is scoped to this grid. +

-
-
- packing - -
+
- - @for (widget of widgets; track widget.id) { -
-
-

{{ widget.title }}

- {{ widget.span }} col · {{ widget.height }}px -
+ + @for (widget of widgets; track widget.id) { +
+
+

{{ widget.title }}

+ {{ widget.span }} col · {{ widget.height }}px +
- - @defer (on viewport) { - - } @placeholder (minimum 400ms) { - - } -
- } -
+ + @defer (on viewport) { + + } @placeholder (minimum 400ms) { + + } +
+ } +
+
`, }) export class DashboardExample { + readonly code = ` + + @for (w of widgets(); track w.id) { +
+

{{ w.title }}

+ @defer (on viewport) { + + } @placeholder { +
+ } +
+ } +
`; + readonly widgets = WIDGETS; readonly horizontalOrder = signal(false); diff --git a/projects/demo/src/app/examples/dynamic.ts b/projects/demo/src/app/examples/dynamic.ts index e273421..069731b 100644 --- a/projects/demo/src/app/examples/dynamic.ts +++ b/projects/demo/src/app/examples/dynamic.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { makeCards, type DemoCard } from './cards'; @@ -15,41 +16,59 @@ const OPTIONS: MasonryGridOptions = { */ @Component({ selector: 'dynamic-example', - imports: [NG_MASONRY_GRID], + imports: [NG_MASONRY_GRID, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Every button below mutates the source array and nothing else. Item order is derived from the - DOM at layout time, so a prepend really lands first — the ordering bug that makes other - masonry wrappers expose a manual reloadItems() cannot occur here. -

+ +

+ Every button below mutates the source array and nothing else. Item order is derived from the + DOM at layout time, so a prepend really lands first — the ordering bug that makes other + masonry wrappers expose a manual reloadItems() cannot occur here. +

-
-
- items — {{ cards().length }} - - - - - - - -
-
+
+
+ items — {{ cards().length }} + + + + + + + +
+
- - @for (card of cards(); track card.id) { -
-
-

{{ card.title }}

-

{{ card.body }}

-
-
- } -
+ + @for (card of cards(); track card.id) { +
+
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+
`, }) export class DynamicExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
+ {{ card.title }} +
+ } +
+ + +add() { this.cards.update((c) => [...c, makeCard(this.next++)]); } +prepend() { this.cards.update((c) => [makeCard(this.next++), ...c]); } +remove(id: number) { this.cards.update((c) => c.filter((x) => x.id !== id)); }`; + readonly options = OPTIONS; readonly cards = signal(makeCards(15)); diff --git a/projects/demo/src/app/examples/gallery.ts b/projects/demo/src/app/examples/gallery.ts index 811cadd..f2a1c24 100644 --- a/projects/demo/src/app/examples/gallery.ts +++ b/projects/demo/src/app/examples/gallery.ts @@ -1,5 +1,6 @@ import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions, type MasonryLayoutEvent } from 'masonry-angular'; import { artwork, makeCards, type DemoCard } from './cards'; @@ -12,111 +13,136 @@ type SizingMode = 'breakpoints' | 'fixed' | 'columnWidth'; */ @Component({ selector: 'gallery-example', - imports: [NG_MASONRY_GRID, DecimalPipe], + imports: [NG_MASONRY_GRID, DecimalPipe, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Column count follows the container width through a breakpoint map — - { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, named against the default - scale, and matched against this panel rather than the viewport. Cards with images are held - back until decode() resolves, so they never land at the wrong height and shove - their neighbours around. -

+ +

+ Column count follows the container width through a breakpoint map — + { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, named against the default + scale, and matched against this panel rather than the viewport. Cards with images are held + back until decode() resolves, so they never land at the wrong height and shove + their neighbours around. +

-
-
- sizing - - - -
- -
- gutter — {{ gutter() }}px - -
+
+
+ sizing + + + +
-
- entry animation -
- @if (stats(); as stat) { -
-
-
columns
-
{{ stat.columns }}
-
-
-
column width
-
{{ stat.columnWidth | number: '1.0-0' }}px
-
-
-
items
-
{{ stat.itemCount }}
-
-
-
pass
-
{{ stat.durationMs | number: '1.2-2' }}ms
-
-
- } -
+
+ entry animation + + +
- - @for (card of cards(); track card.id) { -
- @if (card.image) { - - } -
-

{{ card.title }}

-

{{ card.body }}

-
-
- } -
+ @if (stats(); as stat) { +
+
+
columns
+
{{ stat.columns }}
+
+
+
column width
+
{{ stat.columnWidth | number: '1.0-0' }}px
+
+
+
items
+
{{ stat.itemCount }}
+
+
+
pass
+
{{ stat.durationMs | number: '1.2-2' }}ms
+
+
+ } +
+ + + @for (card of cards(); track card.id) { +
+ @if (card.image) { + + } +
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+
`, }) export class GalleryExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
+ @if (card.image) { + + + } +
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+ + +{ + columns: { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, + gutter: 16, + entryAnimation: {}, +}`; + readonly cards = signal(makeCards(24)); readonly sizing = signal('breakpoints'); readonly gutter = signal(16); diff --git a/projects/demo/src/app/examples/performance.ts b/projects/demo/src/app/examples/performance.ts index b21a54f..4765011 100644 --- a/projects/demo/src/app/examples/performance.ts +++ b/projects/demo/src/app/examples/performance.ts @@ -1,5 +1,6 @@ import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions, type MasonryLayoutEvent } from 'masonry-angular'; interface Tile { @@ -22,94 +23,115 @@ function makeTiles(count: number): Tile[] { */ @Component({ selector: 'performance-example', - imports: [NG_MASONRY_GRID, DecimalPipe], + imports: [NG_MASONRY_GRID, DecimalPipe, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Solve time is measured across the whole pass — reading measurements, running the solver and - writing every transform. Sizes arrive pre-computed from one shared - ResizeObserver, so a pass performs no forced reflow, and repeated passes with - identical input return before touching the DOM. Turn on contentVisibility to let - the browser skip rendering off-screen tiles. -

+ +

+ Solve time is measured across the whole pass — reading measurements, running the solver and + writing every transform. Sizes arrive pre-computed from one shared + ResizeObserver, so a pass performs no forced reflow, and repeated passes with + identical input return before touching the DOM. Turn on contentVisibility to + let the browser skip rendering off-screen tiles. +

-
-
- tiles — {{ count() }} - -
- -
- options - -
- @if (stats(); as stat) { -
-
-
columns
-
{{ stat.columns }}
-
-
-
items
-
{{ stat.itemCount }}
-
-
-
height
-
{{ stat.height | number: '1.0-0' }}px
-
-
-
last pass
-
{{ stat.durationMs | number: '1.2-2' }}ms
-
-
-
passes
-
{{ stat.pass }}
-
-
- } -
+
+ options + + + +
- - @for (tile of tiles(); track tile.id) { -
- {{ tile.id }} -
- } -
+ @if (stats(); as stat) { +
+
+
columns
+
{{ stat.columns }}
+
+
+
items
+
{{ stat.itemCount }}
+
+
+
height
+
{{ stat.height | number: '1.0-0' }}px
+
+
+
last pass
+
{{ stat.durationMs | number: '1.2-2' }}ms
+
+
+
passes
+
{{ stat.pass }}
+
+
+ } + + + + @for (tile of tiles(); track tile.id) { +
+ {{ tile.id }} +
+ } +
+
`, }) export class PerformanceExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
{{ card.title }}
+ } +
`; + readonly count = signal(600); readonly contentVisibility = signal(true); readonly transitions = signal(true); diff --git a/projects/demo/src/app/examples/spans.ts b/projects/demo/src/app/examples/spans.ts index 8b0df33..c4d5386 100644 --- a/projects/demo/src/app/examples/spans.ts +++ b/projects/demo/src/app/examples/spans.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { makeCards, type DemoCard } from './cards'; @@ -9,84 +10,99 @@ import { makeCards, type DemoCard } from './cards'; */ @Component({ selector: 'spans-example', - imports: [NG_MASONRY_GRID], + imports: [NG_MASONRY_GRID, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- masonryColSpan widens an item across whole columns; the solver drops it into the - group of columns with the lowest shared top edge. A masonryGridStamp element is - positioned by you and treated as an obstacle. horizontalOrder switches from - shortest-column packing to strict row order. -

+ +

+ masonryColSpan widens an item across whole columns; the solver drops it into + the group of columns with the lowest shared top edge. A + masonryGridStamp element is positioned by you and treated as an obstacle. + horizontalOrder switches from shortest-column packing to strict row order. +

-
-
- placement - - -
+
-
- wide cards — every {{ spanEvery() }}th - -
- + + @if (stamped()) { + + } - - @if (stamped()) { - - } - - @for (card of cards(); track card.id) { -
-
-

{{ card.title }}

- @if (spanOf(card) > 1) { - spans {{ spanOf(card) }} columns - } -
-
- } -
+ @for (card of cards(); track card.id) { +
+
+

{{ card.title }}

+ @if (spanOf(card) > 1) { + spans {{ spanOf(card) }} columns + } +
+
+ } +
+
`, }) export class SpansExample { + readonly code = ` + + + + @for (card of cards(); track card.id) { +
+ {{ card.title }} +
+ } +
`; + readonly cards = signal(makeCards(22)); readonly horizontalOrder = signal(false); readonly rtl = signal(false);