diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 61879143..e9d480e0 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -16,6 +16,7 @@ import { updateCardContent as updateCardContentExternal, } from './pivot/sprites'; import { syncSpritesToViewport } from './pivot/visibility'; +import { syncScrollSprites } from './pivot/syncScrollSprites'; import { updateGroupBackgrounds as updateGroupBackgroundsExternal, updateHighlight as updateHighlightExternal, @@ -152,6 +153,8 @@ export function PivotCanvas({ const lastViewChangeTimeRef = useRef(0); const previousViewModeRef = useRef(viewMode); const prevLayoutRef = useRef(null); + const transitionLayoutRef = useRef(null); + const transitionSeenIdsRef = useRef>(new Set()); const prevGroupingRef = useRef(null); const prevScrollTopRef = useRef(0); const prevScrollLeftRef = useRef(0); @@ -500,6 +503,8 @@ export function PivotCanvas({ const layoutChanged = prevLayoutRef.current !== layout; if (viewModeChanged || groupingChanged || layoutChanged) { + transitionSeenIdsRef.current.clear(); + if (layoutChanged) transitionLayoutRef.current = prevLayoutRef.current; isViewTransitionRef.current = true; lastViewChangeTimeRef.current = Date.now(); previousViewModeRef.current = viewMode; @@ -540,7 +545,7 @@ export function PivotCanvas({ const currentScrollTop = parentContainerRef.current?.scrollTop || 0; const currentScrollLeft = parentContainerRef.current?.scrollLeft || 0; - syncSpritesToViewport({ + const syncParams = { root: rootRef.current, groupsContainer: groupsContainerRef.current, container: parentContainerRef.current, @@ -585,9 +590,11 @@ export function PivotCanvas({ ), isViewTransition: isViewTransitionRef.current, prevLayout: prevLayoutRef.current, + transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, - }); + }; + syncSpritesToViewport(syncParams); // Update previous scroll position for next frame prevScrollTopRef.current = currentScrollTop; @@ -608,6 +615,8 @@ export function PivotCanvas({ needsRenderRef, spritesRef, isViewTransitionRef, + syncVisibility: () => syncSpritesToViewport({ ...syncParams, isViewTransition: false, sweepImmediately: true }), + onTransitionComplete: () => transitionSeenIdsRef.current.clear(), }); }, [ layout, @@ -713,7 +722,7 @@ export function PivotCanvas({ // because it encapsulates the logic for conditional vertical alignment (offsetY) // in different view modes. Manually setting position here would overwrite that logic. - syncSpritesToViewport({ + syncScrollSprites({ root: rootRef.current, groupsContainer: groupsContainerRef.current, container: parentContainerRef.current, @@ -754,9 +763,10 @@ export function PivotCanvas({ ), isViewTransition: isViewTransitionRef.current, viewMode, + transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, - }); + }, transitionLayoutRef.current); // Update previous scroll position for next frame prevScrollTopRef.current = container.scrollTop || 0; diff --git a/Source/PivotViewer/components/pivot/animation.ts b/Source/PivotViewer/components/pivot/animation.ts index 04326b6a..0c0c1359 100644 --- a/Source/PivotViewer/components/pivot/animation.ts +++ b/Source/PivotViewer/components/pivot/animation.ts @@ -79,15 +79,22 @@ export function startAnimationLoop( needsRenderRef: { current: boolean }; spritesRef: { current: Map }; isViewTransitionRef: { current: boolean }; + onTransitionComplete?: () => void; + syncVisibility?: () => void; }, animationSpeed = 0.15, ) { - const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef } = refs; + const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete, syncVisibility } = refs; const animate = () => { if (!mountedRef.current) return; + const wasTransitioning = isViewTransitionRef.current; const stillAnimating = updatePositions(spritesRef.current, isViewTransitionRef, animationSpeed); + if (wasTransitioning && !isViewTransitionRef.current) { + syncVisibility?.(); + onTransitionComplete?.(); + } if (stillAnimating || needsRenderRef.current) { appRef.current?.renderer.render(appRef.current.stage); diff --git a/Source/PivotViewer/components/pivot/sprites.ts b/Source/PivotViewer/components/pivot/sprites.ts index 97071302..6af2ffd2 100644 --- a/Source/PivotViewer/components/pivot/sprites.ts +++ b/Source/PivotViewer/components/pivot/sprites.ts @@ -45,6 +45,9 @@ export function createCardSprite( sprite.currentX = x; sprite.currentY = y; + // A recycled sprite must start a new hidden grace period if it leaves the viewport. + delete (sprite as CardSprite & { __lastHiddenAt?: number }).__lastHiddenAt; + // Reset animation state delete sprite.animationStartTime; delete sprite.animationDelay; diff --git a/Source/PivotViewer/components/pivot/syncScrollSprites.ts b/Source/PivotViewer/components/pivot/syncScrollSprites.ts new file mode 100644 index 00000000..74c34f4d --- /dev/null +++ b/Source/PivotViewer/components/pivot/syncScrollSprites.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { LayoutResult } from '../../engine/types'; +import { syncSpritesToViewport, type SyncParams } from './visibility'; + +/** Keep the scroll path's transition layout in sync with the normal render path. */ +export function syncScrollSprites(params: Omit, 'prevLayout'>, previousLayout: LayoutResult | null) { + syncSpritesToViewport({ ...params, prevLayout: previousLayout }); +} diff --git a/Source/PivotViewer/components/pivot/visibility.ts b/Source/PivotViewer/components/pivot/visibility.ts index bdfdf3b9..47921e93 100644 --- a/Source/PivotViewer/components/pivot/visibility.ts +++ b/Source/PivotViewer/components/pivot/visibility.ts @@ -29,12 +29,14 @@ export interface SyncParams { isViewTransition?: boolean; viewMode: string; prevLayout?: LayoutResult | null; + transitionSeenIds: Set; + sweepImmediately?: boolean; prevScrollTop?: number; prevScrollLeft?: number; } export function syncSpritesToViewport(params: SyncParams) { - const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout, prevScrollTop, prevScrollLeft } = params; + const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout, transitionSeenIds } = params; if (!root || !container) return; void _visibleIds; @@ -136,43 +138,31 @@ export function syncSpritesToViewport(params: SyncParams) { const inViewportIds: (string | number)[] = []; // Small tolerance in world units to avoid floating-point edge cases when // browser/device zoom or high scroll values produce tiny rounding errors. - // Scale epsilon with invScale so tolerance grows when zoomed out. const worldEpsilon = Math.max(0.5, 0.5 * invScale); - - // Iterate layout positions directly to avoid depending on `visibleIds` - // which may be calculated in a different coordinate space or with - // different assumptions about zoom. Looping the positions map is - // deterministic and uses world coordinates directly. - for (const [id, position] of layout.positions) { - if (!position) continue; - const worldX = position.x; - const worldY = position.y; - const worldCardW = cardWidth; - const worldCardH = cardHeight; - - if ( - worldX + worldCardW >= viewportLeftWorld - worldEpsilon && - worldX <= viewportRightWorld + worldEpsilon && - worldY + worldCardH >= viewportTopWorld - worldEpsilon && - worldY <= viewportBottomWorld + worldEpsilon - ) { + const intersectsViewport = (x: number, y: number) => + x + cardWidth >= viewportLeftWorld - worldEpsilon && + x <= viewportRightWorld + worldEpsilon && + y + cardHeight >= viewportTopWorld - worldEpsilon && + y <= viewportBottomWorld + worldEpsilon; + const addVisibleId = (id: string | number) => { + if (!visibleSet.has(id)) { inViewportIds.push(id); visibleSet.add(id); } - } + }; - // During view transitions, if no cards are visible and we're not animating yet, - // force-add the first few cards from the layout to ensure content appears. - // This prevents a blank screen when switching modes, especially in packaged builds - // where scroll stabilization might be delayed. - if (isViewTransition && inViewportIds.length === 0 && layout.positions.size > 0) { - let count = 0; - for (const [id, position] of layout.positions) { - if (count < 5 && position) { // Add up to 5 cards - inViewportIds.push(id); - visibleSet.add(id); - count++; - } + // Target positions are authoritative, but during a transition cards may + // still be at their old positions while the camera catches up. + for (const [id, position] of layout.positions) { + if (position && intersectsViewport(position.x, position.y)) addVisibleId(id); + } + if (isViewTransition) { + for (const [id, position] of prevLayout?.positions ?? []) { + if (!sprites.has(id) && !transitionSeenIds.has(id) && layout.positions.has(id) && position && intersectsViewport(position.x, position.y)) addVisibleId(id); + } + for (const [id, sprite] of sprites) { + transitionSeenIds.add(id); + if (layout.positions.has(id) && intersectsViewport(sprite.currentX, sprite.currentY)) addVisibleId(id); } } @@ -200,66 +190,11 @@ export function syncSpritesToViewport(params: SyncParams) { void e; } - // Fallback: if no sprites are calculated as visible (e.g., due to rounding - // or scroll/zoom race conditions), force a handful of cards into view so - // the canvas never renders empty at certain zoom levels. - // When transitioning views, be more aggressive to ensure content appears during the transition - let injectedFallback = false; - const fallbackCount = isViewTransition ? 30 : 12; - if (inViewportIds.length === 0 && layout.positions.size > 0) { - injectedFallback = true; - let count = 0; - for (const [id] of layout.positions) { - inViewportIds.push(id); - visibleSet.add(id); - count++; - if (count >= fallbackCount) break; - } - } - - // If we detect a very large discrepancy between created sprites and the - // computed in-viewport count, that's a signal our culling math may be - // unstable (especially at non-100% zoom). In that case, skip hiding this - // frame as a conservative safeguard to avoid mass disappearing tiles. - // However, disable this safeguard during view transitions to ensure old sprites are cleaned up. - // EXCEPT: During view transitions, if scroll position hasn't stabilized yet (e.g., switching to grouped - // mode triggers a scroll-to-bottom), keep all sprites visible to prevent flickering. - // Check scroll stabilization by comparing current scroll to previous scroll position. - const currentScrollTop = container.scrollTop || 0; - const currentScrollLeft = container.scrollLeft || 0; - const scrollTopDelta = Math.abs(currentScrollTop - (prevScrollTop || currentScrollTop)); - const scrollLeftDelta = Math.abs(currentScrollLeft - (prevScrollLeft || currentScrollLeft)); - const scrollStabilized = scrollTopDelta < 10 && scrollLeftDelta < 10; - const aggressiveCull = !injectedFallback && ( - (!isViewTransition && sprites.size > Math.max(120, Math.ceil(inViewportIds.length * 1.5))) || - (isViewTransition && !scrollStabilized) - ); + // Outside transitions, avoid mass-hiding sprites on a potentially stale viewport calculation. + const aggressiveCull = !isViewTransition && sprites.size > Math.max(120, Math.ceil(inViewportIds.length * 1.5)); for (const [id, sprite] of sprites) { if (!visibleSet.has(id)) { - // If view transition is active, check if this sprite has a valid target in the new layout - // If so, keep it visible and animate it to the new position (even if off-screen) - if (isViewTransition && layout.positions.has(id)) { - const newPos = layout.positions.get(id); - if (newPos) { - sprite.targetX = newPos.x; - sprite.targetY = newPos.y; - - // Trigger animation if not already animating - if (sprite.animationStartTime === undefined) { - sprite.startX = sprite.currentX; - sprite.startY = sprite.currentY; - sprite.animationStartTime = Date.now(); - sprite.animationDelay = Math.random() * 300; - } - - try { if (sprite.container) sprite.container.visible = true; } catch (e) { void e; } - // Don't mark as hidden, so it won't be swept - if ((sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt) delete (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt; - continue; - } - } - if (aggressiveCull) { // Keep sprite visible this frame to avoid visual holes try { if (sprite.container) sprite.container.visible = true; } catch (e) { void e; } @@ -270,7 +205,7 @@ export function syncSpritesToViewport(params: SyncParams) { if (sprite.container) { sprite.container.visible = false; } - (sprite as unknown as { __lastHiddenAt: number }).__lastHiddenAt = Date.now(); + (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt ??= Date.now(); } catch (e) { void e; } @@ -290,7 +225,7 @@ export function syncSpritesToViewport(params: SyncParams) { const now = Date.now(); for (const [id, sprite] of sprites) { const lastHidden = (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt; - if (lastHidden && now - lastHidden > SWEEP_MS) { + if (lastHidden && (params.sweepImmediately || now - lastHidden > SWEEP_MS)) { try { // remove from parent if present if (sprite.container && sprite.container.parent) sprite.container.parent.removeChild(sprite.container); @@ -335,7 +270,7 @@ export function syncSpritesToViewport(params: SyncParams) { // If view transition, try to find old position to fly in from if (isViewTransition && prevLayout && prevLayout.positions.has(id)) { const oldPos = prevLayout.positions.get(id); - if (oldPos) { + if (oldPos && intersectsViewport(oldPos.x, oldPos.y)) { startX = oldPos.x; startY = oldPos.y; @@ -355,6 +290,7 @@ export function syncSpritesToViewport(params: SyncParams) { sprite = createCardSprite(id, startX, startY); sprites.set(id, sprite); + if (isViewTransition) transitionSeenIds.add(id); if (sprite.container) { root.addChild(sprite.container); sprite.currentX = startX; diff --git a/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts new file mode 100644 index 00000000..bfd382dd --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as PIXI from 'pixi.js'; +import type { LayoutResult } from '../../engine/types'; +import type { CardSprite } from '../../components/pivot/constants'; +import type { SyncParams } from '../../components/pivot/visibility'; + +export class a_transition { + root = new PIXI.Container(); + container = { clientWidth: 200, clientHeight: 200, scrollLeft: 0, scrollTop: 0 } as HTMLDivElement; + sprites = new Map(); + transitionSeenIds = new Set(); + layout: LayoutResult = { + positions: new Map(Array.from({ length: 5 }, (_, index) => [index, { x: 0, y: 15000 + index * 30, groupIndex: 0 }] as const)), + totalWidth: 200, + totalHeight: 50000, + }; + previousLayout: LayoutResult = { positions: new Map(), totalWidth: 200, totalHeight: 50000 }; + createdIds: (number | string)[] = []; + + get params(): SyncParams<{ name: string }> { + return { + root: this.root, + container: this.container, + sprites: this.sprites, + layout: this.layout, + prevLayout: this.previousLayout, + transitionSeenIds: this.transitionSeenIds, + visibleIds: new Uint32Array(), + items: [], + cardWidth: 20, + cardHeight: 20, + panX: 0, + panY: 0, + viewportWidth: 200, + viewportHeight: 200, + zoomLevel: 1, + viewMode: 'collection', + isViewTransition: true, + createCardSprite: (id, x, y) => { + this.createdIds.push(id); + return { + itemId: id, + container: new PIXI.Container(), + currentX: x, + currentY: y, + targetX: x, + targetY: y, + } as CardSprite; + }, + updateCardContent: () => {}, + }; + } +} diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts new file mode 100644 index 00000000..803e6a89 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { createCardSprite, destroySprite, clearSpritePool } from '../components/pivot/sprites'; +import { DEFAULT_COLORS } from '../components/pivot/constants'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { a_transition } from './given/a_transition'; + +describe('when a pooled sprite is hidden again', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + const oldSprite = context.params.createCardSprite(6, 10, 16000); + (oldSprite as unknown as { __lastHiddenAt: number }).__lastHiddenAt = 1000; + destroySprite(oldSprite); + clock.tick(500); + const reusedSprite = createCardSprite( + 7, 10, 16000, [{ name: 'Sample User' }], () => {}, () => {}, + 20, 20, DEFAULT_COLORS, item => ({ title: item.name }), item => item.name, + ); + context.layout.positions.set(7, { x: 10, y: 16000, groupIndex: 0 }); + context.sprites.set(7, reusedSprite); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should retain the freshly hidden sprite for the sweep grace period', () => { + context.sprites.has(7).should.be.true; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts new file mode 100644 index 00000000..f87b0a72 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when a swept transition card is still at its previous position', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + const sprite = context.sprites.get(6)!; + sprite.currentY = 16000; + syncSpritesToViewport(context.params); + clock.tick(200); + syncSpritesToViewport(context.params); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should not recreate the card from its previous position', () => { + context.createdIds.should.deep.equal([6]); + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts new file mode 100644 index 00000000..81dd7a11 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when a transition card leaves the viewport', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + const sprite = context.params.createCardSprite(6, 10, 16000); + context.sprites.set(6, sprite); + syncSpritesToViewport(context.params); + clock.tick(200); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should release the offscreen card instead of keeping it for the entire transition', () => { + context.sprites.has(6).should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts new file mode 100644 index 00000000..099b63c8 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { startAnimationLoop } from '../components/pivot/animation'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when animation finishes without a scroll', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + const sprite = context.sprites.get(6)!; + sprite.animationStartTime = 1000; + sprite.animationDelay = 0; + clock.tick(1000); + const isViewTransitionRef = { current: true }; + startAnimationLoop({ + mountedRef: { current: true }, + appRef: { current: null }, + animationFrameRef: { current: 0 }, + isAnimatingRef: { current: false }, + needsRenderRef: { current: false }, + spritesRef: { current: context.sprites }, + isViewTransitionRef, + onTransitionComplete: () => context.transitionSeenIds.clear(), + syncVisibility: () => syncSpritesToViewport({ ...context.params, isViewTransition: isViewTransitionRef.current, sweepImmediately: true }), + }); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should release the card that finished offscreen', () => { + context.sprites.has(6).should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts new file mode 100644 index 00000000..4f9a6e6b --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when scrolling during a transition', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.layout.positions.set(7, { x: 10, y: 17000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + context.previousLayout.positions.set(7, { x: 10, y: 1100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + context.container.scrollTop = 1000; + syncSpritesToViewport({ ...context.params, prevScrollTop: 0 }); + }); + + it('should render the card now in the viewport', () => { + context.createdIds.should.contain(7); + }); + +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts new file mode 100644 index 00000000..515644a1 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when scrolling during a transition and a card leaves the viewport', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + context.container.scrollTop = 1000; + syncSpritesToViewport({ ...context.params, prevScrollTop: 0 }); + }); + + it('should stop displaying the card left behind by the scroll', () => { + context.sprites.get(6)!.container.visible.should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts new file mode 100644 index 00000000..b8818509 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncScrollSprites } from '../../components/pivot/syncScrollSprites'; +import { a_transition } from '../given/a_transition'; + +describe('when scrolling during a transition with an offscreen target and a previous on-screen position', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + const { prevLayout: _previousLayout, ...scrollParams } = context.params; + syncScrollSprites(scrollParams, context.previousLayout); + }); + + it('should create the card at its previous on-screen position', () => { + context.sprites.get(6)!.currentY.should.equal(100); + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts new file mode 100644 index 00000000..2dbe15a3 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when transitioning with offscreen target positions and initial layout entries are offscreen', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + }); + + it('should not create the first offscreen cards', () => { + context.createdIds.should.deep.equal([6]); + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts new file mode 100644 index 00000000..08eb84f4 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when transitioning with offscreen target positions', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + }); + + it('should create the card at its previous on-screen position', () => { + context.createdIds.should.contain(6); + context.sprites.get(6)!.currentY.should.equal(100); + }); + +});