Compare commits
41 Commits
jazz-bette
...
jazz-bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceaa555e83 | ||
|
|
03229b2ea9 | ||
|
|
e2737d44b6 | ||
|
|
4b73834883 | ||
|
|
1b3d43d5f4 | ||
|
|
9c9a689879 | ||
|
|
2fd88b938c | ||
|
|
d1f955006f | ||
|
|
bb3d5f1f87 | ||
|
|
26ce61ab78 | ||
|
|
1f300114d5 | ||
|
|
da69f812f8 | ||
|
|
0bcbf551ca | ||
|
|
6b3d5b5560 | ||
|
|
d1bdbf5d49 | ||
|
|
621e809fad | ||
|
|
d6600d9322 | ||
|
|
2b08bd77c1 | ||
|
|
9b22fc74cd | ||
|
|
1bebe3c6c8 | ||
|
|
e1bd16d08b | ||
|
|
0967c2ee5a | ||
|
|
f22ef4e646 | ||
|
|
6c35d0031d | ||
|
|
93f3fb231b | ||
|
|
01d13d5df2 | ||
|
|
944e725b95 | ||
|
|
16024fec8e | ||
|
|
f90414ab95 | ||
|
|
492eecb46a | ||
|
|
51144ec832 | ||
|
|
fcaf4b9c30 | ||
|
|
afae2649f5 | ||
|
|
b5b0284c61 | ||
|
|
bf1475a143 | ||
|
|
e82cb80ca4 | ||
|
|
32c2a617d6 | ||
|
|
d3c2a41c81 | ||
|
|
72b5542130 | ||
|
|
5fd9225a54 | ||
|
|
9138d30208 |
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.112",
|
||||
"version": "0.0.114",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
35
examples/image-upload/src/ProfileImageComponent.tsx
Normal file
35
examples/image-upload/src/ProfileImageComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
examples/image-upload/src/ProfileImageImperative.tsx
Normal file
80
examples/image-upload/src/ProfileImageImperative.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
98
homepage/homepage/content/docs/upgrade/0-17-0.mdx
Normal file
98
homepage/homepage/content/docs/upgrade/0-17-0.mdx
Normal 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).
|
||||
@@ -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. Here’s 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>
|
||||
|
||||
@@ -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. Here’s 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>
|
||||
|
||||
@@ -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. Here’s 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>
|
||||
|
||||
@@ -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. Here’s 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>
|
||||
|
||||
|
||||
@@ -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. Here’s 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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,6 @@ export class StorageApiSync implements StorageAPI {
|
||||
if (!correction) {
|
||||
logger.error("Correction callback returned undefined", {
|
||||
knownState,
|
||||
correction: correction ?? null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
195
packages/jazz-tools/src/media/create-image.test.ts
Normal file
195
packages/jazz-tools/src/media/create-image.test.ts
Normal file
File diff suppressed because one or more lines are too long
180
packages/jazz-tools/src/media/create-image.ts
Normal file
180
packages/jazz-tools/src/media/create-image.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
150
packages/jazz-tools/src/media/index.browser.ts
Normal file
150
packages/jazz-tools/src/media/index.browser.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
153
packages/jazz-tools/src/media/index.native.ts
Normal file
153
packages/jazz-tools/src/media/index.native.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
61
packages/jazz-tools/src/media/index.ts
Normal file
61
packages/jazz-tools/src/media/index.ts
Normal 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>;
|
||||
373
packages/jazz-tools/src/media/utils.test.ts
Normal file
373
packages/jazz-tools/src/media/utils.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
205
packages/jazz-tools/src/media/utils.ts
Normal file
205
packages/jazz-tools/src/media/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
159
packages/jazz-tools/src/react-native-core/media/image.tsx
Normal file
159
packages/jazz-tools/src/react-native-core/media/image.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
210
packages/jazz-tools/src/react/media/image.tsx
Normal file
210
packages/jazz-tools/src/react/media/image.tsx
Normal 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" },
|
||||
);
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
588
packages/jazz-tools/src/react/tests/media/image.test.tsx
Normal file
588
packages/jazz-tools/src/react/tests/media/image.test.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
131
packages/jazz-tools/src/svelte/media/image.svelte
Normal file
131
packages/jazz-tools/src/svelte/media/image.svelte
Normal 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}
|
||||
/>
|
||||
1
packages/jazz-tools/src/svelte/media/index.ts
Normal file
1
packages/jazz-tools/src/svelte/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Image } from "./image.svelte";
|
||||
583
packages/jazz-tools/src/svelte/tests/media/image.svelte.test.ts
Normal file
583
packages/jazz-tools/src/svelte/tests/media/image.svelte.test.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
33
packages/jazz-tools/src/svelte/tests/testUtils.ts
Normal file
33
packages/jazz-tools/src/svelte/tests/testUtils.ts
Normal 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 };
|
||||
3
packages/jazz-tools/src/svelte/tests/types.d.ts
vendored
Normal file
3
packages/jazz-tools/src/svelte/tests/types.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "*.svelte" {
|
||||
export { SvelteComponentDev as default } from "svelte/internal";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,4 +113,5 @@ export {
|
||||
experimental_defineRequest,
|
||||
JazzRequestError,
|
||||
isJazzRequestError,
|
||||
type HttpRoute,
|
||||
} from "./coValues/request.js";
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
"src/**/*.ts",
|
||||
"./src/**/*.js",
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./src/**/*.svelte"
|
||||
],
|
||||
"exclude": ["./node_modules"]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
packages/quint-ui/app/docs/input/page.tsx
Normal file
63
packages/quint-ui/app/docs/input/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
packages/quint-ui/src/components/input.tsx
Normal file
39
packages/quint-ui/src/components/input.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
45
packages/quint-ui/src/components/label.tsx
Normal file
45
packages/quint-ui/src/components/label.tsx
Normal 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
507
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "svelte-passkey-auth",
|
||||
"version": "0.0.117",
|
||||
"version": "0.0.119",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<a href="/costate">CoState</a>
|
||||
<a href="/media">Media</a>
|
||||
<a href="/virtual-list">Virtual List</a>
|
||||
|
||||
22
tests/jazz-svelte/src/routes/media/+layout.svelte
Normal file
22
tests/jazz-svelte/src/routes/media/+layout.svelte
Normal 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>
|
||||
63
tests/jazz-svelte/src/routes/media/+page.svelte
Normal file
63
tests/jazz-svelte/src/routes/media/+page.svelte
Normal 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>
|
||||
10
tests/jazz-svelte/src/routes/media/schema.ts
Normal file
10
tests/jazz-svelte/src/routes/media/schema.ts
Normal 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({}),
|
||||
});
|
||||
Reference in New Issue
Block a user