jazz-tools 0.16.6 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.svelte-kit/__package__/index.d.ts +1 -0
  2. package/.svelte-kit/__package__/index.d.ts.map +1 -1
  3. package/.svelte-kit/__package__/index.js +1 -0
  4. package/.svelte-kit/__package__/media/image.svelte +131 -0
  5. package/.svelte-kit/__package__/media/image.svelte.d.ts +10 -0
  6. package/.svelte-kit/__package__/media/image.svelte.d.ts.map +1 -0
  7. package/.svelte-kit/__package__/media/index.d.ts +2 -0
  8. package/.svelte-kit/__package__/media/index.d.ts.map +1 -0
  9. package/.svelte-kit/__package__/media/index.js +1 -0
  10. package/.svelte-kit/__package__/tests/media/image.svelte.test.d.ts +2 -0
  11. package/.svelte-kit/__package__/tests/media/image.svelte.test.d.ts.map +1 -0
  12. package/.svelte-kit/__package__/tests/media/image.svelte.test.js +430 -0
  13. package/.svelte-kit/__package__/tests/testUtils.d.ts +11 -0
  14. package/.svelte-kit/__package__/tests/testUtils.d.ts.map +1 -0
  15. package/.svelte-kit/__package__/tests/testUtils.js +17 -0
  16. package/.svelte-kit/__package__/tests/types.d.ts +3 -0
  17. package/.turbo/turbo-build.log +47 -51
  18. package/CHANGELOG.md +24 -0
  19. package/dist/{chunk-R2VNCMG6.js → chunk-2SH44VLX.js} +33 -38
  20. package/dist/chunk-2SH44VLX.js.map +1 -0
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/media/chunk-JBLT443O.js +211 -0
  24. package/dist/media/chunk-JBLT443O.js.map +1 -0
  25. package/dist/media/create-image.d.ts +48 -0
  26. package/dist/media/create-image.d.ts.map +1 -0
  27. package/dist/media/create-image.test.d.ts +2 -0
  28. package/dist/media/create-image.test.d.ts.map +1 -0
  29. package/dist/media/index.browser.d.ts +15 -0
  30. package/dist/media/index.browser.d.ts.map +1 -0
  31. package/dist/media/index.browser.js +113 -0
  32. package/dist/media/index.browser.js.map +1 -0
  33. package/dist/media/index.d.ts +53 -0
  34. package/dist/media/index.d.ts.map +1 -0
  35. package/dist/media/index.js +13 -0
  36. package/dist/media/index.js.map +1 -0
  37. package/dist/media/index.native.d.ts +17 -0
  38. package/dist/media/index.native.d.ts.map +1 -0
  39. package/dist/media/index.native.js +126 -0
  40. package/dist/media/index.native.js.map +1 -0
  41. package/dist/media/utils.d.ts +17 -0
  42. package/dist/media/utils.d.ts.map +1 -0
  43. package/dist/media/utils.test.d.ts +2 -0
  44. package/dist/media/utils.test.d.ts.map +1 -0
  45. package/dist/react/index.d.ts +1 -2
  46. package/dist/react/index.d.ts.map +1 -1
  47. package/dist/react/index.js +176 -59
  48. package/dist/react/index.js.map +1 -1
  49. package/dist/react/media/image.d.ts +62 -0
  50. package/dist/react/media/image.d.ts.map +1 -0
  51. package/dist/react/tests/media/image.test.d.ts +2 -0
  52. package/dist/react/tests/media/image.test.d.ts.map +1 -0
  53. package/dist/react-core/tests/useDemoAuth.test.d.ts +2 -0
  54. package/dist/react-core/tests/useDemoAuth.test.d.ts.map +1 -0
  55. package/dist/react-native-core/index.d.ts +1 -1
  56. package/dist/react-native-core/index.d.ts.map +1 -1
  57. package/dist/react-native-core/index.js +84 -66
  58. package/dist/react-native-core/index.js.map +1 -1
  59. package/dist/react-native-core/media/image.d.ts +93 -0
  60. package/dist/react-native-core/media/image.d.ts.map +1 -0
  61. package/dist/react-native-core/testing.d.ts +2 -0
  62. package/dist/react-native-core/testing.d.ts.map +1 -0
  63. package/dist/svelte/index.d.ts +1 -0
  64. package/dist/svelte/index.d.ts.map +1 -1
  65. package/dist/svelte/index.js +1 -0
  66. package/dist/svelte/media/image.svelte +131 -0
  67. package/dist/svelte/media/image.svelte.d.ts +10 -0
  68. package/dist/svelte/media/image.svelte.d.ts.map +1 -0
  69. package/dist/svelte/media/index.d.ts +2 -0
  70. package/dist/svelte/media/index.d.ts.map +1 -0
  71. package/dist/svelte/media/index.js +1 -0
  72. package/dist/svelte/tests/media/image.svelte.test.d.ts +2 -0
  73. package/dist/svelte/tests/media/image.svelte.test.d.ts.map +1 -0
  74. package/dist/svelte/tests/media/image.svelte.test.js +430 -0
  75. package/dist/svelte/tests/testUtils.d.ts +11 -0
  76. package/dist/svelte/tests/testUtils.d.ts.map +1 -0
  77. package/dist/svelte/tests/testUtils.js +17 -0
  78. package/dist/svelte/tests/types.d.ts +3 -0
  79. package/dist/testing.js +1 -1
  80. package/dist/tools/coValues/coFeed.d.ts +15 -0
  81. package/dist/tools/coValues/coFeed.d.ts.map +1 -1
  82. package/dist/tools/coValues/extensions/imageDef.d.ts +3 -9
  83. package/dist/tools/coValues/extensions/imageDef.d.ts.map +1 -1
  84. package/dist/tools/coValues/request.d.ts +1 -1
  85. package/dist/tools/coValues/request.d.ts.map +1 -1
  86. package/dist/tools/exports.d.ts +1 -1
  87. package/dist/tools/exports.d.ts.map +1 -1
  88. package/dist/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.d.ts +1 -0
  89. package/dist/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.d.ts.map +1 -1
  90. package/package.json +12 -12
  91. package/src/media/create-image.test.ts +195 -0
  92. package/src/media/create-image.ts +180 -0
  93. package/src/media/index.browser.ts +150 -0
  94. package/src/media/index.native.ts +153 -0
  95. package/src/media/index.ts +61 -0
  96. package/src/media/utils.test.ts +373 -0
  97. package/src/media/utils.ts +205 -0
  98. package/src/react/index.ts +1 -2
  99. package/src/react/media/image.tsx +210 -0
  100. package/src/react/tests/media/image.test.tsx +588 -0
  101. package/src/react-native-core/index.ts +1 -1
  102. package/src/react-native-core/media/image.tsx +159 -0
  103. package/src/svelte/index.ts +1 -0
  104. package/src/svelte/media/image.svelte +131 -0
  105. package/src/svelte/media/index.ts +1 -0
  106. package/src/svelte/tests/media/image.svelte.test.ts +583 -0
  107. package/src/svelte/tests/testUtils.ts +33 -0
  108. package/src/svelte/tests/types.d.ts +3 -0
  109. package/src/tools/coValues/coFeed.ts +37 -5
  110. package/src/tools/coValues/extensions/imageDef.ts +3 -49
  111. package/src/tools/coValues/request.ts +1 -1
  112. package/src/tools/exports.ts +1 -0
  113. package/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts +6 -0
  114. package/src/tools/tests/coMap.record.test.ts +3 -2
  115. package/src/tools/tests/coOptional.test.ts +3 -1
  116. package/tsconfig.json +1 -0
  117. package/tsup.config.ts +4 -9
  118. package/vitest.config.ts +14 -1
  119. package/dist/browser-media-images/index.d.ts +0 -9
  120. package/dist/browser-media-images/index.d.ts.map +0 -1
  121. package/dist/browser-media-images/index.js +0 -72
  122. package/dist/browser-media-images/index.js.map +0 -1
  123. package/dist/chunk-R2VNCMG6.js.map +0 -1
  124. package/dist/react/media.d.ts +0 -24
  125. package/dist/react/media.d.ts.map +0 -1
  126. package/dist/react-native-core/media.d.ts +0 -24
  127. package/dist/react-native-core/media.d.ts.map +0 -1
  128. package/dist/react-native-media-images/index.d.ts +0 -7
  129. package/dist/react-native-media-images/index.d.ts.map +0 -1
  130. package/dist/react-native-media-images/index.js +0 -177
  131. package/dist/react-native-media-images/index.js.map +0 -1
  132. package/dist/tools/tests/imageDef.test.d.ts +0 -2
  133. package/dist/tools/tests/imageDef.test.d.ts.map +0 -1
  134. package/src/browser-media-images/index.ts +0 -131
  135. package/src/react/media.tsx +0 -74
  136. package/src/react/scratch.tsx +0 -50
  137. package/src/react-native-core/media.tsx +0 -79
  138. package/src/react-native-media-images/index.ts +0 -238
  139. package/src/tools/tests/imageDef.test.ts +0 -278
@@ -0,0 +1,205 @@
1
+ import type { CoID } from "cojson";
2
+ import { Account, FileStream, ImageDefinition } from "jazz-tools";
3
+
4
+ export function highestResAvailable(
5
+ image: ImageDefinition,
6
+ wantedWidth: number,
7
+ wantedHeight: number,
8
+ ): { width: number; height: number; image: FileStream } | null {
9
+ const availableSizes: [number, number, string][] = image._raw
10
+ .keys()
11
+ .filter((key) => /^\d+x\d+$/.test(key))
12
+ .map((key) => {
13
+ const [w, h] = key.split("x").map(Number) as [number, number];
14
+ return [w, h, key];
15
+ });
16
+
17
+ if (availableSizes.length === 0) {
18
+ return image.original
19
+ ? {
20
+ width: image.originalSize[0],
21
+ height: image.originalSize[1],
22
+ image: image.original,
23
+ }
24
+ : null;
25
+ }
26
+
27
+ const sortedSizes = availableSizes
28
+ .map((size) => {
29
+ return {
30
+ size,
31
+ match: sizesMatchWanted(size[0], size[1], wantedWidth, wantedHeight),
32
+ isLoaded: isLoaded(image._raw.get(size[2]) as CoID<any> | undefined),
33
+ };
34
+ })
35
+ .sort((a, b) => a.match - b.match);
36
+
37
+ // We try to find the better already loaded image
38
+ // note: `toReversed` is not available in react-native.
39
+ const bestLoaded = [...sortedSizes]
40
+ .reverse()
41
+ .find((el) => el.isLoaded && image[el.size[2]]?.getChunks());
42
+
43
+ // if I can't find a good match, let's use the highest resolution
44
+ const bestTarget =
45
+ sortedSizes.find((el) => el.match > 0.95) || sortedSizes.at(-1);
46
+
47
+ // if the best target is already loaded, we are done
48
+ if (image[bestTarget!.size[2]]?.getChunks()) {
49
+ return image[bestTarget!.size[2]]
50
+ ? {
51
+ width: bestTarget!.size[0],
52
+ height: bestTarget!.size[1],
53
+ image: image[bestTarget!.size[2]]!,
54
+ }
55
+ : null;
56
+ }
57
+
58
+ // if the best already loaded is not the best target
59
+ // let's trigger the load of the best target
60
+ if (bestLoaded) {
61
+ image[bestTarget!.size[2]]?.getChunks();
62
+ return image[bestLoaded.size[2]]
63
+ ? {
64
+ width: bestLoaded.size[0],
65
+ height: bestLoaded.size[1],
66
+ image: image[bestLoaded.size[2]]!,
67
+ }
68
+ : null;
69
+ }
70
+
71
+ // if nothing is loaded, then start fetching all the images till the best
72
+ for (let size of sortedSizes) {
73
+ if (size.match <= bestTarget!.match) {
74
+ image[size.size[2]]?.getChunks();
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ function sizesMatchWanted(
82
+ w: number,
83
+ h: number,
84
+ wantedW: number,
85
+ wantedH: number,
86
+ ): number {
87
+ const area1 = w * h;
88
+ const area2 = wantedW * wantedH;
89
+
90
+ const areaRatio = area1 / area2;
91
+
92
+ // // Below 0.95 means the image is too small, we don't want to upscale it
93
+ // if (areaRatio < 0.95) {
94
+ // return 9999;
95
+ // }
96
+
97
+ return areaRatio;
98
+ }
99
+
100
+ function isLoaded(id: CoID<any> | null | undefined): boolean {
101
+ if (!id) {
102
+ return false;
103
+ }
104
+
105
+ return !!Account.getMe()._raw.core.node.getLoaded(id);
106
+ }
107
+
108
+ export async function loadImage(
109
+ imageOrId: ImageDefinition | string,
110
+ ): Promise<{ width: number; height: number; image: FileStream } | null> {
111
+ if (typeof imageOrId === "string") {
112
+ const image = await ImageDefinition.load(imageOrId, {
113
+ resolve: {
114
+ original: true,
115
+ },
116
+ });
117
+
118
+ if (image === null || image.original === null) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ width: image.originalSize[0],
124
+ height: image.originalSize[1],
125
+ image: image.original,
126
+ };
127
+ }
128
+
129
+ if (!imageOrId.original) {
130
+ console.warn("Unable to find the original image");
131
+ return null;
132
+ }
133
+
134
+ const loadedOriginal = await FileStream.load(imageOrId.original.id);
135
+
136
+ if (!loadedOriginal) {
137
+ console.warn("Unable to find the original image");
138
+ return null;
139
+ }
140
+
141
+ return {
142
+ width: imageOrId.originalSize[0],
143
+ height: imageOrId.originalSize[1],
144
+ image: loadedOriginal,
145
+ };
146
+ }
147
+
148
+ export async function loadImageBySize(
149
+ imageOrId: ImageDefinition | string,
150
+ wantedWidth: number,
151
+ wantedHeight: number,
152
+ ): Promise<{ width: number; height: number; image: FileStream } | null> {
153
+ const image: ImageDefinition | null =
154
+ typeof imageOrId === "string"
155
+ ? await ImageDefinition.load(imageOrId)
156
+ : imageOrId;
157
+
158
+ if (image === null) {
159
+ return null;
160
+ }
161
+
162
+ if (image.progressive === false) {
163
+ return loadImage(imageOrId);
164
+ }
165
+
166
+ const availableSizes: [number, number, string][] = image._raw
167
+ .keys()
168
+ .filter((key) => /^\d+x\d+$/.test(key))
169
+ .map((key) => {
170
+ const [w, h] = key.split("x").map(Number) as [number, number];
171
+ return [w, h, key];
172
+ });
173
+
174
+ if (availableSizes.length === 0) {
175
+ return null;
176
+ }
177
+
178
+ const sortedSizes = availableSizes
179
+ .map((size) => ({
180
+ size,
181
+ match: sizesMatchWanted(size[0], size[1], wantedWidth, wantedHeight),
182
+ }))
183
+ .sort((a, b) => a.match - b.match);
184
+
185
+ const bestTarget =
186
+ sortedSizes.find((el) => el.match > 0.95) || sortedSizes.at(-1)!;
187
+
188
+ const file = image[bestTarget.size[2]];
189
+
190
+ if (!file) {
191
+ return null;
192
+ }
193
+
194
+ const loadedFile = await FileStream.load(file.id);
195
+
196
+ if (!loadedFile) {
197
+ return null;
198
+ }
199
+
200
+ return {
201
+ width: bestTarget.size[0],
202
+ height: bestTarget.size[1],
203
+ image: loadedFile,
204
+ };
205
+ }
@@ -12,5 +12,4 @@ export {
12
12
  export { createInviteLink, parseInviteLink } from "jazz-tools/browser";
13
13
 
14
14
  export * from "./auth/auth.js";
15
- export * from "./media.js";
16
- export { createImage } from "jazz-tools/browser-media-images";
15
+ export * from "./media/image.js";
@@ -0,0 +1,210 @@
1
+ import { ImageDefinition } from "jazz-tools";
2
+ import {
3
+ type JSX,
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { highestResAvailable } from "../../media/index.js";
12
+ import { useCoState } from "../hooks.js";
13
+
14
+ export type ImageProps = Omit<
15
+ JSX.IntrinsicElements["img"],
16
+ "src" | "srcSet" | "width" | "height"
17
+ > & {
18
+ /** The ID of the ImageDefinition to display */
19
+ imageId: string;
20
+ /**
21
+ * The desired width of the image. Can be a number in pixels or "original" to use the image's original width.
22
+ * When set to a number, the component will select the best available resolution and maintain aspect ratio.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * // Use original width
27
+ * <Image imageId="123" width="original" />
28
+ *
29
+ * // Set width to 600px, height will be calculated to maintain aspect ratio
30
+ * <Image imageId="123" width={600} />
31
+ *
32
+ * // Set both width and height to maintain aspect ratio
33
+ * <Image imageId="123" width={600} height={400} />
34
+ * ```
35
+ */
36
+ width?: number | "original";
37
+ /**
38
+ * The desired height of the image. Can be a number in pixels or "original" to use the image's original height.
39
+ * When set to a number, the component will select the best available resolution and maintain aspect ratio.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * // Use original height
44
+ * <Image imageId="123" height="original" />
45
+ *
46
+ * // Set height to 400px, width will be calculated to maintain aspect ratio
47
+ * <Image imageId="123" height={400} />
48
+ *
49
+ * // Set both width and height to maintain aspect ratio
50
+ * <Image imageId="123" width={600} height={400} />
51
+ * ```
52
+ */
53
+ height?: number | "original";
54
+ };
55
+
56
+ /**
57
+ * A React component for displaying images stored as ImageDefinition CoValues.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * import { Image } from "jazz-tools/react";
62
+ *
63
+ * // Force specific dimensions (may crop or stretch)
64
+ * function Avatar({ imageId }: { imageId: string }) {
65
+ * return (
66
+ * <Image
67
+ * imageId={imageId}
68
+ * width={100}
69
+ * height={100}
70
+ * alt="Avatar"
71
+ * style={{ borderRadius: "50%", objectFit: "cover" }}
72
+ * />
73
+ * );
74
+ * }
75
+ * ```
76
+ */
77
+ export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
78
+ { imageId, width, height, ...props },
79
+ ref,
80
+ ) {
81
+ const image = useCoState(ImageDefinition, imageId);
82
+ const lastBestImage = useRef<[string, string] | null>(null);
83
+
84
+ /**
85
+ * For lazy loading, we use the browser's strategy for images with loading="lazy".
86
+ * We use an empty image, and when the browser triggers the load event, we load the best available image.
87
+ * On page loading, if the image url is already in browser's cache, the load event is triggered immediately.
88
+ * This is why we need to use a different blob url for every image.
89
+ */
90
+ const [waitingLazyLoading, setWaitingLazyLoading] = useState(
91
+ props.loading === "lazy",
92
+ );
93
+ const lazyPlaceholder = useMemo(
94
+ () =>
95
+ waitingLazyLoading ? URL.createObjectURL(emptyPixelBlob) : undefined,
96
+ [waitingLazyLoading],
97
+ );
98
+
99
+ const dimensions: { width: number | undefined; height: number | undefined } =
100
+ useMemo(() => {
101
+ const originalWidth = image?.originalSize?.[0];
102
+ const originalHeight = image?.originalSize?.[1];
103
+
104
+ // Both width and height are "original"
105
+ if (width === "original" && height === "original") {
106
+ return { width: originalWidth, height: originalHeight };
107
+ }
108
+
109
+ // Width is "original", height is a number
110
+ if (width === "original" && typeof height === "number") {
111
+ if (originalWidth && originalHeight) {
112
+ return {
113
+ width: Math.round((height * originalWidth) / originalHeight),
114
+ height,
115
+ };
116
+ }
117
+ return { width: undefined, height };
118
+ }
119
+
120
+ // Height is "original", width is a number
121
+ if (height === "original" && typeof width === "number") {
122
+ if (originalWidth && originalHeight) {
123
+ return {
124
+ width,
125
+ height: Math.round((width * originalHeight) / originalWidth),
126
+ };
127
+ }
128
+ return { width, height: undefined };
129
+ }
130
+
131
+ // In all other cases, use the property value:
132
+ return {
133
+ width: width === "original" ? originalWidth : width,
134
+ height: height === "original" ? originalHeight : height,
135
+ };
136
+ }, [image?.originalSize, width, height]);
137
+
138
+ const src = useMemo(() => {
139
+ if (waitingLazyLoading) {
140
+ return lazyPlaceholder;
141
+ }
142
+
143
+ if (!image) return undefined;
144
+
145
+ const bestImage = highestResAvailable(
146
+ image,
147
+ dimensions.width || dimensions.height || 9999,
148
+ dimensions.height || dimensions.width || 9999,
149
+ );
150
+
151
+ if (!bestImage) return image.placeholderDataURL;
152
+ if (lastBestImage.current?.[0] === bestImage.image.id)
153
+ return lastBestImage.current?.[1];
154
+
155
+ const blob = bestImage.image.toBlob();
156
+
157
+ if (blob) {
158
+ const url = URL.createObjectURL(blob);
159
+ revokeObjectURL(lastBestImage.current?.[1]);
160
+ lastBestImage.current = [bestImage.image.id, url];
161
+ return url;
162
+ }
163
+
164
+ return image.placeholderDataURL;
165
+ }, [image, dimensions.width, dimensions.height, waitingLazyLoading]);
166
+
167
+ const onThresholdReached = useCallback(() => {
168
+ setWaitingLazyLoading(false);
169
+ }, []);
170
+
171
+ // Revoke object URL when component unmounts
172
+ useEffect(
173
+ () => () => {
174
+ // In development mode we don't revokeObjectURL on unmount because
175
+ // it triggers twice under StrictMode.
176
+ if (process.env.NODE_ENV === "development") return;
177
+ revokeObjectURL(lastBestImage.current?.[1]);
178
+ },
179
+ [],
180
+ );
181
+
182
+ return (
183
+ <img
184
+ ref={ref}
185
+ src={src}
186
+ width={dimensions.width}
187
+ height={dimensions.height}
188
+ onLoad={waitingLazyLoading ? onThresholdReached : undefined}
189
+ {...props}
190
+ />
191
+ );
192
+ });
193
+
194
+ function revokeObjectURL(url: string | undefined) {
195
+ if (url && url.startsWith("blob:")) {
196
+ URL.revokeObjectURL(url);
197
+ }
198
+ }
199
+
200
+ const emptyPixelBlob = new Blob(
201
+ [
202
+ Uint8Array.from(
203
+ atob(
204
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
205
+ ),
206
+ (c) => c.charCodeAt(0),
207
+ ),
208
+ ],
209
+ { type: "image/png" },
210
+ );