Files
feuerwerk/src/editor/viewport/FreeViewport.ts
T
adminandClaude Opus 5.5 cf6043a066 Show a wheel step or Fit as a short zoom transition (#309)
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>
2026-10-03 21:01:10 +02:00

184 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}