Glide the asset and object lists and scroll past their end (#309)
The asset panel stopped with its last group flush on the bottom edge, and both lists on the left jumped on every wheel step while the tabs next to them already zoom with a transition. - ScrollContainer gets tailSpace, empty room always kept below the content. Content that fits only without it scrolls by the rest. The content is made that much taller, so clicks and the context menu on that room work as on any empty part of the list. - The asset panel keeps six rows of it. The other lists keep none. - ScrollContainer gets wheelStep, which by default scrolls at once. - scrollTween() in viewTween.ts hands the wheel step to an axisTween. The duration is the zoom transition's from the General settings, quick steps add up and stop at the end of the list, and dragging the bar or a jump to a row ends the glide where it stands. - Only the asset panel and Scene objects use it. Inspector, menus and the console still jump. - Wiki: Concepts describes the glide and the room below the assets. Not checked in the browser by me. The user picked the six rows and asked for the glide after trying the room below the list. Tests: - Unit scroll-container: 10 tests (3 new for tailSpace). - Unit view-tween: 4 new with a fake clock (glide lands on target, quick steps add up and stop at the end, a jump ends it, 0 ms jumps). - Gate on melektaus-atx: CHECK EXIT 0, 299 files, 4593 tests (4586 before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
322fc39d16
commit
be41b1f0d7
6 files changed
+133
-5
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+17
-1
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof fakeClock>) {
|
||||
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<string, string>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user