├── .all-contributorsrc ├── .changeset ├── README.md └── config.json ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .storybook ├── main.ts └── preview.ts ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── compose.yml ├── eslint.config.js ├── flake.lock ├── flake.nix ├── jest.config.js ├── jest.setup.ts ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── scripts └── npm-check-modified ├── shell.nix ├── source ├── Controlled.tsx ├── Uncontrolled.tsx ├── icons.tsx ├── index.ts ├── styles.css ├── types.ts └── utils.ts ├── stories ├── CustomControls.stories.tsx ├── Figure.stories.tsx ├── Galleries.stories.tsx ├── Img.stories.tsx ├── Introduction.mdx ├── Picture.stories.tsx ├── SVG.stories.tsx ├── base.css └── images │ ├── andres-iga-7XKkJVw1d8c-unsplash-smaller.jpg │ ├── andres-iga-7XKkJVw1d8c-unsplash.jpg │ ├── douglas-bagg-wRwa3Z6GtRI-unsplash-smaller.jpg │ ├── douglas-bagg-wRwa3Z6GtRI-unsplash.jpg │ ├── earth-large.jpg │ ├── glenorchy-lagoon.jpg │ ├── index.ts │ ├── laura-smetsers-H-TW2CoNtTk-unsplash-smaller.jpg │ ├── laura-smetsers-H-TW2CoNtTk-unsplash.jpg │ ├── nz-map.svg │ ├── omer-faruk-bekdemir-5BuxuWIJF1Q-unsplash-smaller.jpg │ ├── omer-faruk-bekdemir-5BuxuWIJF1Q-unsplash.jpg │ ├── pablo-heimplatz-PSF2RhUBORs-unsplash-300w.jpg │ ├── pablo-heimplatz-PSF2RhUBORs-unsplash-smaller.jpg │ ├── pablo-heimplatz-PSF2RhUBORs-unsplash.jpg │ ├── petr-vysohlid-9fqwGqGLUxc-unsplash-smaller.jpg │ ├── petr-vysohlid-9fqwGqGLUxc-unsplash.jpg │ ├── rod-long-4dcsLxQxSHY-unsplash-smaller.jpg │ ├── rod-long-4dcsLxQxSHY-unsplash.jpg │ ├── roell-de-ram-2DM7eOR5iyc-unsplash-smaller.jpg │ ├── roell-de-ram-2DM7eOR5iyc-unsplash.jpg │ ├── tobias-keller-73F4pKoUkM0-unsplash-smaller.jpg │ └── tobias-keller-73F4pKoUkM0-unsplash.jpg └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-medium-image-zoom", 3 | "projectOwner": "rpearce", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 40, 8 | "commit": true, 9 | "contributors": [ 10 | { 11 | "login": "rpearce", 12 | "name": "Robert Pearce", 13 | "avatar_url": "https://avatars2.githubusercontent.com/u/592876?v=4", 14 | "profile": "https://robertwpearce.com", 15 | "contributions": [ 16 | "code", 17 | "question", 18 | "test", 19 | "bug", 20 | "example", 21 | "design", 22 | "review", 23 | "ideas", 24 | "doc" 25 | ] 26 | }, 27 | { 28 | "login": "cbothner", 29 | "name": "Cameron Bothner", 30 | "avatar_url": "https://avatars1.githubusercontent.com/u/4642599?v=4", 31 | "profile": "https://github.com/cbothner", 32 | "contributions": [ 33 | "code", 34 | "doc", 35 | "bug", 36 | "example", 37 | "ideas", 38 | "review", 39 | "test" 40 | ] 41 | }, 42 | { 43 | "login": "jeremybini", 44 | "name": "Jeremy Bini", 45 | "avatar_url": "https://avatars2.githubusercontent.com/u/12982155?v=4", 46 | "profile": "https://github.com/jeremybini", 47 | "contributions": [ 48 | "code", 49 | "bug" 50 | ] 51 | }, 52 | { 53 | "login": "ismay", 54 | "name": "ismay", 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/7355199?v=4", 56 | "profile": "https://ismaywolff.nl", 57 | "contributions": [ 58 | "bug", 59 | "ideas" 60 | ] 61 | }, 62 | { 63 | "login": "rajit", 64 | "name": "Rajit Singh", 65 | "avatar_url": "https://avatars0.githubusercontent.com/u/220647?v=4", 66 | "profile": "https://www.qeek.co", 67 | "contributions": [ 68 | "bug" 69 | ] 70 | }, 71 | { 72 | "login": "rsaccon", 73 | "name": "Roberto Saccon", 74 | "avatar_url": "https://avatars1.githubusercontent.com/u/16122?v=4", 75 | "profile": "https://github.com/rsaccon", 76 | "contributions": [ 77 | "bug" 78 | ] 79 | }, 80 | { 81 | "login": "wtfdaemon", 82 | "name": "wtfdaemon", 83 | "avatar_url": "https://avatars0.githubusercontent.com/u/6598350?v=4", 84 | "profile": "https://github.com/wtfdaemon", 85 | "contributions": [ 86 | "bug" 87 | ] 88 | }, 89 | { 90 | "login": "joshsloat", 91 | "name": "Josh Sloat", 92 | "avatar_url": "https://avatars1.githubusercontent.com/u/606159?v=4", 93 | "profile": "http://www.joshsloat.com", 94 | "contributions": [ 95 | "bug", 96 | "code", 97 | "example", 98 | "review", 99 | "ideas", 100 | "doc", 101 | "design", 102 | "question" 103 | ] 104 | }, 105 | { 106 | "login": "aswinckr", 107 | "name": "Aswin", 108 | "avatar_url": "https://avatars1.githubusercontent.com/u/5960217?v=4", 109 | "profile": "https://github.com/aswinckr", 110 | "contributions": [ 111 | "question" 112 | ] 113 | }, 114 | { 115 | "login": "alexshelkov", 116 | "name": "Alex Shelkovskiy", 117 | "avatar_url": "https://avatars3.githubusercontent.com/u/1233347?v=4", 118 | "profile": "https://github.com/alexshelkov", 119 | "contributions": [ 120 | "bug" 121 | ] 122 | }, 123 | { 124 | "login": "Snowsoul", 125 | "name": "Adrian Bindiu", 126 | "avatar_url": "https://avatars1.githubusercontent.com/u/7365629?v=4", 127 | "profile": "http://adrian-design.com", 128 | "contributions": [ 129 | "bug" 130 | ] 131 | }, 132 | { 133 | "login": "kendagriff", 134 | "name": "Kendall Buchanan", 135 | "avatar_url": "https://avatars3.githubusercontent.com/u/110935?v=4", 136 | "profile": "https://github.com/kendagriff", 137 | "contributions": [ 138 | "bug" 139 | ] 140 | }, 141 | { 142 | "login": "HippoDippo", 143 | "name": "Kaycee", 144 | "avatar_url": "https://avatars2.githubusercontent.com/u/25674779?v=4", 145 | "profile": "https://github.com/HippoDippo", 146 | "contributions": [ 147 | "code" 148 | ] 149 | }, 150 | { 151 | "login": "oyeanuj", 152 | "name": "Anuj", 153 | "avatar_url": "https://avatars2.githubusercontent.com/u/9633371?v=4", 154 | "profile": "http://shuffle.do", 155 | "contributions": [ 156 | "bug", 157 | "question" 158 | ] 159 | }, 160 | { 161 | "login": "ludwigfrank", 162 | "name": "Ludwig Frank", 163 | "avatar_url": "https://avatars1.githubusercontent.com/u/10273946?v=4", 164 | "profile": "https://github.com/ludwigfrank", 165 | "contributions": [ 166 | "bug", 167 | "code" 168 | ] 169 | }, 170 | { 171 | "login": "serfgy", 172 | "name": "LX", 173 | "avatar_url": "https://avatars2.githubusercontent.com/u/20569525?v=4", 174 | "profile": "https://github.com/serfgy", 175 | "contributions": [ 176 | "bug", 177 | "ideas" 178 | ] 179 | }, 180 | { 181 | "login": "babyPrince", 182 | "name": "Rosen Tomov", 183 | "avatar_url": "https://avatars3.githubusercontent.com/u/5452135?v=4", 184 | "profile": "http://www.rosentomov.com", 185 | "contributions": [ 186 | "bug" 187 | ] 188 | }, 189 | { 190 | "login": "tommoor", 191 | "name": "Tom Moor", 192 | "avatar_url": "https://avatars2.githubusercontent.com/u/380914?v=4", 193 | "profile": "http://tommoor.com", 194 | "contributions": [ 195 | "code", 196 | "bug" 197 | ] 198 | }, 199 | { 200 | "login": "jpreynat", 201 | "name": "Johan Preynat", 202 | "avatar_url": "https://avatars2.githubusercontent.com/u/7927876?v=4", 203 | "profile": "https://github.com/jpreynat", 204 | "contributions": [ 205 | "code", 206 | "bug" 207 | ] 208 | }, 209 | { 210 | "login": "rgabs", 211 | "name": "Rahul Gaba", 212 | "avatar_url": "https://avatars3.githubusercontent.com/u/7898942?v=4", 213 | "profile": "http://rahulgaba.com", 214 | "contributions": [ 215 | "code", 216 | "bug" 217 | ] 218 | }, 219 | { 220 | "login": "spencerfdavis", 221 | "name": "Spencer Davis", 222 | "avatar_url": "https://avatars3.githubusercontent.com/u/1526292?v=4", 223 | "profile": "https://github.com/spencerfdavis", 224 | "contributions": [ 225 | "code", 226 | "ideas", 227 | "review", 228 | "design" 229 | ] 230 | }, 231 | { 232 | "login": "dnlnvl", 233 | "name": "dnlnvl", 234 | "avatar_url": "https://avatars2.githubusercontent.com/u/39607648?v=4", 235 | "profile": "https://github.com/dnlnvl", 236 | "contributions": [ 237 | "code" 238 | ] 239 | }, 240 | { 241 | "login": "madeleineostoja", 242 | "name": "Madi", 243 | "avatar_url": "https://avatars3.githubusercontent.com/u/6374876?v=4", 244 | "profile": "https://github.com/madeleineostoja", 245 | "contributions": [ 246 | "ideas" 247 | ] 248 | }, 249 | { 250 | "login": "0x6e6562", 251 | "name": "Ben Hood", 252 | "avatar_url": "https://avatars3.githubusercontent.com/u/14088?v=4", 253 | "profile": "https://github.com/0x6e6562", 254 | "contributions": [ 255 | "ideas", 256 | "bug", 257 | "example", 258 | "review" 259 | ] 260 | }, 261 | { 262 | "login": "navilan", 263 | "name": "Navilan", 264 | "avatar_url": "https://avatars2.githubusercontent.com/u/45002?v=4", 265 | "profile": "http://puthir.in", 266 | "contributions": [ 267 | "ideas" 268 | ] 269 | }, 270 | { 271 | "login": "13806", 272 | "name": "13806", 273 | "avatar_url": "https://avatars1.githubusercontent.com/u/31736960?v=4", 274 | "profile": "https://github.com/13806", 275 | "contributions": [ 276 | "bug" 277 | ] 278 | }, 279 | { 280 | "login": "deadcoder0904", 281 | "name": "Akshay Kadam (A2K)", 282 | "avatar_url": "https://avatars1.githubusercontent.com/u/16436270?v=4", 283 | "profile": "https://www.twitter.com/deadcoder0904", 284 | "contributions": [ 285 | "bug", 286 | "ideas" 287 | ] 288 | }, 289 | { 290 | "login": "xerona", 291 | "name": "Jake Stewart", 292 | "avatar_url": "https://avatars0.githubusercontent.com/u/8929085?v=4", 293 | "profile": "https://github.com/xerona", 294 | "contributions": [ 295 | "bug", 296 | "ideas" 297 | ] 298 | }, 299 | { 300 | "login": "henrych4", 301 | "name": "hhh", 302 | "avatar_url": "https://avatars0.githubusercontent.com/u/19466940?v=4", 303 | "profile": "https://github.com/henrych4", 304 | "contributions": [ 305 | "bug" 306 | ] 307 | }, 308 | { 309 | "login": "davalapar", 310 | "name": "@davalapar", 311 | "avatar_url": "https://avatars0.githubusercontent.com/u/41451953?v=4", 312 | "profile": "https://github.com/davalapar", 313 | "contributions": [ 314 | "bug" 315 | ] 316 | }, 317 | { 318 | "login": "sunknudsen", 319 | "name": "Sun Knudsen", 320 | "avatar_url": "https://avatars3.githubusercontent.com/u/2117655?v=4", 321 | "profile": "https://sunknudsen.com", 322 | "contributions": [ 323 | "code", 324 | "bug", 325 | "ideas", 326 | "example", 327 | "question", 328 | "review", 329 | "test", 330 | "doc" 331 | ] 332 | }, 333 | { 334 | "login": "dougg0k", 335 | "name": "Douglas Galdino", 336 | "avatar_url": "https://avatars3.githubusercontent.com/u/10801221?v=4", 337 | "profile": "http://dougg0k.js.org", 338 | "contributions": [ 339 | "code", 340 | "doc", 341 | "bug", 342 | "ideas", 343 | "example", 344 | "review", 345 | "test" 346 | ] 347 | }, 348 | { 349 | "login": "MohammedFaragallah", 350 | "name": "Mohammed Faragallah", 351 | "avatar_url": "https://avatars0.githubusercontent.com/u/14910456?v=4", 352 | "profile": "http://mohammedfaragallah.herokuapp.com", 353 | "contributions": [ 354 | "bug", 355 | "ideas", 356 | "example" 357 | ] 358 | }, 359 | { 360 | "login": "rokoroku", 361 | "name": "Youngrok Kim", 362 | "avatar_url": "https://avatars1.githubusercontent.com/u/5208632?v=4", 363 | "profile": "http://rokoroku.github.io", 364 | "contributions": [ 365 | "code", 366 | "bug" 367 | ] 368 | }, 369 | { 370 | "login": "nandhae", 371 | "name": "Nandhagopal Ezhilmaran", 372 | "avatar_url": "https://avatars1.githubusercontent.com/u/11366094?v=4", 373 | "profile": "http://nandhae.me", 374 | "contributions": [ 375 | "bug" 376 | ] 377 | }, 378 | { 379 | "login": "equinusocio", 380 | "name": "Mattia Astorino", 381 | "avatar_url": "https://avatars.githubusercontent.com/u/10454741?v=4", 382 | "profile": "https://equinusocio.dev", 383 | "contributions": [ 384 | "bug" 385 | ] 386 | }, 387 | { 388 | "login": "valtism", 389 | "name": "Dan Wood", 390 | "avatar_url": "https://avatars.githubusercontent.com/u/1286001?v=4", 391 | "profile": "http://valtism.com", 392 | "contributions": [ 393 | "doc" 394 | ] 395 | }, 396 | { 397 | "login": "zacherygentry", 398 | "name": "Zachery C Gentry", 399 | "avatar_url": "https://avatars.githubusercontent.com/u/14227467?v=4", 400 | "profile": "https://github.com/zacherygentry", 401 | "contributions": [ 402 | "bug" 403 | ] 404 | }, 405 | { 406 | "login": "xmflsct", 407 | "name": "xmflsct", 408 | "avatar_url": "https://avatars.githubusercontent.com/u/292204?v=4", 409 | "profile": "https://xmflsct.com/", 410 | "contributions": [ 411 | "bug" 412 | ] 413 | }, 414 | { 415 | "login": "iamwill123", 416 | "name": "Will.iam", 417 | "avatar_url": "https://avatars.githubusercontent.com/u/15272175?v=4", 418 | "profile": "https://supwill.dev/", 419 | "contributions": [ 420 | "code", 421 | "test" 422 | ] 423 | }, 424 | { 425 | "login": "GorvGoyl", 426 | "name": "Gourav Goyal", 427 | "avatar_url": "https://avatars.githubusercontent.com/u/7106086?v=4", 428 | "profile": "https://Gourav.io", 429 | "contributions": [ 430 | "doc" 431 | ] 432 | }, 433 | { 434 | "login": "Josh-Cena", 435 | "name": "Joshua Chen", 436 | "avatar_url": "https://avatars.githubusercontent.com/u/55398995?v=4", 437 | "profile": "https://joshcena.com/", 438 | "contributions": [ 439 | "bug", 440 | "code" 441 | ] 442 | }, 443 | { 444 | "login": "edlerd", 445 | "name": "David Edler", 446 | "avatar_url": "https://avatars.githubusercontent.com/u/1155472?v=4", 447 | "profile": "https://github.com/edlerd", 448 | "contributions": [ 449 | "bug", 450 | "code" 451 | ] 452 | }, 453 | { 454 | "login": "rikusen0335", 455 | "name": "rikusen0335", 456 | "avatar_url": "https://avatars.githubusercontent.com/u/19174234?v=4", 457 | "profile": "https://rikusen.dev/", 458 | "contributions": [ 459 | "ideas" 460 | ] 461 | }, 462 | { 463 | "login": "surjithctly", 464 | "name": "Surjith S M", 465 | "avatar_url": "https://avatars.githubusercontent.com/u/1884712?v=4", 466 | "profile": "http://surjithctly.in/", 467 | "contributions": [ 468 | "ideas" 469 | ] 470 | }, 471 | { 472 | "login": "developergunny", 473 | "name": "developergunny", 474 | "avatar_url": "https://avatars.githubusercontent.com/u/67149898?v=4", 475 | "profile": "https://github.com/developergunny", 476 | "contributions": [ 477 | "bug" 478 | ] 479 | }, 480 | { 481 | "login": "m90khan", 482 | "name": "Khan Mohsin", 483 | "avatar_url": "https://avatars.githubusercontent.com/u/6104751?v=4", 484 | "profile": "https://uxdkhan.com/", 485 | "contributions": [ 486 | "question" 487 | ] 488 | }, 489 | { 490 | "login": "GoudekettingRM", 491 | "name": "Robin Goudeketting", 492 | "avatar_url": "https://avatars.githubusercontent.com/u/56813989?v=4", 493 | "profile": "https://www.linkedin.com/in/robinmgoudeketting/", 494 | "contributions": [ 495 | "bug" 496 | ] 497 | }, 498 | { 499 | "login": "btoro", 500 | "name": "Botros Toro", 501 | "avatar_url": "https://avatars.githubusercontent.com/u/15056753?v=4", 502 | "profile": "https://github.com/btoro", 503 | "contributions": [ 504 | "ideas" 505 | ] 506 | }, 507 | { 508 | "login": "christianguevara", 509 | "name": "Christian Guevara", 510 | "avatar_url": "https://avatars.githubusercontent.com/u/10765364?v=4", 511 | "profile": "https://thedveloper.com/", 512 | "contributions": [ 513 | "question" 514 | ] 515 | }, 516 | { 517 | "login": "johanbook", 518 | "name": "Johan Book", 519 | "avatar_url": "https://avatars.githubusercontent.com/u/13253042?v=4", 520 | "profile": "https://github.com/johanbook", 521 | "contributions": [ 522 | "bug" 523 | ] 524 | }, 525 | { 526 | "login": "PaoloDiBello", 527 | "name": "Paolo Di Bello", 528 | "avatar_url": "https://avatars.githubusercontent.com/u/36816681?v=4", 529 | "profile": "http://dibellopaolo-portfolio-demo-1.rf.gd/", 530 | "contributions": [ 531 | "ideas" 532 | ] 533 | }, 534 | { 535 | "login": "remorses", 536 | "name": "Tommaso De Rossi", 537 | "avatar_url": "https://avatars.githubusercontent.com/u/31321188?v=4", 538 | "profile": "https://github.com/remorses", 539 | "contributions": [ 540 | "doc", 541 | "bug" 542 | ] 543 | }, 544 | { 545 | "login": "lezan", 546 | "name": "Lezan", 547 | "avatar_url": "https://avatars.githubusercontent.com/u/1663016?v=4", 548 | "profile": "https://github.com/lezan", 549 | "contributions": [ 550 | "bug", 551 | "ideas" 552 | ] 553 | }, 554 | { 555 | "login": "MrLibya", 556 | "name": "Ibrahim H. Sluma", 557 | "avatar_url": "https://avatars.githubusercontent.com/u/22921411?v=4", 558 | "profile": "https://www.facebook.com/perma.ban.731/", 559 | "contributions": [ 560 | "bug" 561 | ] 562 | }, 563 | { 564 | "login": "bengotow", 565 | "name": "Ben Gotow", 566 | "avatar_url": "https://avatars.githubusercontent.com/u/1037212?v=4", 567 | "profile": "http://www.foundry376.com/", 568 | "contributions": [ 569 | "bug" 570 | ] 571 | }, 572 | { 573 | "login": "Rubon72", 574 | "name": "Rubon72", 575 | "avatar_url": "https://avatars.githubusercontent.com/u/16108629?v=4", 576 | "profile": "https://github.com/Rubon72", 577 | "contributions": [ 578 | "bug" 579 | ] 580 | }, 581 | { 582 | "login": "wanderingme", 583 | "name": "wanderingme", 584 | "avatar_url": "https://avatars.githubusercontent.com/u/15581?v=4", 585 | "profile": "https://github.com/wanderingme", 586 | "contributions": [ 587 | "bug" 588 | ] 589 | }, 590 | { 591 | "login": "tom2strobl", 592 | "name": "Thomas Strobl", 593 | "avatar_url": "https://avatars.githubusercontent.com/u/557074?v=4", 594 | "profile": "http://thomas-strobl.com/", 595 | "contributions": [ 596 | "bug", 597 | "ideas", 598 | "example", 599 | "question", 600 | "review" 601 | ] 602 | }, 603 | { 604 | "login": "Songkeys", 605 | "name": "Songkeys", 606 | "avatar_url": "https://avatars.githubusercontent.com/u/22665058?v=4", 607 | "profile": "https://github.com/Songkeys", 608 | "contributions": [ 609 | "bug", 610 | "ideas", 611 | "example", 612 | "question", 613 | "review" 614 | ] 615 | }, 616 | { 617 | "login": "AntoineS92", 618 | "name": "AntoineS92", 619 | "avatar_url": "https://avatars.githubusercontent.com/u/58309786?v=4", 620 | "profile": "https://github.com/AntoineS92", 621 | "contributions": [ 622 | "bug" 623 | ] 624 | }, 625 | { 626 | "login": "trebua", 627 | "name": "Sindre Aubert", 628 | "avatar_url": "https://avatars.githubusercontent.com/u/31652936?v=4", 629 | "profile": "https://github.com/trebua", 630 | "contributions": [ 631 | "bug" 632 | ] 633 | }, 634 | { 635 | "login": "mmxdr", 636 | "name": "mx", 637 | "avatar_url": "https://avatars.githubusercontent.com/u/3596230?v=4", 638 | "profile": "https://radio.syg.ma/", 639 | "contributions": [ 640 | "bug" 641 | ] 642 | }, 643 | { 644 | "login": "SanderHeling", 645 | "name": "Sander Heling", 646 | "avatar_url": "https://avatars.githubusercontent.com/u/1461215?v=4", 647 | "profile": "https://github.com/SanderHeling", 648 | "contributions": [ 649 | "bug" 650 | ] 651 | }, 652 | { 653 | "login": "zjhch123", 654 | "name": "Yida Zhang", 655 | "avatar_url": "https://avatars.githubusercontent.com/u/12215513?v=4", 656 | "profile": "https://github.com/zjhch123", 657 | "contributions": [ 658 | "bug", 659 | "code" 660 | ] 661 | }, 662 | { 663 | "login": "nirhaas", 664 | "name": "Nir", 665 | "avatar_url": "https://avatars.githubusercontent.com/u/35661734?v=4", 666 | "profile": "https://github.com/nirhaas", 667 | "contributions": [ 668 | "bug" 669 | ] 670 | }, 671 | { 672 | "login": "hhatakeyama", 673 | "name": "hhatakeyama", 674 | "avatar_url": "https://avatars.githubusercontent.com/u/5581539?v=4", 675 | "profile": "https://github.com/hhatakeyama", 676 | "contributions": [ 677 | "bug" 678 | ] 679 | }, 680 | { 681 | "login": "pacocoursey", 682 | "name": "Paco", 683 | "avatar_url": "https://avatars.githubusercontent.com/u/34928425?v=4", 684 | "profile": "https://github.com/pacocoursey", 685 | "contributions": [ 686 | "bug", 687 | "ideas" 688 | ] 689 | }, 690 | { 691 | "login": "LichLord91", 692 | "name": "LichLord91", 693 | "avatar_url": "https://avatars.githubusercontent.com/u/8435580?v=4", 694 | "profile": "https://github.com/LichLord91", 695 | "contributions": [ 696 | "bug" 697 | ] 698 | }, 699 | { 700 | "login": "just-small-potato", 701 | "name": "just-small-potato", 702 | "avatar_url": "https://avatars.githubusercontent.com/u/68165945?v=4", 703 | "profile": "https://github.com/just-small-potato", 704 | "contributions": [ 705 | "ideas" 706 | ] 707 | }, 708 | { 709 | "login": "walmsles", 710 | "name": "walmsles", 711 | "avatar_url": "https://avatars.githubusercontent.com/u/2704782?v=4", 712 | "profile": "https://github.com/walmsles", 713 | "contributions": [ 714 | "bug" 715 | ] 716 | }, 717 | { 718 | "login": "amkkr", 719 | "name": "tenshin", 720 | "avatar_url": "https://avatars.githubusercontent.com/u/55781271?v=4", 721 | "profile": "https://github.com/amkkr", 722 | "contributions": [ 723 | "question" 724 | ] 725 | }, 726 | { 727 | "login": "steven-tey", 728 | "name": "Steven Tey", 729 | "avatar_url": "https://avatars.githubusercontent.com/u/28986134?v=4", 730 | "profile": "https://github.com/steven-tey", 731 | "contributions": [ 732 | "bug" 733 | ] 734 | }, 735 | { 736 | "login": "eych", 737 | "name": "Sergey", 738 | "avatar_url": "https://avatars.githubusercontent.com/u/112840?v=4", 739 | "profile": "http://15gifts.com/", 740 | "contributions": [ 741 | "bug", 742 | "code", 743 | "doc" 744 | ] 745 | }, 746 | { 747 | "login": "diegoatwa", 748 | "name": "Diego Azevedo", 749 | "avatar_url": "https://avatars.githubusercontent.com/u/26748277?v=4", 750 | "profile": "https://diegoazevedo.com.br/", 751 | "contributions": [ 752 | "doc" 753 | ] 754 | }, 755 | { 756 | "login": "FaizanAhmad1122", 757 | "name": "Faizan Ahmad", 758 | "avatar_url": "https://avatars.githubusercontent.com/u/56729996?v=4", 759 | "profile": "https://github.com/FaizanAhmad1122", 760 | "contributions": [ 761 | "bug" 762 | ] 763 | }, 764 | { 765 | "login": "kizeesmack", 766 | "name": "Kunal L.", 767 | "avatar_url": "https://avatars.githubusercontent.com/u/1277576?v=4", 768 | "profile": "https://github.com/kizeesmack", 769 | "contributions": [ 770 | "bug" 771 | ] 772 | }, 773 | { 774 | "login": "thiskevinwang", 775 | "name": "Kevin Wang", 776 | "avatar_url": "https://avatars.githubusercontent.com/u/26389321?v=4", 777 | "profile": "https://github.com/thiskevinwang", 778 | "contributions": [ 779 | "ideas" 780 | ] 781 | }, 782 | { 783 | "login": "u3u", 784 | "name": "u3u", 785 | "avatar_url": "https://avatars.githubusercontent.com/u/20062482?v=4", 786 | "profile": "https://qwq.cat/", 787 | "contributions": [ 788 | "ideas", 789 | "review" 790 | ] 791 | }, 792 | { 793 | "login": "tszhong0411", 794 | "name": "Hong", 795 | "avatar_url": "https://avatars.githubusercontent.com/u/75498339?v=4", 796 | "profile": "https://honghong.me/", 797 | "contributions": [ 798 | "code" 799 | ] 800 | }, 801 | { 802 | "login": "tshmieldev", 803 | "name": "Wojciech Rok", 804 | "avatar_url": "https://avatars.githubusercontent.com/u/58606210?v=4", 805 | "profile": "https://github.com/tshmieldev", 806 | "contributions": [ 807 | "code", 808 | "ideas" 809 | ] 810 | }, 811 | { 812 | "login": "brew-matija", 813 | "name": "Matija", 814 | "avatar_url": "https://avatars.githubusercontent.com/u/101182777?v=4", 815 | "profile": "https://github.com/brew-matija", 816 | "contributions": [ 817 | "bug" 818 | ] 819 | }, 820 | { 821 | "login": "jiayihu", 822 | "name": "Jiayi Hu", 823 | "avatar_url": "https://avatars.githubusercontent.com/u/10067273?v=4", 824 | "profile": "http://blog.jiayihu.net/", 825 | "contributions": [ 826 | "bug" 827 | ] 828 | }, 829 | { 830 | "login": "zeitderforschung", 831 | "name": "Zeit der Forschung", 832 | "avatar_url": "https://avatars.githubusercontent.com/u/54367033?v=4", 833 | "profile": "https://github.com/zeitderforschung", 834 | "contributions": [ 835 | "bug" 836 | ] 837 | }, 838 | { 839 | "login": "andreibarabas", 840 | "name": "Andrei Barabas", 841 | "avatar_url": "https://avatars.githubusercontent.com/u/2101405?v=4", 842 | "profile": "https://www.linkedin.com/in/andreibarabas", 843 | "contributions": [ 844 | "bug", 845 | "ideas" 846 | ] 847 | }, 848 | { 849 | "login": "Binibeno", 850 | "name": "Németh Benedek", 851 | "avatar_url": "https://avatars.githubusercontent.com/u/53515381?v=4", 852 | "profile": "https://binibeno.hu/", 853 | "contributions": [ 854 | "bug" 855 | ] 856 | }, 857 | { 858 | "login": "imalfect", 859 | "name": "iMalFect", 860 | "avatar_url": "https://avatars.githubusercontent.com/u/77974917?v=4", 861 | "profile": "https://github.com/imalfect", 862 | "contributions": [ 863 | "bug" 864 | ] 865 | }, 866 | { 867 | "login": "karlhorky", 868 | "name": "Karl Horky", 869 | "avatar_url": "https://avatars.githubusercontent.com/u/1935696?v=4", 870 | "profile": "https://upleveled.io/", 871 | "contributions": [ 872 | "bug", 873 | "ideas", 874 | "review" 875 | ] 876 | }, 877 | { 878 | "login": "wenerme", 879 | "name": "陈杨文", 880 | "avatar_url": "https://avatars.githubusercontent.com/u/1777211?v=4", 881 | "profile": "https://wener.me/", 882 | "contributions": [ 883 | "bug" 884 | ] 885 | } 886 | ], 887 | "repoType": "github", 888 | "commitConvention": "none" 889 | } 890 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | .git/ 3 | coverage/ 4 | dist/ 5 | docs/ 6 | node_modules/ 7 | Dockerfile 8 | CHANGELOG.md 9 | CODE_OF_CONDUCT.md 10 | LICENSE 11 | README.md 12 | docker-compose.yml 13 | npm-debug.log 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rpearce 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue Type 2 | 3 | - [ ] Bug report 4 | - [ ] Feature request 5 | - [ ] Question / support request 6 | - [ ] Other 7 | 8 | ## Description 9 | 10 | Place detailed information here 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: rpearce 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Place detailed information here 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: rpearce 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Place detailed information here 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | This pull request addresses (issue) 4 | 5 | ## Testing 6 | 7 | 1. Do this 8 | 2. Do that 9 | 3. It should work 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "npm" 9 | allow: 10 | - dependency-type: "direct" 11 | directory: "/" 12 | open-pull-requests-limit: 5 13 | schedule: 14 | interval: "daily" 15 | versioning-strategy: "increase" 16 | groups: 17 | dev-dependencies: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | - '*.nix' 8 | - '.all-contributorsrc' 9 | - '.vscode/**' 10 | - 'Dockerfile' 11 | - 'LICENSE' 12 | - 'compose.yml' 13 | pull_request: 14 | paths-ignore: 15 | - '*.md' 16 | - '*.nix' 17 | - '.all-contributorsrc' 18 | - '.vscode/**' 19 | - 'Dockerfile' 20 | - 'LICENSE' 21 | - 'compose.yml' 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout repo under GH workspace 29 | uses: actions/checkout@v4 30 | 31 | - name: Use pnpm 32 | uses: pnpm/action-setup@v4 33 | with: 34 | version: 'latest' 35 | 36 | - name: Use nodejs 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 'latest' 40 | cache: 'pnpm' 41 | 42 | - name: Install dependencies 43 | run: pnpm install --frozen-lockfile --ignore-scripts 44 | 45 | - name: Run the CI script 46 | run: pnpm run ci 47 | 48 | - name: Deploy to gh-pages 49 | uses: peaceiris/actions-gh-pages@v4 50 | if: ${{ github.ref == 'refs/heads/main' }} 51 | with: 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | publish_dir: ./docs 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repo under GH workspace 16 | uses: actions/checkout@v4 17 | 18 | - name: Use pnpm 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: 'latest' 22 | 23 | - name: Use nodejs 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 'latest' 27 | cache: 'pnpm' 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile --ignore-scripts 31 | 32 | - name: Run the CI script 33 | run: pnpm run ci 34 | 35 | - name: Create Release Pull Request or Publish to npm 36 | id: changesets 37 | uses: changesets/action@v1 38 | with: 39 | publish: pnpm run release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage/ 3 | dist/ 4 | docs/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | addons: [ 3 | '@storybook/addon-viewport', 4 | '@storybook/addon-docs', 5 | '@storybook/addon-interactions', 6 | '@storybook/addon-a11y', 7 | '@storybook/addon-webpack5-compiler-swc', 8 | ], 9 | stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'], 10 | features: { 11 | interactionsDebugger: true, 12 | }, 13 | framework: { 14 | name: '@storybook/react-webpack5', 15 | options: {}, 16 | }, 17 | core: { 18 | disableTelemetry: true, 19 | }, 20 | docs: { 21 | autodocs: 'tag', 22 | }, 23 | // NOTE: For testing crossorigin 24 | //webpackFinal: async (config) => { 25 | // config.devServer = { 26 | // headers: { 27 | // 'Access-Control-Allow-Origin': '*', 28 | // }, 29 | // } 30 | // return config 31 | //}, 32 | } 33 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | options: { 3 | showPanel: false, 4 | storySort: { 5 | order: [ 6 | 'Introduction', 7 | 'Galleries', 8 | '*', 9 | '', 10 | '*', 11 | '', 12 | '*', 13 | '', 14 | '*', 15 | '', 16 | '*', 17 | 'Custom Controls', 18 | '*', 19 | ], 20 | }, 21 | }, 22 | previewTabs: { 23 | 'storybook/docs/panel': { 24 | hidden: true, 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.2.14 4 | 5 | ### Patch Changes 6 | 7 | - d59b9f1: fix crossorigin images not loading when copied 8 | 9 | ## 5.2.13 10 | 11 | ### Patch Changes 12 | 13 | - 330cd40: fix zooming inside a parent dialog that has a close handler 14 | 15 | ## 5.2.12 16 | 17 | ### Patch Changes 18 | 19 | - 63388a8: support react@19 and credit edlerd 20 | 21 | ## 5.2.11 22 | 23 | ### Patch Changes 24 | 25 | - b647071: fixes zooming inside a dialog with click outside behavior 26 | 27 | ## 5.2.10 28 | 29 | ### Patch Changes 30 | 31 | - 44473b3: include 'use client' directive in esm output 32 | 33 | ## 5.2.9 34 | 35 | ### Patch Changes 36 | 37 | - 868d0e1: fix unzooming getting stuck in Safari 38 | 39 | ## [5.2.8] - 2024-07-15 40 | 41 | ### Fixed 42 | 43 | - Safari: Zoomable images make the body extend beyond the content (#627) 44 | 45 | ## [5.2.7] - 2024-07-08 46 | 47 | ### Fixed 48 | 49 | - flickering after unzooming (#631) 50 | 51 | ## [5.2.6] - 2024-07-08 52 | 53 | ### Fixed 54 | 55 | - img SVGs with no dimensions not scaling (#629) 56 | 57 | ## [5.2.5] - 2024-06-22 58 | 59 | ### Fixed 60 | 61 | - Zooming effect breaks when images is clicked while scrolling (#439) 62 | 63 | ## [5.2.4] - 2024-05-16 64 | 65 | ### Fixed 66 | 67 | - SVG arrows with text disappeared on Zoom (#438, continued again) 68 | - Note: This is a vexing issue due to all the possible things that can go 69 | wrong with cloning an SVG element that has HTML IDs inside of it. Hopefully, 70 | this is the last fix for this issue. 71 | 72 | ## [5.2.3] - 2024-05-14 73 | 74 | ### Fixed 75 | 76 | - SVG arrows with text disappeared on Zoom (#438, continued) 77 | 78 | ## [5.2.2] - 2024-05-13 79 | 80 | ### Fixed 81 | 82 | - Accidental non-dev dependency on `@storybook/test` when this lib should have 83 | zero non-dev dependencies (PR: #563) 84 | 85 | ## [5.2.1] - 2024-05-13 86 | 87 | ### Fixed 88 | 89 | - Zoomable image breaks on Chrome (#470) 90 | 91 | ## [5.2.0] - 2024-04-05 92 | 93 | ### Added 94 | 95 | - `canSwipeToUnzoom` and `swipeToUnzoomThreshold` props 96 | (https://github.com/rpearce/react-medium-image-zoom/pull/472, https://github.com/rpearce/react-medium-image-zoom/pull/510) 97 | 98 | ## [5.1.11] - 2024-03-26 99 | 100 | ### Fixed 101 | 102 | - Improve iOS pinch-to-zoom experience (#436) 103 | 104 | ## [5.1.10] - 2024-01-13 105 | 106 | ### Fixed 107 | 108 | - When I deploy to Vercel / Next 13 and I change routes to a page I get 109 | "DOMException: Failed to execute 'showModal' on 'HTMLDialogElement': The 110 | element is not in a Document." (#429) 111 | 112 | ## [5.1.9] - 2023-12-14 113 | 114 | ### Fixed 115 | 116 | - SVG arrows with text disappeared on Zoom (#438) 117 | 118 | ## [5.1.8] - 2023-08-01 119 | 120 | ### Fixed 121 | 122 | - Fixes for the zoom behaviour and overlay height in iOS Safari (#434) 123 | 124 | ## [5.1.7] - 2023-07-28 125 | 126 | ### Fixed 127 | 128 | - Esc to exit zoom doesn't work on Safari (#430) 129 | 130 | ## [5.1.6] - 2023-05-10 131 | 132 | ### Fixed 133 | 134 | - Pressing back button in browser after zooming an image breaks body scroll (#421) 135 | 136 | ## [5.1.5] - 2023-04-23 137 | 138 | ### Fixed 139 | 140 | - Possibly broke zooming divs where role="img" (#412) 141 | 142 | ## [5.1.4] - 2023-04-17 143 | 144 | ### Fixed 145 | 146 | - Older browser versions querySelector error (#391) 147 | - Cannot read properties of undefined (reading 'left') with Zoom Component and 148 | React SVG Component in Docusaurus (#406) 149 | - Warning: NaN is an invalid value for the width css style property. (#375) 150 | 151 | ## [5.1.3] - 2023-02-25 152 | 153 | ### Fixed 154 | 155 | - Image has already been loaded (https://github.com/rpearce/react-medium-image-zoom/pull/389) 156 | 157 | ## [5.1.2] - 2022-10-25 158 | 159 | ### Fixed 160 | 161 | - Image is hidden when pressing escape during hiding animation (issue #378) 162 | 163 | ## [5.1.1] - 2022-10-14 164 | 165 | ### Fixed 166 | 167 | - `zoomMargin` portion of "Neither zoomMargin nor scrollableEl seem to be working 168 | correctly" (issue #350) 169 | - Clicking on zoomed SVGs doesn't unzoom (issue #369) 170 | 171 | ## [5.1.0] - 2022-10-11 172 | 173 | ### Added 174 | 175 | - Ability to customize the zoom modal content via `` (issue #332) 176 | - Re-added `wrapElement` prop to API; only supports `'div' | 'span'` (issue #356) 177 | - Added a11y support for `prefers-reduced-motion: reduce` (issue #359) 178 | - Added `classDialog` string prop to account for the loss of granular styling 179 | control over different modals resulting from moving the `` rendering 180 | to a portal 181 | 182 | ### Changed 183 | 184 | - Now rendering `` in a portal because of #356 185 | - For the folx using `.my-class [data-rmiz-modal] {}` to change the `` 186 | styles, please use the `classDialog` prop to pass `my-class` to the 187 | ``. I wish I didn't have to do this, but this is something that 188 | needs fixing, and I can't justify a new major version just because of this 189 | new style requirement. The `classDialog` addition (mentioned above) should 190 | solve this nicely. 191 | 192 | ### Fixed 193 | 194 | - Now using the `wheel` event instead of `scroll` to detect trying to leave the 195 | modal (issue #350) 196 | - Fixed mobile scrolling experience (related to issue #350) 197 | 198 | ### Removed 199 | 200 | - Removed the broken `scrollableEl` that has arguably not ever worked (issue #350) 201 | 202 | ## [5.0.3] - 2022-09-19 203 | 204 | ### Fixed 205 | 206 | - Missing class properties transform (#337) (potential issue versions of node 207 | older than LTS) 208 | 209 | ## [5.0.2] - 2022-08-22 210 | 211 | ### Fixed 212 | 213 | - Not working with gatsby image plugin (StaticImage) (#347) 214 | 215 | ## [5.0.1] - 2022-08-04 216 | 217 | ### Fixed 218 | 219 | - React hydration issue (#338) 220 | 221 | ## [5.0.0] - 2022-08-03 222 | 223 | Closes #164, #166, #213, #227, #259, #265, #281, #282 224 | 225 | ### Added 226 | 227 | - Added `IconUnzoom` and `IconZoom` in order to customize the zoom & unzoom buttons 228 | - Added `zoomImg` to provide attributes for an image that should be loaded on zoom 229 | - Added better zooming support for all of the following: 230 | - ``, including all [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) 231 | values, any [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position), 232 | and [`loading="lazy"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading) 233 | - `` and `` with any [`background-image`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-image), 234 | [`background-size`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-size), 235 | and [`background-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-position) 236 | - `` with `` and `` 237 | - `` with `` 238 | 239 | ### Changed 240 | 241 | - Sets `"type": "module"` in `package.json` for ESModule usage 242 | - Renamed `closeText` to `a11yNameButtonUnzoom` 243 | - Renamed `openText` to `a11yNameButtonZoom` 244 | - Images must meet these `querySelector` criteria to be found: 245 | ```js 246 | ':is(img, svg, [role="img"], [data-zoom]):not([aria-hidden="true"])'; 247 | ``` 248 | 249 | ### Removed 250 | 251 | - Removed `focus-options-polyfill` dependency 252 | - Removed `tslib` dependency 253 | - Removed `overlayBgColorStart` (now specified via CSS: `[data-rmiz-modal-overlay="hidden"]`) 254 | - Removed `overlayBgColorEnd` (now specified via CSS: `[data-rmiz-modal-overlay="visible"]`) 255 | - Removed `portalEl` 256 | - Removed `transitionDuration` (now specified via CSS: `[data-rmiz-modal-overlay]` and `[data-rmiz-modal-img]`) 257 | - Removed `wrapElement` 258 | - Removed `wrapStyle` 259 | - Removed `zoomZIndex` 260 | 261 | ## [4.4.3] - 2022-05-23 262 | 263 | ### Fixed 264 | 265 | - Reverts setting `"type": "module"` in `package.json` (issue: #322) 266 | 267 | ## [4.4.2] - 2022-05-22 268 | 269 | ### Fixed 270 | 271 | - Fixes some docs badges 272 | 273 | ## [4.4.1] - 2022-05-22 274 | 275 | ### Changed 276 | 277 | - Switched project name back to `react-medium-image-zoom` and fixed links 278 | - I'm not even sure it was working, but turned off `sourceMaps: true` in the 279 | tsconfig 280 | - Moved `AUTHORS` file into `contributors` key in `package.json` 281 | - Added `funding` info to `package.json` 282 | 283 | ## [4.4.0] - 2022-05-13 284 | 285 | ### Changed 286 | 287 | - Bumped minor version of `tslib` and made sure to actually use it (#306) 288 | - Adds an `aria-label` to the modal to satisfy `axe-core`; note that this is 289 | still not an accessible component. It will be eventually! (#306) 290 | 291 | ### Removed 292 | 293 | - Removed dependency on `react-use` (#306) 294 | 295 | ## [4.3.7] - 2022-04-09 296 | 297 | ### Fixed 298 | 299 | - Fix Incompatible types with types/react v18 (#302) 300 | 301 | ## [4.3.6] - 2022-04-04 302 | 303 | ### Fixed 304 | 305 | - Allow React 18 (#300) 306 | 307 | ## [4.3.5] - 2021-08-25 308 | 309 | ### Fixed 310 | 311 | - Fix not exporting component props (#287) 312 | 313 | ## [4.3.4] - 2021-06-15 314 | 315 | ### Fixed 316 | 317 | - Fix `latest` tag not being v4.x :smh: 318 | 319 | ## [4.3.3] - 2021-03-27 320 | 321 | ### Fixed 322 | 323 | - fixed old links to `/react-medium-image-zoom/` to `/image-zoom/` 324 | 325 | ## [4.3.2] - 2021-03-27 326 | 327 | ### Changed 328 | 329 | - `react` and `react-dom` `peerDependencies` moved from `>= 16.8.0` to `^16.8.0 330 | || ^17.0.0` 331 | - upgraded `react-use` 332 | 333 | ### Fixed 334 | 335 | - set all dependencies to use compatibility `^` selectors 336 | 337 | ## [4.3.1] - 2020-05-26 338 | 339 | ### Fixed 340 | 341 | - `tslib` was listed as dev dependency when it's actually a dependency 342 | 343 | ## [4.3.0] - 2020-03-11 344 | 345 | ### Added 346 | 347 | - support for passing a `wrapElement` (defaults to `div`) 348 | 349 | ### Fixed 350 | 351 | - adding `wrapElement` support resolves `div` vs `span` issue #236 (thanks to 352 | @sunknudsen) 353 | 354 | ## [4.2.0] - 2020-03-07 355 | 356 | ### Added 357 | 358 | - support for typescript (https://github.com/rpearce/react-medium-image-zoom/issues/219 and https://github.com/rpearce/react-medium-image-zoom/issues/220; thanks to @dougg0k and @sunknudsen) 359 | 360 | ## [4.1.0] - 2020-03-05 361 | 362 | ### Added 363 | 364 | - now providing both unminified and minified UMD builds 365 | 366 | ### Fixed 367 | 368 | - _massively_ reduced build size (#226) 369 | - [before: `390.7kB` Minified; `118.5kB Minified` + Gzipped](https://bundlephobia.com/result?p=react-medium-image-zoom@4.0.4) 370 | - [after `18kB` Minified; `5.6kB` Minified + Gzipped](https://bundlephobia.com/result?p=react-medium-image-zoom@4.1.0-alpha.2) 371 | - server-side-rendering (#229) 372 | - webkit 100% width image issue (#222) 373 | 374 | ### Changed 375 | 376 | - CSS styles are now applied using `data-rmiz-` selectors, allowing 377 | the styles to be imported and overridden 378 | - babel 379 | - remove `@babel/cli` because it's not being used 380 | - don't use `@babel/polyfill` for anything; replaced with `@babel/plugin-transform-runtime` 381 | 382 | ### Removed 383 | 384 | - removed `browser` field from package.json b/c bundler confusion 385 | - not including sourcemaps anymore unless that's a specific desire from users 386 | 387 | ## [4.0.4] - 2020-02-17 388 | 389 | - lock down dependencies to specific versions 390 | - upgrade some dev dependencies 391 | - upgrade `react-use` 392 | 393 | ## [4.0.3] - 2020-02-05 394 | 395 | ### Changed 396 | 397 | - Replace temporary focus-options-polyfill with package now that it's fixed 398 | 399 | ## [4.0.2] - 2020-02-02 400 | 401 | ### Changed 402 | 403 | - Added CJS & UMD minified builds 404 | 405 | ## [4.0.1] - 2020-01-22 406 | 407 | ### Fixed 408 | 409 | - resolved prod issue where CSS wasn't included in `sideEffects` 410 | 411 | ## [4.0.0] - 2020-01-19 412 | 413 | Complete rewrite with breaking changes so we can move forward with the project. 414 | 415 | Please see the [README.md](./README.md) for the new API and migrating from v3 to 416 | v4. 417 | 418 | ## [3.1.2] - 2019-10-01 419 | 420 | ### Fixed 421 | 422 | - resolved a few security issues 423 | 424 | ## [3.1.1] - 2019-06-24 425 | 426 | ### Fixed 427 | 428 | - Support Preact by using `React.Fragment` (PR #152) 429 | 430 | ## [3.1.0] - 2019-06-19 431 | 432 | ### Added 433 | 434 | - Support for `React.StrictMode` (PR #151) 435 | 436 | ## [3.0.16] - 2019-04-22 437 | 438 | ### Fixed 439 | 440 | - Fixes JS error when `_allowTabNavigation` is called before image ref (PR #150) 441 | 442 | ## [3.0.15] - 2018-10-25 443 | 444 | ### Fixed 445 | 446 | - Resolved issue #139 where `zoomMargin` wouldn't accept a number in string 447 | format 448 | 449 | ## [3.0.14] - 2018-09-17 450 | 451 | ### Fixed 452 | 453 | - Resolved issue #137 where broken images would continue allowing the component 454 | to function 455 | - Now ensures an `image` prop's `onLoad` callback is adequately sent to the 456 | consumer 457 | 458 | ## [3.0.13] - 2018-07-30 459 | 460 | ### Fixed 461 | 462 | - Resolved issue where we were calculating `imageCenterX` with 463 | `window.innerWidth` instead of `document.body.clientWidth` 464 | (PR: https://github.com/rpearce/react-medium-image-zoom/pull/133) 465 | 466 | ## [3.0.12] - 2018-06-05 467 | 468 | ### Changed 469 | 470 | - Changed shield in README 471 | 472 | ## [3.0.11] - 2018-05-09 473 | 474 | ### Fixed 475 | 476 | - Resolved [issue #128](https://github.com/rpearce/react-medium-image-zoom/issues/128) where initializing with isZoomed={true} was throwing errors. 477 | 478 | ## [3.0.10] - 2018-01-19 479 | 480 | ### Changed 481 | 482 | - removed the Firefox check and apply the flicker "fix" to all browsers equally 483 | 484 | ## [3.0.9] - 2018-01-16 485 | 486 | ### Fixed 487 | 488 | - Resolved Firefox issue where switching an image's `src` attribute causes an obnoxious "flicker" effect (https://github.com/rpearce/react-medium-image-zoom/issues/96) 489 | 490 | ## [3.0.8] - 2018-01-09 491 | 492 | ### Changed 493 | 494 | - `AUTHORS` file 495 | 496 | ## [3.0.7] - 2018-01-09 497 | 498 | ### Added 499 | 500 | - `AUTHORS` file (https://github.com/rpearce/react-medium-image-zoom/issues/107) 501 | 502 | ## [3.0.6] - 2018-01-08 503 | 504 | ### Fixed 505 | 506 | - Fixed issue where quickly, repeatedly triggering the zoom and unzoom actions had some lingering timeout actions that no longer existing on a component, throwing errors in the console (https://github.com/rpearce/react-medium-image-zoom/issues/106) 507 | 508 | ## [3.0.5] - 2017-12-05 509 | 510 | ### Fixed 511 | 512 | - Fixed issue where hitting the tab key on a zoomed imaged would allow 513 | an element in behind the image to receive focus. Further actions 514 | could then be taken on the focused element, causing the DOM to end 515 | up in undesired states. 516 | 517 | ## [2.0.7] - 2017-12-05 518 | 519 | ### Fixed 520 | 521 | - Fixed issue where hitting the tab key on a zoomed imaged would allow 522 | an element in behind the image to receive focus. Further actions 523 | could then be taken on the focused element, causing the DOM to end 524 | up in undesired states. 525 | 526 | ## [3.0.4] - 2017-12-02 527 | 528 | ### Fixed 529 | 530 | - Fixed readme rebase issue 531 | 532 | ## [3.0.3] - 2017-12-02 533 | 534 | ### Fixed 535 | 536 | - Fixed issue from #89 where `hasAlreadyLoaded` was preventing the `zoomImage` source from displaying when `shouldReplaceImage` was set to `false`. 537 | 538 | ## [3.0.2] - 2017-11-06 539 | 540 | ### Fixed 541 | 542 | - Includes the keyboard navigation and clicking updates from 2.0.5 and 2.0.6 for React >16 543 | 544 | ## [2.0.6] - 2017-11-02 545 | 546 | ### Fixed 547 | 548 | - Fixed issue where clicking to open & close a zoomable image resulted in the focusing of that element which could cause the page to scroll it completely into view and leave an outline on the image (aka focus) when it was not accessed via the keyboard. 549 | 550 | ## [2.0.5] - 2017-11-01 551 | 552 | ### Fixed 553 | 554 | - Fixed issue where updating other image attributes after mounting was not respected; the image was cached in `this.state` when only the `src` should have been. 555 | 556 | ## [3.0.1] - 2017-10-20 557 | 558 | ### Fixed 559 | 560 | - Now supporting keyboard interaction for accessibility (https://github.com/rpearce/react-medium-image-zoom/issues/70). Classified this as a bug and therefore a patch version. 561 | 562 | ## [2.0.4] - 2017-10-16 563 | 564 | ### Fixed 565 | 566 | - Now supporting keyboard interaction for accessibility (https://github.com/rpearce/react-medium-image-zoom/issues/70). Classified this as a bug and therefore a patch version. 567 | 568 | ## [3.0.0] - 2017-10-13 569 | 570 | ### Added 571 | 572 | - support for React v16, making use of such things as its portals concept 573 | 574 | ### Removed 575 | 576 | - support for React less than v16 577 | 578 | ## [2.0.3] - 2017-09-22 579 | 580 | ### Fixed 581 | 582 | - `shouldRespectMaxDimension` was allowing images that were already rendered at their maximum size to be "zoomed," thus creating the issue where they don't _actually_ zoom and instead just moved to the center of the screen. 583 | 584 | ## [2.0.2] - 2017-09-21 585 | 586 | ### Fixed 587 | 588 | - Fixed undefined method call (underscore was missing...c'mon eslint) 589 | - Fixed original image not staying hidden until unzoom is complete 590 | 591 | ## [2.0.1] - 2017-09-21 592 | 593 | ### Added 594 | 595 | - Dev: eslint & prettier for code formatting and double-dhecking 596 | 597 | ## [2.0.0] - 2017-09-21 598 | 599 | ### Added 600 | 601 | - Changelog (so meta...) 602 | - When in controlled mode (`isZoomed` is provided), the `onUnzoom` callback will fire when the component normally would have closed 603 | 604 | ### Changed 605 | 606 | - Differentiation between controlled & uncontrolled modes (when `isZoomed` is passed vs letting component control itself) 607 | 608 | ### Removed 609 | 610 | - Removed preload functionality 611 | 612 | ### Fixed 613 | 614 | - Support for the component immediately zooming if `isZoomed={true}` is provided on mount 615 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at me@robertwpearce.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Check out the [issues](https://github.com/rpearce/react-medium-image-zoom/issues) 4 | 1. [Fork](https://guides.github.com/activities/forking/) this repository 5 | 1. [Clone](https://help.github.com/articles/cloning-a-repository/) your fork 6 | 1. Add the upstream project (this one) as a git remote: 7 | ``` 8 | $ git remote add upstream git@github.com:rpearce/react-medium-image-zoom.git 9 | $ git fetch upstream 10 | $ git rebase upstream/main 11 | ``` 12 | 1. Check out a feature branch 13 | ``` 14 | $ git checkout -b my-feature 15 | ``` 16 | 1. Make your changes 17 | 1. Push your branch to your GitHub repo 18 | ``` 19 | $ git push origin my-feature 20 | ``` 21 | 1. Create a [pull request](https://help.github.com/articles/about-pull-requests/) 22 | from your branch to this repo's `main` branch 23 | 1. When all is merged, pull down the upstream changes to your `main` 24 | ``` 25 | $ git checkout main 26 | $ git fetch upstream 27 | $ git rebase upstream/main 28 | ``` 29 | 1. Delete your feature branch (locally and then on GitHub) 30 | ``` 31 | $ git branch -D my-feature 32 | $ git push origin :my-feature 33 | ``` 34 | 35 | ## Testing 36 | Tests are located in the `test/` folder. Here's how to run them: 37 | 38 | ``` 39 | $ npm run test 40 | ``` 41 | 42 | To test in watch mode: 43 | 44 | ``` 45 | $ npm run test --watch 46 | ``` 47 | 48 | To generate a local coverage report: 49 | 50 | ``` 51 | $ npm run test --coverage 52 | $ open coverage/lcov-report/index.html 53 | ``` 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bullseye-slim 2 | 3 | USER node 4 | 5 | WORKDIR /service 6 | 7 | COPY package*.json . 8 | 9 | RUN npm i --no-save && npm cache clean -f 10 | 11 | COPY . /service 12 | 13 | EXPOSE 6006 14 | 15 | CMD ["npm", "start"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Robert Pearce 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Robert Pearce nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | This project has no dependencies other than development dependencies, but if there are any reported vulnerabilities, they will addressed swiftly. 4 | 5 | ## Supported Versions 6 | 7 | The latest minor version is what will receive security updates. 8 | 9 | | Version | Supported | 10 | | ------- | ------------------ | 11 | | 5.2.x | :white_check_mark: | 12 | | < 5.2.x | :x: | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | To report a vulnerability, [open a GitHub issue](https://github.com/rpearce/react-medium-image-zoom/issues), and it will be addressed swiftly. 17 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: . 4 | command: npm start 5 | ports: 6 | - "6006:6006" 7 | volumes: 8 | - '.:/service' 9 | - '/service/node_modules' 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js' 4 | import eslintPluginJSXA11y from 'eslint-plugin-jsx-a11y' 5 | import eslintPluginReact from 'eslint-plugin-react' 6 | import eslintPluginReactHooks from 'eslint-plugin-react-hooks' 7 | import neostandard from 'neostandard' 8 | import tseslint from 'typescript-eslint' 9 | import { fixupPluginRules } from '@eslint/compat' 10 | 11 | export default [ 12 | { 13 | ignores: [ 14 | '!**/.*', 15 | '**/.git/', 16 | '**/.github/', 17 | '**/coverage/', 18 | '**/dist/', 19 | '**/docs/', 20 | '**/node_modules/', 21 | ], 22 | }, 23 | eslint.configs.recommended, 24 | ...neostandard(), 25 | eslintPluginReact.configs.flat.recommended, 26 | eslintPluginJSXA11y.flatConfigs.strict, 27 | ...tseslint.configs.strict, 28 | ...tseslint.configs.stylistic, 29 | { 30 | plugins: { 31 | 'react-hooks': fixupPluginRules(eslintPluginReactHooks), 32 | }, 33 | settings: { 34 | react: { 35 | version: 'detect', 36 | }, 37 | }, 38 | rules: { 39 | ...eslintPluginReactHooks.configs.recommended.rules, 40 | '@stylistic/jsx-closing-bracket-location': 'off', 41 | '@stylistic/jsx-closing-tag-location': 'off', 42 | '@stylistic/jsx-quotes': 'off', 43 | '@stylistic/jsx-wrap-multilines': 'off', 44 | '@stylistic/space-before-function-paren': 'off', 45 | '@stylistic/spaced-comment': 'off', 46 | 'comma-dangle': ['error', { 47 | arrays: 'always-multiline', 48 | exports: 'always-multiline', 49 | functions: 'ignore', 50 | imports: 'always-multiline', 51 | objects: 'always-multiline', 52 | }], 53 | 'react-hooks/exhaustive-deps': 'error', 54 | 'react-hooks/rules-of-hooks': 'error', 55 | 'react/prop-types': 'off', 56 | '@typescript-eslint/prefer-function-type': 'off', 57 | }, 58 | }, 59 | ] 60 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1698882062, 9 | "narHash": "sha256-HkhafUayIqxXyHH1X8d9RDl1M2CkFgZLjKD3MzabiEo=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "8c9fa2545007b49a5db5f650ae91f227672c3877", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1700390070, 24 | "narHash": "sha256-de9KYi8rSJpqvBfNwscWdalIJXPo8NjdIZcEJum1mH0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "e4ad989506ec7d71f7302cc3067abd82730a4beb", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "dir": "lib", 40 | "lastModified": 1698611440, 41 | "narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=", 42 | "owner": "NixOS", 43 | "repo": "nixpkgs", 44 | "rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735", 45 | "type": "github" 46 | }, 47 | "original": { 48 | "dir": "lib", 49 | "owner": "NixOS", 50 | "ref": "nixos-unstable", 51 | "repo": "nixpkgs", 52 | "type": "github" 53 | } 54 | }, 55 | "root": { 56 | "inputs": { 57 | "flake-parts": "flake-parts", 58 | "nixpkgs": "nixpkgs" 59 | } 60 | } 61 | }, 62 | "root": "root", 63 | "version": 7 64 | } 65 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "react-medium-image-zoom"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | 7 | flake-parts.url = "github:hercules-ci/flake-parts"; 8 | flake-parts.inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | 11 | outputs = inputs@{ self, flake-parts, ... }: 12 | flake-parts.lib.mkFlake { inherit inputs; } { 13 | systems = [ "x86_64-linux" "aarch64-darwin" ]; 14 | perSystem = { config, self', inputs', pkgs, system, ... }: { 15 | devShells.default = import ./shell.nix { inherit pkgs; }; 16 | }; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | collectCoverageFrom: ['/source/**/*.{ts,tsx}'], 3 | preset: 'ts-jest', 4 | setupFilesAfterEnv: [ 5 | '@testing-library/jest-dom/extend-expect', 6 | '/jest.setup.ts', 7 | ], 8 | verbose: true, 9 | } 10 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/68468204/680394 2 | //const { TextEncoder, TextDecoder } = require('util') // eslint-disable-line @typescript-eslint/no-var-requires 3 | //global.TextEncoder = TextEncoder 4 | //global.TextDecoder = TextDecoder 5 | 6 | //Object.defineProperty(window, 'scroll', { 7 | // value: () => undefined, 8 | // writable: true, 9 | //}) 10 | 11 | //global.ResizeObserver = jest.fn().mockImplementation(() => ({ 12 | // observe: jest.fn(), 13 | // unobserve: jest.fn(), 14 | // disconnect: jest.fn(), 15 | //})) 16 | 17 | //Object.defineProperty(global.Image.prototype, 'decode', { 18 | // get() { 19 | // return () => Promise.resolve() 20 | // }, 21 | //}) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-medium-image-zoom", 3 | "version": "5.2.14", 4 | "license": "BSD-3-Clause", 5 | "description": "Accessible medium.com-style image zoom for React", 6 | "type": "module", 7 | "main": "./dist/index.js", 8 | "exports": { 9 | ".": "./dist/index.js", 10 | "./dist/styles.css": "./dist/styles.css" 11 | }, 12 | "types": "./dist/index.d.ts", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/rpearce/react-medium-image-zoom.git" 16 | }, 17 | "homepage": "https://github.com/rpearce/react-medium-image-zoom", 18 | "bugs": "https://github.com/rpearce/react-medium-image-zoom/issues", 19 | "author": "Robert Pearce (https://robertwpearce.com)", 20 | "contributors": [ 21 | "Robert Pearce (https://robertwpearce.com)", 22 | "Cameron Bothner (https://github.com/cbothner)", 23 | "ismay (https://www.ismaywolff.nl)", 24 | "Jeremy Bini (https://jbini.com)", 25 | "Josh Sloat (http://www.joshsloat.com)", 26 | "Kaycee Ingram (https://github.com/HippoDippo)", 27 | "Ludwig Frank (https://github.com/ludwigfrank)", 28 | "Rahul Gaba (http://rahulgaba.com)", 29 | "Spencer Davis (https://github.com/spencerfdavis)", 30 | "dnlnvl (https://github.com/dnlnvl)", 31 | "Douglas Galdino (https://dougg0k.js.org)", 32 | "Sun Knudsen (https://sunknudsen.com)", 33 | "Youngrok Kim (https://rokoroku.github.io)", 34 | "Josh-Cena (https://joshcena.com)", 35 | "Yida Zhang ", 36 | "eych", 37 | "tshmieldev", 38 | "David Edler (https://github.com/edlerd)" 39 | ], 40 | "funding": [ 41 | { 42 | "type": "github", 43 | "url": "https://github.com/sponsors/rpearce" 44 | } 45 | ], 46 | "keywords": [ 47 | "react", 48 | "medium", 49 | "image", 50 | "zoom", 51 | "image-zoom", 52 | "modal", 53 | "react-component" 54 | ], 55 | "tags": [ 56 | "react", 57 | "medium", 58 | "image", 59 | "zoom", 60 | "image-zoom", 61 | "modal", 62 | "react-component" 63 | ], 64 | "sideEffects": [ 65 | "**/*.css" 66 | ], 67 | "files": [ 68 | "LICENSE", 69 | "dist/" 70 | ], 71 | "scripts": { 72 | "build": "rm -rf ./dist && rollup -c rollup.config.js && cp ./source/styles.css ./dist/styles.css", 73 | "build:docs": "rm -rf ./docs && mkdir ./docs && storybook build --quiet -o docs", 74 | "changeset": "changeset", 75 | "ci": "concurrently npm:lint npm:build npm:build:docs", 76 | "contributors:add": "all-contributors add", 77 | "contributors:generate": "all-contributors generate", 78 | "lint": "eslint .", 79 | "prepack": "concurrently npm:lint npm:build", 80 | "release": "changeset publish", 81 | "start": "NODE_OPTIONS=--openssl-legacy-provider storybook dev -p 6006", 82 | "storybook": "storybook dev -p 6006" 83 | }, 84 | "devDependencies": { 85 | "@changesets/cli": "^2.29.4", 86 | "@eslint/compat": "^1.2.9", 87 | "@eslint/js": "^9.27.0", 88 | "@rollup/plugin-typescript": "^12.1.2", 89 | "@storybook/addon-a11y": "^8.6.14", 90 | "@storybook/addon-docs": "^8.6.14", 91 | "@storybook/addon-interactions": "^8.6.14", 92 | "@storybook/addon-viewport": "^8.6.14", 93 | "@storybook/addon-webpack5-compiler-swc": "^3.0.0", 94 | "@storybook/react": "^8.6.14", 95 | "@storybook/react-webpack5": "^8.6.14", 96 | "@storybook/test": "^8.6.14", 97 | "@storybook/test-runner": "^0.22.0", 98 | "@types/eslint__js": "^9.14.0", 99 | "@types/node": "^22.15.23", 100 | "@types/react": "^19.1.5", 101 | "@types/react-dom": "^19.1.5", 102 | "all-contributors-cli": "^6.26.1", 103 | "concurrently": "^9.1.2", 104 | "eslint": "^9.27.0", 105 | "eslint-plugin-jsx-a11y": "^6.10.2", 106 | "eslint-plugin-react": "^7.37.5", 107 | "eslint-plugin-react-hooks": "^6.0.0", 108 | "jest": "^29.7.0", 109 | "neostandard": "^0.12.1", 110 | "react": "^19.1.0", 111 | "react-dom": "^19.1.0", 112 | "rollup": "^4.41.1", 113 | "rollup-plugin-dts": "^6.2.1", 114 | "storybook": "^8.6.14", 115 | "typescript": "^5.8.3", 116 | "typescript-eslint": "^8.32.1" 117 | }, 118 | "peerDependencies": { 119 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 120 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import ts from '@rollup/plugin-typescript' 3 | import dts from 'rollup-plugin-dts' 4 | import pkg from './package.json' with { type: 'json' } 5 | 6 | export default (async () => { 7 | const outDir = path.dirname(pkg.main) 8 | const outDirTypes = `${outDir}/types` 9 | 10 | return [ 11 | { 12 | input: './source/index.ts', 13 | output: { 14 | dir: outDir, // TODO: `file: pkg.main` - workaround via https://github.com/rollup/plugins/issues/1772#issuecomment-2519066903 15 | format: 'es', 16 | banner: "'use client';", 17 | }, 18 | external: isExternal, 19 | plugins: [ts({ 20 | tsconfig: './tsconfig.json', 21 | compilerOptions: { declarationDir: outDirTypes }, 22 | })], 23 | }, 24 | { 25 | input: `${outDirTypes}/index.d.ts`, 26 | output: { file: pkg.types, format: 'es' }, 27 | plugins: [dts(), rmOnWrite(outDirTypes)], 28 | }, 29 | ] 30 | })() 31 | 32 | function isExternal(id) { 33 | return !id.startsWith('.') && !id.startsWith('/') 34 | } 35 | 36 | function rmOnWrite(dir) { 37 | return { 38 | name: 'rollup-plugin-rm-on-write', 39 | writeBundle: { 40 | sequential: true, 41 | order: 'post', 42 | async handler() { 43 | return (await import('node:fs/promises')).rm(dir, { force: true, recursive: true }) 44 | }, 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /scripts/npm-check-modified: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NPM_HEADER="Accept: application/vnd.npm.install-v1+json" 4 | NPM_REGISTRY="https://registry.npmjs.org" 5 | OLDER_THAN="1y" 6 | OLDER_THAN_DATE=$(date -v-${OLDER_THAN} '+%Y-%m-%d') 7 | 8 | jq -r '.devDependencies | keys[]' < package.json | \ 9 | xargs -r -I{} curl -sSH "${NPM_HEADER}" "${NPM_REGISTRY}/{}" | \ 10 | jq 'select(.modified | . < "'"${OLDER_THAN_DATE}"'") | pick(.name, .modified)' 11 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import }: 2 | 3 | pkgs.mkShell { 4 | buildInputs = [ 5 | pkgs.nodejs-18_x 6 | ]; 7 | shellHook = '' 8 | export PATH="$PWD/node_modules/.bin/:$PATH" 9 | ''; 10 | } 11 | -------------------------------------------------------------------------------- /source/Controlled.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import type { SupportedImage } from './types' 5 | import { IEnlarge, ICompress } from './icons' 6 | 7 | import { 8 | adjustSvgIDs, 9 | getImgAlt, 10 | getImgSrc, 11 | getStyleGhost, 12 | getStyleModalImg, 13 | testDiv, 14 | testImg, 15 | testImgLoaded, 16 | testSvg, 17 | } from './utils' 18 | 19 | // ============================================================================= 20 | 21 | /** 22 | * The selector query we use to find and track the image 23 | */ 24 | const IMAGE_QUERY = ['img', 'svg', '[role="img"]', '[data-zoom]'] 25 | .map(x => `${x}:not([aria-hidden="true"])`) 26 | .join(',') 27 | 28 | // ============================================================================= 29 | 30 | const enum ModalState { 31 | LOADED = 'LOADED', 32 | LOADING = 'LOADING', 33 | UNLOADED = 'UNLOADED', 34 | UNLOADING = 'UNLOADING' 35 | } 36 | 37 | // ============================================================================= 38 | 39 | interface BodyAttrs { 40 | overflow: string 41 | width: string 42 | } 43 | 44 | /** 45 | * Helps keep track of some key `` attributes 46 | * so we can remove and re-add them when disabling and 47 | * re-enabling body scrolling 48 | */ 49 | const defaultBodyAttrs: BodyAttrs = { 50 | overflow: '', 51 | width: '', 52 | } 53 | 54 | // ============================================================================= 55 | 56 | export interface ControlledProps { 57 | a11yNameButtonUnzoom?: string 58 | a11yNameButtonZoom?: string 59 | canSwipeToUnzoom?: boolean 60 | children: React.ReactNode 61 | classDialog?: string 62 | IconUnzoom?: React.ElementType 63 | IconZoom?: React.ElementType 64 | isZoomed: boolean 65 | onZoomChange?: (value: boolean) => void 66 | swipeToUnzoomThreshold?: number 67 | wrapElement?: 'div' | 'span' 68 | ZoomContent?: (data: { 69 | img: React.ReactElement | null 70 | buttonUnzoom: React.ReactElement 71 | modalState: ModalState 72 | onUnzoom: () => void 73 | }) => React.ReactElement 74 | zoomImg?: React.ImgHTMLAttributes 75 | zoomMargin?: number 76 | } 77 | 78 | export function Controlled (props: ControlledProps) { 79 | return 80 | } 81 | 82 | interface ControlledDefaultProps { 83 | a11yNameButtonUnzoom: string 84 | a11yNameButtonZoom: string 85 | canSwipeToUnzoom: boolean 86 | IconUnzoom: React.ElementType 87 | IconZoom: React.ElementType 88 | swipeToUnzoomThreshold: number 89 | wrapElement: 'div' | 'span' 90 | zoomMargin: number 91 | } 92 | 93 | type ControlledPropsWithDefaults = ControlledDefaultProps & ControlledProps 94 | 95 | interface ControlledState { 96 | id: string, 97 | isZoomImgLoaded: boolean 98 | loadedImgEl: HTMLImageElement | undefined 99 | modalState: ModalState 100 | shouldRefresh: boolean 101 | styleGhost: React.CSSProperties 102 | } 103 | 104 | class ControlledBase extends React.Component { 105 | static defaultProps: ControlledDefaultProps = { 106 | a11yNameButtonUnzoom: 'Minimize image', 107 | a11yNameButtonZoom: 'Expand image', 108 | canSwipeToUnzoom: true, 109 | IconUnzoom: ICompress, 110 | IconZoom: IEnlarge, 111 | swipeToUnzoomThreshold: 10, 112 | wrapElement: 'div', 113 | zoomMargin: 0, 114 | } 115 | 116 | state: ControlledState = { 117 | id: '', 118 | isZoomImgLoaded: false, 119 | loadedImgEl: undefined, 120 | modalState: ModalState.UNLOADED, 121 | shouldRefresh: false, 122 | styleGhost: {}, 123 | } 124 | 125 | private refContent = React.createRef() 126 | private refDialog = React.createRef() 127 | private refModalContent = React.createRef() 128 | private refModalImg = React.createRef() 129 | private refWrap = React.createRef() 130 | 131 | private contentChangeObserver: MutationObserver | undefined 132 | private contentNotFoundChangeObserver: MutationObserver | undefined 133 | private imgEl: SupportedImage | null = null 134 | private imgElResizeObserver: ResizeObserver | undefined 135 | private isScaling = false 136 | private prevBodyAttrs: BodyAttrs = defaultBodyAttrs 137 | private styleModalImg: React.CSSProperties = {} 138 | private touchYStart?: number 139 | private touchYEnd?: number 140 | 141 | private timeoutTransitionEnd?: ReturnType 142 | 143 | render() { 144 | const { 145 | handleBtnUnzoomClick, 146 | handleDialogCancel, 147 | handleDialogClick, 148 | handleDialogClose, 149 | handleUnzoom, 150 | handleZoom, 151 | imgEl, 152 | props: { 153 | a11yNameButtonUnzoom, 154 | a11yNameButtonZoom, 155 | children, 156 | classDialog, 157 | IconUnzoom, 158 | IconZoom, 159 | isZoomed, 160 | wrapElement: WrapElement, 161 | ZoomContent, 162 | zoomImg, 163 | zoomMargin, 164 | }, 165 | refContent, 166 | refDialog, 167 | refModalContent, 168 | refModalImg, 169 | refWrap, 170 | state: { 171 | id, 172 | isZoomImgLoaded, 173 | loadedImgEl, 174 | modalState, 175 | shouldRefresh, 176 | styleGhost, 177 | }, 178 | } = this 179 | 180 | const idModal = `rmiz-modal-${id}` 181 | const idModalImg = `rmiz-modal-img-${id}` 182 | 183 | // ========================================================================= 184 | 185 | const isDiv = testDiv(imgEl) 186 | const isImg = testImg(imgEl) 187 | const isSvg = testSvg(imgEl) 188 | 189 | const imgAlt = getImgAlt(imgEl) 190 | const imgSrc = getImgSrc(imgEl) 191 | const imgSizes = isImg ? imgEl.sizes : undefined 192 | const imgSrcSet = isImg ? imgEl.srcset : undefined 193 | const imgCrossOrigin = isImg ? imgEl.crossOrigin : undefined 194 | 195 | const hasZoomImg = !!zoomImg?.src 196 | 197 | const hasImage = this.hasImage() 198 | 199 | const labelBtnZoom = imgAlt 200 | ? `${a11yNameButtonZoom}: ${imgAlt}` 201 | : a11yNameButtonZoom 202 | 203 | const isModalActive = modalState === ModalState.LOADING || 204 | modalState === ModalState.LOADED 205 | 206 | const dataContentState = hasImage ? 'found' : 'not-found' 207 | 208 | const dataOverlayState = 209 | modalState === ModalState.UNLOADED || modalState === ModalState.UNLOADING 210 | ? 'hidden' 211 | : 'visible' 212 | 213 | // ========================================================================= 214 | 215 | const styleContent: React.CSSProperties = { 216 | visibility: modalState === ModalState.UNLOADED ? 'visible' : 'hidden', 217 | } 218 | 219 | // Share this with UNSAFE_handleSvg 220 | this.styleModalImg = hasImage 221 | ? getStyleModalImg({ 222 | hasZoomImg, 223 | imgSrc, 224 | isSvg, 225 | isZoomed: isZoomed && isModalActive, 226 | loadedImgEl, 227 | offset: zoomMargin, 228 | shouldRefresh, 229 | targetEl: imgEl as SupportedImage, 230 | }) 231 | : {} 232 | 233 | // ========================================================================= 234 | 235 | let modalContent = null 236 | 237 | if (hasImage) { 238 | const modalImg = isImg || isDiv 239 | ? 254 | : isSvg 255 | ? 260 | : null 261 | 262 | const modalBtnUnzoom = 268 | 269 | 270 | 271 | modalContent = ZoomContent 272 | ? 278 | : <>{modalImg}{modalBtnUnzoom}> 279 | } 280 | 281 | // ========================================================================= 282 | 283 | return ( 284 | 285 | 286 | {children} 287 | 288 | {hasImage && 289 | 295 | 296 | 297 | } 298 | {hasImage && ReactDOM.createPortal( 299 | 311 | 312 | 313 | {modalContent} 314 | 315 | 316 | , this.getDialogContainer() 317 | )} 318 | 319 | ) 320 | } 321 | 322 | // =========================================================================== 323 | 324 | componentDidMount() { 325 | this.setId() 326 | this.setAndTrackImg() 327 | this.handleImgLoad() 328 | this.UNSAFE_handleSvg() 329 | } 330 | 331 | componentWillUnmount() { 332 | if (this.state.modalState !== ModalState.UNLOADED) { 333 | this.bodyScrollEnable() 334 | } 335 | this.contentChangeObserver?.disconnect?.() 336 | this.contentNotFoundChangeObserver?.disconnect?.() 337 | this.imgElResizeObserver?.disconnect?.() 338 | this.imgEl?.removeEventListener?.('load', this.handleImgLoad) 339 | this.imgEl?.removeEventListener?.('click', this.handleZoom) 340 | this.refModalImg.current?.removeEventListener?.('transitionend', this.handleImgTransitionEnd) 341 | window.removeEventListener('wheel', this.handleWheel) 342 | window.removeEventListener('touchstart', this.handleTouchStart) 343 | window.removeEventListener('touchmove', this.handleTouchMove) 344 | window.removeEventListener('touchend', this.handleTouchEnd) 345 | window.removeEventListener('touchcancel', this.handleTouchCancel) 346 | window.removeEventListener('resize', this.handleResize) 347 | document.removeEventListener('keydown', this.handleKeyDown, true) 348 | } 349 | 350 | // =========================================================================== 351 | 352 | componentDidUpdate(prevProps: ControlledPropsWithDefaults, prevState: ControlledState) { 353 | this.handleModalStateChange(prevState.modalState) 354 | this.UNSAFE_handleSvg() 355 | this.handleIfZoomChanged(prevProps.isZoomed) 356 | } 357 | 358 | handleModalStateChange = (prevModalState: ControlledState['modalState']) => { 359 | const { modalState } = this.state 360 | 361 | if (prevModalState !== ModalState.LOADING && modalState === ModalState.LOADING) { 362 | this.loadZoomImg() 363 | window.addEventListener('resize', this.handleResize, { passive: true }) 364 | window.addEventListener('touchstart', this.handleTouchStart, { passive: true }) 365 | window.addEventListener('touchmove', this.handleTouchMove, { passive: true }) 366 | window.addEventListener('touchend', this.handleTouchEnd, { passive: true }) 367 | window.addEventListener('touchcancel', this.handleTouchCancel, { passive: true }) 368 | document.addEventListener('keydown', this.handleKeyDown, true) 369 | } else if (prevModalState !== ModalState.LOADED && modalState === ModalState.LOADED) { 370 | window.addEventListener('wheel', this.handleWheel, { passive: true }) 371 | } else if (prevModalState !== ModalState.UNLOADING && modalState === ModalState.UNLOADING) { 372 | this.ensureImgTransitionEnd() 373 | window.removeEventListener('wheel', this.handleWheel) 374 | window.removeEventListener('touchstart', this.handleTouchStart) 375 | window.removeEventListener('touchmove', this.handleTouchMove) 376 | window.removeEventListener('touchend', this.handleTouchEnd) 377 | window.removeEventListener('touchcancel', this.handleTouchCancel) 378 | document.removeEventListener('keydown', this.handleKeyDown, true) 379 | } else if (prevModalState !== ModalState.UNLOADED && modalState === ModalState.UNLOADED) { 380 | this.bodyScrollEnable() 381 | window.removeEventListener('resize', this.handleResize) 382 | this.refModalImg.current?.removeEventListener?.('transitionend', this.handleImgTransitionEnd) 383 | this.refDialog.current?.close?.() 384 | } 385 | } 386 | 387 | // =========================================================================== 388 | 389 | /** 390 | * Find or create a container for the dialog 391 | */ 392 | getDialogContainer = (): HTMLDivElement => { 393 | let el = document.querySelector('[data-rmiz-portal]') 394 | 395 | if (el == null) { 396 | el = document.createElement('div') 397 | el.setAttribute('data-rmiz-portal', '') 398 | document.body.appendChild(el) 399 | } 400 | 401 | return el as HTMLDivElement 402 | } 403 | 404 | // =========================================================================== 405 | 406 | /** 407 | * Because of SSR, set a unique ID after render 408 | */ 409 | setId = () => { 410 | const gen4 = () => Math.random().toString(16).slice(-4) 411 | this.setState({ id: gen4() + gen4() + gen4() }) 412 | } 413 | 414 | // =========================================================================== 415 | 416 | /** 417 | * Find and set the image we're working with 418 | */ 419 | setAndTrackImg = () => { 420 | const contentEl = this.refContent.current 421 | 422 | if (!contentEl) return 423 | 424 | this.imgEl = contentEl.querySelector(IMAGE_QUERY) as SupportedImage | null 425 | 426 | if (this.imgEl) { 427 | this.contentNotFoundChangeObserver?.disconnect?.() 428 | this.imgEl.addEventListener('load', this.handleImgLoad) 429 | this.imgEl.addEventListener('click', this.handleZoom) 430 | 431 | if (!this.state.loadedImgEl) { 432 | this.handleImgLoad() 433 | } 434 | 435 | this.imgElResizeObserver = new ResizeObserver(entries => { 436 | const entry = entries[0] 437 | 438 | if (entry?.target) { 439 | this.imgEl = entry.target as SupportedImage 440 | 441 | // Update ghost and force a re-render. 442 | // NOTE: Always force a re-render here, even if we remove 443 | // all state changes. Pass `{}` in that case. 444 | this.setState({ styleGhost: getStyleGhost(this.imgEl) }) 445 | } 446 | }) 447 | 448 | this.imgElResizeObserver.observe(this.imgEl) 449 | 450 | // Watch for any reasonable DOM changes and update ghost if so 451 | if (!this.contentChangeObserver) { 452 | this.contentChangeObserver = new MutationObserver(() => { 453 | this.setState({ styleGhost: getStyleGhost(this.imgEl) }) 454 | }) 455 | 456 | this.contentChangeObserver.observe(contentEl, { attributes: true, childList: true, subtree: true }) 457 | } 458 | } else if (!this.contentNotFoundChangeObserver) { 459 | this.contentNotFoundChangeObserver = new MutationObserver(this.setAndTrackImg) 460 | this.contentNotFoundChangeObserver.observe(contentEl, { childList: true, subtree: true }) 461 | } 462 | } 463 | 464 | // =========================================================================== 465 | 466 | /** 467 | * Show modal when zoomed; hide modal when unzoomed 468 | */ 469 | handleIfZoomChanged = (prevIsZoomed: boolean) => { 470 | const { isZoomed } = this.props 471 | 472 | if (!prevIsZoomed && isZoomed) { 473 | this.zoom() 474 | } else if (prevIsZoomed && !isZoomed) { 475 | this.unzoom() 476 | } 477 | } 478 | 479 | // =========================================================================== 480 | 481 | /** 482 | * Ensure we always have the latest img src value loaded 483 | */ 484 | handleImgLoad = () => { 485 | const imgSrc = getImgSrc(this.imgEl) 486 | 487 | if (!imgSrc) return 488 | 489 | const img = new Image() 490 | 491 | if (testImg(this.imgEl)) { 492 | img.sizes = this.imgEl.sizes 493 | img.srcset = this.imgEl.srcset 494 | img.crossOrigin = this.imgEl.crossOrigin 495 | } 496 | 497 | // img.src must be set after sizes and srcset 498 | // because of Firefox flickering on zoom 499 | img.src = imgSrc 500 | 501 | const setLoaded = () => { 502 | this.setState({ 503 | loadedImgEl: img, 504 | styleGhost: getStyleGhost(this.imgEl), 505 | }) 506 | } 507 | 508 | img 509 | .decode() 510 | .then(setLoaded) 511 | .catch(() => { 512 | if (testImgLoaded(img)) { 513 | setLoaded() 514 | return 515 | } 516 | img.onload = setLoaded 517 | }) 518 | } 519 | 520 | // =========================================================================== 521 | 522 | /** 523 | * Report that zooming should occur 524 | */ 525 | handleZoom = () => { 526 | if (this.hasImage()) { 527 | this.props.onZoomChange?.(true) 528 | } 529 | } 530 | 531 | /** 532 | * Report that unzooming should occur 533 | */ 534 | handleUnzoom = () => { 535 | this.props.onZoomChange?.(false) 536 | } 537 | 538 | // =========================================================================== 539 | 540 | /** 541 | * Capture click event when clicking unzoom button 542 | */ 543 | handleBtnUnzoomClick = (e: React.MouseEvent) => { 544 | e.preventDefault() 545 | e.stopPropagation() 546 | this.handleUnzoom() 547 | } 548 | 549 | // =========================================================================== 550 | 551 | /** 552 | * Prevent the browser from removing the dialog on Escape 553 | */ 554 | handleDialogCancel = (e: React.SyntheticEvent) => { 555 | e.preventDefault() 556 | } 557 | 558 | // =========================================================================== 559 | 560 | /** 561 | * Have dialog.click() only close in certain situations 562 | */ 563 | handleDialogClick = (e: React.MouseEvent) => { 564 | if (e.target === this.refModalContent.current || e.target === this.refModalImg.current) { 565 | e.stopPropagation() 566 | this.handleUnzoom() 567 | } 568 | } 569 | 570 | // =========================================================================== 571 | 572 | /** 573 | * Prevent dialog's close event from closing a parent modal 574 | */ 575 | handleDialogClose = (e: React.SyntheticEvent) => { 576 | e.stopPropagation() 577 | this.handleUnzoom() 578 | } 579 | 580 | // =========================================================================== 581 | 582 | /** 583 | * Intercept default dialog.close() and use ours so we can animate 584 | */ 585 | handleKeyDown = (e: KeyboardEvent) => { 586 | if (e.key === 'Escape' || e.keyCode === 27) { 587 | e.preventDefault() 588 | e.stopPropagation() 589 | this.handleUnzoom() 590 | } 591 | } 592 | 593 | // =========================================================================== 594 | 595 | /** 596 | * Unzoom on wheel event 597 | */ 598 | handleWheel = (e: WheelEvent) => { 599 | // don't handle the event when the user is zooming with ctrl + wheel (or with pinch to zoom) 600 | if (e.ctrlKey) return 601 | 602 | e.stopPropagation() 603 | queueMicrotask(() => { 604 | this.handleUnzoom() 605 | }) 606 | } 607 | 608 | /** 609 | * Start tracking the Y-axis but abort if non-scroll 610 | * gesture is detected (like pinch-to-zoom) 611 | */ 612 | handleTouchStart = (e: TouchEvent) => { 613 | if (e.touches.length > 1) { 614 | this.isScaling = true 615 | return 616 | } 617 | 618 | if (e.changedTouches.length === 1 && e.changedTouches[0]) { 619 | this.touchYStart = e.changedTouches[0].screenY 620 | } 621 | } 622 | 623 | /** 624 | * If the window isn't browser zoomed, 625 | * track how far we've moved on the Y-axis 626 | * and unzoom if we detect a swipe 627 | */ 628 | handleTouchMove = (e: TouchEvent) => { 629 | const browserScale = window.visualViewport?.scale ?? 1 630 | 631 | if ( 632 | this.props.canSwipeToUnzoom && 633 | !this.isScaling && 634 | browserScale <= 1 && this.touchYStart != null && 635 | e.changedTouches[0] 636 | ) { 637 | this.touchYEnd = e.changedTouches[0].screenY 638 | 639 | const max = Math.max(this.touchYStart, this.touchYEnd) 640 | const min = Math.min(this.touchYStart, this.touchYEnd) 641 | const delta = Math.abs(max - min) 642 | 643 | if (delta > this.props.swipeToUnzoomThreshold) { 644 | this.touchYStart = undefined 645 | this.touchYEnd = undefined 646 | this.handleUnzoom() 647 | } 648 | } 649 | } 650 | 651 | /** 652 | * Reset the scaling check and the Y-axis start and end tracking points 653 | */ 654 | handleTouchEnd = () => { 655 | this.isScaling = false 656 | this.touchYStart = undefined 657 | this.touchYEnd = undefined 658 | } 659 | 660 | /** 661 | * Reset the scaling check and the Y-axis start and end tracking points 662 | */ 663 | handleTouchCancel = () => { 664 | this.isScaling = false 665 | this.touchYStart = undefined 666 | this.touchYEnd = undefined 667 | } 668 | 669 | // =========================================================================== 670 | 671 | /** 672 | * Force re-render on resize 673 | */ 674 | handleResize = () => { 675 | this.setState({ shouldRefresh: true }) 676 | } 677 | 678 | // =========================================================================== 679 | 680 | /** 681 | * Check if we have a loaded image to work with 682 | */ 683 | hasImage = () => { 684 | return this.imgEl && 685 | (this.state.loadedImgEl || testSvg(this.imgEl)) && 686 | window.getComputedStyle(this.imgEl).display !== 'none' 687 | } 688 | 689 | // =========================================================================== 690 | 691 | /** 692 | * Perform zooming actions 693 | */ 694 | zoom = () => { 695 | this.bodyScrollDisable() 696 | this.refDialog.current?.showModal?.() 697 | this.refModalImg.current?.addEventListener?.('transitionend', this.handleImgTransitionEnd) // must be added after showModal 698 | this.setState({ modalState: ModalState.LOADING }) 699 | } 700 | 701 | /** 702 | * Perform unzooming actions 703 | */ 704 | unzoom = () => { 705 | this.setState({ modalState: ModalState.UNLOADING }) 706 | } 707 | 708 | // =========================================================================== 709 | 710 | /** 711 | * Handle img zoom/unzoom transitionend events and update states: 712 | * - LOADING -> LOADED 713 | * - UNLOADING -> UNLOADED 714 | */ 715 | handleImgTransitionEnd = () => { 716 | clearTimeout(this.timeoutTransitionEnd) 717 | 718 | if (this.state.modalState === ModalState.LOADING) { 719 | this.setState({ modalState: ModalState.LOADED }) 720 | } else if (this.state.modalState === ModalState.UNLOADING) { 721 | this.setState({ shouldRefresh: false, modalState: ModalState.UNLOADED }) 722 | } 723 | } 724 | 725 | /** 726 | * Ensure handleImgTransitionEnd gets called. Safari can have significant 727 | * delays before firing the event. 728 | */ 729 | ensureImgTransitionEnd = () => { 730 | if (this.refModalImg.current) { 731 | const td = window.getComputedStyle(this.refModalImg.current).transitionDuration 732 | const tdFloat = parseFloat(td) 733 | 734 | if (tdFloat) { 735 | const tdMs = tdFloat * (td.endsWith('ms') ? 1 : 1000) + 50 736 | this.timeoutTransitionEnd = setTimeout(this.handleImgTransitionEnd, tdMs) 737 | } 738 | } 739 | } 740 | 741 | // =========================================================================== 742 | 743 | /** 744 | * Disable body scrolling 745 | */ 746 | bodyScrollDisable = () => { 747 | this.prevBodyAttrs = { 748 | overflow: document.body.style.overflow, 749 | width: document.body.style.width, 750 | } 751 | 752 | // Get clientWidth before setting overflow: 'hidden' 753 | const clientWidth = document.body.clientWidth 754 | 755 | document.body.style.overflow = 'hidden' 756 | document.body.style.width = `${clientWidth}px` 757 | } 758 | 759 | /** 760 | * Enable body scrolling 761 | */ 762 | bodyScrollEnable = () => { 763 | document.body.style.width = this.prevBodyAttrs.width 764 | document.body.style.overflow = this.prevBodyAttrs.overflow 765 | this.prevBodyAttrs = defaultBodyAttrs 766 | } 767 | 768 | // =========================================================================== 769 | 770 | /** 771 | * Load the zoomImg manually 772 | */ 773 | loadZoomImg = () => { 774 | const { props: { zoomImg } } = this 775 | const zoomImgSrc = zoomImg?.src 776 | 777 | if (zoomImgSrc) { 778 | const img = new Image() 779 | img.sizes = zoomImg?.sizes ?? '' 780 | img.srcset = zoomImg?.srcSet ?? '' 781 | // @ts-expect-error crossOrigin type is odd 782 | img.crossOrigin = zoomImg?.crossOrigin ?? undefined 783 | img.src = zoomImgSrc 784 | 785 | const setLoaded = () => { 786 | this.setState({ isZoomImgLoaded: true }) 787 | } 788 | 789 | img 790 | .decode() 791 | .then(setLoaded) 792 | .catch(() => { 793 | if (testImgLoaded(img)) { 794 | setLoaded() 795 | return 796 | } 797 | img.onload = setLoaded 798 | }) 799 | } 800 | } 801 | 802 | // =========================================================================== 803 | 804 | /** 805 | * Hackily deal with SVGs because of all of their unknowns 806 | */ 807 | UNSAFE_handleSvg = () => { 808 | const { imgEl, refModalImg, styleModalImg } = this 809 | 810 | if (testSvg(imgEl)) { 811 | const svgEl = imgEl.cloneNode(true) as typeof imgEl 812 | 813 | // Deal with cloned SVG ID duplicate issues from https://github.com/rpearce/react-medium-image-zoom/issues/438 814 | adjustSvgIDs(svgEl) 815 | 816 | svgEl.style.width = `${styleModalImg.width || 0}px` 817 | svgEl.style.height = `${styleModalImg.height || 0}px` 818 | svgEl.addEventListener('click', this.handleUnzoom) 819 | 820 | refModalImg.current?.firstChild?.remove?.() 821 | refModalImg.current?.appendChild?.(svgEl) 822 | } 823 | } 824 | } 825 | -------------------------------------------------------------------------------- /source/Uncontrolled.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Controlled, ControlledProps } from './Controlled' 3 | 4 | // ============================================================================= 5 | 6 | export type UncontrolledProps = 7 | Omit 8 | 9 | export function Uncontrolled (props: UncontrolledProps) { 10 | const [isZoomed, setIsZoomed] = React.useState(false) 11 | 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /source/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // ============================================================================= 4 | 5 | export function ICompress () { 6 | return ( 7 | 15 | 16 | 17 | ) 18 | } 19 | 20 | // ============================================================================= 21 | 22 | export function IEnlarge () { 23 | return ( 24 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export { Uncontrolled as default } from './Uncontrolled' 2 | export { Controlled } from './Controlled' 3 | export type { UncontrolledProps } from './Uncontrolled' 4 | export type { ControlledProps } from './Controlled' 5 | -------------------------------------------------------------------------------- /source/styles.css: -------------------------------------------------------------------------------- 1 | [data-rmiz-ghost] { 2 | position: absolute; 3 | pointer-events: none; 4 | } 5 | [data-rmiz-btn-zoom], 6 | [data-rmiz-btn-unzoom] { 7 | background-color: rgba(0, 0, 0, 0.7); 8 | border-radius: 50%; 9 | border: none; 10 | box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); 11 | color: #fff; 12 | height: 40px; 13 | margin: 0; 14 | outline-offset: 2px; 15 | padding: 9px; 16 | touch-action: manipulation; 17 | width: 40px; 18 | -webkit-appearance: none; 19 | -moz-appearance: none; 20 | appearance: none; 21 | } 22 | [data-rmiz-btn-zoom]:not(:focus):not(:active) { 23 | position: absolute; 24 | clip: rect(0 0 0 0); 25 | clip-path: inset(50%); 26 | height: 1px; 27 | overflow: hidden; 28 | pointer-events: none; 29 | white-space: nowrap; 30 | width: 1px; 31 | } 32 | [data-rmiz-btn-zoom] { 33 | position: absolute; 34 | inset: 10px 10px auto auto; 35 | cursor: zoom-in; 36 | } 37 | [data-rmiz-btn-unzoom] { 38 | position: absolute; 39 | inset: 20px 20px auto auto; 40 | cursor: zoom-out; 41 | z-index: 1; 42 | } 43 | [data-rmiz-content="found"] img, 44 | [data-rmiz-content="found"] svg, 45 | [data-rmiz-content="found"] [role="img"], 46 | [data-rmiz-content="found"] [data-zoom] { 47 | cursor: zoom-in; 48 | } 49 | [data-rmiz-modal]::backdrop { 50 | display: none; 51 | } 52 | [data-rmiz-modal][open] { 53 | position: fixed; 54 | width: 100vw; 55 | width: 100dvw; 56 | height: 100vh; 57 | height: 100dvh; 58 | max-width: none; 59 | max-height: none; 60 | margin: 0; 61 | padding: 0; 62 | border: 0; 63 | background: transparent; 64 | overflow: hidden; 65 | } 66 | [data-rmiz-modal-overlay] { 67 | position: absolute; 68 | inset: 0; 69 | transition: background-color 0.3s; 70 | } 71 | [data-rmiz-modal-overlay="hidden"] { 72 | background-color: rgba(255, 255, 255, 0); 73 | } 74 | [data-rmiz-modal-overlay="visible"] { 75 | background-color: rgba(255, 255, 255, 1); 76 | } 77 | [data-rmiz-modal-content] { 78 | position: relative; 79 | width: 100%; 80 | height: 100%; 81 | } 82 | [data-rmiz-modal-img] { 83 | position: absolute; 84 | cursor: zoom-out; 85 | image-rendering: high-quality; 86 | transform-origin: top left; 87 | transition: transform 0.3s; 88 | } 89 | @media (prefers-reduced-motion: reduce) { 90 | [data-rmiz-modal-overlay], 91 | [data-rmiz-modal-img] { 92 | transition-duration: 0.01ms !important; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /source/types.ts: -------------------------------------------------------------------------------- 1 | export type SupportedImage = 2 | HTMLImageElement 3 | | HTMLDivElement 4 | | HTMLSpanElement 5 | | SVGElement 6 | -------------------------------------------------------------------------------- /source/utils.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { SupportedImage } from './types' 3 | 4 | // ============================================================================= 5 | 6 | interface TestElType { 7 | (type: string, el: unknown): boolean 8 | } 9 | 10 | const testElType: TestElType = (type, el) => 11 | type === (el as Element)?.tagName?.toUpperCase?.() 12 | 13 | export const testDiv = (el: unknown): el is HTMLDivElement | HTMLSpanElement => testElType('DIV', el) || testElType('SPAN', el) 14 | export const testImg = (el: unknown): el is HTMLImageElement => testElType('IMG', el) 15 | export const testImgLoaded = (el: HTMLImageElement) => el.complete && el.naturalHeight !== 0 16 | export const testSvg = (el: unknown): el is SVGElement => testElType('SVG', el) 17 | 18 | // ============================================================================= 19 | 20 | export interface GetScaleToWindow { 21 | (data: { 22 | width: number, 23 | height: number, 24 | offset: number 25 | }): number 26 | } 27 | 28 | export const getScaleToWindow: GetScaleToWindow = ({ height, offset, width }) => { 29 | return Math.min( 30 | (window.innerWidth - offset * 2) / width, // scale X-axis 31 | (window.innerHeight - offset * 2) / height // scale Y-axis 32 | ) 33 | } 34 | 35 | // ============================================================================= 36 | 37 | export interface GetScaleToWindowMax { 38 | (data: { 39 | containerHeight: number, 40 | containerWidth: number, 41 | offset: number, 42 | targetHeight: number, 43 | targetWidth: number, 44 | }): number 45 | } 46 | 47 | export const getScaleToWindowMax: GetScaleToWindowMax = ({ 48 | containerHeight, 49 | containerWidth, 50 | offset, 51 | targetHeight, 52 | targetWidth, 53 | }) => { 54 | const scale = getScaleToWindow({ 55 | height: targetHeight, 56 | offset, 57 | width: targetWidth, 58 | }) 59 | 60 | const ratio = targetWidth > targetHeight 61 | ? targetWidth / containerWidth 62 | : targetHeight / containerHeight 63 | 64 | return scale > 1 ? ratio : scale * ratio 65 | } 66 | 67 | // ============================================================================= 68 | 69 | export interface GetScale { 70 | (data: { 71 | containerHeight: number, 72 | containerWidth: number, 73 | hasScalableSrc: boolean, 74 | offset: number, 75 | targetHeight: number, 76 | targetWidth: number, 77 | }): number 78 | } 79 | 80 | export const getScale: GetScale = ({ 81 | containerHeight, 82 | containerWidth, 83 | hasScalableSrc, 84 | offset, 85 | targetHeight, 86 | targetWidth, 87 | }) => { 88 | if (!containerHeight || !containerWidth) { 89 | return 1 90 | } 91 | 92 | return !hasScalableSrc && targetHeight && targetWidth 93 | ? getScaleToWindowMax({ 94 | containerHeight, 95 | containerWidth, 96 | offset, 97 | targetHeight, 98 | targetWidth, 99 | }) 100 | : getScaleToWindow({ 101 | height: containerHeight, 102 | offset, 103 | width: containerWidth, 104 | }) 105 | } 106 | 107 | // ============================================================================= 108 | 109 | const URL_REGEX = /url(?:\(['"]?)(.*?)(?:['"]?\))/ 110 | 111 | export interface GetImgSrc { 112 | (imgEl: SupportedImage | null): string | undefined 113 | } 114 | 115 | export const getImgSrc: GetImgSrc = (imgEl) => { 116 | if (imgEl) { 117 | if (testImg(imgEl)) { 118 | return imgEl.currentSrc 119 | } else if (testDiv(imgEl)) { 120 | const bgImg = window.getComputedStyle(imgEl).backgroundImage 121 | 122 | if (bgImg) { 123 | return URL_REGEX.exec(bgImg)?.[1] 124 | } 125 | } 126 | } 127 | } 128 | 129 | // ============================================================================= 130 | 131 | export interface GetImgAlt { 132 | (imgEl: SupportedImage | null): string | undefined 133 | } 134 | 135 | export const getImgAlt: GetImgAlt = (imgEl) => { 136 | if (imgEl) { 137 | if (testImg(imgEl)) { 138 | return imgEl.alt ?? undefined 139 | } else { 140 | return imgEl.getAttribute('aria-label') ?? undefined 141 | } 142 | } 143 | } 144 | 145 | // ============================================================================= 146 | 147 | export interface GetImgRegularStyle { 148 | (data: { 149 | containerHeight: number, 150 | containerLeft: number, 151 | containerTop: number, 152 | containerWidth: number, 153 | hasScalableSrc: boolean, 154 | offset: number, 155 | targetHeight: number, 156 | targetWidth: number, 157 | }): React.CSSProperties 158 | } 159 | 160 | export const getImgRegularStyle: GetImgRegularStyle = ({ 161 | containerHeight, 162 | containerLeft, 163 | containerTop, 164 | containerWidth, 165 | hasScalableSrc, 166 | offset, 167 | targetHeight, 168 | targetWidth, 169 | }) => { 170 | const scale = getScale({ 171 | containerHeight, 172 | containerWidth, 173 | hasScalableSrc, 174 | offset, 175 | targetHeight, 176 | targetWidth, 177 | }) 178 | 179 | return { 180 | top: containerTop, 181 | left: containerLeft, 182 | width: containerWidth * scale, 183 | height: containerHeight * scale, 184 | transform: `translate(0,0) scale(${1 / scale})`, 185 | } 186 | } 187 | 188 | // ============================================================================= 189 | 190 | export interface ParsePosition { 191 | (data: { 192 | position: string, 193 | relativeNum: number 194 | }): number 195 | } 196 | 197 | export const parsePosition: ParsePosition = ({ position, relativeNum }) => { 198 | const positionNum = parseFloat(position) 199 | 200 | return position.endsWith('%') 201 | ? relativeNum * positionNum / 100 202 | : positionNum 203 | } 204 | 205 | // ============================================================================= 206 | 207 | export interface GetImgObjectFitStyle { 208 | (data: { 209 | containerHeight: number, 210 | containerLeft: number, 211 | containerTop: number, 212 | containerWidth: number, 213 | hasScalableSrc: boolean, 214 | objectFit: string, 215 | objectPosition: string, 216 | offset: number, 217 | targetHeight: number, 218 | targetWidth: number, 219 | }): React.CSSProperties 220 | } 221 | 222 | export const getImgObjectFitStyle: GetImgObjectFitStyle = ({ 223 | containerHeight, 224 | containerLeft, 225 | containerTop, 226 | containerWidth, 227 | hasScalableSrc, 228 | objectFit, 229 | objectPosition, 230 | offset, 231 | targetHeight, 232 | targetWidth, 233 | }) => { 234 | if (objectFit === 'scale-down') { 235 | if (targetWidth <= containerWidth && targetHeight <= containerHeight) { 236 | objectFit = 'none' 237 | } else { 238 | objectFit = 'contain' 239 | } 240 | } 241 | 242 | if (objectFit === 'cover' || objectFit === 'contain') { 243 | const widthRatio = containerWidth / targetWidth 244 | const heightRatio = containerHeight / targetHeight 245 | 246 | const ratio = objectFit === 'cover' 247 | ? Math.max(widthRatio, heightRatio) 248 | : Math.min(widthRatio, heightRatio) 249 | 250 | const [posLeft = '50%', posTop = '50%'] = objectPosition.split(' ') 251 | const posX = parsePosition({ position: posLeft, relativeNum: containerWidth - targetWidth * ratio }) 252 | const posY = parsePosition({ position: posTop, relativeNum: containerHeight - targetHeight * ratio }) 253 | 254 | const scale = getScale({ 255 | containerHeight: targetHeight * ratio, 256 | containerWidth: targetWidth * ratio, 257 | hasScalableSrc, 258 | offset, 259 | targetHeight, 260 | targetWidth, 261 | }) 262 | 263 | return { 264 | top: containerTop + posY, 265 | left: containerLeft + posX, 266 | width: targetWidth * ratio * scale, 267 | height: targetHeight * ratio * scale, 268 | transform: `translate(0,0) scale(${1 / scale})`, 269 | } 270 | } else if (objectFit === 'none') { 271 | const [posLeft = '50%', posTop = '50%'] = objectPosition.split(' ') 272 | const posX = parsePosition({ position: posLeft, relativeNum: containerWidth - targetWidth }) 273 | const posY = parsePosition({ position: posTop, relativeNum: containerHeight - targetHeight }) 274 | 275 | const scale = getScale({ 276 | containerHeight: targetHeight, 277 | containerWidth: targetWidth, 278 | hasScalableSrc, 279 | offset, 280 | targetHeight, 281 | targetWidth, 282 | }) 283 | 284 | return { 285 | top: containerTop + posY, 286 | left: containerLeft + posX, 287 | width: targetWidth * scale, 288 | height: targetHeight * scale, 289 | transform: `translate(0,0) scale(${1 / scale})`, 290 | } 291 | } else if (objectFit === 'fill') { 292 | const widthRatio = containerWidth / targetWidth 293 | const heightRatio = containerHeight / targetHeight 294 | const ratio = Math.max(widthRatio, heightRatio) 295 | 296 | const scale = getScale({ 297 | containerHeight: targetHeight * ratio, 298 | containerWidth: targetWidth * ratio, 299 | hasScalableSrc, 300 | offset, 301 | targetHeight, 302 | targetWidth, 303 | }) 304 | 305 | return { 306 | width: containerWidth * scale, 307 | height: containerHeight * scale, 308 | transform: `translate(0,0) scale(${1 / scale})`, 309 | } 310 | } else { 311 | return {} 312 | } 313 | } 314 | 315 | // ============================================================================= 316 | 317 | export interface GetDivImgStyle { 318 | (data: { 319 | backgroundPosition: string, 320 | backgroundSize: string, 321 | containerHeight: number, 322 | containerLeft: number, 323 | containerTop: number, 324 | containerWidth: number, 325 | hasScalableSrc: boolean, 326 | offset: number, 327 | targetHeight: number, 328 | targetWidth: number, 329 | }): React.CSSProperties 330 | } 331 | 332 | export const getDivImgStyle: GetDivImgStyle = ({ 333 | backgroundPosition, 334 | backgroundSize, 335 | containerHeight, 336 | containerLeft, 337 | containerTop, 338 | containerWidth, 339 | hasScalableSrc, 340 | offset, 341 | targetHeight, 342 | targetWidth, 343 | }) => { 344 | if (backgroundSize === 'cover' || backgroundSize === 'contain') { 345 | const widthRatio = containerWidth / targetWidth 346 | const heightRatio = containerHeight / targetHeight 347 | 348 | const ratio = backgroundSize === 'cover' 349 | ? Math.max(widthRatio, heightRatio) 350 | : Math.min(widthRatio, heightRatio) 351 | 352 | const [posLeft = '50%', posTop = '50%'] = backgroundPosition.split(' ') 353 | const posX = parsePosition({ position: posLeft, relativeNum: containerWidth - targetWidth * ratio }) 354 | const posY = parsePosition({ position: posTop, relativeNum: containerHeight - targetHeight * ratio }) 355 | 356 | const scale = getScale({ 357 | containerHeight: targetHeight * ratio, 358 | containerWidth: targetWidth * ratio, 359 | hasScalableSrc, 360 | offset, 361 | targetHeight, 362 | targetWidth, 363 | }) 364 | 365 | return { 366 | top: containerTop + posY, 367 | left: containerLeft + posX, 368 | width: targetWidth * ratio * scale, 369 | height: targetHeight * ratio * scale, 370 | transform: `translate(0,0) scale(${1 / scale})`, 371 | } 372 | } else if (backgroundSize === 'auto') { 373 | const [posLeft = '50%', posTop = '50%'] = backgroundPosition.split(' ') 374 | const posX = parsePosition({ position: posLeft, relativeNum: containerWidth - targetWidth }) 375 | const posY = parsePosition({ position: posTop, relativeNum: containerHeight - targetHeight }) 376 | 377 | const scale = getScale({ 378 | containerHeight: targetHeight, 379 | containerWidth: targetWidth, 380 | hasScalableSrc, 381 | offset, 382 | targetHeight, 383 | targetWidth, 384 | }) 385 | 386 | return { 387 | top: containerTop + posY, 388 | left: containerLeft + posX, 389 | width: targetWidth * scale, 390 | height: targetHeight * scale, 391 | transform: `translate(0,0) scale(${1 / scale})`, 392 | } 393 | } else { 394 | const [sizeW = '50%', sizeH = '50%'] = backgroundSize.split(' ') 395 | const sizeWidth = parsePosition({ position: sizeW, relativeNum: containerWidth }) 396 | const sizeHeight = parsePosition({ position: sizeH, relativeNum: containerHeight }) 397 | 398 | const widthRatio = sizeWidth / targetWidth 399 | const heightRatio = sizeHeight / targetHeight 400 | 401 | // @TODO: something funny is happening with this ratio 402 | const ratio = Math.min(widthRatio, heightRatio) 403 | 404 | const [posLeft = '50%', posTop = '50%'] = backgroundPosition.split(' ') 405 | const posX = parsePosition({ position: posLeft, relativeNum: containerWidth - targetWidth * ratio }) 406 | const posY = parsePosition({ position: posTop, relativeNum: containerHeight - targetHeight * ratio }) 407 | 408 | const scale = getScale({ 409 | containerHeight: targetHeight * ratio, 410 | containerWidth: targetWidth * ratio, 411 | hasScalableSrc, 412 | offset, 413 | targetHeight, 414 | targetWidth, 415 | }) 416 | 417 | return { 418 | top: containerTop + posY, 419 | left: containerLeft + posX, 420 | width: targetWidth * ratio * scale, 421 | height: targetHeight * ratio * scale, 422 | transform: `translate(0,0) scale(${1 / scale})`, 423 | } 424 | } 425 | } 426 | 427 | // ============================================================================= 428 | 429 | const SRC_SVG_REGEX = /\.svg$/i 430 | 431 | export interface GetStyleModalImg { 432 | (data: { 433 | hasZoomImg: boolean, 434 | imgSrc: string | undefined, 435 | isSvg: boolean, 436 | isZoomed: boolean, 437 | loadedImgEl: HTMLImageElement | undefined, 438 | offset: number, 439 | shouldRefresh: boolean, 440 | targetEl: SupportedImage, 441 | }): React.CSSProperties 442 | } 443 | 444 | export const getStyleModalImg: GetStyleModalImg = ({ 445 | hasZoomImg, 446 | imgSrc, 447 | isSvg, 448 | isZoomed, 449 | loadedImgEl, 450 | offset, 451 | shouldRefresh, 452 | targetEl, 453 | }) => { 454 | const hasScalableSrc = 455 | isSvg || 456 | imgSrc?.slice?.(0, 18) === 'data:image/svg+xml' || 457 | hasZoomImg || 458 | !!(imgSrc && SRC_SVG_REGEX.test(imgSrc)) 459 | 460 | const imgRect = targetEl.getBoundingClientRect() 461 | const targetElComputedStyle = window.getComputedStyle(targetEl) 462 | 463 | const isDivImg = loadedImgEl != null && testDiv(targetEl) 464 | const isImgObjectFit = loadedImgEl != null && !isDivImg 465 | 466 | const styleImgRegular = getImgRegularStyle({ 467 | containerHeight: imgRect.height, 468 | containerLeft: imgRect.left, 469 | containerTop: imgRect.top, 470 | containerWidth: imgRect.width, 471 | hasScalableSrc, 472 | offset, 473 | targetHeight: loadedImgEl?.naturalHeight || imgRect.height, 474 | targetWidth: loadedImgEl?.naturalWidth || imgRect.width, 475 | }) 476 | 477 | const styleImgObjectFit = isImgObjectFit 478 | ? getImgObjectFitStyle({ 479 | containerHeight: imgRect.height, 480 | containerLeft: imgRect.left, 481 | containerTop: imgRect.top, 482 | containerWidth: imgRect.width, 483 | hasScalableSrc, 484 | objectFit: targetElComputedStyle.objectFit, 485 | objectPosition: targetElComputedStyle.objectPosition, 486 | offset, 487 | targetHeight: loadedImgEl?.naturalHeight || imgRect.height, 488 | targetWidth: loadedImgEl?.naturalWidth || imgRect.width, 489 | }) 490 | : undefined 491 | 492 | const styleDivImg = isDivImg 493 | ? getDivImgStyle({ 494 | backgroundPosition: targetElComputedStyle.backgroundPosition, 495 | backgroundSize: targetElComputedStyle.backgroundSize, 496 | containerHeight: imgRect.height, 497 | containerLeft: imgRect.left, 498 | containerTop: imgRect.top, 499 | containerWidth: imgRect.width, 500 | hasScalableSrc, 501 | offset, 502 | targetHeight: loadedImgEl?.naturalHeight || imgRect.height, 503 | targetWidth: loadedImgEl?.naturalWidth || imgRect.width, 504 | }) 505 | : undefined 506 | 507 | const style = Object.assign( 508 | {}, 509 | styleImgRegular, 510 | styleImgObjectFit, 511 | styleDivImg 512 | ) 513 | 514 | if (isZoomed) { 515 | const viewportX = window.innerWidth / 2 516 | const viewportY = window.innerHeight / 2 517 | 518 | const childCenterX = parseFloat(String(style.left || 0)) + (parseFloat(String(style.width || 0)) / 2) 519 | const childCenterY = parseFloat(String(style.top || 0)) + (parseFloat(String(style.height || 0)) / 2) 520 | 521 | const translateX = viewportX - childCenterX 522 | const translateY = viewportY - childCenterY 523 | 524 | // For scenarios like resizing the browser window 525 | if (shouldRefresh) { 526 | style.transitionDuration = '0.01ms' 527 | } 528 | 529 | style.transform = `translate(${translateX}px,${translateY}px) scale(1)` 530 | } 531 | 532 | return style 533 | } 534 | 535 | // ============================================================================= 536 | 537 | export interface GetStyleGhost { 538 | (imgEl: SupportedImage | null): React.CSSProperties 539 | } 540 | 541 | export const getStyleGhost: GetStyleGhost = (imgEl) => { 542 | if (!imgEl) { 543 | return {} 544 | } 545 | 546 | if (testSvg(imgEl)) { 547 | const parentEl = imgEl.parentElement 548 | const rect = imgEl.getBoundingClientRect() 549 | 550 | if (parentEl) { 551 | const parentRect = parentEl.getBoundingClientRect() 552 | 553 | return { 554 | height: rect.height, 555 | left: parentRect.left - rect.left, 556 | top: parentRect.top - rect.top, 557 | width: rect.width, 558 | } 559 | } else { 560 | return { 561 | height: rect.height, 562 | left: rect.left, 563 | width: rect.width, 564 | top: rect.top, 565 | } 566 | } 567 | } else { 568 | return { 569 | height: imgEl.offsetHeight, 570 | left: imgEl.offsetLeft, 571 | width: imgEl.offsetWidth, 572 | top: imgEl.offsetTop, 573 | } 574 | } 575 | } 576 | 577 | // ============================================================================= 578 | 579 | /** 580 | * Deal with cloned SVG ID duplicate issues from https://github.com/rpearce/react-medium-image-zoom/issues/438 581 | */ 582 | export const adjustSvgIDs = (svgEl: SVGElement): void => { 583 | const newIdSuffix = '-zoom' 584 | 585 | // SVG attributes that can use `url(#foo)` 586 | const attrs = [ 587 | 'clip-path', 588 | 'fill', 589 | 'mask', 590 | 'marker-start', 591 | 'marker-mid', 592 | 'marker-end', 593 | ] 594 | 595 | // Map between old IDs and new IDs 596 | const idMap = new Map() 597 | 598 | // Update SVG element's ID, if present 599 | if (svgEl.hasAttribute('id')) { 600 | const oldId = svgEl.id 601 | const newId = oldId + newIdSuffix 602 | idMap.set(oldId, newId) 603 | svgEl.id = newId 604 | } 605 | 606 | // Update all old IDs to new IDs and store values mapping for later 607 | svgEl.querySelectorAll('[id]').forEach(el => { 608 | const oldId = el.id 609 | const newId = oldId + newIdSuffix 610 | idMap.set(oldId, newId) 611 | el.id = newId 612 | }) 613 | 614 | idMap.forEach((newId, oldId) => { 615 | const urlOldID = `url(#${oldId})` 616 | const urlNewID = `url(#${newId})` 617 | const attrsQuery = attrs.map(attr => `[${attr}="${urlOldID}"]`).join(', ') 618 | 619 | // Look for all SVG attributes that can use `url(#foo)` in a single query, 620 | // find the attribute(s) affected, and update them 621 | svgEl.querySelectorAll(attrsQuery).forEach(usedEl => { 622 | attrs.forEach(attr => { 623 | if (usedEl.getAttribute(attr) === urlOldID) { 624 | usedEl.setAttribute(attr, urlNewID) 625 | } 626 | }) 627 | }) 628 | }) 629 | 630 | // Update any SVG `