@michaelyagi/shoji 0.1.0-beta.15 → 0.1.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -519,7 +519,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
519
519
  const child = target.firstElementChild;
520
520
  const isPlaceholder = child instanceof HTMLElement && (child.classList.contains("shoji-slide-spinner") || child.classList.contains("shoji-slide-open-placeholder"));
521
521
  if (child instanceof HTMLElement && !isPlaceholder) {
522
- const rect = child.getBoundingClientRect();
522
+ const measureTarget = child.classList.contains("shoji-slide-provider-video") ? child.querySelector(".shoji-video-mount, iframe") ?? child : child;
523
+ const rect = measureTarget.getBoundingClientRect();
523
524
  if (rect.width > 0 && rect.height > 0) return rect;
524
525
  }
525
526
  const containerRect = target.getBoundingClientRect();
@@ -528,11 +529,23 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
528
529
  }
529
530
  return containerRect;
530
531
  }
531
- function transformBetween(from, to) {
532
+ function transformBetween(pivot, from, to) {
532
533
  const scale = Math.min(to.width / from.width, to.height / from.height);
533
- const translateX = to.left + to.width / 2 - (from.left + from.width / 2);
534
- const translateY = to.top + to.height / 2 - (from.top + from.height / 2);
535
- return `translate3d(${translateX}px, ${translateY}px, 0) scale3d(${scale}, ${scale}, 1)`;
534
+ return transformString(pivot, from, to, scale, scale);
535
+ }
536
+ function transformBetweenStretch(pivot, from, to) {
537
+ return transformString(pivot, from, to, to.width / from.width, to.height / from.height);
538
+ }
539
+ function transformString(pivot, from, to, scaleX, scaleY) {
540
+ const pivotX = pivot.left + pivot.width / 2;
541
+ const pivotY = pivot.top + pivot.height / 2;
542
+ const fromX = from.left + from.width / 2;
543
+ const fromY = from.top + from.height / 2;
544
+ const toX = to.left + to.width / 2;
545
+ const toY = to.top + to.height / 2;
546
+ const translateX = toX - pivotX - scaleX * (fromX - pivotX);
547
+ const translateY = toY - pivotY - scaleY * (fromY - pivotY);
548
+ return `translate3d(${translateX}px, ${translateY}px, 0) scale3d(${scaleX}, ${scaleY}, 1)`;
536
549
  }
537
550
  function effectiveOriginBox(origin) {
538
551
  const img = origin.querySelector("img");
@@ -542,14 +555,34 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
542
555
  }
543
556
  return origin.getBoundingClientRect();
544
557
  }
545
- function computeTransform(origin, target, aspectRatio, naturalSize) {
558
+ const ASPECT_MISMATCH_THRESHOLD = 2;
559
+ function hasRealTargetContent(target) {
560
+ const child = target.firstElementChild;
561
+ const isPlaceholder = child instanceof HTMLElement && (child.classList.contains("shoji-slide-spinner") || child.classList.contains("shoji-slide-open-placeholder"));
562
+ if (!(child instanceof HTMLElement) || isPlaceholder) return false;
563
+ const rect = child.getBoundingClientRect();
564
+ return rect.width > 0 && rect.height > 0;
565
+ }
566
+ function computeTransformOutcome(origin, target, direction, aspectRatio, naturalSize) {
546
567
  const originRect = effectiveOriginBox(origin);
547
568
  const targetRect = effectiveTargetBox(target, aspectRatio, naturalSize);
569
+ const pivotRect = target.getBoundingClientRect();
548
570
  if (targetRect.width === 0 || targetRect.height === 0 || originRect.width === 0 || originRect.height === 0) {
549
- return null;
571
+ return { kind: "none" };
572
+ }
573
+ const unknownTargetSize = !naturalSize && !hasRealTargetContent(target);
574
+ if (unknownTargetSize) {
575
+ return { kind: "fade", transform: transformBetween(pivotRect, targetRect, originRect) };
550
576
  }
551
- return transformBetween(targetRect, originRect);
577
+ const originRatio = originRect.width / originRect.height;
578
+ const targetRatio = targetRect.width / targetRect.height;
579
+ const ratioOfRatios = Math.max(originRatio, targetRatio) / Math.min(originRatio, targetRatio);
580
+ const isMismatch = direction === "out" && ratioOfRatios > ASPECT_MISMATCH_THRESHOLD;
581
+ const transform = isMismatch ? transformBetweenStretch(pivotRect, targetRect, originRect) : transformBetween(pivotRect, targetRect, originRect);
582
+ return isMismatch ? { kind: "fade", transform } : { kind: "zoom", transform };
552
583
  }
584
+ const FADE_IN_TRANSITION = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-fade-duration, 120ms) var(--shoji-easing)";
585
+ const FADE_OUT_TRANSITION = "transform var(--shoji-duration) var(--shoji-easing), opacity var(--shoji-fade-duration, 120ms) var(--shoji-easing) calc(var(--shoji-duration) - var(--shoji-fade-duration, 120ms))";
553
586
  function waitForTransitionEnd(target, cb, property = "transform") {
554
587
  const durationMs = parseCssTime$1(getComputedStyle(target).transitionDuration);
555
588
  let done = false;
@@ -574,15 +607,19 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
574
607
  }
575
608
  function zoomIn({ origin, target, aspectRatio, naturalSize }) {
576
609
  if (prefersReducedMotion$1()) return;
577
- const transform = computeTransform(origin, target, aspectRatio, naturalSize);
578
- if (!transform) return;
610
+ const outcome = computeTransformOutcome(origin, target, "in", aspectRatio, naturalSize);
611
+ if (outcome.kind === "none") return;
612
+ const { transform } = outcome;
613
+ const isFade = outcome.kind === "fade";
579
614
  target.style.transition = "none";
580
615
  target.style.transformOrigin = "center";
581
- target.style.willChange = "transform";
616
+ target.style.willChange = isFade ? "transform, opacity" : "transform";
582
617
  target.style.transform = transform;
618
+ if (isFade) target.style.opacity = "0";
583
619
  void target.offsetHeight;
584
- target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
620
+ target.style.transition = isFade ? FADE_IN_TRANSITION : "transform var(--shoji-duration) var(--shoji-easing)";
585
621
  target.style.transform = "none";
622
+ if (isFade) target.style.opacity = "1";
586
623
  waitForTransitionEnd(target, () => clearInlineTransform$1(target, "none"));
587
624
  }
588
625
  function zoomOut({ origin, target, aspectRatio, naturalSize, dragStart, zoomStart }, onComplete) {
@@ -590,13 +627,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
590
627
  onComplete();
591
628
  return;
592
629
  }
593
- const transform = computeTransform(origin, target, aspectRatio, naturalSize);
594
- if (!transform) {
630
+ const outcome = computeTransformOutcome(origin, target, "out", aspectRatio, naturalSize);
631
+ if (outcome.kind === "none") {
595
632
  onComplete();
596
633
  return;
597
634
  }
635
+ const { transform } = outcome;
636
+ const isFade = outcome.kind === "fade";
598
637
  target.style.transformOrigin = "center";
599
- target.style.willChange = "transform";
638
+ target.style.willChange = isFade ? "transform, opacity" : "transform";
600
639
  if (dragStart) {
601
640
  target.style.transition = "none";
602
641
  target.style.transform = `translate3d(0px, ${dragStart.translateY}px, 0px) scale3d(${dragStart.scale}, ${dragStart.scale}, 1)`;
@@ -606,15 +645,16 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
606
645
  const targetRect = effectiveTargetBox(target, aspectRatio, naturalSize);
607
646
  if (targetRect.width > 0 && targetRect.height > 0) {
608
647
  const existing = target.style.transform;
609
- const jump = transformBetween(targetRect, zoomStart);
648
+ const jump = transformBetween(target.getBoundingClientRect(), targetRect, zoomStart);
610
649
  target.style.transition = "none";
611
650
  target.style.transform = existing && existing !== "none" ? `${existing} ${jump}` : jump;
612
651
  void target.offsetHeight;
613
652
  }
614
653
  }
615
- target.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
654
+ target.style.transition = isFade ? FADE_OUT_TRANSITION : "transform var(--shoji-duration) var(--shoji-easing)";
616
655
  void target.offsetHeight;
617
656
  target.style.transform = transform;
657
+ if (isFade) target.style.opacity = "0";
618
658
  const appliedTransform = target.style.transform;
619
659
  waitForTransitionEnd(target, () => {
620
660
  clearInlineTransform$1(target, appliedTransform);
@@ -831,7 +871,13 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
831
871
  const n = Number(raw);
832
872
  return Number.isFinite(n) ? n : void 0;
833
873
  }
834
- const NO_OWN_DATA_FIELD = /* @__PURE__ */ new Set(["no-drag", "video-id", "video-provider"]);
874
+ const NO_OWN_DATA_FIELD = /* @__PURE__ */ new Set([
875
+ "no-drag",
876
+ "video-id",
877
+ "video-provider",
878
+ "thumbnail-width",
879
+ "thumbnail-height"
880
+ ]);
835
881
  function applyCommon(element, item, src) {
836
882
  const caption = attr(element, "data-shoji-caption");
837
883
  if (caption) item.caption = caption;
@@ -857,6 +903,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
857
903
  if (width !== void 0) item.width = width;
858
904
  const height = numAttr(element, "data-shoji-height");
859
905
  if (height !== void 0) item.height = height;
906
+ const thumbnailWidth = numAttr(element, "data-shoji-thumbnail-width");
907
+ if (thumbnailWidth !== void 0) item.thumbnailWidth = thumbnailWidth;
908
+ const thumbnailHeight = numAttr(element, "data-shoji-thumbnail-height");
909
+ if (thumbnailHeight !== void 0) item.thumbnailHeight = thumbnailHeight;
860
910
  const srcset = attr(element, "data-shoji-srcset");
861
911
  if (srcset) item.srcset = srcset;
862
912
  const sizes = attr(element, "data-shoji-sizes");
@@ -1112,8 +1162,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1112
1162
  slot.root.style.transform = `translateX(calc(${slot.offset * 100}% + ${this.dragOffsetPx}px))`;
1113
1163
  }
1114
1164
  }
1115
- /** Re-renders whichever slots need a different item; `onLoad` fires per index once its media settles. `openPlaceholderSrc` (only from `Gallery.open()`) swaps the centerIndex slot's spinner for a low-res placeholder once it decodes, if not already ready. `loop` matches `Gallery.ts`'s own `nextIndex()`/`prevIndex()` wrap-around — without it, the pool slot just past the last (or before the first) item has no item to preload at all, so a boundary drag reveals nothing even though the completed navigation itself already wraps correctly. */
1116
- render(items, centerIndex, onLoad, openPlaceholderSrc, loop = false) {
1165
+ /** Re-renders whichever slots need a different item; `onLoad` fires per index once its media settles. `openPlaceholderSrc` (only from `Gallery.open()`) swaps the centerIndex slot's spinner for a low-res placeholder once it decodes, if not already ready; `onOpenPlaceholderReady` fires once that swap happens (`Gallery.ts`'s `beginZoom` doc comment explains why). `openPlaceholderAlreadyVisible` (`Gallery.ts`'s own doc comment on it) skips that decode wait entirely when it's not actually needed. `loop` matches `Gallery.ts`'s own `nextIndex()`/`prevIndex()` wrap-around — without it, the pool slot just past the last (or before the first) item has no item to preload at all, so a boundary drag reveals nothing even though the completed navigation itself already wraps correctly. */
1166
+ render(items, centerIndex, onLoad, openPlaceholderSrc, loop = false, onOpenPlaceholderReady, openPlaceholderAlreadyVisible = false) {
1117
1167
  const canWrap = loop && items.length > this.slots.length;
1118
1168
  const targets = this.slots.map((_, i) => {
1119
1169
  const offset = i - this.preload;
@@ -1163,7 +1213,16 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1163
1213
  this.applyAspect(slot, item);
1164
1214
  if (index === centerIndex && openPlaceholderSrc) {
1165
1215
  const naturalSize = item.width && item.height ? { width: item.width, height: item.height } : void 0;
1166
- this.revealOpenPlaceholder(openPlaceholderSrc, slot, index, naturalSize);
1216
+ const aspectRatio = item.thumbnailWidth && item.thumbnailHeight ? item.thumbnailWidth / item.thumbnailHeight : naturalSize && naturalSize.width / naturalSize.height;
1217
+ this.revealOpenPlaceholder(
1218
+ openPlaceholderSrc,
1219
+ slot,
1220
+ index,
1221
+ naturalSize,
1222
+ aspectRatio,
1223
+ onOpenPlaceholderReady,
1224
+ openPlaceholderAlreadyVisible
1225
+ );
1167
1226
  }
1168
1227
  if (((_a = item.video) == null ? void 0 : _a.provider) === "html5") {
1169
1228
  this.renderVideo(item, slot, index, onLoad);
@@ -1264,25 +1323,41 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1264
1323
  * (`.shoji-slide`) fixes it: same box, only ever `translateX`'d for
1265
1324
  * pool-offset positioning, never scaled — a stable read regardless of
1266
1325
  * what its child is mid-animation through.
1326
+ *
1327
+ * A third real bug: `aspectRatio` (the box's *shape*) must come from the
1328
+ * thumbnail's own aspect (`item.thumbnailWidth`/`thumbnailHeight` when
1329
+ * declared, the caller's own doc comment on this call explains the
1330
+ * priority) — this image's actual pixels are the thumbnail's, and sizing
1331
+ * the box to `naturalSize`'s aspect (the real photo's) instead left most
1332
+ * of it transparent whenever the two differ, `object-fit: contain`
1333
+ * letterboxing the real thumbnail content down to a thin strip inside a
1334
+ * wrongly-shaped box (DESIGN.md §2.3b).
1267
1335
  */
1268
- revealOpenPlaceholder(src, slot, index, naturalSize) {
1336
+ revealOpenPlaceholder(src, slot, index, naturalSize, aspectRatio, onReady, alreadyVisible = false) {
1269
1337
  const img = createOpenPlaceholder(src);
1270
1338
  const reveal = () => {
1271
- if (slot.assignedIndex !== index || slot.ready) return;
1339
+ if (slot.assignedIndex !== index) return;
1340
+ if (slot.ready) {
1341
+ onReady == null ? void 0 : onReady();
1342
+ return;
1343
+ }
1272
1344
  slot.media.querySelectorAll(".shoji-slide-spinner, .shoji-slide-provider-poster").forEach((el) => el.remove());
1273
1345
  slot.media.appendChild(img);
1274
- if (naturalSize) {
1346
+ if (aspectRatio) {
1275
1347
  const containerRect = slot.root.getBoundingClientRect();
1276
1348
  const box = containedBox(
1277
1349
  { left: 0, top: 0, width: containerRect.width, height: containerRect.height },
1278
- naturalSize.width / naturalSize.height,
1350
+ aspectRatio,
1279
1351
  naturalSize
1280
1352
  );
1281
1353
  img.style.width = `${box.width}px`;
1282
1354
  img.style.height = `${box.height}px`;
1283
1355
  }
1356
+ onReady == null ? void 0 : onReady();
1284
1357
  };
1285
- if (typeof img.decode === "function") {
1358
+ if (alreadyVisible) {
1359
+ queueMicrotask(reveal);
1360
+ } else if (typeof img.decode === "function") {
1286
1361
  img.decode().then(reveal, reveal);
1287
1362
  } else {
1288
1363
  img.addEventListener("load", reveal, { once: true });
@@ -2890,7 +2965,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
2890
2965
  this.onActivity();
2891
2966
  this.bus.emit("captionModalChange", { open: false });
2892
2967
  }
2893
- renderCurrentSlide(openPlaceholderSrc) {
2968
+ /** `onCenterContentVisible` — only `open()` passes this, to start the zoom-in once something real is actually visible (its own `beginZoom` doc comment). */
2969
+ renderCurrentSlide(openPlaceholderSrc, onCenterContentVisible, openPlaceholderAlreadyVisible = false) {
2894
2970
  if (!this.slides || !this.dom) return;
2895
2971
  const dom = this.dom;
2896
2972
  this.slides.render(
@@ -2901,10 +2977,13 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
2901
2977
  this.bus.emit("slideItemLoad", { index: loadedIndex });
2902
2978
  this.setSlideLoading(false);
2903
2979
  if (!this.captionFadePending) this.updateCaptionVisibility();
2980
+ onCenterContentVisible == null ? void 0 : onCenterContentVisible();
2904
2981
  }
2905
2982
  },
2906
2983
  openPlaceholderSrc,
2907
- this.loop
2984
+ this.loop,
2985
+ onCenterContentVisible,
2986
+ openPlaceholderAlreadyVisible
2908
2987
  );
2909
2988
  this.setSlideLoading(!this.slides.isActiveReady());
2910
2989
  const item = this.itemList[this.activeIndex];
@@ -2935,27 +3014,39 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
2935
3014
  this.captionFadePending = true;
2936
3015
  this.dom.caption.style.opacity = "0";
2937
3016
  }
3017
+ const openPlaceholderSrc = naturalSize && !((_a = this.itemList[index]) == null ? void 0 : _a.video) ? this.resolveOpenPlaceholderSrc(this.itemList[index], origin) : void 0;
3018
+ const originImg = origin == null ? void 0 : origin.querySelector("img");
3019
+ const openPlaceholderAlreadyVisible = !!openPlaceholderSrc && !!originImg && (originImg.currentSrc || originImg.src) === openPlaceholderSrc;
3020
+ let zoomStarted = false;
3021
+ const beginZoom = () => {
3022
+ var _a2;
3023
+ if (zoomStarted) return;
3024
+ zoomStarted = true;
3025
+ const media = (_a2 = this.slides) == null ? void 0 : _a2.getActiveMedia();
3026
+ if (media && origin) {
3027
+ zoomIn({
3028
+ origin,
3029
+ target: media,
3030
+ aspectRatio: this.resolveAspectRatio(index, origin),
3031
+ naturalSize
3032
+ });
3033
+ }
3034
+ if (fadeInOnOpen && this.dom) {
3035
+ this.captionFadePending = false;
3036
+ this.updateCaptionVisibility();
3037
+ this.dom.caption.style.opacity = "";
3038
+ }
3039
+ };
2938
3040
  this.renderCurrentSlide(
2939
- naturalSize && !((_a = this.itemList[index]) == null ? void 0 : _a.video) ? this.resolveOpenPlaceholderSrc(this.itemList[index], origin) : void 0
3041
+ openPlaceholderSrc,
3042
+ openPlaceholderSrc ? beginZoom : void 0,
3043
+ openPlaceholderAlreadyVisible
2940
3044
  );
2941
3045
  this.dom.outer.classList.add("shoji-open");
2942
3046
  document.addEventListener("keydown", this.onKeydown);
2943
3047
  this.focusTrap.activate(this.dom.dialog);
2944
3048
  this.scheduleAutoHide();
2945
- const media = (_b = this.slides) == null ? void 0 : _b.getActiveMedia();
2946
- if (media && origin && naturalSize) {
2947
- zoomIn({
2948
- origin,
2949
- target: media,
2950
- aspectRatio: this.resolveAspectRatio(index, origin),
2951
- naturalSize
2952
- });
2953
- }
2954
- if (fadeInOnOpen && this.dom) {
2955
- this.captionFadePending = false;
2956
- this.updateCaptionVisibility();
2957
- this.dom.caption.style.opacity = "";
2958
- }
3049
+ if (!openPlaceholderSrc || ((_b = this.slides) == null ? void 0 : _b.isActiveReady())) beginZoom();
2959
3050
  this.bus.emit("open", { index });
2960
3051
  this.bus.emit("afterOpen", { index });
2961
3052
  }
@@ -3127,9 +3218,12 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3127
3218
  (_a = this.dom) == null ? void 0 : _a.dialog.classList.toggle("shoji-controls-hidden-for-drag", hidden);
3128
3219
  this.bus.emit("dragCloseThreshold", { hidden });
3129
3220
  }
3130
- /** `item.width`/`height`, else origin's `naturalWidth`/`naturalHeight` (accurate when `item.thumb` is unset). Feeds `computeTransform`'s letterbox-aware sizing. */
3221
+ /** `item.thumbnailWidth`/`thumbnailHeight` (the thumbnail's own true shape) first, else `item.width`/`height` (the photo's), else origin's live `naturalWidth`/`naturalHeight`. Shape only — see `resolveNaturalSize` for why the size cap below uses the opposite order. DESIGN.md §2.3b (sixteenth/seventeenth entries). */
3131
3222
  resolveAspectRatio(index, origin) {
3132
3223
  const item = this.itemList[index];
3224
+ if ((item == null ? void 0 : item.thumbnailWidth) && item.thumbnailHeight) {
3225
+ return item.thumbnailWidth / item.thumbnailHeight;
3226
+ }
3133
3227
  if ((item == null ? void 0 : item.width) && item.height) return item.width / item.height;
3134
3228
  const thumbImg = origin == null ? void 0 : origin.querySelector("img");
3135
3229
  if ((thumbImg == null ? void 0 : thumbImg.naturalWidth) && thumbImg.naturalHeight) {
@@ -3137,18 +3231,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3137
3231
  }
3138
3232
  return void 0;
3139
3233
  }
3140
- /**
3141
- * `item.width`/`height` only — deliberately never a thumbnail's own
3142
- * `naturalWidth`/`naturalHeight` the way `resolveAspectRatio` above will:
3143
- * that's a fine stand-in for *shape*, but using it as the real photo's
3144
- * true pixel size would under-cap a genuinely large photo down to
3145
- * thumbnail resolution. `undefined` here just means "genuinely unknown,"
3146
- * not "assume small" — `zoomTransition.ts`'s `containedBox` already
3147
- * treats it that way (no cap applied at all).
3148
- */
3234
+ /** `item.width`/`height` (the real photo) first, `item.thumbnailWidth`/`thumbnailHeight` only once that's unknown too — opposite priority from `resolveAspectRatio` on purpose: this only caps the placeholder's *size*, never its shape, so preferring the real (usually larger) photo size is never a distortion risk, and is what lets the placeholder grow to a properly large size instead of the thumbnail's own tiny one. DESIGN.md §2.3b (seventeenth entry) has the real bug this fixed. */
3149
3235
  resolveNaturalSize(index) {
3150
3236
  const item = this.itemList[index];
3151
- return (item == null ? void 0 : item.width) && item.height ? { width: item.width, height: item.height } : void 0;
3237
+ if ((item == null ? void 0 : item.width) && item.height) return { width: item.width, height: item.height };
3238
+ if ((item == null ? void 0 : item.thumbnailWidth) && item.thumbnailHeight) {
3239
+ return { width: item.thumbnailWidth, height: item.thumbnailHeight };
3240
+ }
3241
+ return void 0;
3152
3242
  }
3153
3243
  /**
3154
3244
  * Idempotent on purpose: a pending zoom-out's `transitionend`/fallback