@gradio/imageslider 0.4.6 → 0.5.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 +20 -0
- package/ImageSlider.test.ts +714 -0
- package/Index.svelte +10 -13
- package/dist/Index.svelte +10 -13
- package/dist/shared/ImageEl.svelte +1 -0
- package/dist/shared/Slider.svelte +1 -0
- package/package.json +5 -5
- package/shared/ImageEl.svelte +1 -0
- package/shared/Slider.svelte +1 -0
- package/shared/zoom.test.ts +121 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @gradio/imageslider
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- [#13252](https://github.com/gradio-app/gradio/pull/13252) [`e39f028`](https://github.com/gradio-app/gradio/commit/e39f0284582bbac93fd01b2a5eae4ecae219f252) - Add ImageSlider unit tests. Thanks @pngwn!
|
|
8
|
+
|
|
9
|
+
### Dependency updates
|
|
10
|
+
|
|
11
|
+
- @gradio/atoms@0.24.0
|
|
12
|
+
- @gradio/statustracker@0.14.1
|
|
13
|
+
- @gradio/upload@0.17.9
|
|
14
|
+
|
|
15
|
+
## 0.4.7
|
|
16
|
+
|
|
17
|
+
### Dependency updates
|
|
18
|
+
|
|
19
|
+
- @gradio/atoms@0.23.1
|
|
20
|
+
- @gradio/statustracker@0.14.0
|
|
21
|
+
- @gradio/client@2.2.0
|
|
22
|
+
|
|
3
23
|
## 0.4.6
|
|
4
24
|
|
|
5
25
|
### Dependency updates
|
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { test, describe, afterEach, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
cleanup,
|
|
4
|
+
render,
|
|
5
|
+
fireEvent,
|
|
6
|
+
waitFor,
|
|
7
|
+
upload_file,
|
|
8
|
+
mock_client,
|
|
9
|
+
TEST_JPG
|
|
10
|
+
} from "@self/tootils/render";
|
|
11
|
+
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
12
|
+
import type { FileData } from "@gradio/client";
|
|
13
|
+
|
|
14
|
+
import ImageSlider from "./Index.svelte";
|
|
15
|
+
|
|
16
|
+
const fake_image = (id: string): FileData => ({
|
|
17
|
+
path: `${id}.png`,
|
|
18
|
+
url: `https://example.com/${id}.png`,
|
|
19
|
+
orig_name: `${id}.png`,
|
|
20
|
+
size: 1024,
|
|
21
|
+
mime_type: "image/png",
|
|
22
|
+
is_stream: false,
|
|
23
|
+
meta: { _type: "gradio.FileData" }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const img_a = fake_image("img_a");
|
|
27
|
+
const img_b = fake_image("img_b");
|
|
28
|
+
|
|
29
|
+
const default_props = {
|
|
30
|
+
value: [null, null] as [FileData | null, FileData | null],
|
|
31
|
+
label: "ImageSlider",
|
|
32
|
+
show_label: true,
|
|
33
|
+
interactive: false,
|
|
34
|
+
buttons: [] as (string | { value: string; id: number; icon: null })[],
|
|
35
|
+
slider_position: 50,
|
|
36
|
+
input_ready: true,
|
|
37
|
+
upload_count: 2,
|
|
38
|
+
slider_color: "#ff0000",
|
|
39
|
+
max_height: 500
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// interactive: false ensures SliderPreview is rendered for elem_id/elem_classes/visible tests.
|
|
43
|
+
// has_label: false because BlockLabel's show_label=false uses class:hide (display:none) but
|
|
44
|
+
// is applied directly on the <label> element, not on a data-testid='block-info' wrapper that
|
|
45
|
+
// the shared test expects. Custom label tests are below.
|
|
46
|
+
run_shared_prop_tests({
|
|
47
|
+
component: ImageSlider,
|
|
48
|
+
name: "ImageSlider",
|
|
49
|
+
base_props: { ...default_props },
|
|
50
|
+
has_label: false
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("Props: label", () => {
|
|
54
|
+
afterEach(() => cleanup());
|
|
55
|
+
|
|
56
|
+
test("label text is rendered", async () => {
|
|
57
|
+
const { getByTestId } = await render(ImageSlider, {
|
|
58
|
+
...default_props,
|
|
59
|
+
label: "My Comparison"
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(getByTestId("block-label")).toHaveTextContent("My Comparison");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("show_label: true makes the label visible", async () => {
|
|
66
|
+
const { getByTestId } = await render(ImageSlider, {
|
|
67
|
+
...default_props,
|
|
68
|
+
show_label: true
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(getByTestId("block-label")).toBeVisible();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("show_label: false hides the label", async () => {
|
|
75
|
+
const { getByTestId } = await render(ImageSlider, {
|
|
76
|
+
...default_props,
|
|
77
|
+
show_label: false
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// BlockLabel uses class:hide (display:none) when show_label=false
|
|
81
|
+
expect(getByTestId("block-label")).not.toBeVisible();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("ImageSlider", () => {
|
|
86
|
+
afterEach(() => cleanup());
|
|
87
|
+
|
|
88
|
+
test("renders empty state when non-interactive with no images", async () => {
|
|
89
|
+
const { queryAllByTestId } = await render(ImageSlider, {
|
|
90
|
+
...default_props,
|
|
91
|
+
interactive: false,
|
|
92
|
+
value: [null, null]
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(queryAllByTestId("imageslider-image")).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("renders upload area when interactive with no images", async () => {
|
|
99
|
+
// Upload component renders two buttons (one per slot); getAllByRole handles both
|
|
100
|
+
const { getAllByRole } = await render(ImageSlider, {
|
|
101
|
+
...default_props,
|
|
102
|
+
interactive: true,
|
|
103
|
+
value: [null, null],
|
|
104
|
+
client: mock_client()
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const btns = getAllByRole("button", {
|
|
108
|
+
name: "Click to upload or drop files"
|
|
109
|
+
});
|
|
110
|
+
expect(btns[0]).toBeVisible();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("renders both images when value has two images", async () => {
|
|
114
|
+
const { getAllByTestId } = await render(ImageSlider, {
|
|
115
|
+
...default_props,
|
|
116
|
+
value: [img_a, img_b]
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("Props: value", () => {
|
|
124
|
+
afterEach(() => cleanup());
|
|
125
|
+
|
|
126
|
+
test("[null, null] + interactive shows upload area", async () => {
|
|
127
|
+
// Two upload buttons (one per slot); getAllByRole avoids "multiple elements" error
|
|
128
|
+
const { getAllByRole } = await render(ImageSlider, {
|
|
129
|
+
...default_props,
|
|
130
|
+
interactive: true,
|
|
131
|
+
value: [null, null],
|
|
132
|
+
client: mock_client()
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const btns = getAllByRole("button", {
|
|
136
|
+
name: "Click to upload or drop files"
|
|
137
|
+
});
|
|
138
|
+
expect(btns[0]).toBeVisible();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("both images show their URLs in <img> src attributes", async () => {
|
|
142
|
+
const { getAllByTestId } = await render(ImageSlider, {
|
|
143
|
+
...default_props,
|
|
144
|
+
value: [img_a, img_b]
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const imgs = getAllByTestId("imageslider-image");
|
|
148
|
+
const srcs = imgs.map((img) => (img as HTMLImageElement).src);
|
|
149
|
+
expect(srcs).toContain("https://example.com/img_a.png");
|
|
150
|
+
expect(srcs).toContain("https://example.com/img_b.png");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("[img, null] + interactive shows upload mode (second image missing)", async () => {
|
|
154
|
+
const { getByRole } = await render(ImageSlider, {
|
|
155
|
+
...default_props,
|
|
156
|
+
interactive: true,
|
|
157
|
+
value: [img_a, null],
|
|
158
|
+
client: mock_client()
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(
|
|
162
|
+
getByRole("button", { name: "Click to upload or drop files" })
|
|
163
|
+
).toBeVisible();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("[null, img] + interactive shows upload mode (first image missing)", async () => {
|
|
167
|
+
const { getByRole } = await render(ImageSlider, {
|
|
168
|
+
...default_props,
|
|
169
|
+
interactive: true,
|
|
170
|
+
value: [null, img_b],
|
|
171
|
+
client: mock_client()
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
getByRole("button", { name: "Click to upload or drop files" })
|
|
176
|
+
).toBeVisible();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("[img, img] + interactive switches to preview mode", async () => {
|
|
180
|
+
const { getAllByTestId, queryByRole } = await render(ImageSlider, {
|
|
181
|
+
...default_props,
|
|
182
|
+
interactive: true,
|
|
183
|
+
value: [img_a, img_b]
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(
|
|
187
|
+
queryByRole("button", { name: "Click to upload or drop files" })
|
|
188
|
+
).toBeNull();
|
|
189
|
+
expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("set_data with both images updates DOM with correct src values", async () => {
|
|
193
|
+
const { set_data, getAllByTestId } = await render(ImageSlider, {
|
|
194
|
+
...default_props,
|
|
195
|
+
value: [null, null]
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await set_data({ value: [img_a, img_b] });
|
|
199
|
+
|
|
200
|
+
const imgs = getAllByTestId("imageslider-image");
|
|
201
|
+
const srcs = imgs.map((img) => (img as HTMLImageElement).src);
|
|
202
|
+
expect(srcs).toContain("https://example.com/img_a.png");
|
|
203
|
+
expect(srcs).toContain("https://example.com/img_b.png");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("set_data with [null, null] removes images from DOM", async () => {
|
|
207
|
+
const { set_data, queryAllByTestId } = await render(ImageSlider, {
|
|
208
|
+
...default_props,
|
|
209
|
+
interactive: false,
|
|
210
|
+
value: [img_a, img_b]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await set_data({ value: [null, null] });
|
|
214
|
+
|
|
215
|
+
expect(queryAllByTestId("imageslider-image")).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("Props: interactive", () => {
|
|
220
|
+
afterEach(() => cleanup());
|
|
221
|
+
|
|
222
|
+
test("interactive: false with no images shows static empty state without file inputs", async () => {
|
|
223
|
+
const { queryByTestId } = await render(ImageSlider, {
|
|
224
|
+
...default_props,
|
|
225
|
+
interactive: false,
|
|
226
|
+
value: [null, null]
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(queryByTestId("file-upload")).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("interactive: true with no images shows upload area", async () => {
|
|
233
|
+
// Two upload buttons (one per slot); getAllByRole avoids "multiple elements" error
|
|
234
|
+
const { getAllByRole } = await render(ImageSlider, {
|
|
235
|
+
...default_props,
|
|
236
|
+
interactive: true,
|
|
237
|
+
value: [null, null],
|
|
238
|
+
client: mock_client()
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const btns = getAllByRole("button", {
|
|
242
|
+
name: "Click to upload or drop files"
|
|
243
|
+
});
|
|
244
|
+
expect(btns[0]).toBeVisible();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("interactive: true with both images shows preview without upload buttons", async () => {
|
|
248
|
+
const { getAllByTestId, queryByRole } = await render(ImageSlider, {
|
|
249
|
+
...default_props,
|
|
250
|
+
interactive: true,
|
|
251
|
+
value: [img_a, img_b]
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(
|
|
255
|
+
queryByRole("button", { name: "Click to upload or drop files" })
|
|
256
|
+
).toBeNull();
|
|
257
|
+
expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("interactive: false with both images shows static preview", async () => {
|
|
261
|
+
const { queryByTestId, getAllByTestId } = await render(ImageSlider, {
|
|
262
|
+
...default_props,
|
|
263
|
+
interactive: false,
|
|
264
|
+
value: [img_a, img_b]
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(queryByTestId("file-upload")).toBeNull();
|
|
268
|
+
expect(getAllByTestId("imageslider-image").length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("Props: slider_position", () => {
|
|
273
|
+
afterEach(() => cleanup());
|
|
274
|
+
|
|
275
|
+
test("slider_position: 50 renders the slider element", async () => {
|
|
276
|
+
const { getByTestId } = await render(ImageSlider, {
|
|
277
|
+
...default_props,
|
|
278
|
+
value: [img_a, img_b],
|
|
279
|
+
slider_position: 50
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(getByTestId("slider")).toBeInTheDocument();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test.todo(
|
|
286
|
+
"VISUAL: slider_position=0 positions the slider at the left edge — needs Playwright visual regression screenshot comparison"
|
|
287
|
+
);
|
|
288
|
+
test.todo(
|
|
289
|
+
"VISUAL: slider_position=100 positions the slider at the right edge — needs Playwright visual regression screenshot comparison"
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("Props: buttons", () => {
|
|
294
|
+
afterEach(() => cleanup());
|
|
295
|
+
|
|
296
|
+
const preview_props = {
|
|
297
|
+
...default_props,
|
|
298
|
+
interactive: false,
|
|
299
|
+
value: [img_a, img_b] as [FileData, FileData]
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
test("buttons: ['download'] shows a download link", async () => {
|
|
303
|
+
const { getByTestId } = await render(ImageSlider, {
|
|
304
|
+
...preview_props,
|
|
305
|
+
buttons: ["download"]
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(getByTestId("download-link")).toBeInTheDocument();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("buttons: [] hides the download link", async () => {
|
|
312
|
+
const { queryByTestId } = await render(ImageSlider, {
|
|
313
|
+
...preview_props,
|
|
314
|
+
buttons: []
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(queryByTestId("download-link")).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("buttons: ['fullscreen'] shows the fullscreen button", async () => {
|
|
321
|
+
const { getByLabelText } = await render(ImageSlider, {
|
|
322
|
+
...preview_props,
|
|
323
|
+
buttons: ["fullscreen"]
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(getByLabelText("Fullscreen")).toBeVisible();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("buttons: [] hides the fullscreen button", async () => {
|
|
330
|
+
const { queryByLabelText } = await render(ImageSlider, {
|
|
331
|
+
...preview_props,
|
|
332
|
+
buttons: []
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(queryByLabelText("Fullscreen")).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("buttons: ['download', 'fullscreen'] shows both buttons", async () => {
|
|
339
|
+
const { getByTestId, getByLabelText } = await render(ImageSlider, {
|
|
340
|
+
...preview_props,
|
|
341
|
+
buttons: ["download", "fullscreen"]
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(getByTestId("download-link")).toBeInTheDocument();
|
|
345
|
+
expect(getByLabelText("Fullscreen")).toBeVisible();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("interactive: true with both images shows Remove Image button", async () => {
|
|
349
|
+
const { getByLabelText } = await render(ImageSlider, {
|
|
350
|
+
...default_props,
|
|
351
|
+
interactive: true,
|
|
352
|
+
value: [img_a, img_b]
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(getByLabelText("Remove Image")).toBeVisible();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("interactive: false hides the Remove Image button", async () => {
|
|
359
|
+
const { queryByLabelText } = await render(ImageSlider, {
|
|
360
|
+
...preview_props,
|
|
361
|
+
interactive: false
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(queryByLabelText("Remove Image")).toBeNull();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("custom button fires custom_button_click with correct id", async () => {
|
|
368
|
+
const { listen, getByLabelText } = await render(ImageSlider, {
|
|
369
|
+
...preview_props,
|
|
370
|
+
buttons: [{ value: "Analyze", id: 42, icon: null }]
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const custom = listen("custom_button_click");
|
|
374
|
+
await fireEvent.click(getByLabelText("Analyze"));
|
|
375
|
+
|
|
376
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
377
|
+
expect(custom).toHaveBeenCalledWith({ id: 42 });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("multiple custom buttons each dispatch their own id", async () => {
|
|
381
|
+
const { listen, getByLabelText } = await render(ImageSlider, {
|
|
382
|
+
...preview_props,
|
|
383
|
+
buttons: [
|
|
384
|
+
{ value: "Action A", id: 1, icon: null },
|
|
385
|
+
{ value: "Action B", id: 2, icon: null }
|
|
386
|
+
]
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const custom = listen("custom_button_click");
|
|
390
|
+
|
|
391
|
+
await fireEvent.click(getByLabelText("Action A"));
|
|
392
|
+
await fireEvent.click(getByLabelText("Action B"));
|
|
393
|
+
|
|
394
|
+
expect(custom).toHaveBeenCalledTimes(2);
|
|
395
|
+
expect(custom).toHaveBeenNthCalledWith(1, { id: 1 });
|
|
396
|
+
expect(custom).toHaveBeenNthCalledWith(2, { id: 2 });
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("Props: placeholder", () => {
|
|
401
|
+
afterEach(() => cleanup());
|
|
402
|
+
|
|
403
|
+
test("shows placeholder text in the upload areas", async () => {
|
|
404
|
+
// Both upload slots show the same placeholder text; getAllByText handles both
|
|
405
|
+
const { getAllByText } = await render(ImageSlider, {
|
|
406
|
+
...default_props,
|
|
407
|
+
interactive: true,
|
|
408
|
+
value: [null, null],
|
|
409
|
+
placeholder: "Drop comparison images here",
|
|
410
|
+
client: mock_client()
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const placeholders = getAllByText("Drop comparison images here");
|
|
414
|
+
expect(placeholders[0]).toBeVisible();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("Events: change", () => {
|
|
419
|
+
afterEach(() => cleanup());
|
|
420
|
+
|
|
421
|
+
test("no spurious change event on initial mount", async () => {
|
|
422
|
+
const { listen } = await render(ImageSlider, {
|
|
423
|
+
...default_props,
|
|
424
|
+
value: [img_a, img_b]
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const change = listen("change", { retrospective: true });
|
|
428
|
+
|
|
429
|
+
expect(change).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("fires when set_data changes value", async () => {
|
|
433
|
+
const { listen, set_data } = await render(ImageSlider, {
|
|
434
|
+
...default_props,
|
|
435
|
+
value: [null, null]
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const change = listen("change");
|
|
439
|
+
|
|
440
|
+
await set_data({ value: [img_a, img_b] });
|
|
441
|
+
|
|
442
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("fires again when value changes to a different pair", async () => {
|
|
446
|
+
const { listen, set_data } = await render(ImageSlider, {
|
|
447
|
+
...default_props,
|
|
448
|
+
value: [null, null]
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const change = listen("change");
|
|
452
|
+
const alt_a = fake_image("alt_a");
|
|
453
|
+
const alt_b = fake_image("alt_b");
|
|
454
|
+
|
|
455
|
+
await set_data({ value: [img_a, img_b] });
|
|
456
|
+
await set_data({ value: [alt_a, alt_b] });
|
|
457
|
+
|
|
458
|
+
expect(change).toHaveBeenCalledTimes(2);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("setting value to [null, null] after having images fires change", async () => {
|
|
462
|
+
const { listen, set_data } = await render(ImageSlider, {
|
|
463
|
+
...default_props,
|
|
464
|
+
value: [img_a, img_b]
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const change = listen("change");
|
|
468
|
+
|
|
469
|
+
await set_data({ value: [null, null] });
|
|
470
|
+
|
|
471
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("Events: clear", () => {
|
|
476
|
+
afterEach(() => cleanup());
|
|
477
|
+
|
|
478
|
+
test("fires when Remove Image button is clicked", async () => {
|
|
479
|
+
const { listen, getByLabelText } = await render(ImageSlider, {
|
|
480
|
+
...default_props,
|
|
481
|
+
interactive: true,
|
|
482
|
+
value: [img_a, img_b]
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const clear = listen("clear");
|
|
486
|
+
|
|
487
|
+
await fireEvent.click(getByLabelText("Remove Image"));
|
|
488
|
+
|
|
489
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("value is [null, null] after clicking Remove Image", async () => {
|
|
493
|
+
const { get_data, getByLabelText } = await render(ImageSlider, {
|
|
494
|
+
...default_props,
|
|
495
|
+
interactive: true,
|
|
496
|
+
value: [img_a, img_b]
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await fireEvent.click(getByLabelText("Remove Image"));
|
|
500
|
+
|
|
501
|
+
const data = await get_data();
|
|
502
|
+
expect(data.value).toEqual([null, null]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe("Events: upload", () => {
|
|
507
|
+
afterEach(() => cleanup());
|
|
508
|
+
|
|
509
|
+
test("fires after a file is uploaded in interactive mode", async () => {
|
|
510
|
+
const { listen } = await render(ImageSlider, {
|
|
511
|
+
...default_props,
|
|
512
|
+
interactive: true,
|
|
513
|
+
value: [null, null],
|
|
514
|
+
client: mock_client(),
|
|
515
|
+
root: "https://example.com"
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const upload = listen("upload");
|
|
519
|
+
|
|
520
|
+
await upload_file(TEST_JPG);
|
|
521
|
+
|
|
522
|
+
await waitFor(() => {
|
|
523
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("uploading also fires change", async () => {
|
|
528
|
+
const { listen } = await render(ImageSlider, {
|
|
529
|
+
...default_props,
|
|
530
|
+
interactive: true,
|
|
531
|
+
value: [null, null],
|
|
532
|
+
client: mock_client(),
|
|
533
|
+
root: "https://example.com"
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const upload = listen("upload");
|
|
537
|
+
const change = listen("change");
|
|
538
|
+
|
|
539
|
+
await upload_file(TEST_JPG);
|
|
540
|
+
|
|
541
|
+
await waitFor(() => {
|
|
542
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
543
|
+
});
|
|
544
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe("Events: input", () => {
|
|
549
|
+
afterEach(() => cleanup());
|
|
550
|
+
|
|
551
|
+
test("fires when a file is uploaded by the user", async () => {
|
|
552
|
+
const { listen } = await render(ImageSlider, {
|
|
553
|
+
...default_props,
|
|
554
|
+
interactive: true,
|
|
555
|
+
value: [null, null],
|
|
556
|
+
client: mock_client(),
|
|
557
|
+
root: "https://example.com"
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const input = listen("input");
|
|
561
|
+
|
|
562
|
+
await upload_file(TEST_JPG);
|
|
563
|
+
|
|
564
|
+
await waitFor(() => {
|
|
565
|
+
expect(input).toHaveBeenCalledTimes(1);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("does not fire when value changes via set_data (backend output)", async () => {
|
|
570
|
+
const { listen, set_data } = await render(ImageSlider, {
|
|
571
|
+
...default_props,
|
|
572
|
+
value: [null, null]
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const input = listen("input");
|
|
576
|
+
|
|
577
|
+
await set_data({ value: [img_a, img_b] });
|
|
578
|
+
|
|
579
|
+
expect(input).not.toHaveBeenCalled();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("fires when Remove Image button is clicked", async () => {
|
|
583
|
+
const { listen, getByLabelText } = await render(ImageSlider, {
|
|
584
|
+
...default_props,
|
|
585
|
+
interactive: true,
|
|
586
|
+
value: [img_a, img_b]
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const input = listen("input");
|
|
590
|
+
|
|
591
|
+
await fireEvent.click(getByLabelText("Remove Image"));
|
|
592
|
+
|
|
593
|
+
expect(input).toHaveBeenCalledTimes(1);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
describe("Events: custom_button_click", () => {
|
|
598
|
+
afterEach(() => cleanup());
|
|
599
|
+
|
|
600
|
+
test("fires with { id } when a custom button is clicked", async () => {
|
|
601
|
+
const { listen, getByLabelText } = await render(ImageSlider, {
|
|
602
|
+
...default_props,
|
|
603
|
+
interactive: false,
|
|
604
|
+
value: [img_a, img_b],
|
|
605
|
+
buttons: [{ value: "Run", id: 99, icon: null }]
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const custom = listen("custom_button_click");
|
|
609
|
+
|
|
610
|
+
await fireEvent.click(getByLabelText("Run"));
|
|
611
|
+
|
|
612
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
613
|
+
expect(custom).toHaveBeenCalledWith({ id: 99 });
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("get_data / set_data", () => {
|
|
618
|
+
afterEach(() => cleanup());
|
|
619
|
+
|
|
620
|
+
test("get_data returns the initial value", async () => {
|
|
621
|
+
const { get_data } = await render(ImageSlider, {
|
|
622
|
+
...default_props,
|
|
623
|
+
value: [img_a, img_b]
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const data = await get_data();
|
|
627
|
+
expect(data.value).toEqual([img_a, img_b]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("get_data returns [null, null] when no images are set", async () => {
|
|
631
|
+
const { get_data } = await render(ImageSlider, {
|
|
632
|
+
...default_props,
|
|
633
|
+
value: [null, null]
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const data = await get_data();
|
|
637
|
+
expect(data.value).toEqual([null, null]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("set_data then get_data round-trips correctly", async () => {
|
|
641
|
+
const { set_data, get_data } = await render(ImageSlider, {
|
|
642
|
+
...default_props,
|
|
643
|
+
value: [null, null]
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await set_data({ value: [img_a, img_b] });
|
|
647
|
+
|
|
648
|
+
const data = await get_data();
|
|
649
|
+
expect(data.value).toEqual([img_a, img_b]);
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
describe("Edge cases", () => {
|
|
654
|
+
afterEach(() => cleanup());
|
|
655
|
+
|
|
656
|
+
test("[null, null] value renders without crash", async () => {
|
|
657
|
+
const { container } = await render(ImageSlider, {
|
|
658
|
+
...default_props,
|
|
659
|
+
value: [null, null]
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(container).toBeInTheDocument();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("[img, null] partial value renders without crash in static mode", async () => {
|
|
666
|
+
const { container } = await render(ImageSlider, {
|
|
667
|
+
...default_props,
|
|
668
|
+
interactive: false,
|
|
669
|
+
value: [img_a, null]
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(container).toBeInTheDocument();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("[null, img] partial value renders without crash in static mode", async () => {
|
|
676
|
+
const { container } = await render(ImageSlider, {
|
|
677
|
+
...default_props,
|
|
678
|
+
interactive: false,
|
|
679
|
+
value: [null, img_b]
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
expect(container).toBeInTheDocument();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("no change event on initial mount when value is set", async () => {
|
|
686
|
+
const { listen } = await render(ImageSlider, {
|
|
687
|
+
...default_props,
|
|
688
|
+
value: [img_a, img_b]
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const change = listen("change", { retrospective: true });
|
|
692
|
+
|
|
693
|
+
expect(change).not.toHaveBeenCalled();
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test.todo(
|
|
698
|
+
"VISUAL: slider_color applies the given color to the slider line and handles — needs Playwright visual regression screenshot comparison"
|
|
699
|
+
);
|
|
700
|
+
test.todo(
|
|
701
|
+
"VISUAL: height prop constrains the component height — needs Playwright visual regression screenshot comparison"
|
|
702
|
+
);
|
|
703
|
+
test.todo(
|
|
704
|
+
"VISUAL: width prop constrains the component width — needs Playwright visual regression screenshot comparison"
|
|
705
|
+
);
|
|
706
|
+
test.todo(
|
|
707
|
+
"VISUAL: max_height limits image display height — needs Playwright visual regression screenshot comparison"
|
|
708
|
+
);
|
|
709
|
+
test.todo(
|
|
710
|
+
"VISUAL: scroll wheel zoom scales both images together — needs Playwright visual regression screenshot comparison"
|
|
711
|
+
);
|
|
712
|
+
test.todo(
|
|
713
|
+
"VISUAL: mouse drag pans both images when zoomed in — needs Playwright visual regression screenshot comparison"
|
|
714
|
+
);
|
package/Index.svelte
CHANGED
|
@@ -30,8 +30,6 @@
|
|
|
30
30
|
const props = $props();
|
|
31
31
|
const gradio = new ImageSliderGradio(props);
|
|
32
32
|
|
|
33
|
-
let value_is_output = $state(false);
|
|
34
|
-
let old_value = $state(gradio.props.value);
|
|
35
33
|
let fullscreen = $state(false);
|
|
36
34
|
let dragging = $state(false);
|
|
37
35
|
let active_source: sources = $state(null);
|
|
@@ -41,15 +39,7 @@
|
|
|
41
39
|
Math.max(0, Math.min(100, gradio.props.slider_position)) / 100
|
|
42
40
|
);
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
if (old_value != gradio.props.value) {
|
|
46
|
-
old_value = gradio.props.value;
|
|
47
|
-
gradio.dispatch("change");
|
|
48
|
-
if (!value_is_output) {
|
|
49
|
-
gradio.dispatch("input");
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
});
|
|
42
|
+
gradio.watch_for_change();
|
|
53
43
|
|
|
54
44
|
const handle_drag_event = (event: Event): void => {
|
|
55
45
|
const drag_event = event as DragEvent;
|
|
@@ -101,7 +91,10 @@
|
|
|
101
91
|
on:select={({ detail }) => gradio.dispatch("select", detail)}
|
|
102
92
|
on:share={({ detail }) => gradio.dispatch("share", detail)}
|
|
103
93
|
on:error={({ detail }) => gradio.dispatch("error", detail)}
|
|
104
|
-
on:clear={() =>
|
|
94
|
+
on:clear={() => {
|
|
95
|
+
gradio.dispatch("clear");
|
|
96
|
+
gradio.dispatch("input");
|
|
97
|
+
}}
|
|
105
98
|
on:fullscreen={({ detail }) => {
|
|
106
99
|
fullscreen = detail;
|
|
107
100
|
}}
|
|
@@ -162,9 +155,13 @@
|
|
|
162
155
|
on:edit={() => gradio.dispatch("edit")}
|
|
163
156
|
on:clear={() => {
|
|
164
157
|
gradio.dispatch("clear");
|
|
158
|
+
gradio.dispatch("input");
|
|
165
159
|
}}
|
|
166
160
|
on:drag={({ detail }) => (dragging = detail)}
|
|
167
|
-
on:upload={() =>
|
|
161
|
+
on:upload={() => {
|
|
162
|
+
gradio.dispatch("upload");
|
|
163
|
+
gradio.dispatch("input");
|
|
164
|
+
}}
|
|
168
165
|
on:error={({ detail }) => {
|
|
169
166
|
if (gradio.shared.loading_status)
|
|
170
167
|
gradio.shared.loading_status.status = "error";
|
package/dist/Index.svelte
CHANGED
|
@@ -30,8 +30,6 @@
|
|
|
30
30
|
const props = $props();
|
|
31
31
|
const gradio = new ImageSliderGradio(props);
|
|
32
32
|
|
|
33
|
-
let value_is_output = $state(false);
|
|
34
|
-
let old_value = $state(gradio.props.value);
|
|
35
33
|
let fullscreen = $state(false);
|
|
36
34
|
let dragging = $state(false);
|
|
37
35
|
let active_source: sources = $state(null);
|
|
@@ -41,15 +39,7 @@
|
|
|
41
39
|
Math.max(0, Math.min(100, gradio.props.slider_position)) / 100
|
|
42
40
|
);
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
if (old_value != gradio.props.value) {
|
|
46
|
-
old_value = gradio.props.value;
|
|
47
|
-
gradio.dispatch("change");
|
|
48
|
-
if (!value_is_output) {
|
|
49
|
-
gradio.dispatch("input");
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
});
|
|
42
|
+
gradio.watch_for_change();
|
|
53
43
|
|
|
54
44
|
const handle_drag_event = (event: Event): void => {
|
|
55
45
|
const drag_event = event as DragEvent;
|
|
@@ -101,7 +91,10 @@
|
|
|
101
91
|
on:select={({ detail }) => gradio.dispatch("select", detail)}
|
|
102
92
|
on:share={({ detail }) => gradio.dispatch("share", detail)}
|
|
103
93
|
on:error={({ detail }) => gradio.dispatch("error", detail)}
|
|
104
|
-
on:clear={() =>
|
|
94
|
+
on:clear={() => {
|
|
95
|
+
gradio.dispatch("clear");
|
|
96
|
+
gradio.dispatch("input");
|
|
97
|
+
}}
|
|
105
98
|
on:fullscreen={({ detail }) => {
|
|
106
99
|
fullscreen = detail;
|
|
107
100
|
}}
|
|
@@ -162,9 +155,13 @@
|
|
|
162
155
|
on:edit={() => gradio.dispatch("edit")}
|
|
163
156
|
on:clear={() => {
|
|
164
157
|
gradio.dispatch("clear");
|
|
158
|
+
gradio.dispatch("input");
|
|
165
159
|
}}
|
|
166
160
|
on:drag={({ detail }) => (dragging = detail)}
|
|
167
|
-
on:upload={() =>
|
|
161
|
+
on:upload={() => {
|
|
162
|
+
gradio.dispatch("upload");
|
|
163
|
+
gradio.dispatch("input");
|
|
164
|
+
}}
|
|
168
165
|
on:error={({ detail }) => {
|
|
169
166
|
if (gradio.shared.loading_status)
|
|
170
167
|
gradio.shared.loading_status.status = "error";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradio/imageslider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Gradio UI packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "",
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
"@types/d3-selection": "^3.0.11",
|
|
12
12
|
"d3-drag": "^3.0.0",
|
|
13
13
|
"d3-selection": "^3.0.0",
|
|
14
|
-
"@gradio/atoms": "^0.
|
|
15
|
-
"@gradio/client": "^2.
|
|
14
|
+
"@gradio/atoms": "^0.24.0",
|
|
15
|
+
"@gradio/client": "^2.2.0",
|
|
16
16
|
"@gradio/icons": "^0.15.1",
|
|
17
|
-
"@gradio/statustracker": "^0.
|
|
18
|
-
"@gradio/upload": "^0.17.
|
|
17
|
+
"@gradio/statustracker": "^0.14.1",
|
|
18
|
+
"@gradio/upload": "^0.17.9",
|
|
19
19
|
"@gradio/utils": "^0.12.2"
|
|
20
20
|
},
|
|
21
21
|
"exports": {
|
package/shared/ImageEl.svelte
CHANGED
package/shared/Slider.svelte
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, expect, vi } from "vitest";
|
|
2
|
+
import { ZoomableImage } from "./zoom";
|
|
3
|
+
|
|
4
|
+
describe("ZoomableImage", () => {
|
|
5
|
+
let container: HTMLDivElement;
|
|
6
|
+
let img: HTMLImageElement;
|
|
7
|
+
let zi: ZoomableImage;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
container = document.createElement("div");
|
|
11
|
+
img = document.createElement("img");
|
|
12
|
+
container.appendChild(img);
|
|
13
|
+
zi = new ZoomableImage(container, img);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
zi.destroy();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("initializes with scale=1 and zero offsets", () => {
|
|
21
|
+
expect(zi.scale).toBe(1);
|
|
22
|
+
expect(zi.offsetX).toBe(0);
|
|
23
|
+
expect(zi.offsetY).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("subscribe callback receives transform updates via updateTransform", () => {
|
|
27
|
+
const cb = vi.fn();
|
|
28
|
+
zi.subscribe(cb);
|
|
29
|
+
zi.updateTransform();
|
|
30
|
+
|
|
31
|
+
expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("reset_zoom restores scale=1 and zero offsets", () => {
|
|
35
|
+
zi.scale = 3;
|
|
36
|
+
zi.offsetX = 100;
|
|
37
|
+
zi.offsetY = -50;
|
|
38
|
+
|
|
39
|
+
const cb = vi.fn();
|
|
40
|
+
zi.subscribe(cb);
|
|
41
|
+
|
|
42
|
+
zi.reset_zoom();
|
|
43
|
+
|
|
44
|
+
expect(zi.scale).toBe(1);
|
|
45
|
+
expect(zi.offsetX).toBe(0);
|
|
46
|
+
expect(zi.offsetY).toBe(0);
|
|
47
|
+
expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("unsubscribe removes callback so it no longer receives updates", () => {
|
|
51
|
+
const cb = vi.fn();
|
|
52
|
+
zi.subscribe(cb);
|
|
53
|
+
zi.unsubscribe(cb);
|
|
54
|
+
zi.updateTransform();
|
|
55
|
+
|
|
56
|
+
expect(cb).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("multiple subscribers all receive the same transform", () => {
|
|
60
|
+
const cb1 = vi.fn();
|
|
61
|
+
const cb2 = vi.fn();
|
|
62
|
+
|
|
63
|
+
zi.subscribe(cb1);
|
|
64
|
+
zi.subscribe(cb2);
|
|
65
|
+
zi.updateTransform();
|
|
66
|
+
|
|
67
|
+
expect(cb1).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
|
|
68
|
+
expect(cb2).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("notify delivers arbitrary transform values to subscribers", () => {
|
|
72
|
+
const cb = vi.fn();
|
|
73
|
+
zi.subscribe(cb);
|
|
74
|
+
|
|
75
|
+
zi.notify({ x: 15, y: -20, scale: 2.5 });
|
|
76
|
+
|
|
77
|
+
expect(cb).toHaveBeenCalledWith({ x: 15, y: -20, scale: 2.5 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("destroy does not throw", () => {
|
|
81
|
+
expect(() => zi.destroy()).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("reset_zoom after destroy does not notify destroyed listeners", () => {
|
|
85
|
+
const cb = vi.fn();
|
|
86
|
+
zi.subscribe(cb);
|
|
87
|
+
|
|
88
|
+
zi.destroy();
|
|
89
|
+
|
|
90
|
+
// After destroy, event listeners are removed but subscribers list is not cleared.
|
|
91
|
+
// reset_zoom still calls notify internally, so we verify the subscriber still fires
|
|
92
|
+
// (destroy only removes DOM listeners, not the in-memory subscriber list).
|
|
93
|
+
zi.reset_zoom();
|
|
94
|
+
expect(cb).toHaveBeenCalledWith({ x: 0, y: 0, scale: 1 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("compute_new_offset scales offset correctly relative to cursor", () => {
|
|
98
|
+
const result = zi.compute_new_offset({
|
|
99
|
+
cursor_position: 100,
|
|
100
|
+
current_offset: 0,
|
|
101
|
+
new_scale: 2,
|
|
102
|
+
old_scale: 1
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// cursor_position - (new_scale / old_scale) * (cursor_position - current_offset)
|
|
106
|
+
// = 100 - 2 * (100 - 0) = 100 - 200 = -100
|
|
107
|
+
expect(result).toBe(-100);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("compute_new_offset accounts for existing offset", () => {
|
|
111
|
+
const result = zi.compute_new_offset({
|
|
112
|
+
cursor_position: 100,
|
|
113
|
+
current_offset: 50,
|
|
114
|
+
new_scale: 2,
|
|
115
|
+
old_scale: 1
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// = 100 - 2 * (100 - 50) = 100 - 100 = 0
|
|
119
|
+
expect(result).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|