@gradio/image 0.26.3 → 0.28.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 +46 -0
- package/Example.svelte +9 -3
- package/Image.stories.svelte +0 -68
- package/Image.test.ts +65 -0
- package/ImageExample.stories.svelte +0 -11
- package/Index.svelte +27 -50
- package/dist/Example.svelte +9 -3
- package/dist/Example.svelte.d.ts +4 -18
- package/dist/Index.svelte +27 -50
- package/dist/shared/Image.svelte +30 -10
- package/dist/shared/Image.svelte.d.ts +9 -23
- package/dist/shared/ImagePreview.svelte +39 -23
- package/dist/shared/ImagePreview.svelte.d.ts +10 -26
- package/dist/shared/ImageUploader.svelte +104 -63
- package/dist/shared/ImageUploader.svelte.d.ts +19 -42
- package/dist/shared/Webcam.svelte +63 -47
- package/dist/shared/Webcam.svelte.d.ts +11 -27
- package/dist/shared/WebcamPermissions.svelte +2 -5
- package/dist/shared/WebcamPermissions.svelte.d.ts +5 -19
- package/dist/shared/types.d.ts +1 -1
- package/dist/shared/utils.js +7 -14
- package/package.json +7 -7
- package/shared/Image.svelte +30 -10
- package/shared/ImagePreview.svelte +39 -23
- package/shared/ImageUploader.svelte +104 -63
- package/shared/Webcam.svelte +63 -47
- package/shared/WebcamPermissions.svelte +2 -5
- package/shared/types.ts +1 -1
- package/shared/utils.ts +11 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { tick, type Snippet } from "svelte";
|
|
3
3
|
import { BlockLabel, IconButtonWrapper, IconButton } from "@gradio/atoms";
|
|
4
4
|
import { Clear, Image as ImageIcon } from "@gradio/icons";
|
|
5
5
|
import { FullscreenButton } from "@gradio/atoms";
|
|
@@ -17,36 +17,80 @@
|
|
|
17
17
|
import Image from "./Image.svelte";
|
|
18
18
|
import type { Base64File, WebcamOptions } from "./types";
|
|
19
19
|
|
|
20
|
-
export let value: null | FileData | Base64File = null;
|
|
21
|
-
export let label: string | undefined = undefined;
|
|
22
|
-
export let show_label: boolean;
|
|
23
|
-
|
|
24
20
|
type source_type = "upload" | "webcam" | "clipboard" | "microphone" | null;
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
22
|
+
let {
|
|
23
|
+
value = $bindable<null | FileData | Base64File>(null),
|
|
24
|
+
label = undefined,
|
|
25
|
+
show_label,
|
|
26
|
+
sources = ["upload", "clipboard", "webcam"],
|
|
27
|
+
streaming = false,
|
|
28
|
+
pending = $bindable(false),
|
|
29
|
+
webcam_options,
|
|
30
|
+
selectable = false,
|
|
31
|
+
root,
|
|
32
|
+
i18n,
|
|
33
|
+
max_file_size = null,
|
|
34
|
+
upload,
|
|
35
|
+
stream_handler,
|
|
36
|
+
stream_every,
|
|
37
|
+
time_limit,
|
|
38
|
+
show_fullscreen_button = true,
|
|
39
|
+
stream_state = "closed",
|
|
40
|
+
upload_promise = $bindable<Promise<any> | null>(null),
|
|
41
|
+
onerror,
|
|
42
|
+
uploading = $bindable(false),
|
|
43
|
+
active_source = $bindable<source_type>(null),
|
|
44
|
+
fullscreen = $bindable(false),
|
|
45
|
+
dragging = $bindable(false),
|
|
46
|
+
onchange,
|
|
47
|
+
onstream,
|
|
48
|
+
onclear,
|
|
49
|
+
ondrag,
|
|
50
|
+
onupload,
|
|
51
|
+
onselect,
|
|
52
|
+
onfullscreen,
|
|
53
|
+
onclose_stream,
|
|
54
|
+
children
|
|
55
|
+
}: {
|
|
56
|
+
value?: null | FileData | Base64File;
|
|
57
|
+
label?: string;
|
|
58
|
+
show_label: boolean;
|
|
59
|
+
sources?: source_type[];
|
|
60
|
+
streaming?: boolean;
|
|
61
|
+
pending?: boolean;
|
|
62
|
+
webcam_options: WebcamOptions;
|
|
63
|
+
selectable?: boolean;
|
|
64
|
+
root: string;
|
|
65
|
+
i18n: I18nFormatter;
|
|
66
|
+
max_file_size?: number | null;
|
|
67
|
+
upload: Client["upload"];
|
|
68
|
+
stream_handler: Client["stream"];
|
|
69
|
+
stream_every: number;
|
|
70
|
+
time_limit: number;
|
|
71
|
+
show_fullscreen_button?: boolean;
|
|
72
|
+
stream_state?: "open" | "waiting" | "closed";
|
|
73
|
+
upload_promise?: Promise<any> | null;
|
|
74
|
+
onerror?: (error: string) => void;
|
|
75
|
+
uploading?: boolean;
|
|
76
|
+
active_source?: source_type;
|
|
77
|
+
fullscreen?: boolean;
|
|
78
|
+
dragging?: boolean;
|
|
79
|
+
onchange?: (value?: null | FileData | Base64File) => void;
|
|
80
|
+
onstream?: (value: ValueData) => void;
|
|
81
|
+
onclear?: () => void;
|
|
82
|
+
ondrag?: (dragging: boolean) => void;
|
|
83
|
+
onupload?: () => void;
|
|
84
|
+
onselect?: (value: SelectData) => void;
|
|
85
|
+
onfullscreen?: (fullscreen: boolean) => void;
|
|
86
|
+
onclose_stream?: () => void;
|
|
87
|
+
children?: Snippet;
|
|
88
|
+
} = $props();
|
|
42
89
|
|
|
43
90
|
let upload_input: Upload;
|
|
44
|
-
export let uploading = false;
|
|
45
|
-
export let active_source: source_type = null;
|
|
46
|
-
export let fullscreen = false;
|
|
47
91
|
|
|
48
|
-
let files
|
|
49
|
-
let upload_id
|
|
92
|
+
let files = $state<FileData[]>([]);
|
|
93
|
+
let upload_id = $state("");
|
|
50
94
|
|
|
51
95
|
async function handle_upload(detail: FileData): Promise<void> {
|
|
52
96
|
if (!streaming) {
|
|
@@ -62,14 +106,14 @@
|
|
|
62
106
|
}
|
|
63
107
|
|
|
64
108
|
await tick();
|
|
65
|
-
|
|
109
|
+
onupload?.();
|
|
66
110
|
}
|
|
67
111
|
}
|
|
68
112
|
|
|
69
113
|
function handle_clear(): void {
|
|
70
114
|
value = null;
|
|
71
|
-
|
|
72
|
-
|
|
115
|
+
onclear?.();
|
|
116
|
+
onchange?.(null);
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
function handle_remove_image_click(event: MouseEvent): void {
|
|
@@ -82,7 +126,7 @@
|
|
|
82
126
|
event: "change" | "stream" | "upload"
|
|
83
127
|
): Promise<void> {
|
|
84
128
|
if (event === "stream") {
|
|
85
|
-
|
|
129
|
+
onstream?.({
|
|
86
130
|
value: { url: img_blob } as Base64File,
|
|
87
131
|
is_value_data: true
|
|
88
132
|
});
|
|
@@ -105,38 +149,31 @@
|
|
|
105
149
|
if (event === "change" || event === "upload") {
|
|
106
150
|
value = f?.[0] || null;
|
|
107
151
|
await tick();
|
|
108
|
-
|
|
152
|
+
onchange?.();
|
|
109
153
|
}
|
|
110
154
|
pending = false;
|
|
111
155
|
}
|
|
112
156
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
drag: boolean;
|
|
121
|
-
upload?: never;
|
|
122
|
-
select: SelectData;
|
|
123
|
-
end_stream: never;
|
|
124
|
-
}>();
|
|
125
|
-
|
|
126
|
-
export let dragging = false;
|
|
127
|
-
|
|
128
|
-
$: dispatch("drag", dragging);
|
|
157
|
+
let active_streaming = $derived(streaming && active_source === "webcam");
|
|
158
|
+
$effect(() => {
|
|
159
|
+
if (uploading && !active_streaming) value = null;
|
|
160
|
+
});
|
|
161
|
+
$effect(() => {
|
|
162
|
+
ondrag?.(dragging);
|
|
163
|
+
});
|
|
129
164
|
|
|
130
165
|
function handle_click(evt: MouseEvent): void {
|
|
131
166
|
let coordinates = get_coordinates_of_clicked_image(evt);
|
|
132
167
|
if (coordinates) {
|
|
133
|
-
|
|
168
|
+
onselect?.({ index: coordinates, value: null });
|
|
134
169
|
}
|
|
135
170
|
}
|
|
136
171
|
|
|
137
|
-
|
|
138
|
-
active_source
|
|
139
|
-
|
|
172
|
+
$effect(() => {
|
|
173
|
+
if (!active_source && sources) {
|
|
174
|
+
active_source = sources[0];
|
|
175
|
+
}
|
|
176
|
+
});
|
|
140
177
|
|
|
141
178
|
async function handle_select_source(
|
|
142
179
|
source: (typeof sources)[number]
|
|
@@ -184,7 +221,13 @@
|
|
|
184
221
|
<IconButtonWrapper>
|
|
185
222
|
{#if value?.url && !active_streaming}
|
|
186
223
|
{#if show_fullscreen_button}
|
|
187
|
-
<FullscreenButton
|
|
224
|
+
<FullscreenButton
|
|
225
|
+
{fullscreen}
|
|
226
|
+
onclick={(is_fullscreen) => {
|
|
227
|
+
fullscreen = is_fullscreen;
|
|
228
|
+
onfullscreen?.(is_fullscreen);
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
188
231
|
{/if}
|
|
189
232
|
<IconButton
|
|
190
233
|
Icon={Clear}
|
|
@@ -198,8 +241,8 @@
|
|
|
198
241
|
class="upload-container"
|
|
199
242
|
class:reduced-height={sources.length > 1}
|
|
200
243
|
style:width={value ? "auto" : "100%"}
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
ondragover={on_drag_over}
|
|
245
|
+
ondrop={on_drop}
|
|
203
246
|
>
|
|
204
247
|
<Upload
|
|
205
248
|
bind:upload_promise
|
|
@@ -218,7 +261,7 @@
|
|
|
218
261
|
aria_label={i18n("image.drop_to_upload")}
|
|
219
262
|
>
|
|
220
263
|
{#if value === null}
|
|
221
|
-
|
|
264
|
+
{#if children}{@render children()}{/if}
|
|
222
265
|
{/if}
|
|
223
266
|
</Upload>
|
|
224
267
|
{#if active_source === "webcam" && !streaming && pending}
|
|
@@ -227,12 +270,10 @@
|
|
|
227
270
|
<Webcam
|
|
228
271
|
{root}
|
|
229
272
|
{value}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
on:upload={(e) => handle_save(e.detail, "upload")}
|
|
235
|
-
on:close_stream
|
|
273
|
+
oncapture={(detail) => handle_save(detail, "change")}
|
|
274
|
+
onstream={(detail) => handle_save(detail, "stream")}
|
|
275
|
+
{onerror}
|
|
276
|
+
{onclose_stream}
|
|
236
277
|
{stream_state}
|
|
237
278
|
mirror_webcam={webcam_options.mirror}
|
|
238
279
|
{stream_every}
|
|
@@ -247,7 +288,7 @@
|
|
|
247
288
|
{:else if value !== null && !streaming}
|
|
248
289
|
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
|
249
290
|
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
|
250
|
-
<div class:selectable class="image-frame"
|
|
291
|
+
<div class:selectable class="image-frame" onclick={handle_click}>
|
|
251
292
|
<Image src={value.url} restProps={{ alt: value.alt_text }} />
|
|
252
293
|
</div>
|
|
253
294
|
{/if}
|
|
@@ -257,7 +298,7 @@
|
|
|
257
298
|
{sources}
|
|
258
299
|
bind:active_source
|
|
259
300
|
{handle_clear}
|
|
260
|
-
handle_select={handle_select_source}
|
|
301
|
+
handle_select={(source) => handle_select_source(source as source_type)}
|
|
261
302
|
/>
|
|
262
303
|
{/if}
|
|
263
304
|
</div>
|
package/shared/Webcam.svelte
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { onDestroy, onMount } from "svelte";
|
|
3
3
|
import {
|
|
4
4
|
Camera,
|
|
5
5
|
Circle,
|
|
@@ -20,33 +20,51 @@
|
|
|
20
20
|
import type { Base64File } from "./types";
|
|
21
21
|
|
|
22
22
|
let video_source: HTMLVideoElement;
|
|
23
|
-
let available_video_devices
|
|
24
|
-
let selected_device
|
|
25
|
-
|
|
26
|
-
export let stream_state: "open" | "waiting" | "closed" = "closed";
|
|
23
|
+
let available_video_devices = $state<MediaDeviceInfo[]>([]);
|
|
24
|
+
let selected_device = $state<MediaDeviceInfo | null>(null);
|
|
27
25
|
|
|
28
26
|
let canvas: HTMLCanvasElement;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
27
|
+
let {
|
|
28
|
+
stream_state = "closed",
|
|
29
|
+
streaming = false,
|
|
30
|
+
pending = false,
|
|
31
|
+
root = "",
|
|
32
|
+
stream_every = 1,
|
|
33
|
+
mode = "image",
|
|
34
|
+
mirror_webcam,
|
|
35
|
+
include_audio,
|
|
36
|
+
webcam_constraints = null,
|
|
37
|
+
i18n,
|
|
38
|
+
upload,
|
|
39
|
+
value = null,
|
|
40
|
+
time_limit = null,
|
|
41
|
+
onstream,
|
|
42
|
+
oncapture,
|
|
43
|
+
onerror,
|
|
44
|
+
onstart_recording,
|
|
45
|
+
onstop_recording,
|
|
46
|
+
onclose_stream
|
|
47
|
+
}: {
|
|
48
|
+
stream_state?: "open" | "waiting" | "closed";
|
|
49
|
+
streaming?: boolean;
|
|
50
|
+
pending?: boolean;
|
|
51
|
+
root?: string;
|
|
52
|
+
stream_every?: number;
|
|
53
|
+
mode?: "image" | "video";
|
|
54
|
+
mirror_webcam: boolean;
|
|
55
|
+
include_audio: boolean;
|
|
56
|
+
webcam_constraints?: { [key: string]: any } | null;
|
|
57
|
+
i18n: I18nFormatter;
|
|
58
|
+
upload: Client["upload"];
|
|
59
|
+
value?: FileData | null | Base64File;
|
|
60
|
+
time_limit?: number | null;
|
|
61
|
+
onstream?: (value: Blob | string) => void;
|
|
62
|
+
oncapture?: (value: FileData | Blob | null) => void;
|
|
63
|
+
onerror?: (error: string) => void;
|
|
64
|
+
onstart_recording?: () => void;
|
|
65
|
+
onstop_recording?: () => void;
|
|
66
|
+
onclose_stream?: () => void;
|
|
67
|
+
} = $props();
|
|
50
68
|
|
|
51
69
|
onMount(() => {
|
|
52
70
|
canvas = document.createElement("canvas");
|
|
@@ -101,11 +119,11 @@
|
|
|
101
119
|
});
|
|
102
120
|
|
|
103
121
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
104
|
-
|
|
122
|
+
onerror?.(i18n("image.no_webcam_support"));
|
|
105
123
|
}
|
|
106
124
|
} catch (err) {
|
|
107
125
|
if (err instanceof DOMException && err.name == "NotAllowedError") {
|
|
108
|
-
|
|
126
|
+
onerror?.(i18n("image.allow_webcam_access"));
|
|
109
127
|
} else {
|
|
110
128
|
throw err;
|
|
111
129
|
}
|
|
@@ -139,13 +157,17 @@
|
|
|
139
157
|
}
|
|
140
158
|
if (streaming) {
|
|
141
159
|
const image_data = canvas.toDataURL("image/jpeg");
|
|
142
|
-
|
|
160
|
+
onstream?.(image_data);
|
|
143
161
|
return;
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
canvas.toBlob(
|
|
147
165
|
(blob) => {
|
|
148
|
-
|
|
166
|
+
if (streaming) {
|
|
167
|
+
onstream?.(blob as Blob);
|
|
168
|
+
} else {
|
|
169
|
+
oncapture?.(blob);
|
|
170
|
+
}
|
|
149
171
|
},
|
|
150
172
|
`image/${streaming ? "jpeg" : "png"}`,
|
|
151
173
|
0.8
|
|
@@ -153,7 +175,7 @@
|
|
|
153
175
|
}
|
|
154
176
|
}
|
|
155
177
|
|
|
156
|
-
let recording = false;
|
|
178
|
+
let recording = $state(false);
|
|
157
179
|
let recorded_blobs: BlobPart[] = [];
|
|
158
180
|
let stream: MediaStream;
|
|
159
181
|
let mimeType: string;
|
|
@@ -174,13 +196,13 @@
|
|
|
174
196
|
let val_ = (
|
|
175
197
|
(await upload(val, root))?.filter(Boolean) as FileData[]
|
|
176
198
|
)[0];
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
oncapture?.(val_);
|
|
200
|
+
onstop_recording?.();
|
|
179
201
|
}
|
|
180
202
|
};
|
|
181
203
|
ReaderObj.readAsDataURL(video_blob);
|
|
182
204
|
} else if (typeof MediaRecorder !== "undefined") {
|
|
183
|
-
|
|
205
|
+
onstart_recording?.();
|
|
184
206
|
recorded_blobs = [];
|
|
185
207
|
let validMimeTypes = ["video/webm", "video/mp4"];
|
|
186
208
|
for (let validMimeType of validMimeTypes) {
|
|
@@ -204,7 +226,7 @@
|
|
|
204
226
|
recording = !recording;
|
|
205
227
|
}
|
|
206
228
|
|
|
207
|
-
let webcam_accessed = false;
|
|
229
|
+
let webcam_accessed = $state(false);
|
|
208
230
|
|
|
209
231
|
function record_video_or_photo({
|
|
210
232
|
destroy
|
|
@@ -222,11 +244,11 @@
|
|
|
222
244
|
}
|
|
223
245
|
|
|
224
246
|
if (!recording && stream) {
|
|
225
|
-
|
|
247
|
+
onclose_stream?.();
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
229
|
-
let options_open = false;
|
|
251
|
+
let options_open = $state(false);
|
|
230
252
|
|
|
231
253
|
export function click_outside(node: Node, cb: any): any {
|
|
232
254
|
const handle_click = (event: MouseEvent): void => {
|
|
@@ -281,12 +303,12 @@
|
|
|
281
303
|
title="grant webcam access"
|
|
282
304
|
style="height: 100%"
|
|
283
305
|
>
|
|
284
|
-
<WebcamPermissions
|
|
306
|
+
<WebcamPermissions onclick={async () => access_webcam()} />
|
|
285
307
|
</div>
|
|
286
308
|
{:else}
|
|
287
309
|
<div class="button-wrap">
|
|
288
310
|
<button
|
|
289
|
-
|
|
311
|
+
onclick={() => record_video_or_photo()}
|
|
290
312
|
aria-label={mode === "image" ? "capture photo" : "start recording"}
|
|
291
313
|
>
|
|
292
314
|
{#if mode === "video" || streaming}
|
|
@@ -321,7 +343,7 @@
|
|
|
321
343
|
{#if !recording}
|
|
322
344
|
<button
|
|
323
345
|
class="icon"
|
|
324
|
-
|
|
346
|
+
onclick={() => (options_open = true)}
|
|
325
347
|
aria-label="select input source"
|
|
326
348
|
>
|
|
327
349
|
<DropdownArrow />
|
|
@@ -333,14 +355,8 @@
|
|
|
333
355
|
class="select-wrap"
|
|
334
356
|
aria-label="select source"
|
|
335
357
|
use:click_outside={handle_click_outside}
|
|
336
|
-
|
|
358
|
+
onchange={handle_device_change}
|
|
337
359
|
>
|
|
338
|
-
<!-- <button
|
|
339
|
-
class="inset-icon"
|
|
340
|
-
on:click|stopPropagation={() => (options_open = false)}
|
|
341
|
-
>
|
|
342
|
-
<DropdownArrow />
|
|
343
|
-
</button> -->
|
|
344
360
|
{#if available_video_devices.length === 0}
|
|
345
361
|
<option value="">{i18n("common.no_devices")}</option>
|
|
346
362
|
{:else}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Webcam } from "@gradio/icons";
|
|
3
|
-
import { createEventDispatcher } from "svelte";
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
click: undefined;
|
|
7
|
-
}>();
|
|
4
|
+
let { onclick }: { onclick?: () => void } = $props();
|
|
8
5
|
</script>
|
|
9
6
|
|
|
10
|
-
<button style:height="100%"
|
|
7
|
+
<button style:height="100%" {onclick}>
|
|
11
8
|
<div class="wrap">
|
|
12
9
|
<span class="icon-wrap">
|
|
13
10
|
<Webcam />
|
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
|
}
|