Skip to content

Commit 69dfbcc

Browse files
committed
fix(angular): mirror the origin-flight and zoom fixes
1 parent 42e16bd commit 69dfbcc

6 files changed

Lines changed: 222 additions & 18 deletions

File tree

packages/angular/plugins/zoom/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export class LgZoomWrapperComponent {
254254
private detachWindow: (() => void) | null = null;
255255
private cancelSpring: (() => void) | null = null;
256256
private lastTap = 0;
257+
private lastTouchToggle = 0;
257258
private armTimer: ReturnType<typeof setTimeout> | null = null;
258259

259260
/** Narrow: re-run the arm effect only when THIS slide's flag flips. */
@@ -891,6 +892,12 @@ export class LgZoomWrapperComponent {
891892
const now = Date.now();
892893
if (now - this.lastTap < 300) {
893894
this.lastTap = 0;
895+
// 2.x prevents the second touchstart's default: without
896+
// this the browser synthesizes click + dblclick after
897+
// the double tap, and onDoubleClick toggles straight
898+
// back to fit.
899+
event.preventDefault();
900+
this.lastTouchToggle = now;
894901
this.toggleActualSize(this.eventPoint(event));
895902
return;
896903
}
@@ -919,6 +926,11 @@ export class LgZoomWrapperComponent {
919926
if (!isImageTarget(event.target)) {
920927
return;
921928
}
929+
// Synthesized dblclick trailing a touch double-tap (belt for
930+
// browsers that fire it despite the canceled pointerdown).
931+
if (Date.now() - this.lastTouchToggle < 700) {
932+
return;
933+
}
922934
this.toggleActualSize(this.eventPoint(event));
923935
}
924936
}

packages/angular/src/lib/gallery.component.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,80 @@ describe('LgGalleryComponent (core gallery)', () => {
411411
);
412412
});
413413
});
414+
415+
@Component({
416+
imports: [LgGalleryComponent, LgGalleryItemDirective],
417+
template: `
418+
<lg-gallery>
419+
@for (item of items; track item.src) {
420+
<a href="#" class="trigger" [lgGalleryItem]="item">
421+
<img [src]="item.thumb" [alt]="item.alt" />
422+
</a>
423+
}
424+
</lg-gallery>
425+
`,
426+
})
427+
class DummyFlightHost {
428+
readonly items = ITEMS.map((item) => ({
429+
...item,
430+
lgSize: '1600-1067',
431+
}));
432+
}
433+
434+
describe('zoom-from-origin dummy image', () => {
435+
beforeEach(() => {
436+
vi.useFakeTimers();
437+
});
438+
afterEach(() => {
439+
vi.useRealTimers();
440+
});
441+
442+
it('flies the thumb as lg-dummy-img and drops it after the load settles', async () => {
443+
// jsdom rects are 0×0; a real-looking rect makes computeOrigin
444+
// produce a flight (lgSize is the other precondition).
445+
const rectSpy = vi
446+
.spyOn(Element.prototype, 'getBoundingClientRect')
447+
.mockReturnValue({
448+
left: 10,
449+
top: 10,
450+
width: 100,
451+
height: 80,
452+
right: 110,
453+
bottom: 90,
454+
x: 10,
455+
y: 10,
456+
toJSON: () => ({}),
457+
} as DOMRect);
458+
const fixture = TestBed.createComponent(DummyFlightHost);
459+
await flush(fixture);
460+
queryAll('.trigger')[0]!.click();
461+
await flush(fixture);
462+
await advance(fixture, 20);
463+
464+
// 2.x first-slide contract: ONLY the thumb-dummy exists during
465+
// the flight — the real image must not fetch/decode mid-flight.
466+
const dummy = query('img.lg-dummy-img');
467+
expect(dummy).not.toBeNull();
468+
expect(dummy!.getAttribute('src')).toBe('a-t.jpg');
469+
expect(query('.lg-item.lg-current img.lg-image')).toBeNull();
470+
expect(query('.lg-item.lg-first-slide')).not.toBeNull();
471+
expect(query('.lg-outer.lg-first-slide-loading')).not.toBeNull();
472+
473+
// Flight lands: the real image mounts, the dummy stays on top.
474+
await advance(fixture, SPEED + 120);
475+
const real = query('.lg-item.lg-current img.lg-image');
476+
expect(real).not.toBeNull();
477+
expect(query('img.lg-dummy-img')).not.toBeNull();
478+
479+
// Real image load settles, then the 300ms drop buffer removes
480+
// the dummy and the loading classes.
481+
real!.dispatchEvent(new Event('load'));
482+
await flush(fixture);
483+
await advance(fixture, 310);
484+
expect(query('img.lg-dummy-img')).toBeNull();
485+
expect(query('.lg-item.lg-first-slide')).toBeNull();
486+
expect(query('.lg-outer.lg-first-slide-loading')).toBeNull();
487+
expect(query('.lg-item.lg-current.lg-complete')).not.toBeNull();
488+
rectSpy.mockRestore();
489+
});
490+
});

packages/angular/src/lib/gallery.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ export class LgGalleryComponent implements LgGalleryHandle, OnDestroy {
790790
'lg-hide-items',
791791
this.zoomClosing() && 'lg-closing',
792792
this.timeline().noTrans && 'lg-no-trans',
793+
this.runtime.firstSlideLoading() && 'lg-first-slide-loading',
793794
this.touchSlideMode() &&
794795
this.settings().mode !== 'lg-slide' &&
795796
'lg-slide',
@@ -1678,6 +1679,12 @@ export class LgGalleryComponent implements LgGalleryHandle, OnDestroy {
16781679
containerRect.width,
16791680
containerRect.height - (top + bottom),
16801681
);
1682+
// Degenerate measurement (zero-sized/hidden viewport, offsets
1683+
// taller than the stage): the shared math would emit a mirrored
1684+
// flight — fall back to the startClass fade instead.
1685+
if (imageSize.width <= 0 || imageSize.height <= 0) {
1686+
return null;
1687+
}
16811688
return getOriginTransform({
16821689
triggerRect,
16831690
containerRect,

packages/angular/src/lib/image-slide.component.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,51 @@ import type { LgGalleryItem } from './types';
1717
changeDetection: ChangeDetectionStrategy.OnPush,
1818
template: `
1919
<picture class="lg-img-wrap">
20-
@for (source of item().sources ?? []; track $index) {
21-
<source
22-
[attr.media]="source.media ?? null"
23-
[attr.srcset]="source.srcset"
24-
[attr.sizes]="source.sizes ?? null"
25-
[attr.type]="source.type ?? null"
20+
@if (!deferSrc()) {
21+
@for (source of item().sources ?? []; track $index) {
22+
<source
23+
[attr.media]="source.media ?? null"
24+
[attr.srcset]="source.srcset"
25+
[attr.sizes]="source.sizes ?? null"
26+
[attr.type]="source.type ?? null"
27+
/>
28+
}
29+
<img
30+
class="lg-object lg-image"
31+
[attr.data-index]="index()"
32+
[attr.src]="item().src ?? null"
33+
[attr.srcset]="item().srcset ?? null"
34+
[attr.sizes]="item().sizes ?? null"
35+
[alt]="item().alt ?? ''"
36+
draggable="false"
37+
(load)="mediaLoad.emit()"
38+
(error)="mediaError.emit()"
39+
(dragstart)="$event.preventDefault()"
40+
/>
41+
}
42+
@if (dummySrc(); as dummy) {
43+
<!-- v2 sizes the dummy to the fitted image box with
44+
inline width/height; containing it inside the full
45+
wrap lands the same visible box without measuring. -->
46+
<img
47+
class="lg-dummy-img"
48+
[attr.src]="dummy"
49+
alt=""
50+
aria-hidden="true"
51+
draggable="false"
52+
style="width: 100%; height: 100%; object-fit: contain; transform: translate(-50%, -50%)"
2653
/>
2754
}
28-
<img
29-
class="lg-object lg-image"
30-
[attr.data-index]="index()"
31-
[attr.src]="item().src ?? null"
32-
[attr.srcset]="item().srcset ?? null"
33-
[attr.sizes]="item().sizes ?? null"
34-
[alt]="item().alt ?? ''"
35-
draggable="false"
36-
(load)="mediaLoad.emit()"
37-
(error)="mediaError.emit()"
38-
(dragstart)="$event.preventDefault()"
39-
/>
4055
</picture>
4156
`,
4257
})
4358
export class LgImageSlideComponent {
4459
readonly item = input.required<LgGalleryItem>();
4560
readonly index = input.required<number>();
61+
/** First-slide dummy (2.x): the thumb that flies while `src` loads. */
62+
readonly dummySrc = input<string | null>(null);
63+
/** Hold back the real img while the origin flight runs (2.x). */
64+
readonly deferSrc = input(false);
4665
/** Native image dragging would swallow the swipe gesture. */
4766
readonly mediaLoad = output<void>();
4867
readonly mediaError = output<void>();

packages/angular/src/lib/runtime.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,24 @@ export class LgGalleryRuntime {
137137
private readonly registrationsSignal = signal<LgItemRegistration[]>([]);
138138
readonly registrations = this.registrationsSignal.asReadonly();
139139

140+
/** True while the first-slide dummy is up (`lg-first-slide-loading`). */
141+
readonly firstSlideLoading = signal(false);
142+
143+
/**
144+
* Src for the first-slide dummy image (2.x `getDummyImageContent`):
145+
* the item's `thumb`, else the trigger's rendered img — pixels that
146+
* are already decoded and can fly without waiting on the network.
147+
*/
148+
getDummySrc(index: number): string | null {
149+
const thumb = this.items()[index]?.thumb;
150+
if (thumb) {
151+
return thumb;
152+
}
153+
const element = this.registrationsSignal()[index]?.element;
154+
const img = element?.querySelector('img');
155+
return img?.currentSrc || img?.src || null;
156+
}
157+
140158
registerItem(registration: LgItemRegistration): () => void {
141159
this.registrationsSignal.update((prev) => [...prev, registration]);
142160
return () => {

packages/angular/src/lib/slide.component.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ChangeDetectionStrategy,
44
Component,
55
computed,
6+
DestroyRef,
67
effect,
78
inject,
89
input,
@@ -59,6 +60,7 @@ export interface OriginAnimation {
5960
'[class]': 'hostClasses()',
6061
'[style.transform]': 'originTransform()',
6162
'[style.transition-duration]': 'originDuration()',
63+
'[style.transition-property]': 'originTransitionProperty()',
6264
},
6365
template: `
6466
<ng-template #slideContent>
@@ -75,6 +77,8 @@ export interface OriginAnimation {
7577
<lg-image-slide
7678
[item]="item()!"
7779
[index]="index()"
80+
[dummySrc]="dummySrc()"
81+
[deferSrc]="deferSrc()"
7882
(mediaLoad)="onLoad()"
7983
(mediaError)="onError()"
8084
/>
@@ -198,6 +202,20 @@ export class LgSlideComponent {
198202
!!this.item(),
199203
);
200204

205+
// 2.x first-slide dummy (`getDummyImageContent`): while the
206+
// zoom-from-origin flight runs, the trigger's already-decoded
207+
// thumbnail flies enlarged in place of the still-loading image; the
208+
// real image mounts only once the flight lands and the dummy drops
209+
// shortly after the load settles (`loadContentOnFirstSlideLoad`).
210+
protected readonly dummySrc = signal<string | null>(null);
211+
private dummyDone = false;
212+
private dummyDropTimer: ReturnType<typeof setTimeout> | null = null;
213+
/** v2 mounts the real image only once the flight lands. */
214+
protected readonly deferSrc = computed(() => {
215+
const anim = this.originAnim();
216+
return !!this.dummySrc() && !!anim && !anim.closing;
217+
});
218+
201219
constructor() {
202220
// React counterpart: Slide's sticky `shouldLoad` ref — once content
203221
// mounts it stays for as long as the slide is in the DOM window.
@@ -206,6 +224,48 @@ export class LgSlideComponent {
206224
this.sticky.set(true);
207225
}
208226
});
227+
effect(() => {
228+
const anim = this.originAnim();
229+
if (
230+
this.dummyDone ||
231+
this.dummySrc() ||
232+
!anim ||
233+
anim.closing ||
234+
this.completed() ||
235+
this.slideType() !== 'image'
236+
) {
237+
return;
238+
}
239+
untracked(() => {
240+
const src = this.runtime.getDummySrc(this.index());
241+
if (src) {
242+
this.dummySrc.set(src);
243+
this.runtime.firstSlideLoading.set(true);
244+
} else {
245+
this.dummyDone = true;
246+
}
247+
});
248+
});
249+
effect(() => {
250+
if (!this.dummySrc() || !this.completed()) {
251+
return;
252+
}
253+
untracked(() => {
254+
this.dummyDropTimer = setTimeout(() => {
255+
this.dummyDone = true;
256+
this.dummySrc.set(null);
257+
this.runtime.firstSlideLoading.set(false);
258+
}, 300);
259+
});
260+
});
261+
inject(DestroyRef).onDestroy(() => {
262+
if (this.dummyDropTimer !== null) {
263+
clearTimeout(this.dummyDropTimer);
264+
}
265+
if (this.dummySrc()) {
266+
this.runtime.firstSlideLoading.set(false);
267+
}
268+
});
209269
// React counterpart: Slide's afterAppendSlide mount effect (2.x
210270
// afterAppendSlide fired once when the slide's content mounts).
211271
effect(() => {
@@ -257,6 +317,7 @@ export class LgSlideComponent {
257317
this.inProgress() && 'lg-slide-progress',
258318
this.shouldLoad() && 'lg-loaded',
259319
this.completed() && 'lg-complete lg-complete_',
320+
!!this.dummySrc() && 'lg-first-slide',
260321
this.originClasses(),
261322
),
262323
);
@@ -281,6 +342,16 @@ export class LgSlideComponent {
281342
: null;
282343
});
283344

345+
// The origin transform must LAND, never animate: measuring
346+
// (computeOrigin) forces a recalc that baselines the item at
347+
// identity, and the `:not(.lg-start-end-progress)` inherit rule
348+
// would transition identity → origin — a visible
349+
// fullscreen→thumbnail shrink before the flight.
350+
protected readonly originTransitionProperty = computed(() => {
351+
const anim = this.originAnim();
352+
return anim && anim.stage === 'init' ? 'none' : null;
353+
});
354+
284355
private readonly originClasses = computed(() => {
285356
const anim = this.originAnim();
286357
if (!anim || anim.stage === 'init') {

0 commit comments

Comments
 (0)