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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 21:01:10 +02:00
1 parent 8452a99c64
commit cf6043a066
21 files changed
+779 -49

No files matched your search

+15
View File
@@ -45,6 +45,8 @@ import type { ReleaseStore } from '../core/store/ReleaseStore';
import type { ReleaseDialog } from './ReleaseDialog';
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog';
import { generalSettings, keepGeneralSettings } from './generalSettings';
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
@@ -256,6 +258,7 @@ export class Editor implements PlayHost {
plugins: PluginManager | null = null;
private readonly toolbar: Toolbar;
private readonly passwordDialog = dialogSlot<PasswordDialog>();
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
@@ -601,11 +604,22 @@ export class Editor implements PlayHost {
const open = () => this.openDocument('management');
return [
{ separator: true },
{ label: 'General settings…', onSelect: () => this.openGeneralSettings() },
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
{ label: 'Plugins', onSelect: open },
];
}
/** What the editor does for whoever sits at it, kept in this browser (#309). */
openGeneralSettings(): GeneralSettingsDialog {
return this.generalDialog.open(new GeneralSettingsDialog(generalSettings(), keepGeneralSettings));
}
/** The open "General settings" dialog, for the check scripts. */
generalSettingsDialog(): GeneralSettingsDialog | null {
return this.generalDialog.current();
}
/**
* The account menu under its button (#182). The name stands in it as an entry that cannot be
* picked: it says who is signed in without costing the crowded toolbar another label.
@@ -921,6 +935,7 @@ export class Editor implements PlayHost {
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
this.passwordDialog.current()?.close();
this.generalDialog.current()?.close();
// The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied.
tooltips.dismiss();
// A menu or a picker left open belongs to widgets that are about to be destroyed.
+45
View File
@@ -0,0 +1,45 @@
import { Label, NumberField } from '../ui';
import { type GeneralSettings, ZOOM_TRANSITION_MAX_MS, zoomTransitionMs } from './generalSettings';
import { FormDialog } from './FormDialog';
/**
* How the editor behaves for whoever sits at it (#309), as opposed to the project's own settings:
* kept in this browser, the same in every project, and not an undo step.
*/
export class GeneralSettingsDialog extends FormDialog {
private zoomMs: number;
private readonly zoomField: NumberField;
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
super(340, 'General settings');
this.zoomMs = settings.zoomTransitionMs;
this.zoomField = new NumberField(this.zoomMs, {
width: 150,
min: 0,
max: ZOOM_TRANSITION_MAX_MS,
step: 10,
decimals: 0,
onInput: (value) => {
this.zoomMs = value;
},
onChange: (value) => {
this.zoomMs = value;
},
});
this.content.add(this.formRow(this.fieldLabel('Zoom (ms)'), this.zoomField));
this.content.add(
new Label('How long a zoom or Fit takes to settle. 0 jumps at once.', { style: 'muted' }),
);
this.addButtons('Apply', 'primary', () => onApply({ zoomTransitionMs: zoomTransitionMs(this.zoomMs) }));
}
/** The settings as the fields stand (test hook). */
values(): GeneralSettings {
return { zoomTransitionMs: this.zoomMs };
}
/** The zoom field itself, so a check can type into it. */
get zoomControl(): NumberField {
return this.zoomField;
}
}
+6 -3
View File
@@ -63,12 +63,15 @@ export class PreviewCanvas extends FreeViewport {
return item ? { x: item.x, y: item.y, props: item.props } : null;
}
/** Skipped while the tab is hidden, like the Game tab: a frame costs a collect of the root. */
/**
* Skipped while the tab is hidden, like the Game tab: a frame costs a collect of the root. A fresh
* subject is framed at once, not by a transition from the last one's view.
*/
override redraw(): void {
if (!this.visible) return;
if (this.fitPending && this.w > 0 && this.h > 0) {
this.fit();
return;
this.fitPending = false;
this.frameContent();
}
super.redraw();
}
+17 -2
View File
@@ -43,6 +43,7 @@ import {
crispText,
} from '../../ui';
import { type EditorState, trackSelection } from '../state';
import { ViewTween } from '../viewTween';
import { FramesDialog, openFramesDialog } from './FramesDialog';
import {
clampMove,
@@ -107,6 +108,14 @@ export class TimelineCanvas extends Widget {
private readonly hint = crispText({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private view: TimelineView = { ...DEFAULT_VIEW };
/** Only across: a frame's cell is `cellWidth` wide and frame 0 starts at `panX`. */
private readonly tween = new ViewTween({
read: () => ({ scale: this.view.cellWidth, x: this.view.panX, y: 0 }),
write: (map) => {
this.view = { ...this.view, panX: map.x, cellWidth: map.scale };
},
draw: () => this.redraw(),
});
private assetId = '';
/** Where the playhead stands, in frames – fractional while playing. */
playhead = 0;
@@ -163,6 +172,7 @@ export class TimelineCanvas extends Widget {
}
saveView(): TimelineView {
this.tween.land();
return { ...this.view };
}
@@ -403,6 +413,7 @@ export class TimelineCanvas extends Widget {
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
this.tween.finish();
this.panning = { pointer: point, panX: this.view.panX };
e.stopPropagation();
return;
@@ -529,11 +540,15 @@ export class TimelineCanvas extends Widget {
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
if (e.shiftKey) {
this.tween.land();
this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY });
this.redraw();
} else {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
this.tween.change(() => {
this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH));
this.redraw();
});
}
this.redraw();
e.preventDefault();
}
+4
View File
@@ -51,6 +51,10 @@ export function sceneHooks({ state, editor }: HookContext) {
sceneHeaderAt: (name: 'fit' | 'visibility') =>
centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton),
viewportInfo: () => viewport.debugInfo(),
/** The scene view's centre and zoom, and whether a zoom transition is under way (#309). */
sceneView: () => viewport.viewState(),
/** The world point under a screen point, as the scene view shows it right now. */
sceneWorldAt: (x: number, y: number) => viewport.worldAt(x, y),
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
viewportRedraw: () => viewport.redraw(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
+16
View File
@@ -13,6 +13,7 @@ import {
} from '../../ui';
import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker';
import { generalSettings, keepGeneralSettings } from '../generalSettings';
import { currentGroupDialog } from '../panels/GroupNameDialog';
import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe';
@@ -318,6 +319,21 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.symbolScaleControl) : null;
},
/** The "General settings" dialog (#309) as its field stands, null while shut. */
generalSettingsDialog: () => editor.generalSettingsDialog()?.values() ?? null,
/** Where to click the zoom field inside that dialog. */
generalSettingsZoomAt: () => {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.zoomControl) : null;
},
/**
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
*/
zoomTransition: (ms?: number) => {
if (ms !== undefined) keepGeneralSettings({ zoomTransitionMs: ms });
return generalSettings().zoomTransitionMs;
},
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
/** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */
+64
View File
@@ -0,0 +1,64 @@
/**
* How the editor itself behaves, kept in this browser rather than in a project (#309): it is about
* the person at the screen, not the game. Best effort, like the panel sizes – a browser that
* refuses `localStorage` keeps the settings for the page only.
*/
import { clamp } from '../ui/math';
const STORAGE_KEY = 'game-maker.general-settings';
export interface GeneralSettings {
/** How long a zoom takes to reach its new step, in ms; 0 jumps at once. */
zoomTransitionMs: number;
}
export const ZOOM_TRANSITION_MAX_MS = 1000;
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200 };
let settings: GeneralSettings | null = null;
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
/** A whole number of ms within the range the dialog offers; anything else is the default. */
export function zoomTransitionMs(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(clamp(value, 0, ZOOM_TRANSITION_MAX_MS))
: DEFAULTS.zoomTransitionMs;
}
function read(): GeneralSettings {
try {
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
const raw = typeof stored === 'object' && stored !== null ? (stored as Record<string, unknown>) : {};
return { zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs) };
} catch {
return { ...DEFAULTS };
}
}
export function generalSettings(): GeneralSettings {
settings ??= read();
return { ...settings };
}
export function keepGeneralSettings(change: Partial<GeneralSettings>): void {
settings = { ...generalSettings(), ...change };
settings.zoomTransitionMs = zoomTransitionMs(settings.zoomTransitionMs);
try {
storage()?.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// Kept for this page only.
}
}
/** Forgets what was read, so the next question asks the browser again (tests). */
export function resetGeneralSettings(): void {
settings = null;
}
+24 -8
View File
@@ -30,6 +30,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
import { BeadClock } from './beads';
import {
activeDrag,
@@ -116,6 +117,14 @@ export class GraphCanvas extends Widget {
private assetId = '';
private pan = { x: 0, y: 0 };
private zoom = 1;
private readonly tween = new ViewTween({
read: () => ({ scale: this.zoom, x: this.pan.x, y: this.pan.y }),
write: (map) => {
this.pan = { x: map.x, y: map.y };
this.zoom = map.scale;
},
draw: () => this.applyView(),
});
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private snapping = false;
private readonly wiring: GraphWiring;
@@ -190,12 +199,15 @@ export class GraphCanvas extends Widget {
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
this.toLocal(e.global),
zoomFactor(e.deltaY),
));
this.applyView();
const at = this.toLocal(e.global);
this.tween.change(() => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
at,
zoomFactor(e.deltaY),
));
this.applyView();
});
e.preventDefault();
});
@@ -226,11 +238,14 @@ export class GraphCanvas extends Widget {
}
fit(): void {
this.needsFit = true;
this.fitView();
this.tween.change(() => {
this.needsFit = true;
this.fitView();
});
}
saveView(): GraphViewState {
this.tween.land();
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
}
@@ -507,6 +522,7 @@ export class GraphCanvas extends Widget {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
const startPan = () => {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
};
if (e.button === 1) {
+25 -6
View File
@@ -1,6 +1,7 @@
import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js';
import { clamp, drawGrid, zoomFactor } from '../../ui';
import type { Point } from '../framing';
import { ViewTween } from '../viewTween';
/**
* What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid
@@ -48,18 +49,34 @@ export function zoomedAbout(
export type WheelZoom =
{ min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null);
/** The transition (#309) of a board whose view is a `GraphView`. */
export function graphViewTween(
get: () => GraphView,
put: (view: GraphView) => void,
draw: () => void,
): ViewTween {
return new ViewTween({
read: () => ({ scale: get().zoom, x: get().pan.x, y: get().pan.y }),
write: (map) => put({ pan: { x: map.x, y: map.y }, zoom: map.scale }),
draw,
});
}
/**
* Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer
* when its content takes none. `set` receives each new view and which gesture made it.
* when its content takes none. `set` receives each new view and which gesture made it; `tween`
* shows the wheel's views as a transition (#309).
*/
export function panAndZoom(
surface: Container,
get: () => GraphView,
set: (view: GraphView, gesture: 'pan' | 'zoom') => void,
zoom: WheelZoom = ZOOM_LIMITS,
tween: ViewTween,
): void {
let panning: { start: Point; pan: Point } | null = null;
surface.on('pointerdown', (e: FederatedPointerEvent) => {
tween.finish();
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
});
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
@@ -72,11 +89,13 @@ export function panAndZoom(
surface.on('wheel', (e: FederatedWheelEvent) => {
e.preventDefault();
const at = surface.toLocal(e.global);
const view =
typeof zoom === 'function'
? zoom(get(), at, e.deltaY)
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
if (view) set(view, 'zoom');
tween.change(() => {
const view =
typeof zoom === 'function'
? zoom(get(), at, e.deltaY)
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
if (view) set(view, 'zoom');
});
});
}
+6 -4
View File
@@ -151,8 +151,9 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
this.onChange();
}
/** The step the view is at, or heading for during a transition – the readout names steps only. */
get zoom(): number {
return this.view.zoom;
return this.tween.heading?.scale ?? this.view.zoom;
}
asset(): PixelArtAsset | null {
@@ -354,8 +355,9 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
protected override onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
if (zoom !== this.view.zoom) {
this.tween.change(() => {
const zoom = stepZoom(this.view.zoom, e.deltaY < 0 ? 1 : -1);
if (zoom === this.view.zoom) return;
// The pixel under the pointer stays under it.
const x = (local.x - this.view.panX) / this.view.zoom;
const y = (local.y - this.view.panY) / this.view.zoom;
@@ -363,7 +365,7 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
this.needsFit = false;
this.redraw();
this.onChange();
}
});
e.preventDefault();
}
+21 -2
View File
@@ -2,7 +2,14 @@ import { Container, Graphics } from 'pixi.js';
import type { NodeDef } from '../../core/graph';
import { Button, Label, type Size, Spacer, Theme, VStack, Widget } from '../../ui';
import { type Box, frameBoxes } from '../framing';
import { drawGraphGrid, FIT_MARGIN, type GraphView, panAndZoom, ZOOM_LIMITS } from '../graph/graphView';
import {
drawGraphGrid,
FIT_MARGIN,
type GraphView,
graphViewTween,
panAndZoom,
ZOOM_LIMITS,
} from '../graph/graphView';
import { NodeView } from '../graph/NodeView';
import { TabHeader } from '../TabHeader';
@@ -53,6 +60,11 @@ class BoardCanvas extends Widget {
private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 };
/** Framed once there is a size and something to frame; after that the view is the user's. */
private needsFit = true;
private readonly tween = graphViewTween(
() => this.view,
(view) => (this.view = view),
() => this.applyView(),
);
constructor() {
super();
@@ -66,6 +78,8 @@ class BoardCanvas extends Widget {
this.view = view;
this.applyView();
},
ZOOM_LIMITS,
this.tween,
);
}
@@ -84,6 +98,11 @@ class BoardCanvas extends Widget {
else this.applyView();
}
/** Fit by hand: the Fit button and a plugin's call, shown as a transition. */
refit(): void {
this.tween.change(() => this.fit());
}
fit(): void {
if (this.w === 0 || this.h === 0) return;
const boxes: Box[] = this.views.map((v) => ({ id: v.node.id, x: v.x, y: v.y, w: v.w, h: v.h }));
@@ -151,7 +170,7 @@ export class NodeBoard extends VStack {
}
fit(): void {
this.canvas.fit();
this.canvas.refit();
}
/** The nodes as drawn and the view – what the check scripts read. */
+19 -5
View File
@@ -4,6 +4,7 @@ import type { Vec2 } from '../../core/model/Properties';
import type { CutSize } from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor, crispText } from '../../ui';
import { dashedLine } from '../../viewport/symbols';
import { ViewTween } from '../viewTween';
import { cutLines, imageToLocal, type SheetLine, type SheetView, zoomAbout } from './sheetView';
/**
@@ -24,6 +25,13 @@ export abstract class SheetCanvas<A> extends Widget {
/** Fit once the widget has a real size and a picture to fit (a tab starts hidden). */
protected needsFit = true;
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
protected readonly tween = new ViewTween({
read: () => ({ scale: this.view.zoom, x: this.view.panX, y: this.view.panY }),
write: (map) => {
this.view = { panX: map.x, panY: map.y, zoom: map.scale };
},
draw: () => this.redraw(),
});
constructor() {
super();
@@ -66,6 +74,7 @@ export abstract class SheetCanvas<A> extends Widget {
}
saveView(): { view: SheetView; needsFit: boolean } {
this.tween.land();
return { view: { ...this.view }, needsFit: this.needsFit };
}
@@ -78,8 +87,10 @@ export abstract class SheetCanvas<A> extends Widget {
}
fit(): void {
this.needsFit = true;
this.redraw();
this.tween.change(() => {
this.needsFit = true;
this.redraw();
});
}
/** Where a picture pixel sits on screen; the check scripts aim their drags with it. */
@@ -96,9 +107,11 @@ export abstract class SheetCanvas<A> extends Widget {
protected onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
this.tween.change(() => {
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
this.needsFit = false;
this.redraw();
});
e.preventDefault();
}
@@ -129,6 +142,7 @@ export abstract class SheetCanvas<A> extends Widget {
const local = this.toLocal(e.global);
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
if (e.button === 1 || pans) {
this.tween.finish();
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation();
return;
+13 -4
View File
@@ -23,7 +23,7 @@ import { type Size, Theme, textStyle, ui, Widget, crispText } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
import type { Point } from '../framing';
import { type GraphView, panAndZoom, zoomedAbout } from '../graph/graphView';
import { type GraphView, graphViewTween, panAndZoom, zoomedAbout } from '../graph/graphView';
import type { EditorState } from '../state';
/** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */
@@ -108,6 +108,11 @@ export class TileCanvas extends Widget {
private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } };
private readonly stopPictures: () => void;
private readonly wheel = wheelSteps();
private readonly tween = graphViewTween(
() => this.view,
(view) => (this.view = view),
() => this.applyView(),
);
constructor(private readonly state: EditorState) {
super();
@@ -128,6 +133,7 @@ export class TileCanvas extends Widget {
this.applyView();
},
(view, at, deltaY) => this.zoomedStep(view, at, deltaY),
this.tween,
);
state.on('project', () => this.redraw());
@@ -163,12 +169,15 @@ export class TileCanvas extends Widget {
}
fit(): void {
this.needsFit = true;
this.followsFit = true;
this.redraw();
this.tween.change(() => {
this.needsFit = true;
this.followsFit = true;
this.redraw();
});
}
saveView(): { view: GraphView; needsFit: boolean } {
this.tween.land();
return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit };
}
+156
View File
@@ -0,0 +1,156 @@
import { requestRender, ui } from '../ui';
import { generalSettings } from './generalSettings';
/**
* A view as every zoomable surface of the editor has one, whatever its fields are called: a world
* point `p` is shown at `p * scale + (x, y)`.
*/
export interface ViewMap {
scale: number;
x: number;
y: number;
}
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
return 1 - rest * rest * rest;
}
/**
* The view `t` of the way from `from` to `to` (#309). The scale moves by equal ratios, so zooming
* in and out feel equally fast. The world point that sits at the same place on screen in both
* views stays there all the way – after a wheel step that is the point under the pointer. Two
* views of the same scale have no such point, and between them the view slides.
*/
export function tweenView(from: ViewMap, to: ViewMap, t: number): ViewMap {
if (t <= 0) return { ...from };
if (t >= 1) return { ...to };
const scale = from.scale * (to.scale / from.scale) ** t;
const gap = from.scale - to.scale;
if (Math.abs(gap) <= 1e-9 * from.scale)
return { scale, x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
const held = (a: number, b: number) => {
const world = (b - a) / gap;
return a + world * from.scale - world * scale;
};
return { scale, x: held(from.x, to.x), y: held(from.y, to.y) };
}
function near(a: ViewMap, b: ViewMap): boolean {
const close = (p: number, q: number) => Math.abs(p - q) <= 1e-6 * Math.max(1, Math.abs(p), Math.abs(q));
return close(a.scale, b.scale) && close(a.x, b.x) && close(a.y, b.y);
}
/** How a surface hands its view to the tween. */
export interface TweenedView {
read(): ViewMap;
/** Puts a view in place without drawing it. */
write(view: ViewMap): void;
draw(): void;
}
/** Time and frames; the editor's are the ticker's, a test brings its own. */
export interface TweenClock {
now(): number;
add(fn: () => void): void;
remove(fn: () => void): void;
}
const tickerClock: TweenClock = {
now: () => performance.now(),
add: (fn) => ui().app.ticker.add(fn),
remove: (fn) => ui().app.ticker.remove(fn),
};
/**
* Shows a zoom or a Fit as a short transition instead of a jump (#309). The surface changes its view
* as it always did, and the tween shows the way from what was on screen to it.
*
* Changing the view by other means – a pan, another document, a resize – ends the transition where
* it stands: the tween notices that the view is no longer the one it left and lets go.
*/
export class ViewTween {
private run: { from: ViewMap; to: ViewMap; start: number; ms: number; left: ViewMap } | null = null;
private readonly tick = () => this.step();
constructor(
private readonly view: TweenedView,
private readonly duration: () => number = () => generalSettings().zoomTransitionMs,
private readonly clock: TweenClock = tickerClock,
) {}
/** Whether a transition is under way – the check scripts sample it. */
get moving(): boolean {
return this.run !== null;
}
/** Where the transition is heading, null while none is under way. */
get heading(): ViewMap | null {
return this.run ? { ...this.run.to } : null;
}
/**
* Carries out `change` and shows the way to the view it leaves. A change made during a transition
* starts from where that one was heading, so quick wheel steps add up to exactly as many steps,
* while the way to the new view starts from what is on screen.
*/
change(change: () => void): void {
const shown = this.view.read();
const running = this.run;
if (running) this.view.write(running.to);
change();
const to = this.view.read();
const ms = this.duration();
if (ms <= 0 || near(shown, to)) {
this.stop();
if (running) this.view.draw();
return;
}
if (running && near(running.to, to)) {
this.view.write(shown);
return;
}
if (!running) this.clock.add(this.tick);
// Not drawn here: the next frame draws the first step of the way before it is rendered.
this.view.write(shown);
this.run = { from: shown, to, start: this.clock.now(), ms, left: this.view.read() };
requestRender();
}
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
finish(): void {
if (!this.run) return;
this.land();
this.view.draw();
}
/** Puts the view where the transition was heading, without drawing – before it is saved. */
land(): void {
const run = this.run;
if (!run) return;
this.stop();
this.view.write(run.to);
}
private step(): void {
const run = this.run;
if (!run) return;
if (!near(this.view.read(), run.left)) {
this.stop();
return;
}
const t = (this.clock.now() - run.start) / run.ms;
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
run.left = this.view.read();
if (t >= 1) this.stop();
this.view.draw();
requestRender();
}
private stop(): void {
if (!this.run) return;
this.run = null;
this.clock.remove(this.tick);
}
}
+41 -10
View File
@@ -13,6 +13,7 @@ import {
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;
@@ -29,6 +30,18 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
/** 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);
@@ -40,8 +53,10 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
* placed and where the preview places its root, so content far off to one side would hide it.
*/
fit(): void {
this.frameContent();
this.redraw();
this.tween.change(() => {
this.frameContent();
this.redraw();
});
}
/** Points the camera at everything drawn, without drawing. */
@@ -77,6 +92,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
*/
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();
@@ -88,6 +104,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
/** Where the camera stands, to come back to (#171). */
saveView(): unknown {
this.tween.land();
return { ...this.view };
}
@@ -116,6 +133,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
}
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();
}
@@ -139,14 +157,27 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
e.preventDefault();
const direction = this.wheel(e.deltaY);
if (direction === 0) return;
const zoom = stepFreeZoom(this.view.zoom, direction);
if (zoom === this.view.zoom) return;
const local = this.toLocal(e.global);
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();
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);
}
}
+4 -2
View File
@@ -308,8 +308,10 @@ export class SceneViewport extends FreeViewport {
* switching between the two tabs moves nothing. Without one, frames the whole content.
*/
fitScene(): void {
this.frameScene();
this.redraw();
this.tween.change(() => {
this.frameScene();
this.redraw();
});
}
private frameScene(): void {