Compare commits

...

41 Commits

Author SHA1 Message Date
Guido D'Orsi
ceaa555e83 Merge pull request #2727 from garden-co/changeset-release/main
Version Packages
2025-08-14 11:49:33 +02:00
github-actions[bot]
03229b2ea9 Version Packages 2025-08-14 08:51:24 +00:00
Guido D'Orsi
e2737d44b6 fix: add jazz-run as workspace dependency 2025-08-14 10:48:02 +02:00
Guido D'Orsi
4b73834883 chore: changeset 2025-08-14 10:47:44 +02:00
Guido D'Orsi
1b3d43d5f4 Merge pull request #2728 from legowhales/main
fix(jazz-tools/svelte): Make Image reactive to imageId change
2025-08-14 10:46:52 +02:00
Guido D'Orsi
9c9a689879 Merge pull request #2729 from garden-co/feat/debug-correction
feat: add debug info to correction errors
2025-08-13 17:43:53 +02:00
Guido D'Orsi
2fd88b938c feat: add debug info to correction errors 2025-08-13 12:33:05 +02:00
Sammii
d1f955006f Merge pull request #2702 from garden-co/feat/add-input-label
Feat/add input label
2025-08-13 10:33:51 +01:00
Jérémy Le Mardelé
bb3d5f1f87 fix(jazz-tools/svelte): Make Image reactive to imageId change 2025-08-13 00:42:33 +02:00
Sammii
26ce61ab78 input PR amends 2025-08-12 15:59:30 +01:00
Sammii
1f300114d5 label PR amends 2025-08-12 15:58:42 +01:00
Guido D'Orsi
da69f812f8 Merge pull request #2726 from garden-co/fix/GCO-726
fix(jazz-tools/media): ensure file downloaded in loadImageBySize
2025-08-12 14:30:28 +02:00
Guido D'Orsi
0bcbf551ca fix: export the HttpRoute type 2025-08-12 14:26:31 +02:00
Matteo Manchi
6b3d5b5560 fixup! fix(jazz-tools/media): ensure file downloaded in loadImageBySize 2025-08-12 11:44:54 +02:00
Matteo Manchi
d1bdbf5d49 fix(jazz-tools/media): ensure file downloaded in loadImageBySize 2025-08-12 10:37:34 +02:00
Matteo Manchi
621e809fad Merge pull request #2722 from garden-co/changeset-release/main
Version Packages 0.17
2025-08-11 15:45:03 +02:00
github-actions[bot]
d6600d9322 Version Packages 2025-08-11 13:26:38 +00:00
Matteo Manchi
2b08bd77c1 Merge pull request #2624 from garden-co/feat/new-image-apis
New Image management API
2025-08-11 15:24:33 +02:00
Sammii
9b22fc74cd ignore biome on label cn import 2025-08-11 11:19:49 +01:00
Sammii
1bebe3c6c8 Merge branch 'main' into feat/add-input-label 2025-08-11 11:00:05 +01:00
Sammii
e1bd16d08b amend cn import on label 2025-08-11 10:58:47 +01:00
Sammii
0967c2ee5a pr amends 2025-08-11 10:49:34 +01:00
Matteo Manchi
f22ef4e646 chore: fix import ordering 2025-08-11 11:40:10 +02:00
Matteo Manchi
6c35d0031d chore(jazz-tools/svelte): refactor image component + svelte testing 2025-08-11 11:39:06 +02:00
Matteo Manchi
93f3fb231b fix(jazz-tools/media): fix resize calcs 2025-08-11 11:24:45 +02:00
Matteo Manchi
01d13d5df2 feat(jazz-tools/react): generate Image blobs on lazy loading 2025-08-11 11:24:45 +02:00
Matteo Manchi
944e725b95 fix(jazz-tools/react): disable revokeObjectURL on Image unmounts in development env 2025-08-11 11:24:45 +02:00
Matteo Manchi
16024fec8e chore(jazz-tools/react): show always the img element 2025-08-11 11:24:45 +02:00
Matteo Manchi
f90414ab95 chore(jazz-tools/react-native): move Image component from react-native to react-native-core 2025-08-11 11:24:45 +02:00
Guido D'Orsi
492eecb46a docs: add jsDoc comment to Image and createImage 2025-08-11 11:24:45 +02:00
Matteo Manchi
51144ec832 docs: add 0.17 upgrade guide 2025-08-11 11:24:44 +02:00
Matteo Manchi
fcaf4b9c30 chore: add changeset 2025-08-11 11:24:44 +02:00
Matteo Manchi
afae2649f5 docs: new docs for Image Management 2025-08-11 11:24:44 +02:00
Matteo Manchi
b5b0284c61 feat(jazz-tools/svelte): new Image component based on new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
bf1475a143 feat(jazz-tools/react-native): new Image component based on new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
e82cb80ca4 chore(example/image-upload): refactor using the new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
32c2a617d6 feat(jazz-tools/react): new Image component based on new image management API 2025-08-11 11:24:42 +02:00
Matteo Manchi
d3c2a41c81 feat(jazz-tools/media): new media API for image management 2025-08-11 11:23:55 +02:00
Sammii
72b5542130 Merge branch 'main' into feat/add-input-label 2025-08-05 12:41:06 +01:00
Sammii
5fd9225a54 adding intent styles to input + u0pdating docs 2025-08-05 12:32:52 +01:00
Sammii
9138d30208 create input and label components, with docs 2025-08-05 12:18:49 +01:00
92 changed files with 5491 additions and 2183 deletions

View File

@@ -1,5 +1,21 @@
# passkey-svelte
## 0.0.114
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
## 0.0.113
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
## 0.0.112
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.112",
"version": "0.0.114",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { useProgressiveImg } from '$lib/utils/useProgressiveImage.svelte';
import { Image } from 'jazz-tools/svelte';
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
const { src } = $derived(
useProgressiveImg({
image
})
);
</script>
<img class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1" {src} alt="" />
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
/>

View File

@@ -1,53 +0,0 @@
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { onDestroy } from 'svelte';
export function useProgressiveImg({
image,
maxWidth,
targetWidth
}: {
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}) {
let current = $state<{
src?: string;
res?: `${number}x${number}` | 'placeholder';
}>();
const originalSize = $state(image?.originalSize);
const unsubscribe = image?.subscribe({}, (update: Loaded<typeof ImageDefinition>) => {
const highestRes = ImageDefinition.highestResAvailable(update, { maxWidth, targetWidth });
if (highestRes) {
if (highestRes.res !== current?.res) {
const blob = highestRes.stream.toBlob();
if (blob) {
const blobURI = URL.createObjectURL(blob);
current = { src: blobURI, res: highestRes.res };
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
}
}
} else {
current = {
src: update?.placeholderDataURL,
res: 'placeholder'
};
}
});
onDestroy(() => () => {
unsubscribe?.();
});
return {
get src() {
return current?.src;
},
get res() {
return current?.res;
},
originalSize
};
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { createImage } from 'jazz-tools/browser-media-images';
import { createImage } from 'jazz-tools/media';
import { AccountCoState, CoState } from 'jazz-tools/svelte';
import { Account, CoPlainText, type ID } from 'jazz-tools';

View File

@@ -1,5 +1,6 @@
import { Account } from "jazz-tools";
import { createImage, useAccount, useCoState } from "jazz-tools/react";
import { createImage } from "jazz-tools/media";
import { useAccount, useCoState } from "jazz-tools/react";
import { useEffect, useState } from "react";
import { Chat, Message } from "./schema.ts";
import {
@@ -40,7 +41,11 @@ export function ChatScreen(props: { chatID: string }) {
return;
}
createImage(file, { owner: chat._owner }).then((image) => {
createImage(file, {
owner: chat._owner,
progressive: true,
placeholder: "blur",
}).then((image) => {
chat.push(
Message.create(
{

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { CoPlainText, ImageDefinition } from "jazz-tools";
import { ProgressiveImg } from "jazz-tools/react";
import { Image } from "jazz-tools/react";
import { ImageIcon } from "lucide-react";
import { useId, useRef } from "react";
@@ -83,14 +83,12 @@ export function BubbleText(props: {
export function BubbleImage(props: { image: ImageDefinition }) {
return (
<ProgressiveImg image={props.image}>
{({ src }) => (
<img
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
src={src}
/>
)}
</ProgressiveImg>
<Image
imageId={props.image.id}
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
height="original"
width="original"
/>
);
}

View File

@@ -1,10 +1,24 @@
import ImageUpload from "./ImageUpload.tsx";
import ProfileImageComponent from "./ProfileImageComponent.tsx";
import ProfileImageImperative from "./ProfileImageImperative.tsx";
function App() {
return (
<>
<main className="max-w-3xl mx-auto px-3 py-16">
<ImageUpload />
<main className="max-w-6xl mx-auto px-3 py-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-xl font-semibold mb-4">Upload Image</h2>
<ImageUpload />
</div>
<div>
<h2>Profile Image - imperative way</h2>
<ProfileImageImperative />
<hr />
<h2>Profile Image - component</h2>
<ProfileImageComponent />
</div>
</div>
</main>
</>
);

View File

@@ -1,4 +1,5 @@
import { ProgressiveImg, createImage, useAccount } from "jazz-tools/react";
import { createImage } from "jazz-tools/media";
import { useAccount } from "jazz-tools/react";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { JazzAccount } from "./schema";
@@ -35,9 +36,14 @@ export default function ImageUpload() {
setImagePreviewUrl(objectUrl);
try {
const startTime = performance.now();
me.profile.image = await createImage(file, {
owner: me.profile._owner,
progressive: true,
placeholder: "blur",
});
const endTime = performance.now();
console.log(`Image upload took ${endTime - startTime} milliseconds`);
} catch (error) {
console.error("Error uploading image:", error);
} finally {
@@ -47,29 +53,6 @@ export default function ImageUpload() {
}
};
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (me?.profile?.image) {
return (
<>
<ProgressiveImg image={me.profile.image as any /* TODO: fix this */}>
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
</ProgressiveImg>
<button
type="button"
onClick={deleteImage}
className="mt-5 bg-blue-600 text-white py-2 px-3 rounded"
>
Delete image
</button>
</>
);
}
if (imagePreviewUrl) {
return (
<div className="relative">

View File

@@ -0,0 +1,35 @@
import { Image, useAccount } from "jazz-tools/react";
import { JazzAccount } from "./schema";
export default function ProfileImage() {
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (!me?.profile?.image) {
return (
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
<p className="text-gray-500">No profile image</p>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Profile Image</h2>
<div className="border rounded-lg overflow-hidden">
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
</div>
<button
type="button"
onClick={deleteImage}
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
>
Delete image
</button>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import {
highestResAvailable,
// loadImage,
// loadImageBySize,
} from "jazz-tools/media";
import { useAccount } from "jazz-tools/react";
import { useEffect, useState } from "react";
import { JazzAccount } from "./schema";
export default function ProfileImageImperative() {
const [image, setImage] = useState<string | undefined>(undefined);
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
useEffect(() => {
if (!me?.profile?.image) return;
// `loadImage` returns always the original image
// loadImage(me.profile.image).then((image) => {
// if(image === null) {
// console.error('Unable to load image');
// return;
// }
// console.log('loadImage', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
// });
// `loadImageBySize` returns the best available image for the given size
// loadImageBySize(me.profile.image.id, 1024, 1024).then((image) => {
// if(image === null) {
// console.error('Unable to load image');
// return;
// }
// console.log('loadImageBySize', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
// });
// keep it synced and return the best _loaded_ image for the given size
const unsub = me.profile.image.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 1024, 1024);
console.info(bestImage ? "Blob is ready" : "Blob is not ready");
if (bestImage) {
const blob = bestImage.image.toBlob();
if (blob) {
setImage(URL.createObjectURL(blob));
}
}
});
return () => {
unsub();
};
}, [me?.profile?.image]);
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (!me?.profile?.image) {
return (
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
<p className="text-gray-500">No profile image</p>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Profile Image</h2>
<div className="border rounded-lg overflow-hidden">
<img alt="Profile" src={image} className="w-full h-auto" />
</div>
<button
type="button"
onClick={deleteImage}
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
>
Delete image
</button>
</div>
);
}

View File

@@ -109,6 +109,11 @@ export const docNavigationItems = [
// collapse: true,
prefix: "/docs/upgrade",
items: [
{
name: "0.17.0 - New image APIs",
href: "/docs/upgrade/0-17-0",
done: 100,
},
{
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
href: "/docs/upgrade/0-16-0",
@@ -230,6 +235,7 @@ export const docNavigationItems = [
"react-native": 100,
"react-native-expo": 100,
vanilla: 100,
svelte: 100,
},
},
{

View File

@@ -0,0 +1,98 @@
import { CodeGroup } from '@/components/forMdx'
# Jazz 0.17.0 - New Image APIs
This release introduces a comprehensive refactoring of the image API, from creation to consumption. The result is a more flexible set of components and lower-level primitives that provide better developer experience and performance.
## Motivation
Before 0.17.0, the image APIs had several limitations:
- Progressive loading created confusion in usage patterns, and the API lacked flexibility to support all use cases
- The resize methods were overly opinionated, and the chosen library had compatibility issues in incognito mode
- The imperative functions for loading images were unnecessarily complex for simple use cases
## Breaking Changes
- The `createImage` options have been restructured, and the function has been moved to the `jazz-tools/media` namespace for both React and React Native
- The `<ProgressiveImg>` component has been replaced with `<Image>` from `jazz-tools/react`
- The `<ProgressiveImgNative>` component has been replaced with `<Image>` from `jazz-tools/react-native`
- The `highestResAvailable` function has been moved from `ImageDefinition.highestResAvailable` to `import { highestResAvailable } from "jazz-tools/media"`
- Existing image data remains compatible and accessible
- Progressive images created with previous versions will continue to work
## Changes
### `createImage` Function
The `createImage` function has been refactored to allow opt-in specific features and moved to the `jazz-tools/media` namespace.
<CodeGroup>
```tsx
export type CreateImageOptions = {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
};
```
</CodeGroup>
- By default, images are now created with only the original size saved (no progressive loading or placeholder)
- The `maxSize` property is no longer restricted and affects the original size saved
- Placeholder generation is now a configurable property, disabled by default. Currently, only `"blur"` is supported, with more built-in options planned for future releases
- The `progressive` property creates internal resizes used exclusively via public APIs. Direct manipulation of internal resize state is no longer recommended
The `pica` library used internally for browser image resizing has been replaced with a simpler canvas-based implementation. Since every image manipulation library has trade-offs, we've chosen the simplest solution while providing flexibility through `createImageFactory`. This new factory function allows you to create custom `createImage` instances with your preferred libraries for resizing, placeholder generation, and source reading. It's used internally to create default instances for browsers, React Native, and Node.js environments.
### Replaced `<ProgressiveImg>` Component with `<Image>`
The `<ProgressiveImg>` component has been replaced with `<Image>` component for both React and React Native.
<CodeGroup>
```tsx
// Before
import { ProgressiveImg } from "jazz-tools/react";
<ProgressiveImg image={me.profile.image}>
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
</ProgressiveImg>
// After
import { Image } from "jazz-tools/react";
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
```
</CodeGroup>
The `width` and `height` props are now used internally to load the optimal image size, but only if progressive loading was enabled during image creation.
For detailed usage examples and API reference, see the [Image component documentation](/docs/react/using-covalues/imagedef#displaying-images).
### New `Image` Component for Svelte
A new `Image` component has been added for Svelte, featuring the same API as the React and React Native components.
<CodeGroup>
```svelte
<script lang="ts">
import { Image } from 'jazz-tools/svelte';
</script>
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
width={600}
/>
```
</CodeGroup>
For detailed usage examples and API reference, see the [Image component documentation](/docs/svelte/using-covalues/imagedef#displaying-images).
### New Image Loading Utilities
Two new utility functions are now available from the `jazz-tools/media` package:
- `loadImage` - Fetches the original image file by ID
- `loadImageBySize` - Fetches the best stored size for a given width and height
For detailed usage examples and API reference, see the [Image component documentation](/docs/vanilla/using-covalues/imagedef#displaying-images).

View File

@@ -1,18 +1,18 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
We also offer [`createImage()`](#creating-images), a higher-level function to create an `ImageDefinition` from a file.
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`loadImage`, `loadImageBySize`, `highestResAvailable`](#displaying-images) - functions to load and display images
If you're building with React, we recommend starting with our [React-specific image documentation](/docs/react/using-covalues/imagedef) which covers higher-level components and hooks for working with images.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ImageDefinition`.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
@@ -20,314 +20,258 @@ The easiest way to create and use images in your Jazz application is with the `c
<CodeGroup>
```ts twoslash
import { Group, co, z } from "jazz-tools";
import { Account, Group, ImageDefinition } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: co.optional(co.image()),
});
const MyAccount = co.account({
root: co.map({}),
profile: MyProfile,
});
MyAccount.withMigration((account, creationProps) => {
if (account.profile === undefined) {
const profileGroup = Group.create();
profileGroup.makePublic();
account.profile = MyProfile.create(
{
name: creationProps?.name ?? "New user",
},
profileGroup,
);
}
});
const me = await MyAccount.create({ creationProps: { name: "John Doe" } });
const myGroup = Group.create();
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/browser-media-images";
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImage(file, { owner: myGroup });
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
- Returns the ID of the created `ImageDefinition`
- Returns the created `ImageDefinition`
### Configuration Options
You can configure `createImage()` with additional options:
<CodeGroup>
```ts
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myBlob: Blob;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
// ---cut---
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 as 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImage(file, options);
```
</CodeGroup>
### Ownership
Like other CoValues, you can specify ownership when creating image definitions.
<CodeGroup>
```ts twoslash
import { Group } from "jazz-tools";
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const colleagueAccount = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
// ---cut---
const teamGroup = Group.create();
teamGroup.addMember(colleagueAccount, "writer");
// Create an image with shared ownership
const teamImage = await createImage(file, { owner: teamGroup });
```
</CodeGroup>
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
## Creating ImageDefinitions
Create an `ImageDefinition` by specifying the original dimensions and an optional placeholder:
<CodeGroup>
```ts twoslash
import { ImageDefinition } from "jazz-tools";
// Create with original dimensions
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
// With a placeholder for immediate display
const imageWithPlaceholder = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJ...",
});
```
</CodeGroup>
### Structure
`ImageDefinition` stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`, typically a tiny base64-encoded preview)
- Multiple resolution variants of the same image as [`FileStream`s](./using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
<CodeGroup>
```ts twoslash
import { ImageDefinition, co, z } from "jazz-tools";
const Gallery = co.map({
title: z.string(),
images: co.list(co.image()),
});
```
</CodeGroup>
## Adding Image Resolutions
Add multiple resolutions to an `ImageDefinition` by creating `FileStream`s for each size:
<CodeGroup>
```ts twoslash
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const fullSizeBlob = new Blob([], { type: "image/jpeg" });
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
const thumbnailBlob = new Blob([], { type: "image/jpeg" });
const image = ImageDefinition.create({
originalSize: [1920, 1080],
}, { owner: me });
// ---cut---
// Create FileStreams for different resolutions
const fullRes = await FileStream.createFromBlob(fullSizeBlob);
const mediumRes = await FileStream.createFromBlob(mediumSizeBlob);
const thumbnailRes = await FileStream.createFromBlob(thumbnailBlob);
// Add to the ImageDefinition with appropriate resolution keys
image["1920x1080"] = fullRes;
image["800x450"] = mediumRes;
image["320x180"] = thumbnailRes;
```
</CodeGroup>
## Retrieving Images
The `highestResAvailable` method helps select the best image resolution for the current context:
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
// Simple document environment
global.document = {
createElement: () =>
({ src: "", onload: null }) as unknown as HTMLImageElement,
} as unknown as Document;
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
// Setup
const fakeBlob = new Blob(["fake image data"], { type: "image/jpeg" });
const me = await createJazzTestAccount();
const image = ImageDefinition.create(
{ originalSize: [1920, 1080] },
{ owner: me },
);
image["1920x1080"] = await FileStream.createFromBlob(fakeBlob, { owner: me });
const imageElement = document.createElement("img");
// ---cut---
// Get highest resolution available (unconstrained)
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes);
if (highestRes) {
const blob = highestRes.stream.toBlob();
if (blob) {
// Create a URL for the blob
const url = URL.createObjectURL(blob);
imageElement.src = url;
// Revoke the URL when the image is loaded
imageElement.onload = () => URL.revokeObjectURL(url);
}
}
// Get appropriate resolution for specific width
const appropriateRes = ImageDefinition.highestResAvailable(image, {
targetWidth: window.innerWidth,
});
```
</CodeGroup>
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
// ---cut---
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes?.res); // 800x450
```
</CodeGroup>
## Progressive Loading Patterns
`ImageDefinition` supports simple progressive loading with placeholders and resolution selection:
<CodeGroup>
```ts twoslash
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
// Simple document environment
global.document = {
createElement: () =>
({ src: "", onload: null }) as unknown as HTMLImageElement,
} as unknown as Document;
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
const me = await createJazzTestAccount();
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
const image = ImageDefinition.create(
{
originalSize: [1920, 1080],
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
{ owner: me },
);
image["1920x1080"] = await FileStream.createFromBlob(mediumSizeBlob, {
owner: me,
});
const imageElement = document.createElement("img");
// ---cut---
// Start with placeholder for immediate display
if (image.placeholderDataURL) {
imageElement.src = image.placeholderDataURL;
}
// Then load the best resolution for the current display
const screenWidth = window.innerWidth;
const bestRes = ImageDefinition.highestResAvailable(image, {
targetWidth: screenWidth,
});
if (bestRes) {
const blob = bestRes.stream.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
imageElement.src = url;
// Remember to revoke the URL when no longer needed
imageElement.onload = () => {
URL.revokeObjectURL(url);
};
}
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
}
```
</CodeGroup>
## Best Practices
- **Generate resolutions server-side** when possible for optimal quality
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/vanilla/using-covalues/filestreams#reading-from-filestreams) documentation.
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image === null) {
throw new Error("Image not found");
}
const img = document.createElement("img");
img.width = image.width;
img.height = image.height;
img.src = URL.createObjectURL(image.image.toBlob()!);
img.onload = () => URL.revokeObjectURL(img.src);
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load(imageId);
if(image === null) {
throw new Error("Image not found");
}
const img = document.createElement("img");
img.width = 600;
img.height = 600;
// start with the placeholder
if(image.placeholderDataURL) {
img.src = image.placeholderDataURL;
}
// then listen to the image changes
image.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
}
}
});
```
</CodeGroup>
### Best Practices
- **Set image sizes** when possible to avoid layout shifts
- **Use placeholders** (like LQIP - Low Quality Image Placeholders) for instant rendering
- **Prioritize loading** the resolution appropriate for the current viewport
- **Consider device pixel ratio** (window.devicePixelRatio) for high-DPI displays
- **Always call URL.revokeObjectURL** after the image loads to prevent memory leaks
## Image manipulation custom implementation
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,63 +1,75 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
**Note**: This guide applies to both Expo and framework-less React Native implementations. The functionality described here is identical regardless of which implementation you're using
**Note**: This guide applies to both Expo and framework-less React Native implementations.
Jazz offers several tools to work with images in React Native:
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - React Native component to display a stored image
For examples of use, see our example apps:
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
## Installation
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native-expo/project-setup#install-dependencies) for more details.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```tsx
import { createImageNative } from "jazz-tools/expo-media-images";
import * as ImagePicker from 'expo-image-picker';
```ts
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
import { launchImageLibrary } from 'react-native-image-picker';
async function handleImagePicker() {
try {
// Launch the image picker
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
base64: true,
quality: 1,
// Use your favorite image picker library to get the image URI
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 1,
});
if (!result.didCancel && result.assets && result.assets.length > 0) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
// See the options below for more details.
const image = await createImage(result.assets[0].uri, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
if (!result.canceled) {
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImageNative(base64Uri, {
owner: me.profile._owner,
maxSize: 2048, // Optional: limit maximum resolution
});
// Store the image
me.profile.image = image;
}
} catch (error) {
console.error("Error creating image:", error);
// Store the image
me.profile.image = image;
}
}
```
</CodeGroup>
The `createImageNative()` function:
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
@@ -65,49 +77,96 @@ The `createImageNative()` function:
### Configuration Options
You can configure `createImageNative()` with additional options:
<CodeGroup>
```tsx
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImageNative(base64Uri, options);
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
## Displaying Images with `ProgressiveImgNative`
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myFile: string;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myFile);
const thumbnail = await createImage(myFile, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
The Image component is the best way to let Jazz handle the image loading.
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
<CodeGroup>
```tsx
import { ProgressiveImgNative } from "jazz-tools/expo";
import { Image, StyleSheet } from "react-native";
import { Image } from "jazz-tools/expo";
import { StyleSheet } from "react-native";
function GalleryView({ image }) {
return (
<ProgressiveImgNative
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<Image
source={{ uri: src }}
style={styles.galleryImage}
resizeMode="cover"
/>
)}
</ProgressiveImgNative>
<Image
imageId={image.id}
style={styles.galleryImage}
width={400}
resizeMode="cover"
/>
);
}
@@ -121,120 +180,178 @@ const styles = StyleSheet.create({
```
</CodeGroup>
The `ProgressiveImgNative` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImgNative` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
RNImageProps,
"width" | "height" | "source"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
import { useProgressiveImgNative } from "jazz-tools/expo";
import { Image, View, Text, ActivityIndicator } from "react-native";
<Image imageId="123" />
// <RNImage src={...} /> with the highest resolution available
function CustomImageComponent({ image }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImgNative({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
<Image imageId="123" width="original" height="original" />
// <RNImage width="1920" height="1080" />
<Image imageId="123" width="600" />
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
<Image imageId="123" width="600" height="original" />
// <RNImage width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <RNImage width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <RNImage width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return (
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
<ActivityIndicator size="small" color="#0000ff" />
<Text style={{ marginTop: 10 }}>Loading image...</Text>
</View>
);
}
// When using placeholder
if (res === "placeholder") {
return (
<View style={{ position: 'relative' }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: 200, opacity: 0.7 }}
resizeMode="cover"
/>
<ActivityIndicator
size="large"
color="#ffffff"
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
/>
</View>
);
}
// Full image display with custom overlay
return (
<View style={{ position: 'relative', width: '100%', height: 200 }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
</View>
</View>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```tsx
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = image.highestResAvailable();
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```tsx
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
const highestRes = image.highestResAvailable();
console.log(highestRes.res); // 800x450
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,63 +1,75 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
**Note**: This guide applies to both Expo and framework-less React Native implementations.
Jazz offers several tools to work with images in React Native:
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - React Native component to display a stored image
For examples of use, see our example apps:
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
## Installation
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native/project-setup#install-dependencies) for more details.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```tsx
import { createImageNative } from "jazz-tools/react-native-media-images";
```ts
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
import { launchImageLibrary } from 'react-native-image-picker';
async function handleImagePicker() {
try {
// Launch the image picker
const result = await launchImageLibrary({
mediaType: 'photo',
includeBase64: true,
quality: 1,
// Use your favorite image picker library to get the image URI
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 1,
});
if (!result.didCancel && result.assets && result.assets.length > 0) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
// See the options below for more details.
const image = await createImage(result.assets[0].uri, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
if (!result.canceled) {
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImageNative(base64Uri, {
owner: me.profile._owner,
maxSize: 2048, // Optional: limit maximum resolution
});
// Store the image
me.profile.image = image;
}
} catch (error) {
console.error("Error creating image:", error);
// Store the image
me.profile.image = image;
}
}
```
</CodeGroup>
The `createImageNative()` function:
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
@@ -65,49 +77,96 @@ The `createImageNative()` function:
### Configuration Options
You can configure `createImageNative()` with additional options:
<CodeGroup>
```tsx
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImageNative(base64Uri, options);
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
## Displaying Images with `ProgressiveImgNative`
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myFile: string;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myFile);
const thumbnail = await createImage(myFile, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
The Image component is the best way to let Jazz handle the image loading.
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
<CodeGroup>
```tsx
import { ProgressiveImgNative } from "jazz-tools/react-native";
import { Image, StyleSheet } from "react-native";
import { Image } from "jazz-tools/react-native";
import { StyleSheet } from "react-native";
function GalleryView({ image }) {
return (
<ProgressiveImgNative
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<Image
source={{ uri: src }}
style={styles.galleryImage}
resizeMode="cover"
/>
)}
</ProgressiveImgNative>
<Image
imageId={image.id}
style={styles.galleryImage}
width={400}
resizeMode="cover"
/>
);
}
@@ -121,120 +180,177 @@ const styles = StyleSheet.create({
```
</CodeGroup>
The `ProgressiveImgNative` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImgNative` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
RNImageProps,
"width" | "height" | "source"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
import { useProgressiveImgNative } from "jazz-tools/react-native";
import { Image, View, Text, ActivityIndicator } from "react-native";
<Image imageId="123" />
// <RNImage src={...} /> with the highest resolution available
function CustomImageComponent({ image }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImgNative({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
<Image imageId="123" width="original" height="original" />
// <RNImage width="1920" height="1080" />
<Image imageId="123" width="600" />
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
<Image imageId="123" width="600" height="original" />
// <RNImage width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <RNImage width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <RNImage width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return (
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
<ActivityIndicator size="small" color="#0000ff" />
<Text style={{ marginTop: 10 }}>Loading image...</Text>
</View>
);
}
// When using placeholder
if (res === "placeholder") {
return (
<View style={{ position: 'relative' }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: 200, opacity: 0.7 }}
resizeMode="cover"
/>
<ActivityIndicator
size="large"
color="#ffffff"
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
/>
</View>
);
}
// Full image display with custom overlay
return (
<View style={{ position: 'relative', width: '100%', height: 200 }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
</View>
</View>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```tsx
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = image.highestResAvailable();
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```tsx
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
const highestRes = image.highestResAvailable();
console.log(highestRes.res); // 800x450
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,19 +1,18 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`ProgressiveImg`](#displaying-images-with-progressiveimg) - React component to display an image with progressive loading
- [`useProgressiveImg`](#using-useprogressiveimg-hook) - React hook to load an image in your own component
- [`Image`](#displaying-images) - React component to display a stored image
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ProgressiveImg` and `ImageDefinition`.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
@@ -21,54 +20,38 @@ The easiest way to create and use images in your Jazz application is with the `c
<CodeGroup>
```ts twoslash
import { Group, co, z } from "jazz-tools";
import { Account, Group, ImageDefinition } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: co.optional(co.image()),
});
const MyAccount = co.account({
root: co.map({}),
profile: MyProfile,
});
MyAccount.withMigration((account, creationProps) => {
if (account.profile === undefined) {
const profileGroup = Group.create();
profileGroup.makePublic();
account.profile = MyProfile.create(
{
name: creationProps?.name ?? "New user",
},
profileGroup,
);
}
});
const me = await MyAccount.create({});
const myGroup = Group.create();
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/browser-media-images";
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImage(file, { owner: myGroup });
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
@@ -78,194 +61,285 @@ The `createImage()` function:
### Configuration Options
You can configure `createImage()` with additional options:
<CodeGroup>
```ts twoslash
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
```ts
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 as 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImage(file, options);
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
### Ownership
#### `image`
Like other CoValues, you can specify ownership when creating image definitions.
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
import { Group } from "jazz-tools";
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const colleagueAccount = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
declare const myBlob: Blob;
// ---cut---
const teamGroup = Group.create();
teamGroup.addMember(colleagueAccount, "writer");
// Create an image with shared ownership
const teamImage = await createImage(file, { owner: teamGroup });
```
</CodeGroup>
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
## Displaying Images with `ProgressiveImg`
For a complete progressive loading experience, use the `ProgressiveImg` component:
<CodeGroup>
```tsx twoslash
import * as React from "react";
// ---cut---
import { ProgressiveImg } from "jazz-tools/react";
import { co } from "jazz-tools";
const Image = co.image();
import { createImage } from "jazz-tools/media";
function GalleryView({ image }: { image: co.loaded<typeof Image> }) {
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
### `<Image>` component
The Image component is the best way to let Jazz handle the image loading.
<CodeGroup>
```tsx
import * as React from "react";
import { co } from "jazz-tools";
const ImageDef = co.image();
// ---cut---
import { Image } from "jazz-tools/react";
function GalleryView({ image }: { image: co.loaded<typeof ImageDef> }) {
return (
<div className="image-container">
<ProgressiveImg
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<img
src={src}
alt="Gallery image"
className="gallery-image"
/>
)}
</ProgressiveImg>
<Image imageId={image.id} alt="Profile" width={600} />
</div>
);
}
```
</CodeGroup>
The `ProgressiveImg` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImg` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
JSX.IntrinsicElements["img"],
"src" | "srcSet" | "width" | "height"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
<Image imageId="123" />
// <img src={...} /> with the highest resolution available
<Image imageId="123" width="original" height="original" />
// <img width="1920" height="1080" />
<Image imageId="123" width="600" />
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
<Image imageId="123" width="600" height="original" />
// <img width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <img width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <img width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
#### Lazy loading
The `Image` component supports lazy loading based on [browser's strategy](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#loading). It will generate the blob url for the image when the browser's viewport reaches the image.
<CodeGroup>
```tsx
<Image imageId="123" width="original" height="original" loading="lazy" />
```
</CodeGroup>
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import * as React from "react";
import { co } from "jazz-tools";
const Image = co.image();
// ---cut---
import { useProgressiveImg } from "jazz-tools/react";
import { ImageDefinition } from "jazz-tools";
function CustomImageComponent({ image }: { image: co.loaded<typeof Image> }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImg({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return <div className="image-loading-fallback">Loading image...</div>;
}
// When image is loading, show a placeholder
if (res === "placeholder") {
return <img src={src} alt="Loading..." className="blur-effect" />;
}
// Full image display with custom overlay
return (
<div className="custom-image-wrapper">
<img
src={src}
alt="Custom image"
className="custom-image"
/>
<div className="image-overlay">
<span className="image-caption">Resolution: {res}</span>
</div>
</div>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = ImageDefinition.highestResAvailable(image);
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
}
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
## Image manipulation custom implementation
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
```ts
import { createImageFactory } from "jazz-tools/media";
// ---cut---
const image = ImageDefinition.create({
originalSize: [1920, 1080],
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes?.res); // 800x450
```
</CodeGroup>

View File

@@ -0,0 +1,331 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - Svelte component to display a stored image
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```ts twoslash
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
- Returns the created `ImageDefinition`
### Configuration Options
<CodeGroup>
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myBlob: Blob;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
### `<Image>` component
The Image component is the best way to let Jazz handle the image loading.
<CodeGroup>
```svelte twoslash
<script lang="ts">
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { Image } from 'jazz-tools/svelte';
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
</script>
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
/>
```
</CodeGroup>
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
The component's props are:
<CodeGroup>
```ts
interface ImageProps extends Omit<HTMLImgAttributes, "width" | "height"> {
imageId: string;
width?: number | "original";
height?: number | "original";
}
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```svelte
<Image imageId="123" />
// <img src={...} /> with the highest resolution available
<Image imageId="123" width="original" height="original" />
// <img width="1920" height="1080" />
<Image imageId="123" width="600" />
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
<Image imageId="123" width="600" height="original" />
// <img width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <img width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <img width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
}
```
</CodeGroup>
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -17,6 +17,7 @@
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"happy-dom": "^17.4.4",
"jazz-run": "workspace:*",
"jazz-tools": "workspace:*",
"lefthook": "^1.8.2",
"pkg-pr-new": "^0.0.39",

View File

@@ -1,5 +1,18 @@
# cojson-storage-indexeddb
## 0.17.1
### Patch Changes
- Updated dependencies [2fd88b9]
- cojson@0.17.1
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.16.6",
"version": "0.17.1",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",

View File

@@ -1,5 +1,18 @@
# cojson-storage-sqlite
## 0.17.1
### Patch Changes
- Updated dependencies [2fd88b9]
- cojson@0.17.1
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.16.6",
"version": "0.17.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,5 +1,18 @@
# cojson-transport-nodejs-ws
## 0.17.1
### Patch Changes
- Updated dependencies [2fd88b9]
- cojson@0.17.1
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.16.6",
"version": "0.17.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,5 +1,13 @@
# cojson
## 0.17.1
### Patch Changes
- 2fd88b9: Add debug info to sync correction errors
## 0.17.0
## 0.16.6
### Patch Changes

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.6",
"version": "0.17.1",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"libsql": "^0.5.13",

View File

@@ -84,3 +84,10 @@ export function getContentMessageSize(msg: NewContentMessage) {
);
}, 0);
}
export function getContenDebugInfo(msg: NewContentMessage) {
return Object.entries(msg.new).map(
([sessionID, sessionNewContent]) =>
`Session: ${sessionID} After: ${sessionNewContent.after} New: ${sessionNewContent.newTransactions.length}`,
);
}

View File

@@ -208,7 +208,6 @@ export class StorageApiSync implements StorageAPI {
if (!correction) {
logger.error("Correction callback returned undefined", {
knownState,
correction: correction ?? null,
});
return false;
}

View File

@@ -2,6 +2,7 @@ import { Histogram, ValueType, metrics } from "@opentelemetry/api";
import { PeerState } from "./PeerState.js";
import { SyncStateManager } from "./SyncStateManager.js";
import {
getContenDebugInfo,
getTransactionSize,
knownStateFromContent,
} from "./coValueContentMessage.js";
@@ -673,6 +674,8 @@ export class SyncManager {
"Invalid state assumed when handling new content from storage",
{
id: msg.id,
content: getContenDebugInfo(msg),
knownState: coValue.knownState(),
},
);
}
@@ -803,7 +806,20 @@ export class SyncManager {
// Try to store the content as-is for performance
// In case that some transactions are missing, a correction will be requested, but it's an edge case
storage.store(content, (correction) => {
return value.verified?.newContentSince(correction);
if (!value.verified) {
logger.error(
"Correction requested for a CoValue with no verified content",
{
id: content.id,
content: getContenDebugInfo(content),
correction,
state: value.loadingState,
},
);
return undefined;
}
return value.verified.newContentSince(correction);
});
}

View File

@@ -382,7 +382,6 @@ describe("StorageApiSync", () => {
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
@@ -413,7 +412,6 @@ describe("StorageApiSync", () => {
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);

View File

@@ -1,5 +1,26 @@
# jazz-auth-betterauth
## 0.17.1
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [2fd88b9]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
- cojson@0.17.1
- jazz-betterauth-client-plugin@0.17.1
## 0.17.0
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
- jazz-betterauth-client-plugin@0.17.0
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-auth-betterauth",
"version": "0.16.6",
"version": "0.17.1",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,17 @@
# jazz-betterauth-client-plugin
## 0.17.1
### Patch Changes
- jazz-betterauth-server-plugin@0.17.1
## 0.17.0
### Patch Changes
- jazz-betterauth-server-plugin@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-betterauth-client-plugin",
"version": "0.16.6",
"version": "0.17.1",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,24 @@
# jazz-betterauth-server-plugin
## 0.17.1
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [2fd88b9]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
- cojson@0.17.1
## 0.17.0
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-betterauth-server-plugin",
"version": "0.16.6",
"version": "0.17.1",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,28 @@
# jazz-react-auth-betterauth
## 0.17.1
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [2fd88b9]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
- cojson@0.17.1
- jazz-auth-betterauth@0.17.1
- jazz-betterauth-client-plugin@0.17.1
## 0.17.0
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
- jazz-auth-betterauth@0.17.0
- jazz-betterauth-client-plugin@0.17.0
- cojson@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react-auth-betterauth",
"version": "0.16.6",
"version": "0.17.1",
"type": "module",
"main": "dist/index.js",
"types": "src/index.tsx",

View File

@@ -1,5 +1,28 @@
# jazz-run
## 0.17.1
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [2fd88b9]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
- cojson@0.17.1
- cojson-storage-sqlite@0.17.1
- cojson-transport-ws@0.17.1
## 0.17.0
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
- cojson@0.17.0
- cojson-storage-sqlite@0.17.0
- cojson-transport-ws@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -3,7 +3,7 @@
"bin": "./dist/index.js",
"type": "module",
"license": "MIT",
"version": "0.16.6",
"version": "0.17.1",
"exports": {
"./startSyncServer": {
"types": "./dist/startSyncServer.d.ts",
@@ -28,11 +28,11 @@
"@effect/printer-ansi": "^0.34.5",
"@effect/schema": "^0.71.1",
"@effect/typeclass": "^0.25.5",
"cojson": "workspace:0.16.6",
"cojson-storage-sqlite": "workspace:0.16.6",
"cojson-transport-ws": "workspace:0.16.6",
"cojson": "workspace:0.17.1",
"cojson-storage-sqlite": "workspace:0.17.1",
"cojson-transport-ws": "workspace:0.17.1",
"effect": "^3.6.5",
"jazz-tools": "workspace:0.16.6",
"jazz-tools": "workspace:0.17.1",
"ws": "^8.14.2"
},
"devDependencies": {

View File

@@ -1,5 +1,29 @@
# jazz-tools
## 0.17.1
### Patch Changes
- 0bcbf55: Export the HttpRoute type
- d1bdbf5: fix: ensure file downloaded in loadImageBySize
- 4b73834: fix(jazz-tools/svelte): Make Image reactive to imageId change
- Updated dependencies [2fd88b9]
- cojson@0.17.1
- cojson-storage-indexeddb@0.17.1
- cojson-transport-ws@0.17.1
## 0.17.0
### Minor Changes
- fcaf4b9: New image management APIs, refactoring imperative functions for creation and consumption, React and ReactNative components, and new Svelte componente
### Patch Changes
- cojson@0.17.0
- cojson-storage-indexeddb@0.17.0
- cojson-transport-ws@0.17.0
## 0.16.6
### Patch Changes

View File

@@ -24,6 +24,12 @@
"types": "./dist/browser-media-images/index.d.ts",
"default": "./dist/browser-media-images/index.js"
},
"./media": {
"@jazz-tools/source": "./src/media/index.ts",
"types": "./dist/media/index.d.ts",
"react-native": "./dist/media/index.native.js",
"default": "./dist/media/index.browser.js"
},
"./expo": {
"@jazz-tools/source": "./src/expo/index.ts",
"types": "./dist/expo/index.d.ts",
@@ -109,11 +115,6 @@
"types": "./dist/react-native-core/testing.d.ts",
"default": "./dist/react-native-core/testing.js"
},
"./react-native-media-images": {
"@jazz-tools/source": "./src/react-native-media-images/index.ts",
"types": "./dist/react-native-media-images/index.d.ts",
"default": "./dist/react-native-media-images/index.js"
},
"./svelte": {
"svelte": "./dist/svelte/index.js",
"@jazz-tools/source": "./src/svelte/index.ts",
@@ -139,21 +140,18 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.6",
"version": "0.17.1",
"dependencies": {
"@manuscripts/prosemirror-recreate-steps": "^0.1.4",
"@scure/base": "1.2.1",
"@scure/bip39": "^1.3.0",
"@tiptap/core": "^2.12.0",
"@types/image-blob-reduce": "^4.1.1",
"clsx": "^2.0.0",
"cojson": "workspace:*",
"cojson-storage-indexeddb": "workspace:*",
"cojson-transport-ws": "workspace:*",
"fast-myers-diff": "^3.2.0",
"goober": "^2.1.16",
"image-blob-reduce": "^4.1.0",
"pica": "^9.0.1",
"prosemirror-example-setup": "^1.2.2",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.21.1",
@@ -176,9 +174,11 @@
"devDependencies": {
"@scure/bip39": "^1.3.0",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "16.2.0",
"@testing-library/svelte": "^5.2.6",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.0",
"@vitest/browser": "^3.2.4",

View File

@@ -1,131 +0,0 @@
import ImageBlobReduce from "image-blob-reduce";
import {
Account,
FileStream,
Group,
ImageDefinition,
Loaded,
} from "jazz-tools";
import Pica from "pica";
let reducer: ImageBlobReduce.ImageBlobReduce | undefined;
/** @category Image creation */
export async function createImage(
imageBlobOrFile: Blob | File,
options?: {
owner?: Group | Account;
maxSize?: 256 | 1024 | 2048;
},
): Promise<Loaded<typeof ImageDefinition, { $each: true }>> {
// Get the original size of the image
const { width: originalWidth, height: originalHeight } =
await getImageSize(imageBlobOrFile);
const highestDimension = Math.max(originalWidth, originalHeight);
// Calculate the sizes to resize the image to
const resizes = [256, 1024, 2048, highestDimension]
.filter((s) => s <= (options?.maxSize ?? highestDimension))
.toSorted((a, b) => a - b);
// Get the highest resolution to use as final original size
// In case of options.maxSize, it's not the originalWidth/Height
const { width: finalWidth, height: finalHeight } = getNewDimensions(
originalWidth,
originalHeight,
resizes.at(-1)!,
);
const imageDefinition = ImageDefinition.create(
{ originalSize: [finalWidth, finalHeight] },
options?.owner,
);
const owner = imageDefinition._owner;
// Placeholder 8x8
imageDefinition.placeholderDataURL =
await getPlaceholderBase64(imageBlobOrFile);
// Resizes for progressive loading
for (let size of resizes) {
// Calculate width and height respecting the aspect ratio
const { width, height } = getNewDimensions(
originalWidth,
originalHeight,
size,
);
const image = await resize(imageBlobOrFile, width, height);
const binaryStream = await FileStream.createFromBlob(image, owner);
imageDefinition[`${width}x${height}`] = binaryStream;
}
return imageDefinition;
}
async function getImageSize(
imageBlobOrFile: Blob | File,
): Promise<{ width: number; height: number }> {
const { width, height } = await new Promise<{
width: number;
height: number;
}>((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
reject(new Error("Failed to load image"));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(imageBlobOrFile);
});
return { width, height };
}
async function getPlaceholderBase64(
imageBlobOrFile: Blob | File,
): Promise<string> {
// Inizialize Reducer here to not have module side effects
if (!reducer) {
reducer = new ImageBlobReduce({ pica: new Pica() });
}
const canvas = await reducer.toCanvas(imageBlobOrFile, { max: 8 });
return canvas.toDataURL("image/png");
}
async function resize(
imageBlobOrFile: Blob | File,
width: number,
height: number,
): Promise<Blob> {
// Inizialize Reducer here to not have module side effects
if (!reducer) {
reducer = new ImageBlobReduce({ pica: new Pica() });
}
return reducer.toBlob(imageBlobOrFile, { max: Math.max(width, height) });
}
const getNewDimensions = (
originalWidth: number,
originalHeight: number,
maxSize: number,
) => {
const width =
originalWidth > originalHeight
? maxSize
: Math.round(maxSize * (originalWidth / originalHeight));
const height =
originalHeight > originalWidth
? maxSize
: Math.round(maxSize * (originalHeight / originalWidth));
return { width, height };
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,180 @@
import {
Account,
FileStream,
Group,
ImageDefinition,
type Loaded,
} from "jazz-tools";
export type SourceType = Blob | File | string;
export type CreateImageOptions = {
/** The owner of the image. Can be either a Group or Account. If not specified, the current user will be the owner. */
owner?: Group | Account;
/**
* Controls placeholder generation for the image.
* - `"blur"`: Generates a blurred placeholder image (default)
* - `false`: No placeholder is generated
* @default "blur"
*/
placeholder?: "blur" | false;
/**
* Maximum size constraint for the image. The image will be resized to fit within this size while maintaining aspect ratio.
* If the image is smaller than maxSize in both dimensions, no resizing occurs.
* @example 1024 // Resizes image to fit within 1024px in the largest dimension
*/
maxSize?: number; // | [number, number];
/**
* The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available.
* This is useful for improving the user experience by showing a placeholder while the image is loading.
*
* Passing progressive: true to createImage() will create internal smaller versions of the image for future uses.
*
* @default false
*/
progressive?: boolean;
};
export type CreateImageImpl = {
createFileStreamFromSource: (
imageBlobOrFile: SourceType,
owner?: Group | Account,
) => Promise<FileStream>;
getImageSize: (
imageBlobOrFile: SourceType,
) => Promise<{ width: number; height: number }>;
getPlaceholderBase64: (imageBlobOrFile: SourceType) => Promise<string>;
resize: (
imageBlobOrFile: SourceType,
width: number,
height: number,
) => Promise<Blob | string>;
};
export function createImageFactory(impl: CreateImageImpl) {
return (source: SourceType, options: CreateImageOptions) =>
createImage(source, options, impl);
}
async function createImage(
imageBlobOrFile: SourceType,
options: CreateImageOptions,
impl: CreateImageImpl,
): Promise<Loaded<typeof ImageDefinition, { $each: true }>> {
// Get the original size of the image
const { width: originalWidth, height: originalHeight } =
await impl.getImageSize(imageBlobOrFile);
const def: {
originalSize: [number, number];
progressive: boolean;
placeholderDataURL: string | undefined;
original?: FileStream;
files: Record<string, FileStream>;
} = {
originalSize: [originalWidth, originalHeight],
progressive: false,
placeholderDataURL: undefined,
files: {},
};
// Placeholder
if (options?.placeholder === "blur") {
def.placeholderDataURL = await impl.getPlaceholderBase64(imageBlobOrFile);
}
/**
* Original
*
* Save the original image.
* If the maxSize is set, resize the image to the maxSize if needed
*/
if (options?.maxSize === undefined) {
def.original = await impl.createFileStreamFromSource(
imageBlobOrFile,
options?.owner,
);
def.files[`${originalWidth}x${originalHeight}`] = def.original;
} else if (
options?.maxSize >= originalWidth &&
options?.maxSize >= originalHeight
) {
// no resizes required, just return the original image
def.original = await impl.createFileStreamFromSource(
imageBlobOrFile,
options?.owner,
);
def.files[`${originalWidth}x${originalHeight}`] = def.original;
} else {
const { width, height } = getNewDimensions(
originalWidth,
originalHeight,
options.maxSize,
);
const blob = await impl.resize(imageBlobOrFile, width, height);
def.originalSize = [width, height];
def.original = await impl.createFileStreamFromSource(blob, options?.owner);
def.files[`${width}x${height}`] = def.original;
}
const imageCoValue = ImageDefinition.create(
{
originalSize: def.originalSize,
progressive: def.progressive,
placeholderDataURL: def.placeholderDataURL,
original: def.original,
...def.files,
},
options?.owner,
);
/**
* Progressive loading
*
* Save a set of resized images using three sizes: 256, 1024, 2048
*
* On the client side, the image will be loaded progressively, starting from the smallest size and increasing the size until the original size is reached.
*/
if (options?.progressive) {
imageCoValue.progressive = true;
const resizes = ([256, 1024, 2048] as const).filter(
(s) =>
s <
Math.max(imageCoValue.originalSize[0], imageCoValue.originalSize[1]),
);
for (const size of resizes) {
const { width, height } = getNewDimensions(
originalWidth,
originalHeight,
size,
);
const blob = await impl.resize(imageBlobOrFile, width, height);
imageCoValue[`${width}x${height}`] =
await impl.createFileStreamFromSource(blob, options?.owner);
}
}
return imageCoValue;
}
const getNewDimensions = (
originalWidth: number,
originalHeight: number,
maxSize: number,
) => {
if (originalWidth > originalHeight) {
return {
width: maxSize,
height: Math.round(maxSize * (originalHeight / originalWidth)),
};
}
return {
width: Math.round(maxSize * (originalWidth / originalHeight)),
height: maxSize,
};
};

View File

@@ -0,0 +1,150 @@
import { Account, FileStream, Group, ImageDefinition } from "jazz-tools";
import { CreateImageOptions, createImageFactory } from "./create-image.js";
export { highestResAvailable, loadImage, loadImageBySize } from "./utils.js";
export { createImageFactory };
export async function createImage(
imageBlobOrFile: Blob | File | string,
options?: CreateImageOptions,
) {
return createImageFactory({
createFileStreamFromSource,
getImageSize,
getPlaceholderBase64,
resize,
})(imageBlobOrFile, options || {});
}
// Image Manipulations
async function createFileStreamFromSource(
imageBlobOrFile: Blob | File | string,
owner?: Account | Group,
): Promise<FileStream> {
if (typeof imageBlobOrFile === "string") {
throw new Error(
"createFileStreamFromSource(string) is not supported on this platform",
);
}
return FileStream.createFromBlob(imageBlobOrFile, owner);
}
// using createImageBitmap is ~10x slower than Image object
// Image object: 640 milliseconds
// createImageBitmap: 8128 milliseconds
function getImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
reject(new Error("Failed to load image"));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(blob);
});
}
async function getImageSize(
imageBlobOrFile: Blob | File | string,
): Promise<{ width: number; height: number }> {
if (typeof imageBlobOrFile === "string") {
throw new Error("getImageSize(string) is not supported on browser");
}
const image = await getImageFromBlob(imageBlobOrFile);
return { width: image.width, height: image.height };
}
async function getPlaceholderBase64(
imageBlobOrFile: Blob | File | string,
): Promise<string> {
if (typeof imageBlobOrFile === "string") {
throw new Error("getPlaceholderBase64(string) is not supported on browser");
}
const image = await getImageFromBlob(imageBlobOrFile);
const { width, height } = resizeDimensionsKeepingAspectRatio(
image.width,
image.height,
8,
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get context");
}
ctx.drawImage(image, 0, 0, width, height);
return canvas.toDataURL("image/png");
}
const resizeDimensionsKeepingAspectRatio = (
width: number,
height: number,
maxSize: number,
): { width: number; height: number } => {
if (width <= maxSize && height <= maxSize) {
return { width, height };
}
const aspectRatio = width / height;
if (width >= height) {
return { width: maxSize, height: Math.round(maxSize / aspectRatio) };
} else {
return { width: Math.round(maxSize * aspectRatio), height: maxSize };
}
};
async function resize(
imageBlobOrFile: Blob | File | string,
width: number,
height: number,
): Promise<Blob> {
if (typeof imageBlobOrFile === "string") {
throw new Error("resize(string) is not supported on browser");
}
const mimeType = imageBlobOrFile.type;
const image = await getImageFromBlob(imageBlobOrFile);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get context");
}
ctx.drawImage(image, 0, 0, width, height);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to convert canvas to blob"));
return;
}
resolve(blob);
},
mimeType,
0.8,
);
});
}

View File

@@ -0,0 +1,153 @@
import ImageResizer from "@bam.tech/react-native-image-resizer";
import type { Account, Group, ImageDefinition } from "jazz-tools";
import { FileStream } from "jazz-tools";
import { Image } from "react-native";
import {
CreateImageOptions,
SourceType,
createImageFactory,
} from "./create-image.js";
export { highestResAvailable, loadImage, loadImageBySize } from "./utils.js";
export { createImageFactory };
export async function createImage(
imageBlobOrFile: Blob | File | string,
options?: CreateImageOptions,
) {
return createImageFactory({
getImageSize,
getPlaceholderBase64,
createFileStreamFromSource,
resize,
})(imageBlobOrFile, options || {});
}
async function getImageSize(
filePath: SourceType,
): Promise<{ width: number; height: number }> {
if (typeof filePath !== "string") {
throw new Error(
"createImage(Blob | File) is not supported on this platform",
);
}
const { width, height } = await Image.getSize(filePath);
return { width, height };
}
async function getPlaceholderBase64(filePath: SourceType): Promise<string> {
if (typeof filePath !== "string") {
throw new Error(
"createImage(Blob | File) is not supported on this platform",
);
}
if (typeof ImageResizer === "undefined" || ImageResizer === null) {
throw new Error(
"ImageResizer is not installed, please run `npm install @bam.tech/react-native-image-resizer`",
);
}
const { uri } = await ImageResizer.createResizedImage(
filePath,
8,
8,
"PNG",
100,
);
return imageUrlToBase64(uri);
}
async function resize(
filePath: SourceType,
width: number,
height: number,
): Promise<string> {
if (typeof filePath !== "string") {
throw new Error(
"createImage(Blob | File) is not supported on this platform",
);
}
if (typeof ImageResizer === "undefined" || ImageResizer === null) {
throw new Error(
"ImageResizer is not installed, please run `npm install @bam.tech/react-native-image-resizer`",
);
}
const mimeType = await getMimeType(filePath);
const { uri } = await ImageResizer.createResizedImage(
filePath,
width,
height,
contentTypeToFormat(mimeType),
80,
);
return uri;
}
function getMimeType(filePath: string): Promise<string> {
return fetch(filePath)
.then((res) => res.blob())
.then((blob) => blob.type);
}
function contentTypeToFormat(contentType: string) {
if (contentType.includes("image/png")) return "PNG";
if (contentType.includes("image/jpeg")) return "JPEG";
if (contentType.includes("image/webp")) return "WEBP";
return "PNG";
}
export async function createFileStreamFromSource(
filePath: SourceType,
owner?: Account | Group,
): Promise<FileStream> {
if (typeof filePath !== "string") {
throw new Error(
"createImage(Blob | File) is not supported on this platform",
);
}
const blob = await fetch(filePath).then((res) => res.blob());
const arrayBuffer = await toArrayBuffer(blob);
return FileStream.createFromArrayBuffer(arrayBuffer, blob.type, undefined, {
owner,
});
}
// TODO: look for more efficient way to do this as React Native hasn't blob.arrayBuffer()
function toArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as ArrayBuffer);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(blob);
});
}
async function imageUrlToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
return new Promise((onSuccess, onError) => {
try {
const reader = new FileReader();
reader.onload = function () {
onSuccess(reader.result as string);
};
reader.readAsDataURL(blob);
} catch (e) {
onError(e);
}
});
}

View File

@@ -0,0 +1,61 @@
import type { ImageDefinition } from "jazz-tools";
import {
CreateImageOptions,
SourceType,
createImageFactory,
} from "./create-image.js";
export { highestResAvailable, loadImage, loadImageBySize } from "./utils.js";
export { createImageFactory };
/**
* Creates an ImageDefinition from an image file or blob with built-in UX features.
*
* This function creates a specialized CoValue for managing images in Jazz applications.
* It supports blurry placeholders, built-in resizing, and progressive loading patterns.
*
* @returns Promise that resolves to an ImageDefinition
*
* @example
* ```ts
* import { createImage } from "jazz-tools/media";
*
* // Create an image from a file input
* async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
* const file = event.target.files?.[0];
* if (file) {
* // Creates ImageDefinition with a blurry placeholder, limited to 1024px
* // on the longest side, and multiple resolutions automatically
* const image = await createImage(file, {
* owner: me._owner,
* maxSize: 1024,
* placeholder: "blur",
* progressive: true,
* });
*
* // Store the image in your application data
* me.profile.image = image;
* }
* }
* ```
*
* @example
* ```ts
* // React Native example
* import { createImage } from "jazz-tools/media";
*
* async function uploadImageFromCamera(imagePath: string) {
* const image = await createImage(imagePath, {
* maxSize: 800,
* placeholder: "blur",
* progressive: false,
* });
*
* return image;
* }
* ```
*/
export declare function createImage(
imageBlobOrFile: SourceType,
options?: CreateImageOptions,
): Promise<ImageDefinition>;

View File

@@ -0,0 +1,373 @@
import { Account, FileStream, Group, ImageDefinition } from "jazz-tools";
import {
createJazzTestAccount,
setActiveAccount,
setupJazzTestSync,
} from "jazz-tools/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { highestResAvailable, loadImageBySize } from "./utils.js";
const createFileStream = (account: any, blobSize?: number) => {
return FileStream.createFromBlob(
new Blob([new Uint8Array(blobSize || 1)], { type: "image/png" }),
{
owner: account,
},
);
};
describe("highestResAvailable", async () => {
let account: Account;
beforeEach(async () => {
account = await createJazzTestAccount({
isCurrentActiveAccount: true,
});
vi.spyOn(Account, "getMe").mockReturnValue(account);
await setupJazzTestSync();
});
it("returns original if progressive is false", async () => {
const original = await createFileStream(account._owner);
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
progressive: false,
original,
},
{ owner: account._owner },
);
imageDef["1920x1080"] = original;
const result = highestResAvailable(imageDef, 256, 256);
expect(result?.image.id).toBe(original.id);
});
it("returns original if progressive is true but no resizes present", async () => {
const original = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["1920x1080"] = original;
const result = highestResAvailable(imageDef, 256, 256);
expect(result?.image.id).toBe(original.id);
});
it("returns closest available resize if progressive is true", async () => {
const original = await createFileStream(account._owner);
const resize256 = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["1920x1080"] = original;
imageDef["256x256"] = resize256;
const result = highestResAvailable(imageDef, 256, 256);
expect(result?.image.id).toBe(resize256.id);
});
it("returns original if wanted size matches original size", async () => {
const original = await createFileStream(account._owner);
const imageDef = ImageDefinition.create(
{
originalSize: [1024, 1024],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["1024x1024"] = original;
const result = highestResAvailable(imageDef, 1024, 1024);
expect(result?.image.id).toBe(original.id);
});
it("returns best fit among multiple resizes", async () => {
const original = await createFileStream(account._owner);
const resize256 = await createFileStream(account._owner, 1);
const resize1024 = await createFileStream(account._owner, 1);
const resize2048 = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [2048, 2048],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["256x256"] = resize256;
imageDef["1024x1024"] = resize1024;
imageDef["2048x2048"] = resize2048;
// Closest to 900x900 is 1024
const result = highestResAvailable(imageDef, 900, 900);
expect(result?.image.id).toBe(resize1024.id);
});
it("returns the best fit resolution", async () => {
const original = await createFileStream(account._owner, 1);
const resize256 = await createFileStream(account._owner, 1);
const resize2048 = await createFileStream(account._owner, 1);
// 1024 is not loaded yet
const resize1024 = FileStream.create({ owner: account._owner });
resize1024.start({ mimeType: "image/jpeg" });
// Don't end resize1024, so it has no chunks
const imageDef = ImageDefinition.create(
{
originalSize: [2048, 2048],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["256x256"] = resize256;
imageDef["1024x1024"] = resize1024;
imageDef["2048x2048"] = resize2048;
// Closest to 900x900 is 1024
const result = highestResAvailable(imageDef, 900, 900);
expect(result?.image.id).toBe(resize2048.id);
});
it("returns original if no resizes are loaded (missing chunks)", async () => {
const original = await createFileStream(account._owner);
const imageDef = ImageDefinition.create(
{
originalSize: [256, 256],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["256x256"] = original;
// 1024 is not loaded yet
const resize1024 = FileStream.create({ owner: account._owner });
resize1024.start({ mimeType: "image/jpeg" });
// Don't end resize1024, so it has no chunks
imageDef["1024x1024"] = resize1024;
const result = highestResAvailable(imageDef, 1024, 1024);
// Only original is valid
expect(result?.image.id).toBe(original.id);
});
it("returns the first loaded resize if original is not loaded yet(missing chunks)", async () => {
const original = FileStream.create({ owner: account._owner });
original.start({ mimeType: "image/jpeg" });
// Don't call .end(), so it has no chunks
const imageDef = ImageDefinition.create(
{
originalSize: [300, 300],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["256x256"] = await createFileStream(account._owner, 1);
const result = highestResAvailable(imageDef, 1024, 1024);
// Only original is valid
expect(result?.image.id).toBe(imageDef["256x256"].id);
});
it("returns the highest resolution if no good match is found", async () => {
const original = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [300, 300],
progressive: true,
original,
},
{ owner: account._owner },
);
imageDef["256x256"] = await createFileStream(account._owner, 1);
imageDef["300x300"] = original;
const result = highestResAvailable(imageDef, 1024, 1024);
expect(result?.image.id).toBe(original.id);
});
});
describe("loadImageBySize", async () => {
let account: Account;
beforeEach(async () => {
account = await setupJazzTestSync();
setActiveAccount(account);
});
const createImageDef = async (
sizes: Array<[number, number]>,
progressive = true,
owner: Account | Group = account,
) => {
if (sizes.length === 0) throw new Error("sizes array must not be empty");
const originalSize = sizes[sizes.length - 1]!;
sizes = sizes.slice(0, -1);
const original = await createFileStream(owner, 1);
// Ensure sizes array is not empty
const imageDef = ImageDefinition.create(
{
originalSize,
progressive,
original,
},
{ owner },
);
imageDef[`${originalSize[0]}x${originalSize[1]}`] = original;
for (const size of sizes) {
if (!size) continue;
const [w, h] = size;
imageDef[`${w}x${h}`] = await createFileStream(owner, 1);
}
return imageDef;
};
it("returns original if progressive is false", async () => {
const imageDef = await createImageDef([[1920, 1080]], false);
const result = await loadImageBySize(imageDef, 256, 256);
expect(result?.image.id).toBe(imageDef["1920x1080"]!.id);
});
it("returns the original image already loaded", async () => {
const account = await setupJazzTestSync({ asyncPeers: true });
const account2 = await createJazzTestAccount();
setActiveAccount(account);
const group = Group.create();
group.addMember("everyone", "reader");
const imageDef = await createImageDef([[1920, 1080]], false, group);
setActiveAccount(account2);
const result = await loadImageBySize(imageDef, 256, 256);
expect(result?.image.id).toBe(imageDef["1920x1080"]!.id);
expect(result?.image.isBinaryStreamEnded()).toBe(true);
expect(result?.image.asBase64()).toStrictEqual(expect.any(String));
});
it("returns null if no sizes are available", async () => {
const original = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
progressive: true,
original,
},
{ owner: account._owner },
);
const result = await loadImageBySize(imageDef, 256, 256);
expect(result).toBeNull();
});
it("returns the closest available resize if progressive is true", async () => {
const imageDef = await createImageDef([
[256, 256],
[1920, 1080],
]);
const result = await loadImageBySize(imageDef.id, 256, 256);
expect(result?.image.id).toBe(imageDef["256x256"]!.id);
expect(result?.width).toBe(256);
expect(result?.height).toBe(256);
});
it("returns the best fit among multiple resizes", async () => {
const imageDef = await createImageDef([
[256, 256],
[1024, 1024],
[2048, 2048],
]);
const result = await loadImageBySize(imageDef, 900, 900);
expect(result?.image.id).toBe(imageDef["1024x1024"]!.id);
expect(result?.width).toBe(1024);
expect(result?.height).toBe(1024);
});
it("returns the highest resolution if no good match is found", async () => {
const imageDef = await createImageDef([
[256, 256],
[300, 300],
]);
const result = await loadImageBySize(imageDef, 1024, 1024);
expect(result?.image.id).toBe(imageDef["300x300"]!.id);
expect(result?.width).toBe(300);
expect(result?.height).toBe(300);
});
it("returns null if the best target is not loaded", async () => {
const original = await createFileStream(account._owner, 1);
const imageDef = ImageDefinition.create(
{
originalSize: [256, 256],
progressive: true,
original,
},
{ owner: account._owner },
);
// No resizes added
const result = await loadImageBySize(imageDef, 1024, 1024);
expect(result).toBeNull();
});
it("returns the correct size when wanted size matches available size exactly", async () => {
const imageDef = await createImageDef([
[512, 512],
[1024, 1024],
]);
const result = await loadImageBySize(imageDef, 1024, 1024);
expect(result?.image.id).toBe(imageDef["1024x1024"]!.id);
expect(result?.width).toBe(1024);
expect(result?.height).toBe(1024);
});
it("returns the image already loaded", async () => {
const account = await setupJazzTestSync({ asyncPeers: true });
const account2 = await createJazzTestAccount();
setActiveAccount(account);
const group = Group.create();
group.addMember("everyone", "reader");
const imageDef = await createImageDef(
[
[512, 512],
[1024, 1024],
],
undefined,
group,
);
setActiveAccount(account2);
const result = await loadImageBySize(imageDef, 1024, 1024);
expect(result?.image.id).toBe(imageDef["1024x1024"]!.id);
expect(result?.image.isBinaryStreamEnded()).toBe(true);
expect(result?.image.asBase64()).toStrictEqual(expect.any(String));
});
});

View File

@@ -0,0 +1,205 @@
import type { CoID } from "cojson";
import { Account, FileStream, ImageDefinition } from "jazz-tools";
export function highestResAvailable(
image: ImageDefinition,
wantedWidth: number,
wantedHeight: number,
): { width: number; height: number; image: FileStream } | null {
const availableSizes: [number, number, string][] = image._raw
.keys()
.filter((key) => /^\d+x\d+$/.test(key))
.map((key) => {
const [w, h] = key.split("x").map(Number) as [number, number];
return [w, h, key];
});
if (availableSizes.length === 0) {
return image.original
? {
width: image.originalSize[0],
height: image.originalSize[1],
image: image.original,
}
: null;
}
const sortedSizes = availableSizes
.map((size) => {
return {
size,
match: sizesMatchWanted(size[0], size[1], wantedWidth, wantedHeight),
isLoaded: isLoaded(image._raw.get(size[2]) as CoID<any> | undefined),
};
})
.sort((a, b) => a.match - b.match);
// We try to find the better already loaded image
// note: `toReversed` is not available in react-native.
const bestLoaded = [...sortedSizes]
.reverse()
.find((el) => el.isLoaded && image[el.size[2]]?.getChunks());
// if I can't find a good match, let's use the highest resolution
const bestTarget =
sortedSizes.find((el) => el.match > 0.95) || sortedSizes.at(-1);
// if the best target is already loaded, we are done
if (image[bestTarget!.size[2]]?.getChunks()) {
return image[bestTarget!.size[2]]
? {
width: bestTarget!.size[0],
height: bestTarget!.size[1],
image: image[bestTarget!.size[2]]!,
}
: null;
}
// if the best already loaded is not the best target
// let's trigger the load of the best target
if (bestLoaded) {
image[bestTarget!.size[2]]?.getChunks();
return image[bestLoaded.size[2]]
? {
width: bestLoaded.size[0],
height: bestLoaded.size[1],
image: image[bestLoaded.size[2]]!,
}
: null;
}
// if nothing is loaded, then start fetching all the images till the best
for (let size of sortedSizes) {
if (size.match <= bestTarget!.match) {
image[size.size[2]]?.getChunks();
}
}
return null;
}
function sizesMatchWanted(
w: number,
h: number,
wantedW: number,
wantedH: number,
): number {
const area1 = w * h;
const area2 = wantedW * wantedH;
const areaRatio = area1 / area2;
// // Below 0.95 means the image is too small, we don't want to upscale it
// if (areaRatio < 0.95) {
// return 9999;
// }
return areaRatio;
}
function isLoaded(id: CoID<any> | null | undefined): boolean {
if (!id) {
return false;
}
return !!Account.getMe()._raw.core.node.getLoaded(id);
}
export async function loadImage(
imageOrId: ImageDefinition | string,
): Promise<{ width: number; height: number; image: FileStream } | null> {
if (typeof imageOrId === "string") {
const image = await ImageDefinition.load(imageOrId, {
resolve: {
original: true,
},
});
if (image === null || image.original === null) {
return null;
}
return {
width: image.originalSize[0],
height: image.originalSize[1],
image: image.original,
};
}
if (!imageOrId.original) {
console.warn("Unable to find the original image");
return null;
}
const loadedOriginal = await FileStream.load(imageOrId.original.id);
if (!loadedOriginal) {
console.warn("Unable to find the original image");
return null;
}
return {
width: imageOrId.originalSize[0],
height: imageOrId.originalSize[1],
image: loadedOriginal,
};
}
export async function loadImageBySize(
imageOrId: ImageDefinition | string,
wantedWidth: number,
wantedHeight: number,
): Promise<{ width: number; height: number; image: FileStream } | null> {
const image: ImageDefinition | null =
typeof imageOrId === "string"
? await ImageDefinition.load(imageOrId)
: imageOrId;
if (image === null) {
return null;
}
if (image.progressive === false) {
return loadImage(imageOrId);
}
const availableSizes: [number, number, string][] = image._raw
.keys()
.filter((key) => /^\d+x\d+$/.test(key))
.map((key) => {
const [w, h] = key.split("x").map(Number) as [number, number];
return [w, h, key];
});
if (availableSizes.length === 0) {
return null;
}
const sortedSizes = availableSizes
.map((size) => ({
size,
match: sizesMatchWanted(size[0], size[1], wantedWidth, wantedHeight),
}))
.sort((a, b) => a.match - b.match);
const bestTarget =
sortedSizes.find((el) => el.match > 0.95) || sortedSizes.at(-1)!;
const file = image[bestTarget.size[2]];
if (!file) {
return null;
}
const loadedFile = await FileStream.load(file.id);
if (!loadedFile) {
return null;
}
return {
width: bestTarget.size[0],
height: bestTarget.size[1],
image: loadedFile,
};
}

View File

@@ -1,8 +1,8 @@
export * from "./auth/auth.js";
export * from "./hooks.js";
export * from "./media.js";
export * from "./provider.js";
export * from "./storage/kv-store-context.js";
export * from "./media/image.js";
export { SQLiteDatabaseDriverAsync } from "cojson";
export { parseInviteLink } from "jazz-tools";

View File

@@ -1,79 +0,0 @@
import { ImageDefinition, Loaded } from "jazz-tools";
import React, { useEffect, useState } from "react";
/** @category Media */
export function useProgressiveImgNative({
image,
maxWidth,
targetWidth,
}: {
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}) {
const [current, setCurrent] = useState<
{ src?: string; res?: `${number}x${number}` | "placeholder" } | undefined
>(undefined);
useEffect(() => {
let lastHighestRes: string | undefined;
if (!image) return;
const unsub = image.subscribe({}, (update) => {
const highestRes = ImageDefinition.highestResAvailable(update, {
maxWidth,
targetWidth,
});
if (highestRes && highestRes.res !== lastHighestRes) {
lastHighestRes = highestRes.res;
// use the base64 data directly
const dataUrl = highestRes.stream.asBase64({ dataURL: true });
if (dataUrl) {
setCurrent({
src: dataUrl,
res: highestRes.res,
});
} else {
// Fallback to placeholder if chunks aren't available
console.warn("No chunks available for image", image.id);
setCurrent({
src: update?.placeholderDataURL,
res: "placeholder",
});
}
} else if (!highestRes) {
setCurrent({
src: update?.placeholderDataURL,
res: "placeholder",
});
}
});
return unsub;
}, [image?.id, maxWidth]);
return {
src: current?.src,
res: current?.res,
originalSize: image?.originalSize,
};
}
/** @category Media */
export function ProgressiveImgNative({
children,
image,
maxWidth,
targetWidth,
}: {
children: (result: {
src: string | undefined;
res: `${number}x${number}` | "placeholder" | undefined;
originalSize: readonly [number, number] | undefined;
}) => React.ReactNode;
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}) {
const result = useProgressiveImgNative({ image, maxWidth, targetWidth });
return result && children(result);
}

View File

@@ -0,0 +1,159 @@
import { FileStream, ImageDefinition } from "jazz-tools";
import { highestResAvailable } from "jazz-tools/media";
import { forwardRef, useEffect, useMemo, useState } from "react";
import { Image as RNImage, ImageProps as RNImageProps } from "react-native";
import { useCoState } from "../hooks.js";
export type ImageProps = Omit<RNImageProps, "width" | "height" | "source"> & {
/** The ID of the ImageDefinition to display */
imageId: string;
/**
* Width of the image. Can be a number or "original" to use the original image width.
* When set to "original", the component will calculate the appropriate height to maintain aspect ratio.
*
* @example
* ```tsx
* // Fixed width, auto-calculated height
* <Image imageId="123" width={600} />
*
* // Original width
* <Image imageId="123" width="original" />
* ```
*/
width?: number | "original";
/**
* Height of the image. Can be a number or "original" to use the original image height.
* When set to "original", the component will calculate the appropriate width to maintain aspect ratio.
*
* @example
* ```tsx
* // Fixed height, auto-calculated width
* <Image imageId="123" height={400} />
*
* // Original height
* <Image imageId="123" height="original" />
* ```
*/
height?: number | "original";
};
/**
* A React Native Image component that integrates with Jazz's ImageDefinition system.
*
* @example
* ```tsx
* import { Image } from "jazz-tools/react-native";
* import { StyleSheet } from "react-native";
*
* function ProfilePicture({ imageId }) {
* return (
* <Image
* imageId={imageId}
* style={styles.profilePic}
* width={100}
* height={100}
* resizeMode="cover"
* />
* );
* }
*
* const styles = StyleSheet.create({
* profilePic: {
* borderRadius: 50,
* }
* });
* ```
*/
export const Image = forwardRef<RNImage, ImageProps>(function Image(
{ imageId, width, height, ...props },
ref,
) {
const image = useCoState(ImageDefinition, imageId);
const [src, setSrc] = useState<string | undefined>(image?.placeholderDataURL);
const dimensions: { width: number | undefined; height: number | undefined } =
useMemo(() => {
const originalWidth = image?.originalSize?.[0];
const originalHeight = image?.originalSize?.[1];
// Both width and height are "original"
if (width === "original" && height === "original") {
return { width: originalWidth, height: originalHeight };
}
// Width is "original", height is a number
if (width === "original" && typeof height === "number") {
if (originalWidth && originalHeight) {
return {
width: Math.round((height * originalWidth) / originalHeight),
height,
};
}
return { width: undefined, height };
}
// Height is "original", width is a number
if (height === "original" && typeof width === "number") {
if (originalWidth && originalHeight) {
return {
width,
height: Math.round((width * originalHeight) / originalWidth),
};
}
return { width, height: undefined };
}
// In all other cases, use the property value:
return {
width: width === "original" ? originalWidth : width,
height: height === "original" ? originalHeight : height,
};
}, [image?.originalSize, width, height]);
useEffect(() => {
if (!image) return;
let lastBestImage: FileStream | string | undefined =
image.placeholderDataURL;
const unsub = image.subscribe({}, (update) => {
if (lastBestImage === undefined && update.placeholderDataURL) {
setSrc(update.placeholderDataURL);
lastBestImage = update.placeholderDataURL;
}
const bestImage = highestResAvailable(
update,
dimensions.width || dimensions.height || 9999,
dimensions.height || dimensions.width || 9999,
);
if (!bestImage) return;
if (lastBestImage === bestImage.image) return;
const url = bestImage.image.asBase64({ dataURL: true });
if (url) {
setSrc(url);
lastBestImage = bestImage.image;
}
});
return unsub;
}, [image]);
if (!image) {
return null;
}
return (
<RNImage
ref={ref}
source={{ uri: src }}
width={dimensions.width}
height={dimensions.height}
{...props}
/>
);
});

View File

@@ -1,238 +0,0 @@
import ImageResizer from "@bam.tech/react-native-image-resizer";
import * as FileSystem from "expo-file-system";
import {
Account,
FileStream,
Group,
ImageDefinition,
Loaded,
} from "jazz-tools";
import { Image } from "react-native";
function arrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as ArrayBuffer);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(blob);
});
}
async function fileUriToBlob(uri: string): Promise<Blob> {
try {
const response = await fetch(uri);
const blob = await response.blob();
blob.arrayBuffer = () => arrayBuffer(blob);
return blob;
} catch (error) {
console.error("Failed to convert file URI to Blob:", error);
throw new Error("Failed to convert file URI to Blob");
}
}
async function convertFileContentsToBase64DataURI(
fileUri: string,
contentType: string,
) {
try {
const base64 = await FileSystem.readAsStringAsync(fileUri, {
encoding: FileSystem.EncodingType.Base64,
});
return `data:${contentType};base64,${base64}`;
} catch (error) {
console.error("Failed to convert file to base64:", error);
return null;
}
}
function base64DataURIToParts(base64Data: string) {
const parts = base64Data.split(",");
const contentType = parts[0]?.split(":")?.[1]?.split(";")?.[0] || "";
const data = parts[1] || "";
return { contentType, data };
}
function contentTypeToFormat(contentType: string) {
if (contentType.includes("image/png")) return "PNG";
if (contentType.includes("image/jpeg")) return "JPEG";
if (contentType.includes("image/webp")) return "WEBP";
return "PNG";
}
async function base64DataURIToBlob(base64Data: string) {
const { contentType, data } = base64DataURIToParts(base64Data);
const byteCharacters = atob(data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// @ts-expect-error byteArray has data
const blob = new Blob(byteArray, { type: contentType });
blob.arrayBuffer = () => arrayBuffer(blob);
return blob;
}
async function getImageDimensions(
uri: string,
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => resolve({ width, height }),
(error) => {
console.error("Failed to get image dimensions:", error);
reject(new Error("Failed to get image dimensions"));
},
);
});
}
/** @category Image creation */
export async function createImageNative(
base64ImageDataURI: string,
options: {
owner?: Group | Account;
maxSize?: 256 | 1024 | 2048;
} = {},
): Promise<Loaded<typeof ImageDefinition>> {
try {
const { contentType } = base64DataURIToParts(base64ImageDataURI);
const format = contentTypeToFormat(contentType);
let originalWidth, originalHeight;
try {
({ width: originalWidth, height: originalHeight } =
await getImageDimensions(base64ImageDataURI));
} catch (error) {
console.error("Error getting image dimensions:", error);
throw new Error("Failed to get image dimensions");
}
let placeholderImage;
try {
placeholderImage = await ImageResizer.createResizedImage(
base64ImageDataURI,
8,
8,
format,
100,
0,
);
} catch (error) {
console.error("Error creating placeholder image:", error);
throw new Error("Failed to create placeholder image");
}
const placeholderDataURL = await convertFileContentsToBase64DataURI(
placeholderImage.uri,
contentType,
);
if (!placeholderDataURL) {
throw new Error("Failed to create placeholder data URL");
}
const imageDefinition = ImageDefinition.create(
{
originalSize: [originalWidth, originalHeight],
placeholderDataURL,
},
options.owner,
);
const addImageStream = async (
width: number,
height: number,
label: string,
) => {
try {
const resizedImage = await ImageResizer.createResizedImage(
base64ImageDataURI,
width,
height,
format,
80,
0,
);
const binaryStream = await FileStream.createFromBlob(
await fileUriToBlob(resizedImage.uri),
imageDefinition._owner,
);
imageDefinition[label] = binaryStream;
} catch (error) {
console.error(`Error adding image stream for ${label}:`, error);
throw new Error(`Failed to add image stream for ${label}`);
}
};
if (originalWidth > 256 || originalHeight > 256) {
const width =
originalWidth > originalHeight
? 256
: Math.round(256 * (originalWidth / originalHeight));
const height =
originalHeight > originalWidth
? 256
: Math.round(256 * (originalHeight / originalWidth));
await addImageStream(width, height, `${width}x${height}`);
}
if (options.maxSize === 256) return imageDefinition;
if (originalWidth > 1024 || originalHeight > 1024) {
const width =
originalWidth > originalHeight
? 1024
: Math.round(1024 * (originalWidth / originalHeight));
const height =
originalHeight > originalWidth
? 1024
: Math.round(1024 * (originalHeight / originalWidth));
await addImageStream(width, height, `${width}x${height}`);
}
if (options.maxSize === 1024) return imageDefinition;
if (originalWidth > 2048 || originalHeight > 2048) {
const width =
originalWidth > originalHeight
? 2048
: Math.round(2048 * (originalWidth / originalHeight));
const height =
originalHeight > originalWidth
? 2048
: Math.round(2048 * (originalHeight / originalWidth));
await addImageStream(width, height, `${width}x${height}`);
}
if (options.maxSize === 2048) return imageDefinition;
if (options.maxSize === undefined || options.maxSize > 2048) {
try {
const originalBinaryStream = await FileStream.createFromBlob(
await base64DataURIToBlob(base64ImageDataURI),
imageDefinition._owner,
);
imageDefinition[`${originalWidth}x${originalHeight}`] =
originalBinaryStream;
} catch (error) {
console.error("Error adding original image stream:", error);
throw new Error("Failed to add original image stream");
}
}
return imageDefinition;
} catch (error) {
console.error("Error in createImage:", error);
throw error;
}
}

View File

@@ -12,5 +12,4 @@ export {
export { createInviteLink, parseInviteLink } from "jazz-tools/browser";
export * from "./auth/auth.js";
export * from "./media.js";
export { createImage } from "jazz-tools/browser-media-images";
export * from "./media/image.js";

View File

@@ -1,74 +0,0 @@
import { ImageDefinition, Loaded } from "jazz-tools";
import React, { useEffect, useState } from "react";
/** @category Media */
export function useProgressiveImg({
image,
maxWidth,
targetWidth,
}: {
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}) {
const [current, setCurrent] = useState<
{ src?: string; res?: `${number}x${number}` | "placeholder" } | undefined
>(undefined);
useEffect(() => {
let lastHighestRes: string | undefined;
if (!image) return;
const unsub = image.subscribe({}, (update) => {
const highestRes = ImageDefinition.highestResAvailable(update, {
maxWidth,
targetWidth,
});
if (highestRes) {
if (highestRes.res !== lastHighestRes) {
lastHighestRes = highestRes.res;
const blob = highestRes.stream.toBlob();
if (blob) {
const blobURI = URL.createObjectURL(blob);
setCurrent({ src: blobURI, res: highestRes.res });
return () => {
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
};
}
}
} else {
setCurrent({
src: update?.placeholderDataURL,
res: "placeholder",
});
}
});
return unsub;
}, [image?.id, maxWidth]);
return {
src: current?.src,
res: current?.res,
originalSize: image?.originalSize,
};
}
/** @category Media */
export function ProgressiveImg({
children,
image,
maxWidth,
targetWidth,
}: {
children: (result: {
src: string | undefined;
res: `${number}x${number}` | "placeholder" | undefined;
originalSize: readonly [number, number] | undefined;
}) => React.ReactNode;
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}): React.ReactNode {
const result = useProgressiveImg({ image, maxWidth, targetWidth });
return result ? children(result) : null;
}

View File

@@ -0,0 +1,210 @@
import { ImageDefinition } from "jazz-tools";
import {
type JSX,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { highestResAvailable } from "../../media/index.js";
import { useCoState } from "../hooks.js";
export type ImageProps = Omit<
JSX.IntrinsicElements["img"],
"src" | "srcSet" | "width" | "height"
> & {
/** The ID of the ImageDefinition to display */
imageId: string;
/**
* The desired width of the image. Can be a number in pixels or "original" to use the image's original width.
* When set to a number, the component will select the best available resolution and maintain aspect ratio.
*
* @example
* ```tsx
* // Use original width
* <Image imageId="123" width="original" />
*
* // Set width to 600px, height will be calculated to maintain aspect ratio
* <Image imageId="123" width={600} />
*
* // Set both width and height to maintain aspect ratio
* <Image imageId="123" width={600} height={400} />
* ```
*/
width?: number | "original";
/**
* The desired height of the image. Can be a number in pixels or "original" to use the image's original height.
* When set to a number, the component will select the best available resolution and maintain aspect ratio.
*
* @example
* ```tsx
* // Use original height
* <Image imageId="123" height="original" />
*
* // Set height to 400px, width will be calculated to maintain aspect ratio
* <Image imageId="123" height={400} />
*
* // Set both width and height to maintain aspect ratio
* <Image imageId="123" width={600} height={400} />
* ```
*/
height?: number | "original";
};
/**
* A React component for displaying images stored as ImageDefinition CoValues.
*
* @example
* ```tsx
* import { Image } from "jazz-tools/react";
*
* // Force specific dimensions (may crop or stretch)
* function Avatar({ imageId }: { imageId: string }) {
* return (
* <Image
* imageId={imageId}
* width={100}
* height={100}
* alt="Avatar"
* style={{ borderRadius: "50%", objectFit: "cover" }}
* />
* );
* }
* ```
*/
export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
{ imageId, width, height, ...props },
ref,
) {
const image = useCoState(ImageDefinition, imageId);
const lastBestImage = useRef<[string, string] | null>(null);
/**
* For lazy loading, we use the browser's strategy for images with loading="lazy".
* We use an empty image, and when the browser triggers the load event, we load the best available image.
* On page loading, if the image url is already in browser's cache, the load event is triggered immediately.
* This is why we need to use a different blob url for every image.
*/
const [waitingLazyLoading, setWaitingLazyLoading] = useState(
props.loading === "lazy",
);
const lazyPlaceholder = useMemo(
() =>
waitingLazyLoading ? URL.createObjectURL(emptyPixelBlob) : undefined,
[waitingLazyLoading],
);
const dimensions: { width: number | undefined; height: number | undefined } =
useMemo(() => {
const originalWidth = image?.originalSize?.[0];
const originalHeight = image?.originalSize?.[1];
// Both width and height are "original"
if (width === "original" && height === "original") {
return { width: originalWidth, height: originalHeight };
}
// Width is "original", height is a number
if (width === "original" && typeof height === "number") {
if (originalWidth && originalHeight) {
return {
width: Math.round((height * originalWidth) / originalHeight),
height,
};
}
return { width: undefined, height };
}
// Height is "original", width is a number
if (height === "original" && typeof width === "number") {
if (originalWidth && originalHeight) {
return {
width,
height: Math.round((width * originalHeight) / originalWidth),
};
}
return { width, height: undefined };
}
// In all other cases, use the property value:
return {
width: width === "original" ? originalWidth : width,
height: height === "original" ? originalHeight : height,
};
}, [image?.originalSize, width, height]);
const src = useMemo(() => {
if (waitingLazyLoading) {
return lazyPlaceholder;
}
if (!image) return undefined;
const bestImage = highestResAvailable(
image,
dimensions.width || dimensions.height || 9999,
dimensions.height || dimensions.width || 9999,
);
if (!bestImage) return image.placeholderDataURL;
if (lastBestImage.current?.[0] === bestImage.image.id)
return lastBestImage.current?.[1];
const blob = bestImage.image.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
revokeObjectURL(lastBestImage.current?.[1]);
lastBestImage.current = [bestImage.image.id, url];
return url;
}
return image.placeholderDataURL;
}, [image, dimensions.width, dimensions.height, waitingLazyLoading]);
const onThresholdReached = useCallback(() => {
setWaitingLazyLoading(false);
}, []);
// Revoke object URL when component unmounts
useEffect(
() => () => {
// In development mode we don't revokeObjectURL on unmount because
// it triggers twice under StrictMode.
if (process.env.NODE_ENV === "development") return;
revokeObjectURL(lastBestImage.current?.[1]);
},
[],
);
return (
<img
ref={ref}
src={src}
width={dimensions.width}
height={dimensions.height}
onLoad={waitingLazyLoading ? onThresholdReached : undefined}
{...props}
/>
);
});
function revokeObjectURL(url: string | undefined) {
if (url && url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
const emptyPixelBlob = new Blob(
[
Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
),
(c) => c.charCodeAt(0),
),
],
{ type: "image/png" },
);

View File

@@ -1,50 +0,0 @@
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
// Simple document environment
global.document = {
createElement: () =>
({ src: "", onload: null }) as unknown as HTMLImageElement,
} as unknown as Document;
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
const me = await createJazzTestAccount();
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
const image = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
image["100x100"] = await FileStream.createFromBlob(mediumSizeBlob, {
owner: me,
});
image["1920x1080"] = await FileStream.createFromBlob(mediumSizeBlob, {
owner: me,
});
const imageElement = document.createElement("img");
// ---cut---
// Start with placeholder for immediate display
if (image.placeholderDataURL) {
imageElement.src = image.placeholderDataURL;
}
// Then load the best resolution for the current display
const screenWidth = window.innerWidth;
const bestRes = ImageDefinition.highestResAvailable(image, {
targetWidth: screenWidth,
});
if (bestRes) {
const blob = bestRes.stream.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
imageElement.src = url;
// Remember to revoke the URL when no longer needed
imageElement.onload = () => {
URL.revokeObjectURL(url);
};
}
}

View File

@@ -0,0 +1,588 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { Account, FileStream, ImageDefinition } from "../../../";
import { Image } from "../../media/image";
import { createJazzTestAccount } from "../../testing";
import { render, screen, waitFor } from "../testUtils";
describe("Image", async () => {
const account = await createJazzTestAccount({
isCurrentActiveAccount: true,
});
vi.spyOn(Account, "getMe").mockReturnValue(account);
describe("initial rendering", () => {
it("should render nothing if coValue is not found", async () => {
const { container } = render(
<Image imageId="co_zMTubMby3QiKDYnW9e2BEXW7Xaq" alt="test" />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
it("should render an empty image if the image is not loaded yet", async () => {
const original = FileStream.create({ owner: account._owner });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(<Image imageId={im.id} alt="test" />, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
it("should render the placeholder image if the image is not loaded yet", async () => {
const placeholderDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const original = FileStream.create({ owner: account._owner });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
placeholderDataURL: placeholderDataUrl,
},
{
owner: account,
},
);
const { container } = render(<Image imageId={im.id} alt="test" />, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe(placeholderDataUrl);
});
it("should render the original image once loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
render(<Image imageId={im.id} alt="test-loading" />, { account });
await waitFor(() => {
expect(
(screen.getByAltText("test-loading") as HTMLImageElement).src,
).toBe("blob:test-100");
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
});
describe("dimensions", () => {
it("should render the original image if the width and height are not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(<Image imageId={im.id} alt="test" />, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
});
it("should render the original sizes", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" width="original" height="original" />,
{
account,
},
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("100");
expect(img!.getAttribute("height")).toBe("100");
});
it("should render the original size keeping the aspect ratio", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" width="original" height={300} />,
{
account,
},
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("300");
expect(img!.getAttribute("height")).toBe("300");
});
it("should render the width attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" width={50} />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("50");
expect(img!.getAttribute("height")).toBeNull();
});
it("should render the height attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" height={50} />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBeNull();
expect(img!.getAttribute("height")).toBe("50");
});
it("should render the class attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" className="test-class" />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.classList.contains("test-class")).toBe(true);
});
});
describe("progressive loading", () => {
it("should render the resized image if progressive loading is enabled", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(500, account);
const im = ImageDefinition.create(
{
original,
originalSize: [500, 500],
progressive: true,
},
{
owner: account,
},
);
im["500x500"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = render(
<Image imageId={im.id} alt="test-progressive" width={300} />,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-500",
);
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
it("should show the highest resolution images as they are loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(1920, account);
const im = ImageDefinition.create(
{
original,
originalSize: [1920, 1080],
progressive: true,
},
{
owner: account,
},
);
im["1920x1080"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = render(
<Image imageId={im.id} alt="test-progressive" width={1024} />,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1920",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
// Load higher resolution image
im["1024x1024"] = await createDummyFileStream(1024, account);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
it("should show the best loaded resolution if width is set", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await FileStream.createFromBlob(createDummyBlob(1), {
owner: account,
});
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im["100x100"] = original;
im["256x256"] = await createDummyFileStream(256, account);
im["1024x1024"] = await createDummyFileStream(1024, account);
const { container } = render(
<Image imageId={im.id} alt="test-progressive" width={256} />,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should show the original image if asked resolution matches", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(100, account);
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im["100x100"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = render(
<Image imageId={im.id} alt="test-progressive" width={100} />,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should update to a higher resolution image when width/height props are changed at runtime", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(256, account);
const im = ImageDefinition.create(
{
original,
originalSize: [256, 256],
progressive: true,
},
{
owner: account,
},
);
im["256x256"] = original;
im["1024x1024"] = await createDummyFileStream(1024, account);
const { container, rerender } = render(
<Image imageId={im.id} alt="test-dynamic" width={256} height={256} />,
{ account },
);
// Initially, should load 256x256
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
rerender(
<Image imageId={im.id} alt="test-dynamic" width={1024} height={1024} />,
);
// After prop change, should load 1024x1024
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
describe("ref forwarding", () => {
it("should forward ref to the img element", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const ref = { current: null as HTMLImageElement | null };
const { container } = render(
<Image imageId={im.id} alt="test" ref={ref} />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(ref.current).toBe(img);
});
});
describe("lazy loading", () => {
it("should return an empty png if loading is lazy and placeholder is not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" loading="lazy" />,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe("blob:test-70");
});
it("should load the image when threshold is reached", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
<Image imageId={im.id} alt="test" loading="lazy" />,
{ account },
);
const img = container.querySelector("img");
// simulate the load event when the browser's viewport reach the image
img!.dispatchEvent(new Event("load"));
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
});
function createDummyBlob(size: number): Blob {
const blob = new Blob([new Uint8Array(size)], { type: "image/png" });
return blob;
}
function createDummyFileStream(
size: number,
account: Awaited<ReturnType<typeof createJazzTestAccount>>,
) {
return FileStream.createFromBlob(createDummyBlob(size), {
owner: account,
});
}

View File

@@ -3,3 +3,4 @@ export * from "./auth/index.js";
export * from "./jazz.svelte.js";
export * from "./jazz.class.svelte.js";
export { useIsAuthenticated } from "./auth/useIsAuthenticated.svelte.js";
export * from "./media/index.js";

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { ImageDefinition } from "jazz-tools";
import { highestResAvailable } from "jazz-tools/media";
import { onDestroy } from "svelte";
import type { HTMLImgAttributes } from "svelte/elements";
import { CoState } from "../jazz.class.svelte";
interface ImageProps extends Omit<HTMLImgAttributes, "width" | "height"> {
imageId: string;
width?: number | "original";
height?: number | "original";
}
const { imageId, width, height, ...rest }: ImageProps = $props();
const imageState = new CoState(ImageDefinition, () => imageId);
let lastBestImage: [string, string] | null = null;
/**
* For lazy loading, we use the browser's strategy for images with loading="lazy".
* We use an empty image, and when the browser triggers the load event, we load the best available image.
* On page loading, if the image url is already in browser's cache, the load event is triggered immediately.
* This is why we need to use a different blob url for every image.
*/
let waitingLazyLoading = $state(rest.loading === "lazy");
const lazyPlaceholder = $derived.by(() =>
waitingLazyLoading ? URL.createObjectURL(emptyPixelBlob) : undefined,
);
const dimensions = $derived.by<{
width: number | undefined;
height: number | undefined;
}>(() => {
const originalWidth = imageState.current?.originalSize?.[0];
const originalHeight = imageState.current?.originalSize?.[1];
// Both width and height are "original"
if (width === "original" && height === "original") {
return { width: originalWidth, height: originalHeight };
}
// Width is "original", height is a number
if (width === "original" && typeof height === "number") {
if (originalWidth && originalHeight) {
return {
width: Math.round((height * originalWidth) / originalHeight),
height,
};
}
return { width: undefined, height };
}
// Height is "original", width is a number
if (height === "original" && typeof width === "number") {
if (originalWidth && originalHeight) {
return {
width,
height: Math.round((width * originalHeight) / originalWidth),
};
}
return { width, height: undefined };
}
// In all other cases, use the property value:
return {
width: width === "original" ? originalWidth : width,
height: height === "original" ? originalHeight : height,
};
});
const src = $derived.by(() => {
if (waitingLazyLoading) {
return lazyPlaceholder;
}
const image = imageState.current;
if (!image) return undefined;
const bestImage = highestResAvailable(
image,
dimensions.width || dimensions.height || 9999,
dimensions.height || dimensions.width || 9999,
);
if (!bestImage) return image.placeholderDataURL;
if (lastBestImage?.[0] === bestImage.image.id) return lastBestImage?.[1];
const blob = bestImage.image.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
revokeObjectURL(lastBestImage?.[1]);
lastBestImage = [bestImage.image.id, url];
return url;
}
return image.placeholderDataURL;
});
// Cleanup object URL on component destroy
onDestroy(() => {
revokeObjectURL(lastBestImage?.[1]);
});
function revokeObjectURL(url: string | undefined) {
if (url && url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
const emptyPixelBlob = new Blob(
[
Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
),
(c) => c.charCodeAt(0),
),
],
{ type: "image/png" },
);
</script>
<img
{src}
width={dimensions.width}
height={dimensions.height}
alt={rest.alt}
onload={() => {waitingLazyLoading = false}}
{...rest}
/>

View File

@@ -0,0 +1 @@
export { default as Image } from "./image.svelte";

View File

@@ -0,0 +1,583 @@
// @vitest-environment happy-dom
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
import { describe, expect, it, vi } from "vitest";
import Image from "../../media/image.svelte";
import { render, screen, waitFor } from "../testUtils";
describe("Image", async () => {
const account = await createJazzTestAccount({
isCurrentActiveAccount: true,
});
const renderWithAccount = (props: any) => render(Image, props, { account });
describe("initial rendering", () => {
it("should render nothing if coValue is not found", async () => {
const { container } = renderWithAccount({
imageId: "co_zMTubMby3QiKDYnW9e2BEXW7Xaq",
alt: "test",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
it("should render an empty image if the image is not loaded yet", async () => {
const original = FileStream.create({ owner: account._owner });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
it("should render the placeholder image if the image is not loaded yet", async () => {
const placeholderDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const original = FileStream.create({ owner: account._owner });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
placeholderDataURL: placeholderDataUrl,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe(placeholderDataUrl);
});
it("should render the original image once loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
renderWithAccount({
imageId: im.id,
alt: "test-loading",
});
await waitFor(() => {
expect(
(screen.getByAltText("test-loading") as HTMLImageElement).src,
).toBe("blob:test-100");
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
});
describe("dimensions", () => {
it("should render the original image if the width and height are not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
});
it("should render the original sizes", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
width: "original",
height: "original",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("100");
expect(img!.getAttribute("height")).toBe("100");
});
it("should render the original size keeping the aspect ratio", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
width: "original",
height: 300,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("300");
expect(img!.getAttribute("height")).toBe("300");
});
it("should render the width attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
width: 50,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("50");
expect(img!.getAttribute("height")).toBeNull();
});
it("should render the height attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
height: 50,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBeNull();
expect(img!.getAttribute("height")).toBe("50");
});
it("should render the class attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
class: "test-class",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.classList.contains("test-class")).toBe(true);
});
});
describe("progressive loading", () => {
it("should render the resized image if progressive loading is enabled", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(500, account);
const im = ImageDefinition.create(
{
original,
originalSize: [500, 500],
progressive: true,
},
{
owner: account,
},
);
im["500x500"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test-progressive",
width: 300,
});
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-500",
);
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
it("should show the highest resolution images as they are loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(1920, account);
const im = ImageDefinition.create(
{
original,
originalSize: [1920, 1080],
progressive: true,
},
{
owner: account,
},
);
im["1920x1080"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test-progressive",
width: 1024,
});
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1920",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
// Load higher resolution image
im["1024x1024"] = await createDummyFileStream(1024, account);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
it("should show the best loaded resolution if width is set", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await FileStream.createFromBlob(createDummyBlob(1), {
owner: account,
});
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im["100x100"] = original;
im["256x256"] = await createDummyFileStream(256, account);
im["1024x1024"] = await createDummyFileStream(1024, account);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test-progressive",
width: 256,
});
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should show the original image if asked resolution matches", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(100, account);
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im["100x100"] = original;
im["256x256"] = await createDummyFileStream(256, account);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test-progressive",
width: 100,
});
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should update to a higher resolution image when width/height props are changed at runtime", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(256, account);
const im = ImageDefinition.create(
{
original,
originalSize: [256, 256],
progressive: true,
},
{
owner: account,
},
);
im["256x256"] = original;
im["1024x1024"] = await createDummyFileStream(1024, account);
const { container, rerender } = renderWithAccount({
imageId: im.id,
alt: "test-dynamic",
width: 256,
height: 256,
});
// Initially, should load 256x256
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
rerender({
imageId: im.id,
alt: "test-dynamic",
width: 1024,
height: 1024,
});
// After prop change, should load 1024x1024
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
describe("lazy loading", () => {
it("should return an empty png if loading is lazy and placeholder is not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
loading: "lazy",
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe("blob:test-70");
});
it("should load the image when threshold is reached", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = renderWithAccount({
imageId: im.id,
alt: "test",
loading: "lazy",
});
const img = container.querySelector("img");
// simulate the load event when the browser's viewport reach the image
img!.dispatchEvent(new Event("load"));
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
});
function createDummyBlob(size: number): Blob {
const blob = new Blob([new Uint8Array(size)], { type: "image/png" });
return blob;
}
function createDummyFileStream(
size: number,
account: Awaited<ReturnType<typeof createJazzTestAccount>>,
) {
return FileStream.createFromBlob(createDummyBlob(size), {
owner: account,
});
}

View File

@@ -0,0 +1,33 @@
import { render as renderSvelte } from "@testing-library/svelte";
import { Account, AnonymousJazzAgent } from "jazz-tools";
import { TestJazzContextManager } from "jazz-tools/testing";
import { Component, ComponentProps } from "svelte";
import { JAZZ_AUTH_CTX, JAZZ_CTX } from "../jazz.svelte";
type JazzExtendedOptions = {
account: Account | { guest: AnonymousJazzAgent };
};
const render = <T extends Component>(
component: T,
props: ComponentProps<T>,
jazzOptions: JazzExtendedOptions,
) => {
const ctx = TestJazzContextManager.fromAccountOrGuest(jazzOptions.account);
return renderSvelte(
// @ts-expect-error Svelte new Component type is not compatible with @testing-library/svelte
component,
{
props,
context: new Map<any, any>([
[JAZZ_CTX, { current: ctx.getCurrentValue() }],
[JAZZ_AUTH_CTX, ctx.getAuthSecretStorage()],
]),
},
);
};
export * from "@testing-library/svelte";
export { render };

View File

@@ -0,0 +1,3 @@
declare module "*.svelte" {
export { SvelteComponentDev as default } from "svelte/internal";
}

View File

@@ -836,6 +836,38 @@ export class FileStream extends CoValueBase implements CoValue {
}
| Account
| Group,
): Promise<FileStream> {
const arrayBuffer = await blob.arrayBuffer();
return this.createFromArrayBuffer(
arrayBuffer,
blob.type,
blob instanceof File ? blob.name : undefined,
options,
);
}
/**
* Create a `FileStream` from a `Blob` or `File`
*
* @example
* ```ts
* import { coField, FileStream } from "jazz-tools";
*
* const fileStream = await FileStream.createFromBlob(file, {owner: group})
* ```
* @category Content
*/
static async createFromArrayBuffer(
arrayBuffer: ArrayBuffer,
mimeType: string,
fileName: string | undefined,
options?:
| {
owner?: Group | Account;
onProgress?: (progress: number) => void;
}
| Account
| Group,
): Promise<FileStream> {
const stream = this.create(options);
const onProgress =
@@ -843,11 +875,11 @@ export class FileStream extends CoValueBase implements CoValue {
const start = Date.now();
const data = new Uint8Array(await blob.arrayBuffer());
const data = new Uint8Array(arrayBuffer);
stream.start({
mimeType: blob.type,
totalSizeBytes: blob.size,
fileName: blob instanceof File ? blob.name : undefined,
mimeType,
totalSizeBytes: arrayBuffer.byteLength,
fileName,
});
const chunkSize =
cojsonInternals.TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE;
@@ -871,7 +903,7 @@ export class FileStream extends CoValueBase implements CoValue {
"Finished creating binary stream in",
(end - start) / 1000,
"s - Throughput in MB/s",
(1000 * (blob.size / (end - start))) / (1024 * 1024),
(1000 * (arrayBuffer.byteLength / (end - start))) / (1024 * 1024),
);
onProgress?.(1);

View File

@@ -3,58 +3,12 @@ import { Loaded, coFileStreamDefiner, coMapDefiner } from "../../internal.js";
// avoiding circularity by using the standalone definers instead of `co`
const ImageDefinitionBase = coMapDefiner({
original: coFileStreamDefiner(),
originalSize: z.tuple([z.number(), z.number()]),
placeholderDataURL: z.string().optional(),
progressive: z.boolean(),
}).catchall(coFileStreamDefiner());
/** @category Media */
export const ImageDefinition = Object.assign({}, ImageDefinitionBase, {
highestResAvailable(
imageDef: ImageDefinition,
options?: {
maxWidth?: number;
targetWidth?: number;
},
) {
const resolutions = Object.keys(imageDef).filter((key) =>
key.match(/^\d+x\d+$/),
) as `${number}x${number}`[];
let maxWidth = options?.maxWidth;
if (options?.targetWidth) {
const targetWidth = options.targetWidth;
const widths = resolutions.map((res) => Number(res.split("x")[0]));
maxWidth = Math.min(...widths.filter((w) => w >= targetWidth));
}
const validResolutions = resolutions.filter(
(key) => maxWidth === undefined || Number(key.split("x")[0]) <= maxWidth,
) as `${number}x${number}`[];
// Sort the resolutions by width, smallest to largest
validResolutions.sort((a, b) => {
const aWidth = Number(a.split("x")[0]);
const bWidth = Number(b.split("x")[0]);
return aWidth - bWidth; // Sort smallest to largest
});
let highestAvailableResolution: `${number}x${number}` | undefined;
for (const resolution of validResolutions) {
if (imageDef[resolution] && imageDef[resolution]?.getChunks()) {
highestAvailableResolution = resolution;
}
}
// Return the highest complete resolution if we found one
return (
highestAvailableResolution && {
res: highestAvailableResolution,
stream: imageDef[highestAvailableResolution]!,
}
);
},
});
export const ImageDefinition = ImageDefinitionBase;
export type ImageDefinition = Loaded<typeof ImageDefinition>;

View File

@@ -330,7 +330,7 @@ function parseSchemaAndResolve<
};
}
class HttpRoute<
export class HttpRoute<
RequestShape extends MessageShape = z.core.$ZodLooseShape,
RequestResolve extends ResolveQuery<CoMapSchema<RequestShape>> = any,
ResponseShape extends MessageShape = z.core.$ZodLooseShape,

View File

@@ -113,4 +113,5 @@ export {
experimental_defineRequest,
JazzRequestError,
isJazzRequestError,
type HttpRoute,
} from "./coValues/request.js";

View File

@@ -42,6 +42,12 @@ export class FileStreamSchema implements CoreFileStreamSchema {
return this.coValueClass.createFromBlob(blob, options);
}
createFromArrayBuffer(
...args: Parameters<typeof FileStream.createFromArrayBuffer>
) {
return this.coValueClass.createFromArrayBuffer(...args);
}
loadAsBlob(
id: string,
options?: {

View File

@@ -8,8 +8,7 @@ import {
test,
vi,
} from "vitest";
import { Group, co, z } from "../exports.js";
import { InstanceOrPrimitiveOfSchema } from "../implementation/zodSchema/typeConverters/InstanceOrPrimitiveOfSchema.js";
import { FileStream, Group, co, z } from "../exports.js";
import { Loaded } from "../implementation/zodSchema/zodSchema.js";
import { Account } from "../index.js";
import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
@@ -373,6 +372,8 @@ describe("CoMap.Record", async () => {
type: "repro",
name: "John",
image: co.image().create({
original: FileStream.create(),
progressive: false,
originalSize: [1920, 1080],
}),
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
import { CoPlainText, co, z } from "../exports.js";
import { CoPlainText, FileStream, co, z } from "../exports.js";
import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
describe("co.optional", () => {
@@ -69,6 +69,8 @@ describe("co.optional", () => {
schema.fileStream = Schema.shape.fileStream.innerType.create();
schema.image = Schema.shape.image.innerType.create({
originalSize: [1920, 1080],
original: FileStream.create(),
progressive: false,
});
schema.record = Schema.shape.record.innerType.create({ field: "hello" });
schema.map = Schema.shape.map.innerType.create({ field: "hello" });

View File

@@ -1,278 +0,0 @@
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { describe, expect, test } from "vitest";
import { Account, FileStream, ImageDefinition } from "../exports.js";
const Crypto = await WasmCrypto.create();
describe("ImageDefinition", async () => {
const me = await Account.create({
creationProps: { name: "Test User" },
crypto: Crypto,
});
test("Construction with basic properties", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,...",
},
{ owner: me },
);
expect(imageDef.originalSize).toEqual([1920, 1080]);
expect(imageDef.placeholderDataURL).toBe("data:image/jpeg;base64,...");
});
test("highestResAvailable with no resolutions", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeUndefined();
});
test("highestResAvailable with single resolution", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream = FileStream.create({ owner: me });
stream.start({ mimeType: "image/jpeg" });
stream.push(new Uint8Array([1, 2, 3]));
stream.end();
imageDef["1920x1080"] = stream;
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeDefined();
expect(result?.res).toBe("1920x1080");
expect(result?.stream).toStrictEqual(stream);
});
test("highestResAvailable with multiple resolutions", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
stream2.push(new Uint8Array([4, 5, 6]));
stream2.end();
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeDefined();
expect(result?.res).toBe("1920x1080");
expect(result?.stream).toStrictEqual(stream1);
});
test("highestResAvailable with maxWidth option", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
stream2.push(new Uint8Array([4, 5, 6]));
stream2.end();
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
const result = ImageDefinition.highestResAvailable(imageDef, {
maxWidth: 1500,
});
expect(result).toBeDefined();
expect(result?.res).toBe("1280x720");
expect(result?.stream).toStrictEqual(stream2);
});
test("highestResAvailable with missing chunks", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
// Don't end stream2, so it has no chunks
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeDefined();
expect(result?.res).toBe("1920x1080");
expect(result?.stream).toStrictEqual(stream1);
});
test("highestResAvailable with missing chunks in middle stream", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
// Don't end stream2, so it has no chunks
const stream3 = FileStream.create({ owner: me });
stream3.start({ mimeType: "image/jpeg" });
stream3.push(new Uint8Array([7, 8, 9]));
stream3.end();
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
imageDef["1024x576"] = stream3;
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeDefined();
expect(result?.res).toBe("1920x1080");
expect(result?.stream).toStrictEqual(stream1);
});
test("highestResAvailable with non-resolution keys", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream = FileStream.create({ owner: me });
stream.start({ mimeType: "image/jpeg" });
stream.push(new Uint8Array([1, 2, 3]));
stream.end();
imageDef["invalid-key"] = stream;
const result = ImageDefinition.highestResAvailable(imageDef);
expect(result).toBeUndefined();
});
test("highestResAvailable with targetWidth option", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
stream2.push(new Uint8Array([4, 5, 6]));
stream2.end();
const stream3 = FileStream.create({ owner: me });
stream3.start({ mimeType: "image/jpeg" });
stream3.push(new Uint8Array([7, 8, 9]));
stream3.end();
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
imageDef["800x450"] = stream3;
// Should return 1280x720 as it's the smallest resolution >= 1000px
const result1 = ImageDefinition.highestResAvailable(imageDef, {
targetWidth: 1000,
});
expect(result1).toBeDefined();
expect(result1?.res).toBe("1280x720");
expect(result1?.stream).toStrictEqual(stream2);
// Should return 800x450 as it's the smallest resolution >= 700px
const result2 = ImageDefinition.highestResAvailable(imageDef, {
targetWidth: 700,
});
expect(result2).toBeDefined();
expect(result2?.res).toBe("800x450");
expect(result2?.stream).toStrictEqual(stream3);
// Should return 1920x1080 as it's the smallest resolution >= 1500px
const result3 = ImageDefinition.highestResAvailable(imageDef, {
targetWidth: 1500,
});
expect(result3).toBeDefined();
expect(result3?.res).toBe("1920x1080");
expect(result3?.stream).toStrictEqual(stream1);
});
test("highestResAvailable with targetWidth and incomplete streams", () => {
const imageDef = ImageDefinition.create(
{
originalSize: [1920, 1080],
},
{ owner: me },
);
const stream1 = FileStream.create({ owner: me });
stream1.start({ mimeType: "image/jpeg" });
stream1.push(new Uint8Array([1, 2, 3]));
stream1.end();
const stream2 = FileStream.create({ owner: me });
stream2.start({ mimeType: "image/jpeg" });
// Don't end stream2, so it has no chunks
const stream3 = FileStream.create({ owner: me });
stream3.start({ mimeType: "image/jpeg" });
stream3.push(new Uint8Array([7, 8, 9]));
stream3.end();
imageDef["1920x1080"] = stream1;
imageDef["1280x720"] = stream2;
imageDef["800x450"] = stream3;
// Should skip 1280x720 as it's incomplete and return 1920x1080
const result = ImageDefinition.highestResAvailable(imageDef, {
targetWidth: 1000,
});
expect(result).toBeDefined();
expect(result?.res).toBe("800x450");
expect(result?.stream).toStrictEqual(stream1);
});
});

View File

@@ -21,6 +21,7 @@
"src/**/*.ts",
"./src/**/*.js",
"./src/**/*.ts",
"./src/**/*.tsx",
"./src/**/*.svelte"
],
"exclude": ["./node_modules"]

View File

@@ -29,9 +29,11 @@ export default defineConfig([
{
...cfg,
entry: {
index: "src/browser-media-images/index.ts",
index: "src/media/index.ts",
"index.browser": "src/media/index.browser.ts",
"index.native": "src/media/index.native.ts",
},
outDir: "dist/browser-media-images",
outDir: "dist/media",
},
{
...cfg,
@@ -121,13 +123,6 @@ export default defineConfig([
},
outDir: "dist/react-native-core",
},
{
...cfg,
entry: {
index: "src/react-native-media-images/index.ts",
},
outDir: "dist/react-native-media-images",
},
{
...cfg,
entry: {

View File

@@ -1,9 +1,22 @@
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { svelteTesting } from "@testing-library/svelte/vite";
import { defineProject } from "vitest/config";
export default defineProject({
plugins: [
svelte(),
svelteTesting({
resolveBrowser: false,
}),
],
resolve: {
// 'browser' for Svelte Testing Library
// 'node' for "msw/node"
conditions: ["browser", "node"],
},
test: {
name: "jazz-tools",
include: ["src/**/*.test.{js,ts,svelte}"],
include: ["src/**/*.test.{js,ts,tsx,svelte}"],
typecheck: {
enabled: true,
checker: "tsc",

View File

@@ -0,0 +1,63 @@
import Input from "@/src/components/input";
import Label from "@/src/components/label";
import { SearchIcon } from "lucide-react";
export default function InputPage() {
return (
<div className="flex flex-col gap-4">
<h2 className="text-2xl mb-2 font-bold">Input</h2>
<p className="mb-3">
Inputs are used in conjunction with a label and can be styled with the
intent and size props.
</p>
<div className="flex flex-row gap-2">
<Label htmlFor="input">Label</Label>
<Input id="input" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="input">Label</Label>
<Input id="input" intent="primary" />
</div>
<div className="flex flex-row gap-2">
<Label htmlFor="input" size="sm">
Label
</Label>
<Input id="input" intent="tip" sizeStyle="sm" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="input" size="sm">
Label
</Label>
<Input id="input" intent="info" sizeStyle="sm" />
</div>
<div className="flex flex-row gap-2">
<Label htmlFor="input" size="lg">
Label
</Label>
<Input id="input" intent="warning" sizeStyle="lg" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="input" size="lg">
Label
</Label>
<Input id="input" intent="danger" sizeStyle="lg" />
</div>
<p>
Labels should alway be used with an input, but can be hidden with the
isHiddenVisually prop.
</p>
<div className="flex flex-row gap-2 items-center">
<Label htmlFor="input" isHiddenVisually>
Label
</Label>
<SearchIcon />
<Input id="input" />
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ export default function DocsPage() {
<li>
<Link href="/docs/button">Button</Link>
<Link href="/docs/icon">Icon</Link>
<Link href="/docs/input">Input</Link>
</li>
</ul>
</div>

View File

@@ -0,0 +1,39 @@
import { Input as BaseUiInput } from "@base-ui-components/react/input";
import * as React from "react";
import { ComponentProps } from "react";
import { VariantProps, tv } from "tailwind-variants";
type InputVariants = VariantProps<typeof input>;
interface InputProps extends ComponentProps<"input">, InputVariants {}
export default function Input({ sizeStyle, intent, ...props }: InputProps) {
return <BaseUiInput className={input({ sizeStyle, intent })} {...props} />;
}
const input = tv({
base: "w-full rounded-md border pl-3.5 text-base text-gray-900",
variants: {
base: "w-full rounded-md border px-2.5 py-1 shadow-sm h-[36px] font-medium text-stone-900 dark:text-white dark:bg-stone-925",
intent: {
default: "border-stone-500/50 focus:ring-stone-800/50",
primary: "border-primary focus:ring-blue/50",
success: "border-success focus:ring-green/50",
warning: "border-warning focus:ring-yellow/50",
danger: "border-danger focus:ring-red/50",
info: "border-info focus:ring-blue/50",
tip: "border-tip focus:ring-cyan/50",
muted: "border-muted focus:ring-gray/50",
strong: "border-strong focus:ring-stone-900/50",
},
sizeStyle: {
sm: "text-sm py-1 px-2 [&>svg]:size-4 h-7",
md: "py-1.5 px-3 h-[36px] [&>svg]:size-5 h-9",
lg: "py-2 px-5 md:px-6 md:py-2.5 [&>svg]:size-6 h-10",
},
},
defaultVariants: {
sizeStyle: "md",
intent: "default",
},
});

View File

@@ -0,0 +1,45 @@
import { ComponentProps } from "react";
import { VariantProps, tv } from "tailwind-variants";
// biome-ignore lint/correctness/useImportExtensions: <explanation>
import { cn } from "../lib/utils";
type LabelVariants = VariantProps<typeof label>;
interface LabelProps extends ComponentProps<"label">, LabelVariants {}
export default function Label({
size,
isHiddenVisually,
...props
}: LabelProps) {
return (
<label
className={cn(
label({
isHiddenVisually: isHiddenVisually,
size: size,
}),
props.className,
)}
{...props}
/>
);
}
const label = tv({
base: "block text-sm font-medium text-stone-900 dark:text-white flex items-center",
variants: {
isHiddenVisually: {
true: "sr-only",
},
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
size: "md",
isHiddenVisually: false,
},
});

507
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,21 @@
# jazz-react-tailwind-starter
## 0.0.145
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
## 0.0.144
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
## 0.0.143
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-react-passkey-auth-starter",
"private": true,
"version": "0.0.143",
"version": "0.0.145",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# svelte-passkey-auth
## 0.0.119
### Patch Changes
- Updated dependencies [0bcbf55]
- Updated dependencies [d1bdbf5]
- Updated dependencies [4b73834]
- jazz-tools@0.17.1
## 0.0.118
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
## 0.0.117
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "svelte-passkey-auth",
"version": "0.0.117",
"version": "0.0.119",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,69 +1,79 @@
// @vitest-environment happy-dom
import { createImage } from "jazz-tools/browser-media-images";
import { createImage } from "jazz-tools/media";
import { createJazzTestAccount } from "jazz-tools/testing";
import { describe, expect, it } from "vitest";
describe("createImage", () => {
it("should create an image with a single size if width/height < 256", async () => {
const OnePixel =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
const imageBlob = new Blob(
[Uint8Array.from(atob(OnePixel), (c) => c.charCodeAt(0))],
{ type: "image/png" },
);
describe("createImage - Browser Edition", async () => {
const account = await createJazzTestAccount();
const account = await createJazzTestAccount();
const image = await createImage(imageBlob, { owner: account._owner });
expect(image).toBeDefined();
expect(image.originalSize).toEqual([1, 1]);
expect(image.placeholderDataURL).toBeDefined();
expect(image[`1x1`]).toBeDefined();
expect(image[`1x1`]!.getMetadata()!.mimeType).toBe("image/png");
expect(image["256x256"]).not.toBeDefined();
expect(image["1024x1024"]).not.toBeDefined();
});
it("should create an image with three sizes", async () => {
it("should create an image", async () => {
const imageBlob = new Blob(
[Uint8Array.from(White1920, (c) => c.charCodeAt(0))],
{ type: "image/png" },
);
const account = await createJazzTestAccount();
const image = await createImage(imageBlob, { owner: account._owner });
expect(image).toBeDefined();
expect(image.originalSize).toEqual([1920, 400]);
expect(image.placeholderDataURL).toBeDefined();
expect(image[`256x53`]).toBeDefined();
expect(image[`1024x213`]).toBeDefined();
expect(image[`1920x400`]).toBeDefined();
});
it("should lose the original size and create image based on maxSize", async () => {
const imageBlob = new Blob(
[Uint8Array.from(White1920, (c) => c.charCodeAt(0))],
{ type: "image/png" },
);
const account = await createJazzTestAccount();
const image = await createImage(imageBlob, {
owner: account._owner,
maxSize: 256,
progressive: true,
maxSize: 600,
placeholder: "blur",
owner: account,
});
expect(image).toBeDefined();
expect(image.originalSize).toEqual([256, 53]);
expect(image.placeholderDataURL).toBeDefined();
expect(image[`256x53`]).toBeDefined();
expect(image[`1024x213`]).not.toBeDefined();
expect(image[`1920x400`]).not.toBeDefined();
expect(image.originalSize).toEqual([600, 125]);
expect(image["600x125"]).toBeDefined();
expect(image["256x53"]).toBeDefined();
const imgOriginal = document.createElement("img");
imgOriginal.src = URL.createObjectURL(image.original!.toBlob()!);
await new Promise((resolve) => (imgOriginal.onload = resolve));
expect(imgOriginal.width).toBe(600);
expect(imgOriginal.height).toBe(125);
URL.revokeObjectURL(imgOriginal.src);
const imgResized = document.createElement("img");
imgResized.src = URL.createObjectURL(image["256x53"]!.toBlob()!);
await new Promise((resolve) => (imgResized.onload = resolve));
expect(imgResized.width).toBe(256);
expect(imgResized.height).toBe(53);
URL.revokeObjectURL(imgResized.src);
});
it("should keep the original mime type", async () => {
const pngBlob = new Blob(
[Uint8Array.from(White1920, (c) => c.charCodeAt(0))],
{ type: "image/png" },
);
const pngImage = await createImage(pngBlob, {
progressive: true,
maxSize: 600,
placeholder: "blur",
owner: account,
});
expect(pngImage.original!.toBlob()!.type).toBe("image/png");
expect(pngImage["256x53"]!.toBlob()!.type).toBe("image/png");
const jpegBlob = new Blob(
[Uint8Array.from(White1920, (c) => c.charCodeAt(0))],
{ type: "image/jpeg" },
);
const jpegImage = await createImage(jpegBlob, {
progressive: true,
maxSize: 600,
placeholder: "blur",
owner: account,
});
expect(jpegImage.original!.toBlob()!.type).toBe("image/jpeg");
expect(jpegImage["256x53"]!.toBlob()!.type).toBe("image/jpeg");
});
});

View File

@@ -1,6 +1,6 @@
import { page, userEvent } from "@vitest/browser/context";
import { AuthSecretStorage, ImageDefinition } from "jazz-tools";
import { createImage } from "jazz-tools/browser-media-images";
import { AuthSecretStorage } from "jazz-tools";
import { createImage, highestResAvailable } from "jazz-tools/media";
import { assert, afterEach, describe, expect, test } from "vitest";
import { createAccountContext, startSyncServer } from "./testUtils";
@@ -40,13 +40,11 @@ describe("Images upload", () => {
const image = await createImage(file);
const highestRes = ImageDefinition.highestResAvailable(image);
const highestRes = highestResAvailable(image, 512, 512);
assert(highestRes);
expect(highestRes.res).toBe("512x512");
const blob = highestRes.stream.toBlob();
const blob = highestRes.image.toBlob();
assert(blob);

View File

@@ -1,2 +1,3 @@
<a href="/costate">CoState</a>
<a href="/media">Media</a>
<a href="/virtual-list">Virtual List</a>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { JazzSvelteProvider } from 'jazz-tools/svelte';
import "jazz-tools/inspector/register-custom-element"
import { TestAccount } from './schema.js';
if (typeof window !== 'undefined') {
localStorage.clear(); // Want to start always from a fresh account
}
let { children } = $props();
</script>
<JazzSvelteProvider
AccountSchema={TestAccount}
sync={{
when: "never"
}}
>
<jazz-inspector></jazz-inspector>
{@render children()}
</JazzSvelteProvider>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import type { ChangeEventHandler } from 'svelte/elements';
import { AccountCoState, Image } from 'jazz-tools/svelte';
import { createImage } from 'jazz-tools/media';
import { TestAccount } from './schema.js';
const me = new AccountCoState(TestAccount, {
resolve: {
profile: {
image: true
}
}
});
let input = $state<HTMLInputElement>();
const onUploadClick = () => {
input?.click();
};
const onImageChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const file = event.currentTarget.files?.[0];
if (!file || !me.current?.profile) return;
createImage(file, {
owner: me.current?.profile._owner,
maxSize: 400
}).then((image) => {
if (!me.current?.profile) return;
me.current.profile.image = image;
});
}
</script>
<button onclick={onUploadClick}>
{me.current?.profile?.image ? 'Change image' : 'Send image'}
</button>
{#if me.current?.profile?.image}
<Image imageId={me.current.profile.image.id} width={200} height="original" />
{/if}
<label>
<input
bind:this={input}
type="file"
accept="image/png, image/jpeg, image/gif"
onchange={onImageChange}
/>
</label>
<style>
label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>

View File

@@ -0,0 +1,10 @@
import { co } from "jazz-tools";
export const Profile = co.profile({
image: co.optional(co.image()),
});
export const TestAccount = co.account({
profile: Profile,
root: co.map({}),
});