The wheel and the Fit button used to jump to the new view. Now the view takes a short way there, 200 ms by default, everywhere the editor zooms. The zoom steps stay the same, only the change is shown. - viewTween: tweenView is the way between two views as a pure function. The scale moves by equal ratios, and the world point that sits at the same place on screen in both views stays there. After a wheel step that is the point under the pointer. - ViewTween drives it on the ticker. A surface hands it reading, writing and drawing its own view and wraps its wheel and Fit code in change(). A step during a transition starts from where that one was heading, so quick steps add up to exactly as many steps. - Anything else that moves the view ends the transition. A pan jumps to the target first, and saving a view saves the target. - Wired into FreeViewport (Scene, Container, animation preview), SheetCanvas (Texture, Curve, Pixel art, Vector art), GraphCanvas, panAndZoom (Tile board, plugin node board) and the timeline. - Menu > General settings... sets the time in ms, 0 jumps at once. It is kept in this browser like the panel sizes, not in the project, and it is not an undo step. - A document that opens still comes up framed at once. The animation preview frames a new subject itself instead of calling fit(). - The pixel art readout names the step the view is heading for. Measured in the scene view at 200 ms on melektaus-atx, where every frame of the transition is a full redraw: - 500 sprites: redraw 6.7 ms, 16 frames, mean 18 ms - 1000 sprites: redraw 13.0 ms, 9 frames, mean 27 ms - 2000 sprites: redraw 32.2 ms, 5 frames, mean 58 ms Tests: - The harness sets 0 ms on every load, so the existing modules read the step and not the way. - e2e 46i samples one wheel step at 1000 ms a frame later: zoom 1.033 between 1 and 2, the point under the pointer kept at (134, -40). It lands on the view a 0 ms jump reaches. - e2e 46 and 49 expect the new menu entry. - Gate (atx): 234 files, 3563 tests, CHECK EXIT 0 (+15) - e2e 46,49 (atx): ALL PASSED, 43 checks (+3, sum 1735) - Three counter-checks fail view-tween.test.ts as expected. e2e 46i fails when the scene view does not carry its centre along. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
|
||
import { footprintExtent } from '../../core/properties/bounds';
|
||
import { boundsOf } from '../framing';
|
||
import type { EditorState } from '../state';
|
||
import { ui } from '../../ui';
|
||
import {
|
||
freeView,
|
||
nearestZoomStep,
|
||
screenToWorld,
|
||
stepFreeZoom,
|
||
type View,
|
||
wheelSteps,
|
||
zoomStepBelow,
|
||
} from '../../viewport/view';
|
||
import { WorldViewport } from '../../viewport/WorldViewport';
|
||
import { ViewTween } from '../viewTween';
|
||
|
||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||
const FIT_MARGIN = 40;
|
||
|
||
/**
|
||
* A world view with a camera of its own – the Scene and Container tabs and the Animation tab's
|
||
* preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content.
|
||
* The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even.
|
||
*/
|
||
export abstract class FreeViewport extends WorldViewport<EditorState> {
|
||
/** World point at the viewport centre, and zoom in device pixels per world unit. */
|
||
protected view = { x: 0, y: 0, zoom: 1 };
|
||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||
/** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */
|
||
private screenOrigin = { x: 0, y: 0 };
|
||
private readonly wheel = wheelSteps();
|
||
/** The view as a map from world to device pixels around the centre, which a transition keeps still. */
|
||
protected readonly tween = new ViewTween({
|
||
read: () => ({
|
||
scale: this.view.zoom,
|
||
x: -this.view.x * this.view.zoom,
|
||
y: -this.view.y * this.view.zoom,
|
||
}),
|
||
write: (map) => {
|
||
this.view = { x: -map.x / map.scale, y: -map.y / map.scale, zoom: map.scale };
|
||
},
|
||
draw: () => this.redraw(),
|
||
});
|
||
|
||
protected constructor(state: EditorState) {
|
||
super(state);
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.zoomAt(e));
|
||
}
|
||
|
||
/**
|
||
* Frames everything drawn. The origin is always included: it is where a container is grabbed when
|
||
* placed and where the preview places its root, so content far off to one side would hide it.
|
||
*/
|
||
fit(): void {
|
||
this.tween.change(() => {
|
||
this.frameContent();
|
||
this.redraw();
|
||
});
|
||
}
|
||
|
||
/** Points the camera at everything drawn, without drawing. */
|
||
protected frameContent(): void {
|
||
const { items } = this.collectFrame();
|
||
const boxes = items.map((item) => {
|
||
const e = footprintExtent(item.type, item.props, item.m, item.frame);
|
||
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
|
||
});
|
||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||
const width = Math.max(maxX - minX, 1);
|
||
const height = Math.max(maxY - minY, 1);
|
||
this.view = {
|
||
x: (minX + maxX) / 2,
|
||
y: (minY + maxY) / 2,
|
||
zoom: zoomStepBelow(
|
||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(),
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Centres the view on a world point, keeping the zoom (test hook). */
|
||
lookAt(x: number, y: number): void {
|
||
this.view.x = x;
|
||
this.view.y = y;
|
||
this.redraw();
|
||
}
|
||
|
||
/**
|
||
* Keeps the world where it is on screen when the view's edges move – a panel appearing beside it,
|
||
* a splitter, the console – instead of holding the centre, which shifted the content by half the
|
||
* change. The parent positions before it resizes, so the global origin is already the new one.
|
||
*/
|
||
override resize(w: number, h: number): void {
|
||
if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) {
|
||
this.tween.land();
|
||
const before = this.screenOrigin;
|
||
const after = this.getGlobalPosition();
|
||
this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale();
|
||
this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.worldScale();
|
||
}
|
||
super.resize(w, h);
|
||
this.screenOrigin = this.getGlobalPosition();
|
||
}
|
||
|
||
/** Where the camera stands, to come back to (#171). */
|
||
saveView(): unknown {
|
||
this.tween.land();
|
||
return { ...this.view };
|
||
}
|
||
|
||
restoreView(view: unknown): void {
|
||
const saved = view as FreeViewport['view'];
|
||
this.view = { ...saved, zoom: nearestZoomStep(saved.zoom) };
|
||
this.redraw();
|
||
}
|
||
|
||
protected resetView(): void {
|
||
this.view = { x: 0, y: 0, zoom: 1 };
|
||
}
|
||
|
||
protected override currentView(): View {
|
||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h, this.ratio());
|
||
}
|
||
|
||
/** CSS pixels per world unit, for pointer distances. */
|
||
protected worldScale(): number {
|
||
return this.view.zoom / this.ratio();
|
||
}
|
||
|
||
/** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */
|
||
protected ratio(): number {
|
||
return ui().app.renderer.resolution;
|
||
}
|
||
|
||
protected startPan(e: FederatedPointerEvent): void {
|
||
this.tween.finish();
|
||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||
e.stopPropagation();
|
||
}
|
||
|
||
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
|
||
protected panTo(e: FederatedPointerEvent): boolean {
|
||
if (!this.panning) return false;
|
||
const scale = this.worldScale();
|
||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
|
||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
|
||
this.moveCamera();
|
||
return true;
|
||
}
|
||
|
||
protected endPan(): void {
|
||
this.panning = null;
|
||
}
|
||
|
||
/** Zooms a step about the pointer, so the world point under it stays under it. */
|
||
private zoomAt(e: FederatedWheelEvent): void {
|
||
e.preventDefault();
|
||
const direction = this.wheel(e.deltaY);
|
||
if (direction === 0) return;
|
||
const local = this.toLocal(e.global);
|
||
this.tween.change(() => {
|
||
const zoom = stepFreeZoom(this.view.zoom, direction);
|
||
if (zoom === this.view.zoom) return;
|
||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||
this.view.zoom = zoom;
|
||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||
this.view.x += before.x - after.x;
|
||
this.view.y += before.y - after.y;
|
||
this.redraw();
|
||
});
|
||
}
|
||
|
||
/** Centre, zoom and whether a transition is under way – what the check scripts sample (#309). */
|
||
viewState(): { x: number; y: number; zoom: number; moving: boolean } {
|
||
return { ...this.view, moving: this.tween.moving };
|
||
}
|
||
|
||
/** The world point shown at a global screen point, by the rule the wheel zooms with. */
|
||
worldAt(x: number, y: number): { x: number; y: number } {
|
||
const local = this.toLocal({ x, y });
|
||
return screenToWorld(this.currentView(), local.x, local.y);
|
||
}
|
||
}
|