diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index d4ae4fe9..85713f5c 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -44,6 +44,7 @@ import { TOOLBAR_ICON_SVG } from './toolbarIcons'; import { activateAsset, confirmDeleteAsset } from '../actions'; import { openReferenceItems } from '../objectMenu'; import { generalSettings, onGeneralSettings } from '../generalSettings'; +import { scrollTween } from '../viewTween'; import type { EditorState } from '../state'; import { openFontPicker } from '../font/FontPicker'; import { openSoundPicker } from '../sound/SoundPicker'; @@ -108,6 +109,9 @@ export class AssetPanel extends Panel { this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand); else this.setFolded(id.slice(HEADING.length) as AssetType, !expand); }; + // Room to scroll the last group up off the panel's bottom edge, a few rows' worth. + this.scroll.tailSpace = 6 * this.list.rowHeight; + scrollTween(this.scroll); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index cf39068d..7a3cb1e9 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -21,6 +21,7 @@ import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons'; import { hierarchyDrop } from './hierarchyDrop'; import { hierarchyRange } from './hierarchyRange'; import { type EditorState, objectSelection } from '../state'; +import { scrollTween } from '../viewTween'; /** * Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always @@ -66,6 +67,7 @@ export class HierarchyPanel extends Panel { if (assetId) state.pingAsset(assetId); }; this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); + scrollTween(this.scroll); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts index 15c480e0..23058227 100644 --- a/src/editor/viewTween.ts +++ b/src/editor/viewTween.ts @@ -1,4 +1,4 @@ -import { requestRender, ui } from '../ui'; +import { requestRender, type ScrollContainer, ui } from '../ui'; import { generalSettings } from './generalSettings'; /** @@ -161,6 +161,22 @@ export interface AxisMap { offset: number; } +/** + * Shows a wheel step of a scrolled list as a transition, as long as a zoom's. Quick steps add up as + * they do there, and dragging the bar or a jump to a row ends the transition where it stands. + */ +export function scrollTween(scroll: ScrollContainer, duration?: () => number, clock?: TweenClock): ViewTween { + const tween = axisTween( + () => ({ scale: 1, offset: scroll.offset }), + ({ offset }) => scroll.scrollTo(offset), + () => {}, + duration, + clock, + ); + scroll.wheelStep = (step) => tween.change(step); + return tween; +} + /** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */ export function axisTween( read: () => AxisMap, diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index 27f1528f..4775bdb0 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -18,13 +18,24 @@ export class ScrollContainer extends Widget { private draggingBar = false; private dragStart = 0; private dragScrollStart = 0; + /** + * Empty room always kept below the content, so its last row can be scrolled up away from the + * bottom edge. Content that fits only without it scrolls by the difference. Opt-in; the content + * is made this much taller, so a list lays its empty area (clicks, context menu) over it as well. + */ + tailSpace = 0; + /** + * Carries out a wheel step, by default at once. A panel can hand it to a transition instead, + * which makes the step and then shows the way to where it led. + */ + wheelStep: (step: () => void) => void = (step) => step(); constructor() { super(); this.addChild(this.viewportNode, this.maskShape, this.scrollbar); this.viewportNode.mask = this.maskShape; this.on('wheel', (e: FederatedWheelEvent) => { - this.scrollTo(this.scrollY + e.deltaY); + this.wheelStep(() => this.scrollTo(this.scrollY + e.deltaY)); e.preventDefault(); }); this.scrollbar.eventMode = 'static'; @@ -86,9 +97,9 @@ export class ScrollContainer extends Widget { relayoutContent(): void { if (!this.content) return; // Wrapped text grows taller when the scrollbar takes its width, so measure again at that width. - let needed = this.content.heightFor(this.w); + let needed = this.content.heightFor(this.w) + this.tailSpace; const width = Math.max(0, this.w - (needed > this.h ? Theme.scrollbarWidth : 0)); - if (width !== this.w) needed = this.content.heightFor(width); + if (width !== this.w) needed = this.content.heightFor(width) + this.tailSpace; const height = Math.max(needed, this.h); // Content shorter than the viewport is pinned to the viewport height either way, so `resize` // would see no change and skip the layout -- leaving rows added since stacked at the origin. diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts index b4652c88..de4032c9 100644 --- a/tests/editor/view-tween.test.ts +++ b/tests/editor/view-tween.test.ts @@ -9,7 +9,15 @@ import { } from '../../src/editor/generalSettings'; import { zoomedAbout } from '../../src/editor/graph/graphView'; import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView'; -import { axisTween, easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween'; +import { + axisTween, + easeOut, + scrollTween, + tweenView, + type ViewMap, + ViewTween, +} from '../../src/editor/viewTween'; +import { ScrollContainer, Spacer, VStack } from '../../src/ui'; function fakeClock() { let time = 0; @@ -241,6 +249,63 @@ describe('a view whose axes zoom apart (#377)', () => { }); }); +describe('a list scrolled by the wheel', () => { + function list(clock: ReturnType) { + const scroll = new ScrollContainer(); + scroll.resize(200, 100); + const rows = new VStack(); + for (let i = 0; i < 20; i++) rows.add(new Spacer(20)); + scroll.setContent(rows); + const tween = scrollTween(scroll, () => 200, clock); + const wheel = (deltaY: number) => scroll.wheelStep(() => scroll.scrollTo(scroll.offset + deltaY)); + return { scroll, tween, wheel }; + } + + it('glides to where the step leads and lands there', () => { + const clock = fakeClock(); + const { scroll, tween, wheel } = list(clock); + wheel(100); + expect(scroll.offset).toBe(0); + clock.advance(80); + expect(scroll.offset).toBeGreaterThan(0); + expect(scroll.offset).toBeLessThan(100); + clock.advance(200); + expect(scroll.offset).toBe(100); + expect(tween.moving).toBe(false); + }); + + it('adds up quick steps and stops at the end of the list', () => { + const clock = fakeClock(); + const { scroll, wheel } = list(clock); + wheel(100); + clock.advance(50); + wheel(100); + clock.advance(400); + expect(scroll.offset).toBe(200); + wheel(500); + clock.advance(400); + expect(scroll.offset).toBe(scroll.maxScroll); + }); + + it('lets go when the bar or a jump to a row moves the list', () => { + const clock = fakeClock(); + const { scroll, tween, wheel } = list(clock); + wheel(100); + clock.advance(50); + scroll.scrollTo(250); + clock.advance(10); + expect(tween.moving).toBe(false); + expect(scroll.offset).toBe(250); + }); + + it('jumps at once with the transition switched off', () => { + const { scroll } = list(fakeClock()); + scrollTween(scroll, () => 0, fakeClock()); + scroll.wheelStep(() => scroll.scrollTo(60)); + expect(scroll.offset).toBe(60); + }); +}); + describe('the general settings', () => { function memoryStore() { const items = new Map(); diff --git a/tests/ui/scroll-container.test.ts b/tests/ui/scroll-container.test.ts index 6411be95..31566920 100644 --- a/tests/ui/scroll-container.test.ts +++ b/tests/ui/scroll-container.test.ts @@ -37,6 +37,36 @@ describe('a scroll container told its content changed', () => { }); }); +describe('a scroll container with tail space', () => { + const filled = (viewportH: number, rowCount: number) => { + const scroll = new ScrollContainer(); + scroll.tailSpace = 40; + scroll.resize(200, viewportH); + const rows = new VStack(); + for (let i = 0; i < rowCount; i++) rows.add(new Spacer(20)); + scroll.setContent(rows); + return { scroll, rows }; + }; + + it('scrolls that far past the end of content that scrolls', () => { + const { scroll, rows } = filled(50, 3); + expect(rows.h).toBe(100); + expect(scroll.maxScroll).toBe(50); + }); + + it('keeps it below content that fits only without it', () => { + const { scroll, rows } = filled(80, 3); + expect(rows.h).toBe(100); + expect(scroll.maxScroll).toBe(20); + }); + + it('adds nothing to scroll when content and tail fit', () => { + const { scroll, rows } = filled(300, 3); + expect(rows.h).toBe(300); + expect(scroll.maxScroll).toBe(0); + }); +}); + describe('a scroll container and its scrollbar', () => { const filled = (viewportH: number, rowCount: number) => { const scroll = new ScrollContainer();