zone5 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,21 +5,32 @@
5
5
  import Zone5Single from './Zone5Single.svelte';
6
6
  import Zone5Wall from './Zone5Wall.svelte';
7
7
  import Zone5Waterfall from './Zone5Waterfall.svelte';
8
- import { DEFAULT_COLUMN_BREAKPOINTS } from './constants';
8
+ import Zone5Justified from './Zone5Justified.svelte';
9
+ import {
10
+ DEFAULT_COLUMN_BREAKPOINTS,
11
+ DEFAULT_TARGET_ROW_HEIGHT,
12
+ DEFAULT_GAP,
13
+ } from './constants';
9
14
  import type { ImageData } from './types';
10
15
 
11
16
  interface Props {
12
- columnBreakpoints?: { [key: number]: number };
13
17
  images: ImageData[];
14
- mode?: 'wall' | 'waterfall';
18
+ mode?: 'wall' | 'waterfall' | 'justified';
15
19
  nocaption?: boolean;
20
+ // Waterfall mode options
21
+ columnBreakpoints?: { [key: number]: number };
22
+ // Justified mode options
23
+ targetRowHeight?: number;
24
+ gap?: number;
16
25
  }
17
26
 
18
27
  let {
19
- columnBreakpoints = DEFAULT_COLUMN_BREAKPOINTS,
20
28
  images,
21
29
  mode = 'wall',
22
30
  nocaption = false,
31
+ columnBreakpoints = DEFAULT_COLUMN_BREAKPOINTS,
32
+ targetRowHeight = DEFAULT_TARGET_ROW_HEIGHT,
33
+ gap = DEFAULT_GAP,
23
34
  }: Props = $props();
24
35
 
25
36
  const imageStore = useImageRegistry();
@@ -71,4 +82,11 @@
71
82
  {images}
72
83
  onImageClick={imageStore ? handleImageClick : undefined}
73
84
  />
85
+ {:else if mode === 'justified'}
86
+ <Zone5Justified
87
+ {images}
88
+ {targetRowHeight}
89
+ {gap}
90
+ onImageClick={imageStore ? handleImageClick : undefined}
91
+ />
74
92
  {/if}
@@ -1,11 +1,13 @@
1
1
  import type { ImageData } from './types';
2
2
  interface Props {
3
+ images: ImageData[];
4
+ mode?: 'wall' | 'waterfall' | 'justified';
5
+ nocaption?: boolean;
3
6
  columnBreakpoints?: {
4
7
  [key: number]: number;
5
8
  };
6
- images: ImageData[];
7
- mode?: 'wall' | 'waterfall';
8
- nocaption?: boolean;
9
+ targetRowHeight?: number;
10
+ gap?: number;
9
11
  }
10
12
  declare const Zone5: import("svelte").Component<Props, {}, "">;
11
13
  type Zone5 = ReturnType<typeof Zone5>;
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ import Img from './Zone5Img.svelte';
3
+ import { DEFAULT_TARGET_ROW_HEIGHT, DEFAULT_GAP, PANORAMA_THRESHOLD } from './constants';
4
+ import type { ImageData } from './types';
5
+
6
+ interface Props {
7
+ images: ImageData[];
8
+ onImageClick?: (index: number) => void;
9
+ targetRowHeight?: number;
10
+ gap?: number;
11
+ }
12
+
13
+ let {
14
+ images,
15
+ onImageClick,
16
+ targetRowHeight = DEFAULT_TARGET_ROW_HEIGHT,
17
+ gap = DEFAULT_GAP,
18
+ }: Props = $props();
19
+
20
+ let containerWidth = $state(0);
21
+
22
+ interface JustifiedRow {
23
+ images: { image: ImageData; idx: number }[];
24
+ height: number;
25
+ }
26
+
27
+ /**
28
+ * Calculate the row height that makes all images fit the container width exactly.
29
+ * Formula: h = (containerWidth - totalGaps) / sum(aspectRatios)
30
+ */
31
+ function calculateRowHeight(
32
+ row: { image: ImageData; idx: number }[],
33
+ width: number,
34
+ gapSize: number,
35
+ ): number {
36
+ const aspectRatioSum = row.reduce((sum, item) => sum + item.image.properties.aspectRatio, 0);
37
+ const totalGapWidth = (row.length - 1) * gapSize;
38
+ const availableWidth = width - totalGapWidth;
39
+ return availableWidth / aspectRatioSum;
40
+ }
41
+
42
+ /**
43
+ * Calculate justified rows using a greedy algorithm.
44
+ * Images are added to rows until the row height drops to or below the target height.
45
+ */
46
+ let rows = $derived.by((): JustifiedRow[] => {
47
+ if (images.length === 0) {
48
+ return [];
49
+ }
50
+
51
+ // Use containerWidth if available, otherwise fall back to a default for SSR/testing
52
+ const effectiveWidth = containerWidth > 0 ? containerWidth : 1200;
53
+
54
+ const result: JustifiedRow[] = [];
55
+ let currentRow: { image: ImageData; idx: number }[] = [];
56
+
57
+ for (let i = 0; i < images.length; i++) {
58
+ const image = images[i];
59
+ const aspectRatio = image.properties.aspectRatio;
60
+
61
+ // Handle panoramas: if we have images in the current row, finalize it first
62
+ if (aspectRatio > PANORAMA_THRESHOLD && currentRow.length > 0) {
63
+ result.push({
64
+ images: [...currentRow],
65
+ height: calculateRowHeight(currentRow, effectiveWidth, gap),
66
+ });
67
+ currentRow = [];
68
+ }
69
+
70
+ currentRow.push({ image, idx: i });
71
+
72
+ // Panorama gets its own row with height capped at target
73
+ if (aspectRatio > PANORAMA_THRESHOLD) {
74
+ const panoramaHeight = Math.min(effectiveWidth / aspectRatio, targetRowHeight);
75
+ result.push({
76
+ images: [...currentRow],
77
+ height: panoramaHeight,
78
+ });
79
+ currentRow = [];
80
+ continue;
81
+ }
82
+
83
+ const rowHeight = calculateRowHeight(currentRow, effectiveWidth, gap);
84
+
85
+ // If row height is at or below target, finalize this row
86
+ if (rowHeight <= targetRowHeight) {
87
+ result.push({
88
+ images: [...currentRow],
89
+ height: rowHeight,
90
+ });
91
+ currentRow = [];
92
+ }
93
+ }
94
+
95
+ // Handle last row: left-align by capping height at target
96
+ if (currentRow.length > 0) {
97
+ const calculatedHeight = calculateRowHeight(currentRow, effectiveWidth, gap);
98
+ result.push({
99
+ images: currentRow,
100
+ height: Math.min(calculatedHeight, targetRowHeight),
101
+ });
102
+ }
103
+
104
+ return result;
105
+ });
106
+ </script>
107
+
108
+ <div
109
+ class="zone5-justified flex flex-col"
110
+ bind:clientWidth={containerWidth}
111
+ role="list"
112
+ style:gap="{gap}px"
113
+ >
114
+ {#each rows as row, rowIdx (rowIdx)}
115
+ <div class="zone5-justified-row flex" style:height="{row.height}px" style:gap="{gap}px">
116
+ {#each row.images as { image, idx } (idx)}
117
+ <div
118
+ class="zone5-justified-item shrink-0"
119
+ role="listitem"
120
+ style:width="{row.height * image.properties.aspectRatio}px"
121
+ style:height="{row.height}px"
122
+ >
123
+ <Img {image} cover class="w-full h-full" onclick={onImageClick ? () => onImageClick(idx) : undefined} />
124
+ </div>
125
+ {/each}
126
+ </div>
127
+ {/each}
128
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { ImageData } from './types';
2
+ interface Props {
3
+ images: ImageData[];
4
+ onImageClick?: (index: number) => void;
5
+ targetRowHeight?: number;
6
+ gap?: number;
7
+ }
8
+ declare const Zone5Justified: import("svelte").Component<Props, {}, "">;
9
+ type Zone5Justified = ReturnType<typeof Zone5Justified>;
10
+ export default Zone5Justified;
@@ -7,6 +7,19 @@ export declare const DEFAULT_COLUMN_BREAKPOINTS: {
7
7
  readonly 768: 3;
8
8
  readonly 1024: 4;
9
9
  };
10
+ /**
11
+ * Default target row height for justified mode in pixels.
12
+ */
13
+ export declare const DEFAULT_TARGET_ROW_HEIGHT = 300;
14
+ /**
15
+ * Default gap between images in justified mode in pixels.
16
+ */
17
+ export declare const DEFAULT_GAP = 8;
18
+ /**
19
+ * Aspect ratio threshold for panoramic images.
20
+ * Images with aspect ratio greater than this get their own row in justified mode.
21
+ */
22
+ export declare const PANORAMA_THRESHOLD = 3;
10
23
  /**
11
24
  * Height for single image in wall mode (Tailwind class)
12
25
  */
@@ -7,6 +7,19 @@ export const DEFAULT_COLUMN_BREAKPOINTS = {
7
7
  768: 3, // md: 3 columns
8
8
  1024: 4, // lg: 4 columns
9
9
  };
10
+ /**
11
+ * Default target row height for justified mode in pixels.
12
+ */
13
+ export const DEFAULT_TARGET_ROW_HEIGHT = 300;
14
+ /**
15
+ * Default gap between images in justified mode in pixels.
16
+ */
17
+ export const DEFAULT_GAP = 8;
18
+ /**
19
+ * Aspect ratio threshold for panoramic images.
20
+ * Images with aspect ratio greater than this get their own row in justified mode.
21
+ */
22
+ export const PANORAMA_THRESHOLD = 3.0;
10
23
  /**
11
24
  * Height for single image in wall mode (Tailwind class)
12
25
  */
package/dist/config.d.ts CHANGED
@@ -18,6 +18,31 @@ declare const ConfigSchema: z.ZodObject<{
18
18
  resize_gamma: z.ZodOptional<z.ZodNumber>;
19
19
  variants: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
20
20
  }, z.core.$strip>>>;
21
+ gallery: z.ZodPrefault<z.ZodPipe<z.ZodPrefault<z.ZodObject<{
22
+ mode: z.ZodOptional<z.ZodEnum<{
23
+ wall: "wall";
24
+ waterfall: "waterfall";
25
+ justified: "justified";
26
+ }>>;
27
+ columnBreakpoints: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
28
+ targetRowHeight: z.ZodOptional<z.ZodNumber>;
29
+ gap: z.ZodOptional<z.ZodNumber>;
30
+ panoramaThreshold: z.ZodOptional<z.ZodNumber>;
31
+ }, z.core.$strip>>, z.ZodTransform<{
32
+ columnBreakpoints: {
33
+ [k: string]: number;
34
+ } | undefined;
35
+ mode?: "wall" | "waterfall" | "justified" | undefined;
36
+ targetRowHeight?: number | undefined;
37
+ gap?: number | undefined;
38
+ panoramaThreshold?: number | undefined;
39
+ }, {
40
+ mode?: "wall" | "waterfall" | "justified" | undefined;
41
+ columnBreakpoints?: Record<string, number> | undefined;
42
+ targetRowHeight?: number | undefined;
43
+ gap?: number | undefined;
44
+ panoramaThreshold?: number | undefined;
45
+ }>>>;
21
46
  }, z.core.$strip>;
22
47
  export type ConfigType = z.infer<typeof ConfigSchema>;
23
48
  export declare const walkUntilFound: (path: string) => Promise<string | undefined>;
@@ -32,6 +57,15 @@ export declare const load: (configDir?: string | undefined) => Promise<{
32
57
  variants: number[];
33
58
  resize_gamma?: number | undefined;
34
59
  };
60
+ gallery: {
61
+ columnBreakpoints: {
62
+ [k: string]: number;
63
+ } | undefined;
64
+ mode?: "wall" | "waterfall" | "justified" | undefined;
65
+ targetRowHeight?: number | undefined;
66
+ gap?: number | undefined;
67
+ panoramaThreshold?: number | undefined;
68
+ };
35
69
  } | {
36
70
  src: string;
37
71
  base: {
@@ -44,6 +78,15 @@ export declare const load: (configDir?: string | undefined) => Promise<{
44
78
  variants: number[];
45
79
  resize_gamma?: number | undefined;
46
80
  };
81
+ gallery: {
82
+ columnBreakpoints: {
83
+ [k: string]: number;
84
+ } | undefined;
85
+ mode?: "wall" | "waterfall" | "justified" | undefined;
86
+ targetRowHeight?: number | undefined;
87
+ gap?: number | undefined;
88
+ panoramaThreshold?: number | undefined;
89
+ };
47
90
  }>;
48
91
  export declare const toToml: (config: ConfigType & {
49
92
  src?: string;
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { stringify } from 'smol-toml';
5
5
  import { z } from 'zod';
6
6
  import { loadConfig } from 'zod-config';
7
7
  import { tomlAdapter } from 'zod-config/toml-adapter';
8
+ import { GalleryConfigSchema } from './gallery/config.js';
8
9
  import { ProcessorConfigSchema } from './processor/config.js';
9
10
  const BaseConfigSchema = z.object({
10
11
  root: z.string().default(cwd()),
@@ -14,6 +15,7 @@ const BaseConfigSchema = z.object({
14
15
  const ConfigSchema = z.object({
15
16
  base: BaseConfigSchema.prefault({}),
16
17
  processor: ProcessorConfigSchema.prefault({}),
18
+ gallery: GalleryConfigSchema.prefault({}),
17
19
  });
18
20
  export const walkUntilFound = async (path) => {
19
21
  try {
@@ -0,0 +1,32 @@
1
+ import z from 'zod';
2
+ /**
3
+ * Schema for gallery configuration in .zone5.toml
4
+ *
5
+ * These values override the component defaults.
6
+ */
7
+ export declare const GalleryConfigSchema: z.ZodPipe<z.ZodPrefault<z.ZodObject<{
8
+ mode: z.ZodOptional<z.ZodEnum<{
9
+ wall: "wall";
10
+ waterfall: "waterfall";
11
+ justified: "justified";
12
+ }>>;
13
+ columnBreakpoints: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
14
+ targetRowHeight: z.ZodOptional<z.ZodNumber>;
15
+ gap: z.ZodOptional<z.ZodNumber>;
16
+ panoramaThreshold: z.ZodOptional<z.ZodNumber>;
17
+ }, z.core.$strip>>, z.ZodTransform<{
18
+ columnBreakpoints: {
19
+ [k: string]: number;
20
+ } | undefined;
21
+ mode?: "wall" | "waterfall" | "justified" | undefined;
22
+ targetRowHeight?: number | undefined;
23
+ gap?: number | undefined;
24
+ panoramaThreshold?: number | undefined;
25
+ }, {
26
+ mode?: "wall" | "waterfall" | "justified" | undefined;
27
+ columnBreakpoints?: Record<string, number> | undefined;
28
+ targetRowHeight?: number | undefined;
29
+ gap?: number | undefined;
30
+ panoramaThreshold?: number | undefined;
31
+ }>>;
32
+ export type GalleryConfig = z.output<typeof GalleryConfigSchema>;
@@ -0,0 +1,27 @@
1
+ import z from 'zod';
2
+ /**
3
+ * Schema for gallery configuration in .zone5.toml
4
+ *
5
+ * These values override the component defaults.
6
+ */
7
+ export const GalleryConfigSchema = z
8
+ .object({
9
+ /** Default gallery layout mode */
10
+ mode: z.enum(['wall', 'waterfall', 'justified']).optional(),
11
+ /** Viewport width to column count mapping for waterfall mode */
12
+ columnBreakpoints: z.record(z.string(), z.number().int().positive()).optional(),
13
+ /** Target row height in pixels for justified mode */
14
+ targetRowHeight: z.number().int().positive().optional(),
15
+ /** Gap between images in pixels for justified mode */
16
+ gap: z.number().int().nonnegative().optional(),
17
+ /** Aspect ratio threshold for panoramic images in justified mode */
18
+ panoramaThreshold: z.number().positive().optional(),
19
+ })
20
+ .prefault({})
21
+ .transform((data) => ({
22
+ ...data,
23
+ // Convert string keys to numbers for columnBreakpoints
24
+ columnBreakpoints: data.columnBreakpoints
25
+ ? Object.fromEntries(Object.entries(data.columnBreakpoints).map(([k, v]) => [parseInt(k, 10), v]))
26
+ : undefined,
27
+ }));
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { load } from './config.js';
2
2
  export type { BaseConfigType, ConfigType } from './config.js';
3
+ export type { GalleryConfig } from './gallery/config.js';
3
4
  export { default, default as processor } from './processor/index.js';
4
5
  export type * from './processor/index.js';
5
6
  export type * from './components/types.js';
package/dist/remark.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import type { Plugin } from 'unified';
2
+ import type { GalleryConfig } from './gallery/config.js';
3
+ /**
4
+ * Options for the remarkZ5Images plugin
5
+ */
6
+ export interface RemarkZ5ImagesOptions {
7
+ /** Gallery configuration from .zone5.toml */
8
+ gallery?: GalleryConfig;
9
+ }
2
10
  /**
3
11
  * Remark plugin to process Zone5 images
12
+ *
13
+ * @param options - Plugin options including gallery config from .zone5.toml
4
14
  */
5
- export declare const remarkZ5Images: Plugin;
15
+ export declare const remarkZ5Images: Plugin<[RemarkZ5ImagesOptions?]>;
package/dist/remark.js CHANGED
@@ -25,10 +25,56 @@ function buildImagesExpression(imageData) {
25
25
  const imageExpressions = imageData.map((img) => `{...${img.key}, properties: {...${img.key}.properties, alt: ${JSON.stringify(img.alt)}, title: ${JSON.stringify(img.title)}}}`);
26
26
  return '[' + imageExpressions.join(',') + ']';
27
27
  }
28
+ /**
29
+ * Create a string property node
30
+ */
31
+ function createStringProperty(name, value) {
32
+ return {
33
+ type: 'svelteProperty',
34
+ name,
35
+ shorthand: 'none',
36
+ value: [{ type: 'text', value }],
37
+ modifiers: [],
38
+ };
39
+ }
40
+ /**
41
+ * Create a numeric property node (as expression)
42
+ */
43
+ function createNumberProperty(name, value) {
44
+ return {
45
+ type: 'svelteProperty',
46
+ name,
47
+ shorthand: 'none',
48
+ value: [
49
+ {
50
+ type: 'svelteDynamicContent',
51
+ expression: { type: 'svelteExpression', value: String(value) },
52
+ },
53
+ ],
54
+ modifiers: [],
55
+ };
56
+ }
57
+ /**
58
+ * Create an object property node (as expression)
59
+ */
60
+ function createObjectProperty(name, value) {
61
+ return {
62
+ type: 'svelteProperty',
63
+ name,
64
+ shorthand: 'none',
65
+ value: [
66
+ {
67
+ type: 'svelteDynamicContent',
68
+ expression: { type: 'svelteExpression', value: JSON.stringify(value) },
69
+ },
70
+ ],
71
+ modifiers: [],
72
+ };
73
+ }
28
74
  /**
29
75
  * Create a Zone5 Svelte component node
30
76
  */
31
- function createZone5Component(imageData, mode) {
77
+ function createZone5Component(imageData, options) {
32
78
  const properties = [
33
79
  {
34
80
  type: 'svelteProperty',
@@ -46,19 +92,17 @@ function createZone5Component(imageData, mode) {
46
92
  modifiers: [],
47
93
  },
48
94
  ];
49
- if (mode) {
50
- properties.push({
51
- type: 'svelteProperty',
52
- name: 'mode',
53
- shorthand: 'none',
54
- value: [
55
- {
56
- type: 'text',
57
- value: mode,
58
- },
59
- ],
60
- modifiers: [],
61
- });
95
+ if (options.mode) {
96
+ properties.push(createStringProperty('mode', options.mode));
97
+ }
98
+ if (options.columnBreakpoints) {
99
+ properties.push(createObjectProperty('columnBreakpoints', options.columnBreakpoints));
100
+ }
101
+ if (options.targetRowHeight !== undefined) {
102
+ properties.push(createNumberProperty('targetRowHeight', options.targetRowHeight));
103
+ }
104
+ if (options.gap !== undefined) {
105
+ properties.push(createNumberProperty('gap', options.gap));
62
106
  }
63
107
  return {
64
108
  type: 'svelteComponent',
@@ -174,14 +218,26 @@ function createScriptElement(importMap) {
174
218
  }
175
219
  /**
176
220
  * Remark plugin to process Zone5 images
221
+ *
222
+ * @param options - Plugin options including gallery config from .zone5.toml
177
223
  */
178
- export const remarkZ5Images = () => {
224
+ export const remarkZ5Images = (options = {}) => {
225
+ const galleryConfig = (options.gallery ?? {});
179
226
  return (tree, file) => {
180
227
  const rootTree = tree;
181
228
  const importMap = {};
182
229
  const existingKeys = new Set();
230
+ // Get frontmatter values (these override config)
183
231
  const fm = file.data.fm;
184
- const zone5mode = fm?.zone5mode;
232
+ const frontmatterMode = fm?.zone5mode;
233
+ // Merge config with frontmatter (frontmatter takes precedence)
234
+ const resolvedOptions = {
235
+ mode: frontmatterMode ?? galleryConfig.mode,
236
+ columnBreakpoints: galleryConfig.columnBreakpoints,
237
+ targetRowHeight: galleryConfig.targetRowHeight,
238
+ gap: galleryConfig.gap,
239
+ panoramaThreshold: galleryConfig.panoramaThreshold,
240
+ };
185
241
  // First, collect all Z5 images for the import map
186
242
  visit(rootTree, 'image', (node) => {
187
243
  if (isZ5Image(node)) {
@@ -199,7 +255,7 @@ export const remarkZ5Images = () => {
199
255
  const paragraph = child;
200
256
  const z5Images = paragraph.children.filter((ch) => isZ5Image(ch));
201
257
  const imageData = collectImageData(z5Images, existingKeys);
202
- const svelteComponent = createZone5Component(imageData, zone5mode);
258
+ const svelteComponent = createZone5Component(imageData, resolvedOptions);
203
259
  // Replace the multi-image paragraph with the svelte component
204
260
  node.children.splice(i, 1, svelteComponent);
205
261
  }
@@ -214,7 +270,7 @@ export const remarkZ5Images = () => {
214
270
  .map((index) => getImageFromParagraph(node.children[index]))
215
271
  .filter((img) => img !== null);
216
272
  const imageData = collectImageData(imageNodes, existingKeys);
217
- const svelteComponent = createZone5Component(imageData, zone5mode);
273
+ const svelteComponent = createZone5Component(imageData, resolvedOptions);
218
274
  // Calculate how many nodes to remove (including newlines)
219
275
  const startIndex = group[0];
220
276
  const endIndex = group[group.length - 1];
@@ -234,7 +290,7 @@ export const remarkZ5Images = () => {
234
290
  if (isZ5ImageParagraph(child) && child.type === 'paragraph') {
235
291
  const imageNode = child.children[0];
236
292
  const imageData = collectImageData([imageNode], existingKeys);
237
- const svelteComponent = createZone5Component(imageData, zone5mode);
293
+ const svelteComponent = createZone5Component(imageData, resolvedOptions);
238
294
  // Replace the single image paragraph with the svelte component
239
295
  node.children.splice(i, 1, svelteComponent);
240
296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zone5",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/cwygoda/zone5"
6
6
  },