@gradio/image 0.26.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/Image.stories.svelte +0 -68
- package/Image.test.ts +65 -0
- package/ImageExample.stories.svelte +0 -11
- package/Index.svelte +1 -1
- package/dist/Index.svelte +1 -1
- package/dist/shared/Image.svelte +11 -8
- package/dist/shared/Image.svelte.d.ts +5 -4
- package/dist/shared/ImageUploader.svelte +9 -2
- package/dist/shared/ImageUploader.svelte.d.ts +1 -1
- package/dist/shared/types.d.ts +1 -1
- package/dist/shared/utils.js +7 -14
- package/package.json +6 -6
- package/shared/Image.svelte +11 -8
- package/shared/ImageUploader.svelte +9 -2
- package/shared/types.ts +1 -1
- package/shared/utils.ts +11 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @gradio/image
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- [#13526](https://github.com/gradio-app/gradio/pull/13526) [`53cb4ca`](https://github.com/gradio-app/gradio/commit/53cb4cae1ec3521e9170d12867253516413ba37a) - Run `pnpm lint` and `pnpm ts:check` on CI. Thanks @abidlabs!
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- [#13532](https://github.com/gradio-app/gradio/pull/13532) [`0a933b4`](https://github.com/gradio-app/gradio/commit/0a933b428f5b2158cb8c764f38abf0da2312d58a) - Fix `gr.SelectData` coordinates when the image in `gr.Image` does not fill its container. Thanks @hysts!
|
|
12
|
+
- [#13533](https://github.com/gradio-app/gradio/pull/13533) [`e5ec1ca`](https://github.com/gradio-app/gradio/commit/e5ec1ca66c45e7e3a057b75ac2b8b86660b2827b) - Fix fullscreen button not working in ImageSlider, interactive Image, native plots, and AnnotatedImage. Thanks @hysts!
|
|
13
|
+
|
|
14
|
+
### Dependency updates
|
|
15
|
+
|
|
16
|
+
- @gradio/atoms@0.25.0
|
|
17
|
+
- @gradio/statustracker@0.15.0
|
|
18
|
+
- @gradio/utils@0.13.0
|
|
19
|
+
- @gradio/client@2.3.0
|
|
20
|
+
- @gradio/upload@0.18.0
|
|
21
|
+
|
|
22
|
+
## 0.26.3
|
|
23
|
+
|
|
24
|
+
### Dependency updates
|
|
25
|
+
|
|
26
|
+
- @gradio/client@2.2.2
|
|
27
|
+
|
|
28
|
+
## 0.26.3
|
|
29
|
+
|
|
30
|
+
### Dependency updates
|
|
31
|
+
|
|
32
|
+
- @gradio/client@2.2.1
|
|
33
|
+
- @gradio/upload@0.17.10
|
|
34
|
+
|
|
35
|
+
## 0.26.3
|
|
36
|
+
|
|
37
|
+
### Dependency updates
|
|
38
|
+
|
|
39
|
+
- @gradio/atoms@0.24.0
|
|
40
|
+
- @gradio/statustracker@0.14.1
|
|
41
|
+
- @gradio/upload@0.17.9
|
|
42
|
+
|
|
3
43
|
## 0.26.2
|
|
4
44
|
|
|
5
45
|
### Fixes
|
package/Image.stories.svelte
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script module>
|
|
2
2
|
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
3
3
|
import StaticImage from "./Index.svelte";
|
|
4
|
-
import { userEvent, within, expect } from "storybook/test";
|
|
5
4
|
import { allModes } from "../storybook/modes";
|
|
6
5
|
import { wrapProps } from "../storybook/wrapProps";
|
|
7
6
|
|
|
@@ -37,13 +36,6 @@
|
|
|
37
36
|
buttons: ["fullscreen", "download"],
|
|
38
37
|
webcam_options: { mirror: true, constraints: null }
|
|
39
38
|
}}
|
|
40
|
-
play={async ({ canvasElement }) => {
|
|
41
|
-
const canvas = within(canvasElement);
|
|
42
|
-
const expand_btn = await canvas.findByRole("button", {
|
|
43
|
-
name: "Fullscreen"
|
|
44
|
-
});
|
|
45
|
-
expect(expand_btn).toBeTruthy();
|
|
46
|
-
}}
|
|
47
39
|
>
|
|
48
40
|
{#snippet template(args)}
|
|
49
41
|
<div
|
|
@@ -146,41 +138,6 @@
|
|
|
146
138
|
{/snippet}
|
|
147
139
|
</Story>
|
|
148
140
|
|
|
149
|
-
<Story
|
|
150
|
-
name="interactive with upload, clipboard, and webcam"
|
|
151
|
-
args={{
|
|
152
|
-
sources: ["upload", "clipboard", "webcam"],
|
|
153
|
-
value: {
|
|
154
|
-
path: cheetah,
|
|
155
|
-
url: cheetah,
|
|
156
|
-
orig_name: "cheetah.jpg"
|
|
157
|
-
},
|
|
158
|
-
show_label: false,
|
|
159
|
-
interactive: true,
|
|
160
|
-
placeholder: md,
|
|
161
|
-
buttons: [],
|
|
162
|
-
webcam_options: { mirror: true, constraints: null }
|
|
163
|
-
}}
|
|
164
|
-
parameters={{ chromatic: { disableSnapshot: true } }}
|
|
165
|
-
play={async ({ canvasElement }) => {
|
|
166
|
-
const canvas = within(canvasElement);
|
|
167
|
-
const webcamButton = await canvas.findByLabelText("Capture from camera");
|
|
168
|
-
userEvent.click(webcamButton);
|
|
169
|
-
userEvent.click(await canvas.findByTitle("grant webcam access"));
|
|
170
|
-
userEvent.click(await canvas.findByLabelText("Upload file"));
|
|
171
|
-
userEvent.click(await canvas.findByLabelText("Paste from clipboard"));
|
|
172
|
-
}}
|
|
173
|
-
>
|
|
174
|
-
{#snippet template(args)}
|
|
175
|
-
<div
|
|
176
|
-
class="image-container"
|
|
177
|
-
style="width: 300px; position: relative;border-radius: var(--radius-lg);overflow: hidden;"
|
|
178
|
-
>
|
|
179
|
-
<StaticImage {...wrapProps(args)} />
|
|
180
|
-
</div>
|
|
181
|
-
{/snippet}
|
|
182
|
-
</Story>
|
|
183
|
-
|
|
184
141
|
<Story
|
|
185
142
|
name="interactive with webcam"
|
|
186
143
|
args={{
|
|
@@ -217,28 +174,3 @@
|
|
|
217
174
|
</div>
|
|
218
175
|
{/snippet}
|
|
219
176
|
</Story>
|
|
220
|
-
|
|
221
|
-
<Story
|
|
222
|
-
name="interactive webcam with streaming"
|
|
223
|
-
args={{
|
|
224
|
-
sources: ["webcam"],
|
|
225
|
-
interactive: true,
|
|
226
|
-
value: {
|
|
227
|
-
path: cheetah,
|
|
228
|
-
url: cheetah,
|
|
229
|
-
orig_name: "cheetah.jpg"
|
|
230
|
-
},
|
|
231
|
-
streaming: true,
|
|
232
|
-
buttons: ["download"],
|
|
233
|
-
webcam_options: { mirror: true, constraints: null }
|
|
234
|
-
}}
|
|
235
|
-
>
|
|
236
|
-
{#snippet template(args)}
|
|
237
|
-
<div
|
|
238
|
-
class="image-container"
|
|
239
|
-
style="width: 300px; position: relative;border-radius: var(--radius-lg);overflow: hidden;"
|
|
240
|
-
>
|
|
241
|
-
<StaticImage {...wrapProps(args)} />
|
|
242
|
-
</div>
|
|
243
|
-
{/snippet}
|
|
244
|
-
</Story>
|
package/Image.test.ts
CHANGED
|
@@ -326,6 +326,25 @@ describe("Props: buttons (static mode)", () => {
|
|
|
326
326
|
expect(getByLabelText("Fullscreen")).toBeTruthy();
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
test("clicking the fullscreen button toggles fullscreen in interactive mode", async () => {
|
|
330
|
+
const { getByLabelText } = await render(Image, {
|
|
331
|
+
...default_props,
|
|
332
|
+
interactive: true,
|
|
333
|
+
value: fake_value,
|
|
334
|
+
buttons: ["fullscreen"]
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await fireEvent.click(getByLabelText("Fullscreen"));
|
|
338
|
+
await waitFor(() => {
|
|
339
|
+
expect(getByLabelText("Exit fullscreen mode")).toBeVisible();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await fireEvent.click(getByLabelText("Exit fullscreen mode"));
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(getByLabelText("Fullscreen")).toBeVisible();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
329
348
|
test("empty buttons array shows no action buttons", async () => {
|
|
330
349
|
const { queryByLabelText } = await render(Image, {
|
|
331
350
|
...default_props,
|
|
@@ -635,6 +654,52 @@ describe("get_coordinates_of_clicked_image", () => {
|
|
|
635
654
|
expect(result).toEqual([100, 200]);
|
|
636
655
|
});
|
|
637
656
|
|
|
657
|
+
test("handles image shown at natural size with empty space on both axes", () => {
|
|
658
|
+
// `object-fit: scale-down` never upscales: a 100x100 natural image in
|
|
659
|
+
// a 400x300 box is drawn at natural size, centered at offset (150, 100).
|
|
660
|
+
// Click at (150, 100) = top-left corner of the drawn image.
|
|
661
|
+
const evt = make_mock_event(
|
|
662
|
+
150,
|
|
663
|
+
100,
|
|
664
|
+
{
|
|
665
|
+
naturalWidth: 100,
|
|
666
|
+
naturalHeight: 100
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
left: 0,
|
|
670
|
+
top: 0,
|
|
671
|
+
width: 400,
|
|
672
|
+
height: 300
|
|
673
|
+
}
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const result = get_coordinates_of_clicked_image(evt);
|
|
677
|
+
expect(result).toEqual([0, 0]);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("returns null when click is in the empty space around a natural-size image", () => {
|
|
681
|
+
// 100x100 natural image in a 1920x1080 box: drawn at natural size,
|
|
682
|
+
// centered at offset (910, 490). A click at (500, 540) is inside the
|
|
683
|
+
// box but left of the drawn image, so no image pixel is under it.
|
|
684
|
+
const evt = make_mock_event(
|
|
685
|
+
500,
|
|
686
|
+
540,
|
|
687
|
+
{
|
|
688
|
+
naturalWidth: 100,
|
|
689
|
+
naturalHeight: 100
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
left: 0,
|
|
693
|
+
top: 0,
|
|
694
|
+
width: 1920,
|
|
695
|
+
height: 1080
|
|
696
|
+
}
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const result = get_coordinates_of_clicked_image(evt);
|
|
700
|
+
expect(result).toBeNull();
|
|
701
|
+
});
|
|
702
|
+
|
|
638
703
|
test("returns [NaN, NaN] when currentTarget is not an Element", () => {
|
|
639
704
|
const evt = {
|
|
640
705
|
currentTarget: {},
|
package/Index.svelte
CHANGED
package/dist/Index.svelte
CHANGED
package/dist/shared/Image.svelte
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
let {
|
|
3
|
-
src,
|
|
4
|
-
restProps,
|
|
3
|
+
src = "",
|
|
4
|
+
restProps = {},
|
|
5
5
|
data_testid,
|
|
6
|
-
class_names
|
|
6
|
+
class_names = [],
|
|
7
|
+
...imgProps
|
|
7
8
|
}: {
|
|
8
|
-
src
|
|
9
|
-
restProps
|
|
10
|
-
data_testid
|
|
11
|
-
class_names
|
|
9
|
+
src?: string;
|
|
10
|
+
restProps?: Record<string, any>;
|
|
11
|
+
data_testid?: string;
|
|
12
|
+
class_names?: string[];
|
|
13
|
+
[key: string]: any;
|
|
12
14
|
} = $props();
|
|
13
15
|
</script>
|
|
14
16
|
|
|
15
17
|
<!-- svelte-ignore a11y-missing-attribute -->
|
|
16
18
|
<img
|
|
17
19
|
{src}
|
|
18
|
-
class={
|
|
20
|
+
class={class_names.join(" ")}
|
|
19
21
|
data-testid={data_testid}
|
|
20
22
|
{...restProps}
|
|
23
|
+
{...imgProps}
|
|
21
24
|
on:load
|
|
22
25
|
/>
|
|
23
26
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
type $$ComponentProps = {
|
|
2
|
-
src
|
|
3
|
-
restProps
|
|
4
|
-
data_testid
|
|
5
|
-
class_names
|
|
2
|
+
src?: string;
|
|
3
|
+
restProps?: Record<string, any>;
|
|
4
|
+
data_testid?: string;
|
|
5
|
+
class_names?: string[];
|
|
6
|
+
[key: string]: any;
|
|
6
7
|
};
|
|
7
8
|
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
8
9
|
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
upload?: never;
|
|
122
122
|
select: SelectData;
|
|
123
123
|
end_stream: never;
|
|
124
|
+
fullscreen: boolean;
|
|
124
125
|
}>();
|
|
125
126
|
|
|
126
127
|
export let dragging = false;
|
|
@@ -184,7 +185,13 @@
|
|
|
184
185
|
<IconButtonWrapper>
|
|
185
186
|
{#if value?.url && !active_streaming}
|
|
186
187
|
{#if show_fullscreen_button}
|
|
187
|
-
<FullscreenButton
|
|
188
|
+
<FullscreenButton
|
|
189
|
+
{fullscreen}
|
|
190
|
+
onclick={(is_fullscreen) => {
|
|
191
|
+
fullscreen = is_fullscreen;
|
|
192
|
+
dispatch("fullscreen", is_fullscreen);
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
188
195
|
{/if}
|
|
189
196
|
<IconButton
|
|
190
197
|
Icon={Clear}
|
|
@@ -257,7 +264,7 @@
|
|
|
257
264
|
{sources}
|
|
258
265
|
bind:active_source
|
|
259
266
|
{handle_clear}
|
|
260
|
-
handle_select={handle_select_source}
|
|
267
|
+
handle_select={(source) => handle_select_source(source as source_type)}
|
|
261
268
|
/>
|
|
262
269
|
{/if}
|
|
263
270
|
</div>
|
|
@@ -46,7 +46,6 @@ declare const ImageUploader: $$__sveltets_2_IsomorphicComponent<$$__sveltets_2_P
|
|
|
46
46
|
}, {
|
|
47
47
|
default: {};
|
|
48
48
|
}>, {
|
|
49
|
-
fullscreen: any;
|
|
50
49
|
error: CustomEvent<string>;
|
|
51
50
|
drag: CustomEvent<any>;
|
|
52
51
|
close_stream: CustomEvent<undefined>;
|
|
@@ -56,6 +55,7 @@ declare const ImageUploader: $$__sveltets_2_IsomorphicComponent<$$__sveltets_2_P
|
|
|
56
55
|
upload?: CustomEvent<undefined> | undefined;
|
|
57
56
|
select: CustomEvent<SelectData>;
|
|
58
57
|
end_stream: CustomEvent<never>;
|
|
58
|
+
fullscreen: CustomEvent<boolean>;
|
|
59
59
|
} & {
|
|
60
60
|
[evt: string]: CustomEvent<any>;
|
|
61
61
|
}, {
|
package/dist/shared/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LoadingStatus } from "@gradio/statustracker";
|
|
1
|
+
import type { ILoadingStatus as LoadingStatus } from "@gradio/statustracker";
|
|
2
2
|
import type { FileData } from "@gradio/client";
|
|
3
3
|
import type { CustomButton } from "@gradio/utils";
|
|
4
4
|
export interface Base64File {
|
package/dist/shared/utils.js
CHANGED
|
@@ -7,20 +7,13 @@ export const get_coordinates_of_clicked_image = (evt) => {
|
|
|
7
7
|
return [NaN, NaN];
|
|
8
8
|
}
|
|
9
9
|
const imageRect = image.getBoundingClientRect();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
const displayed_width = image.naturalWidth / yScale;
|
|
20
|
-
const x_offset = (imageRect.width - displayed_width) / 2;
|
|
21
|
-
var x = Math.round((evt.clientX - imageRect.left - x_offset) * yScale);
|
|
22
|
-
var y = Math.round((evt.clientY - imageRect.top) * yScale);
|
|
23
|
-
}
|
|
10
|
+
// The image is rendered with `object-fit: scale-down`: centered, never
|
|
11
|
+
// scaled above its natural size, so empty space can appear on both axes.
|
|
12
|
+
const scale = Math.min(imageRect.width / image.naturalWidth, imageRect.height / image.naturalHeight, 1);
|
|
13
|
+
const x_offset = (imageRect.width - image.naturalWidth * scale) / 2;
|
|
14
|
+
const y_offset = (imageRect.height - image.naturalHeight * scale) / 2;
|
|
15
|
+
const x = Math.round((evt.clientX - imageRect.left - x_offset) / scale);
|
|
16
|
+
const y = Math.round((evt.clientY - imageRect.top - y_offset) / scale);
|
|
24
17
|
if (x < 0 || x >= image.naturalWidth || y < 0 || y >= image.naturalHeight) {
|
|
25
18
|
return null;
|
|
26
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradio/image",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Gradio UI packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"cropperjs": "^2.0.1",
|
|
11
11
|
"lazy-brush": "^2.0.2",
|
|
12
12
|
"resize-observer-polyfill": "^1.5.1",
|
|
13
|
+
"@gradio/atoms": "^0.25.0",
|
|
14
|
+
"@gradio/client": "^2.3.0",
|
|
13
15
|
"@gradio/icons": "^0.15.1",
|
|
14
|
-
"@gradio/statustracker": "^0.
|
|
15
|
-
"@gradio/
|
|
16
|
-
"@gradio/
|
|
17
|
-
"@gradio/utils": "^0.12.2",
|
|
18
|
-
"@gradio/atoms": "^0.23.1"
|
|
16
|
+
"@gradio/statustracker": "^0.15.0",
|
|
17
|
+
"@gradio/utils": "^0.13.0",
|
|
18
|
+
"@gradio/upload": "^0.18.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@gradio/preview": "^0.16.2"
|
package/shared/Image.svelte
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
let {
|
|
3
|
-
src,
|
|
4
|
-
restProps,
|
|
3
|
+
src = "",
|
|
4
|
+
restProps = {},
|
|
5
5
|
data_testid,
|
|
6
|
-
class_names
|
|
6
|
+
class_names = [],
|
|
7
|
+
...imgProps
|
|
7
8
|
}: {
|
|
8
|
-
src
|
|
9
|
-
restProps
|
|
10
|
-
data_testid
|
|
11
|
-
class_names
|
|
9
|
+
src?: string;
|
|
10
|
+
restProps?: Record<string, any>;
|
|
11
|
+
data_testid?: string;
|
|
12
|
+
class_names?: string[];
|
|
13
|
+
[key: string]: any;
|
|
12
14
|
} = $props();
|
|
13
15
|
</script>
|
|
14
16
|
|
|
15
17
|
<!-- svelte-ignore a11y-missing-attribute -->
|
|
16
18
|
<img
|
|
17
19
|
{src}
|
|
18
|
-
class={
|
|
20
|
+
class={class_names.join(" ")}
|
|
19
21
|
data-testid={data_testid}
|
|
20
22
|
{...restProps}
|
|
23
|
+
{...imgProps}
|
|
21
24
|
on:load
|
|
22
25
|
/>
|
|
23
26
|
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
upload?: never;
|
|
122
122
|
select: SelectData;
|
|
123
123
|
end_stream: never;
|
|
124
|
+
fullscreen: boolean;
|
|
124
125
|
}>();
|
|
125
126
|
|
|
126
127
|
export let dragging = false;
|
|
@@ -184,7 +185,13 @@
|
|
|
184
185
|
<IconButtonWrapper>
|
|
185
186
|
{#if value?.url && !active_streaming}
|
|
186
187
|
{#if show_fullscreen_button}
|
|
187
|
-
<FullscreenButton
|
|
188
|
+
<FullscreenButton
|
|
189
|
+
{fullscreen}
|
|
190
|
+
onclick={(is_fullscreen) => {
|
|
191
|
+
fullscreen = is_fullscreen;
|
|
192
|
+
dispatch("fullscreen", is_fullscreen);
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
188
195
|
{/if}
|
|
189
196
|
<IconButton
|
|
190
197
|
Icon={Clear}
|
|
@@ -257,7 +264,7 @@
|
|
|
257
264
|
{sources}
|
|
258
265
|
bind:active_source
|
|
259
266
|
{handle_clear}
|
|
260
|
-
handle_select={handle_select_source}
|
|
267
|
+
handle_select={(source) => handle_select_source(source as source_type)}
|
|
261
268
|
/>
|
|
262
269
|
{/if}
|
|
263
270
|
</div>
|
package/shared/types.ts
CHANGED
package/shared/utils.ts
CHANGED
|
@@ -9,19 +9,17 @@ export const get_coordinates_of_clicked_image = (
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const imageRect = image.getBoundingClientRect();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
var y = Math.round((evt.clientY - imageRect.top) * yScale);
|
|
24
|
-
}
|
|
12
|
+
// The image is rendered with `object-fit: scale-down`: centered, never
|
|
13
|
+
// scaled above its natural size, so empty space can appear on both axes.
|
|
14
|
+
const scale = Math.min(
|
|
15
|
+
imageRect.width / image.naturalWidth,
|
|
16
|
+
imageRect.height / image.naturalHeight,
|
|
17
|
+
1
|
|
18
|
+
);
|
|
19
|
+
const x_offset = (imageRect.width - image.naturalWidth * scale) / 2;
|
|
20
|
+
const y_offset = (imageRect.height - image.naturalHeight * scale) / 2;
|
|
21
|
+
const x = Math.round((evt.clientX - imageRect.left - x_offset) / scale);
|
|
22
|
+
const y = Math.round((evt.clientY - imageRect.top - y_offset) / scale);
|
|
25
23
|
if (x < 0 || x >= image.naturalWidth || y < 0 || y >= image.naturalHeight) {
|
|
26
24
|
return null;
|
|
27
25
|
}
|