mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-24 18:49:00 +01:00
Refactor view visibility management and centralize animation logic
This commit is contained in:
@@ -6,12 +6,13 @@
|
||||
import { IBoundarySashes, Orientation } from '../sash/sash.js';
|
||||
import { equals, tail } from '../../../common/arrays.js';
|
||||
import { Event } from '../../../common/event.js';
|
||||
import { Disposable, IDisposable } from '../../../common/lifecycle.js';
|
||||
import { Disposable } from '../../../common/lifecycle.js';
|
||||
import './gridview.css';
|
||||
import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview.js';
|
||||
import type { SplitView, AutoSizing as SplitViewAutoSizing } from '../splitview/splitview.js';
|
||||
import type { SplitView, AutoSizing as SplitViewAutoSizing, IViewVisibilityAnimationOptions } from '../splitview/splitview.js';
|
||||
|
||||
export type { IViewSize };
|
||||
export type { IViewVisibilityAnimationOptions } from '../splitview/splitview.js';
|
||||
export { LayoutPriority, Orientation, orthogonal } from './gridview.js';
|
||||
|
||||
export const enum Direction {
|
||||
@@ -649,24 +650,12 @@ export class Grid<T extends IView = IView> extends Disposable {
|
||||
* Set the visibility state of a {@link IView view}.
|
||||
*
|
||||
* @param view The {@link IView view}.
|
||||
*/
|
||||
setViewVisible(view: T, visible: boolean): void {
|
||||
const location = this.getViewLocation(view);
|
||||
this.gridview.setViewVisible(location, visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the visibility state of a {@link IView view} with a smooth animation.
|
||||
*
|
||||
* @param view The {@link IView view}.
|
||||
* @param visible Whether the view should be visible.
|
||||
* @param duration The transition duration in milliseconds.
|
||||
* @param easing The CSS easing function string.
|
||||
* @returns A disposable that cancels the animation.
|
||||
* @param animation Optional animation options.
|
||||
*/
|
||||
setViewVisibleAnimated(view: T, visible: boolean, duration: number, easing: string, onComplete?: () => void): IDisposable {
|
||||
setViewVisible(view: T, visible: boolean, animation?: IViewVisibilityAnimationOptions): void {
|
||||
const location = this.getViewLocation(view);
|
||||
return this.gridview.setViewVisibleAnimated(location, visible, duration, easing, onComplete);
|
||||
this.gridview.setViewVisible(location, visible, animation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { $ } from '../../dom.js';
|
||||
import { IBoundarySashes, Orientation, Sash } from '../sash/sash.js';
|
||||
import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js';
|
||||
import { DistributeSizing, ISplitViewStyles, IView as ISplitView, IViewVisibilityAnimationOptions, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js';
|
||||
import { equals as arrayEquals, tail } from '../../../common/arrays.js';
|
||||
import { Color } from '../../../common/color.js';
|
||||
import { Emitter, Event, Relay } from '../../../common/event.js';
|
||||
@@ -615,7 +615,7 @@ class BranchNode implements ISplitView<ILayoutContext>, IDisposable {
|
||||
return this.splitview.isViewVisible(index);
|
||||
}
|
||||
|
||||
setChildVisible(index: number, visible: boolean): void {
|
||||
setChildVisible(index: number, visible: boolean, animation?: IViewVisibilityAnimationOptions): void {
|
||||
index = validateIndex(index, this.children.length);
|
||||
|
||||
if (this.splitview.isViewVisible(index) === visible) {
|
||||
@@ -623,7 +623,7 @@ class BranchNode implements ISplitView<ILayoutContext>, IDisposable {
|
||||
}
|
||||
|
||||
const wereAllChildrenHidden = this.splitview.contentSize === 0;
|
||||
this.splitview.setViewVisible(index, visible);
|
||||
this.splitview.setViewVisible(index, visible, animation);
|
||||
const areAllChildrenHidden = this.splitview.contentSize === 0;
|
||||
|
||||
// If all children are hidden then the parent should hide the entire splitview
|
||||
@@ -633,24 +633,6 @@ class BranchNode implements ISplitView<ILayoutContext>, IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
setChildVisibleAnimated(index: number, visible: boolean, duration: number, easing: string, onComplete?: () => void): IDisposable {
|
||||
index = validateIndex(index, this.children.length);
|
||||
|
||||
if (this.splitview.isViewVisible(index) === visible) {
|
||||
return { dispose: () => { } };
|
||||
}
|
||||
|
||||
const wereAllChildrenHidden = this.splitview.contentSize === 0;
|
||||
const disposable = this.splitview.setViewVisibleAnimated(index, visible, duration, easing, onComplete);
|
||||
const areAllChildrenHidden = this.splitview.contentSize === 0;
|
||||
|
||||
if ((visible && wereAllChildrenHidden) || (!visible && areAllChildrenHidden)) {
|
||||
this._onDidVisibilityChange.fire(visible);
|
||||
}
|
||||
|
||||
return disposable;
|
||||
}
|
||||
|
||||
getChildCachedVisibleSize(index: number): number | undefined {
|
||||
index = validateIndex(index, this.children.length);
|
||||
|
||||
@@ -1679,7 +1661,7 @@ export class GridView implements IDisposable {
|
||||
*
|
||||
* @param location The {@link GridLocation location} of the view.
|
||||
*/
|
||||
setViewVisible(location: GridLocation, visible: boolean): void {
|
||||
setViewVisible(location: GridLocation, visible: boolean, animation?: IViewVisibilityAnimationOptions): void {
|
||||
if (this.hasMaximizedView()) {
|
||||
this.exitMaximizedView();
|
||||
return;
|
||||
@@ -1692,32 +1674,7 @@ export class GridView implements IDisposable {
|
||||
throw new Error('Invalid from location');
|
||||
}
|
||||
|
||||
parent.setChildVisible(index, visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the visibility state of a {@link IView view} with a smooth animation.
|
||||
*
|
||||
* @param location The {@link GridLocation location} of the view.
|
||||
* @param visible Whether the view should be visible.
|
||||
* @param duration The transition duration in milliseconds.
|
||||
* @param easing The CSS easing function string.
|
||||
* @returns A disposable that cancels the animation.
|
||||
*/
|
||||
setViewVisibleAnimated(location: GridLocation, visible: boolean, duration: number, easing: string, onComplete?: () => void): IDisposable {
|
||||
if (this.hasMaximizedView()) {
|
||||
this.exitMaximizedView();
|
||||
return { dispose: () => { } };
|
||||
}
|
||||
|
||||
const [rest, index] = tail(location);
|
||||
const [, parent] = this.getNode(rest);
|
||||
|
||||
if (!(parent instanceof BranchNode)) {
|
||||
throw new Error('Invalid from location');
|
||||
}
|
||||
|
||||
return parent.setChildVisibleAnimated(index, visible, duration, easing, onComplete);
|
||||
parent.setChildVisible(index, visible, animation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,94 +5,90 @@
|
||||
|
||||
import './motion.css';
|
||||
|
||||
//#region Duration Constants
|
||||
|
||||
/**
|
||||
* Duration in milliseconds for panel open (entrance) animations.
|
||||
* Per Fluent 2 Enter/Exit pattern - entrance should feel smooth but not sluggish.
|
||||
*/
|
||||
export const PANEL_OPEN_DURATION = 150;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds for panel close (exit) animations.
|
||||
* Exits are faster than entrances - feels snappy and responsive.
|
||||
*/
|
||||
export const PANEL_CLOSE_DURATION = 50;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds for quick input open (entrance) animations.
|
||||
*/
|
||||
export const QUICK_INPUT_OPEN_DURATION = 150;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds for quick input close (exit) animations.
|
||||
*/
|
||||
export const QUICK_INPUT_CLOSE_DURATION = 50;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Easing Curves
|
||||
|
||||
/**
|
||||
* A pre-parsed cubic bezier easing curve that can be evaluated directly
|
||||
* without reparsing a CSS string on every frame.
|
||||
*
|
||||
* Given control points `(x1, y1)` and `(x2, y2)` (the CSS `cubic-bezier`
|
||||
* parameters), {@link solve} finds the bezier parameter `u` such that
|
||||
* `Bx(u) = t` using Newton's method, then returns `By(u)`.
|
||||
*/
|
||||
export class CubicBezierCurve {
|
||||
|
||||
constructor(
|
||||
readonly x1: number,
|
||||
readonly y1: number,
|
||||
readonly x2: number,
|
||||
readonly y2: number,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Evaluate the curve at time `t` (0-1), returning the eased value.
|
||||
*/
|
||||
solve(t: number): number {
|
||||
if (t <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (t >= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Newton's method to find u where Bx(u) = t
|
||||
let u = t; // initial guess
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const currentX = bezierComponent(u, this.x1, this.x2);
|
||||
const error = currentX - t;
|
||||
if (Math.abs(error) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
const dx = bezierComponentDerivative(u, this.x1, this.x2);
|
||||
if (Math.abs(dx) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
u -= error / dx;
|
||||
}
|
||||
|
||||
u = Math.max(0, Math.min(1, u));
|
||||
return bezierComponent(u, this.y1, this.y2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSS `cubic-bezier(…)` string representation, for use in
|
||||
* CSS `transition` or `animation` properties.
|
||||
*/
|
||||
toCssString(): string {
|
||||
return `cubic-bezier(${this.x1}, ${this.y1}, ${this.x2}, ${this.y2})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent 2 ease-out curve - default for entrances and expansions.
|
||||
* Starts fast and decelerates to a stop.
|
||||
*/
|
||||
export const EASE_OUT = 'cubic-bezier(0.1, 0.9, 0.2, 1)';
|
||||
export const EASE_OUT = new CubicBezierCurve(0.1, 0.9, 0.2, 1);
|
||||
|
||||
/**
|
||||
* Fluent 2 ease-in curve - for exits and collapses.
|
||||
* Starts slow and accelerates out.
|
||||
*/
|
||||
export const EASE_IN = 'cubic-bezier(0.9, 0.1, 1, 0.2)';
|
||||
export const EASE_IN = new CubicBezierCurve(0.9, 0.1, 1, 0.2);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cubic Bezier Evaluation
|
||||
|
||||
/**
|
||||
* Parses a CSS `cubic-bezier(x1, y1, x2, y2)` string into its four control
|
||||
* point values. Returns `[0, 0, 1, 1]` (linear) on parse failure.
|
||||
* Parses a CSS `cubic-bezier(x1, y1, x2, y2)` string into a
|
||||
* {@link CubicBezierCurve}. Returns a linear curve on parse failure.
|
||||
*/
|
||||
export function parseCubicBezier(css: string): [number, number, number, number] {
|
||||
export function parseCubicBezier(css: string): CubicBezierCurve {
|
||||
const match = css.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
|
||||
if (!match) {
|
||||
return [0, 0, 1, 1];
|
||||
return new CubicBezierCurve(0, 0, 1, 1);
|
||||
}
|
||||
return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a cubic bezier curve at time `t` (0-1).
|
||||
*
|
||||
* Given control points `(x1, y1)` and `(x2, y2)` (the CSS `cubic-bezier`
|
||||
* parameters), this finds the bezier parameter `u` such that `Bx(u) = t`
|
||||
* using Newton's method, then returns `By(u)`.
|
||||
*/
|
||||
export function solveCubicBezier(x1: number, y1: number, x2: number, y2: number, t: number): number {
|
||||
if (t <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (t >= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Newton's method to find u where Bx(u) = t
|
||||
let u = t; // initial guess
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const currentX = bezierComponent(u, x1, x2);
|
||||
const error = currentX - t;
|
||||
if (Math.abs(error) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
const dx = bezierComponentDerivative(u, x1, x2);
|
||||
if (Math.abs(dx) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
u -= error / dx;
|
||||
}
|
||||
|
||||
u = Math.max(0, Math.min(1, u));
|
||||
return bezierComponent(u, y1, y2);
|
||||
return new CubicBezierCurve(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4]));
|
||||
}
|
||||
|
||||
/** Evaluates one component of a cubic bezier: B(u) with control points p1, p2, endpoints 0 and 1. */
|
||||
|
||||
@@ -8,16 +8,41 @@ import { DomEmitter } from '../../event.js';
|
||||
import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js';
|
||||
import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js';
|
||||
import { pushToEnd, pushToStart, range } from '../../../common/arrays.js';
|
||||
import { CancellationToken } from '../../../common/cancellation.js';
|
||||
import { Color } from '../../../common/color.js';
|
||||
import { Emitter, Event } from '../../../common/event.js';
|
||||
import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js';
|
||||
import { clamp } from '../../../common/numbers.js';
|
||||
import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js';
|
||||
import * as types from '../../../common/types.js';
|
||||
import { isMotionReduced, parseCubicBezier, scaleDuration, solveCubicBezier } from '../motion/motion.js';
|
||||
import { CubicBezierCurve, isMotionReduced, scaleDuration } from '../motion/motion.js';
|
||||
import './splitview.css';
|
||||
export { Orientation } from '../sash/sash.js';
|
||||
|
||||
/**
|
||||
* Options for animating a view visibility change in a {@link SplitView}.
|
||||
*/
|
||||
export interface IViewVisibilityAnimationOptions {
|
||||
|
||||
/** Transition duration in milliseconds. */
|
||||
readonly duration: number;
|
||||
|
||||
/** The easing curve applied to the animation. */
|
||||
readonly easing: CubicBezierCurve;
|
||||
|
||||
/**
|
||||
* Optional callback invoked when the animation finishes naturally.
|
||||
* NOT called if the animation is cancelled via the {@link token}.
|
||||
*/
|
||||
readonly onComplete?: () => void;
|
||||
|
||||
/**
|
||||
* A cancellation token that allows the caller to stop the animation.
|
||||
* When cancellation is requested the animation snaps to its final state.
|
||||
*/
|
||||
readonly token: CancellationToken;
|
||||
}
|
||||
|
||||
export interface ISplitViewStyles {
|
||||
readonly separatorBorder: Color;
|
||||
}
|
||||
@@ -803,22 +828,38 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
/**
|
||||
* Set a {@link IView view}'s visibility.
|
||||
*
|
||||
* When {@link animation} is provided and motion is not reduced, the
|
||||
* visibility change is animated. Otherwise the change is applied
|
||||
* instantly. Any in-flight animation is always cancelled first.
|
||||
*
|
||||
* @param index The {@link IView view} index.
|
||||
* @param visible Whether the {@link IView view} should be visible.
|
||||
* @param animation Optional animation options. When omitted (or when
|
||||
* the user prefers reduced motion) the change is instant.
|
||||
*/
|
||||
setViewVisible(index: number, visible: boolean): void {
|
||||
setViewVisible(index: number, visible: boolean, animation?: IViewVisibilityAnimationOptions): void {
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
|
||||
// Cancel any in-flight animation before changing visibility.
|
||||
// An animated setViewVisibleAnimated interpolates ALL view sizes each
|
||||
// frame, so a concurrent non-animated visibility change on a different
|
||||
// view in this splitview would be overwritten on the next frame.
|
||||
// Snapping the animation to its final state first prevents that.
|
||||
// An animated visibility change interpolates ALL view sizes each
|
||||
// frame, so a concurrent change on a different view would be
|
||||
// overwritten on the next frame. Snapping first prevents that.
|
||||
this._cleanupMotion?.();
|
||||
this._cleanupMotion = undefined;
|
||||
|
||||
if (animation && !animation.token.isCancellationRequested && !isMotionReduced(this.el) && this.viewItems[index].visible !== visible) {
|
||||
this._setViewVisibleAnimated(index, visible, animation);
|
||||
} else {
|
||||
this._setViewVisibleInstant(index, visible);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the visibility change to the model without animation.
|
||||
*/
|
||||
private _setViewVisibleInstant(index: number, visible: boolean): void {
|
||||
const viewItem = this.viewItems[index];
|
||||
viewItem.setVisible(visible);
|
||||
|
||||
@@ -828,39 +869,18 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a {@link IView view}'s visibility with a smooth animation.
|
||||
* Animate the visibility change using `requestAnimationFrame`.
|
||||
*
|
||||
* Uses `requestAnimationFrame` to interpolate all view sizes on each frame,
|
||||
* which naturally cascades layout changes through nested splitviews in the
|
||||
* grid hierarchy (e.g., the bottom panel resizing when the sidebar animates).
|
||||
* Interpolates all view sizes on each frame, which naturally cascades
|
||||
* layout changes through nested splitviews in the grid hierarchy
|
||||
* (e.g., the bottom panel resizing when the sidebar animates).
|
||||
*
|
||||
* If motion is reduced (via accessibility settings), falls back to instant
|
||||
* {@link setViewVisible}.
|
||||
*
|
||||
* @param index The {@link IView view} index.
|
||||
* @param visible Whether the {@link IView view} should be visible.
|
||||
* @param duration The transition duration in milliseconds.
|
||||
* @param easing The CSS `cubic-bezier(...)` easing function string.
|
||||
* @param onComplete Optional callback invoked when the animation finishes.
|
||||
* NOT called if the animation is canceled via {@link IDisposable.dispose}.
|
||||
* @returns A disposable that cancels the animation if disposed before completion.
|
||||
* The animation can be cancelled via {@link IViewVisibilityAnimationOptions.token}.
|
||||
* {@link IViewVisibilityAnimationOptions.onComplete} is only called when the
|
||||
* animation finishes naturally (not on cancellation).
|
||||
*/
|
||||
setViewVisibleAnimated(index: number, visible: boolean, duration: number, easing: string, onComplete?: () => void): IDisposable {
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
|
||||
// Cancel any in-flight animation
|
||||
this._cleanupMotion?.();
|
||||
this._cleanupMotion = undefined;
|
||||
|
||||
const viewItem = this.viewItems[index];
|
||||
|
||||
// If motion is reduced or already in target state, use instant path
|
||||
if (viewItem.visible === visible || isMotionReduced(this.el)) {
|
||||
this.setViewVisible(index, visible);
|
||||
return toDisposable(() => { });
|
||||
}
|
||||
private _setViewVisibleAnimated(index: number, visible: boolean, animation: IViewVisibilityAnimationOptions): void {
|
||||
const { duration: baseDuration, easing, onComplete, token } = animation;
|
||||
|
||||
const container = this.viewContainer.children[index] as HTMLElement;
|
||||
const window = getWindow(this.el);
|
||||
@@ -872,7 +892,7 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
|
||||
// 2. Apply the target visibility to the model instantly.
|
||||
// This computes final sizes, fires events, updates sashes, etc.
|
||||
this.setViewVisible(index, visible);
|
||||
this._setViewVisibleInstant(index, visible);
|
||||
|
||||
// 3. Snapshot sizes AFTER the visibility change (the animation end state)
|
||||
const finalSizes = this.viewItems.map(v => v.size);
|
||||
@@ -901,13 +921,12 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
|
||||
// 7. Scale duration based on pixel distance for consistent perceived velocity
|
||||
const pixelDistance = Math.abs(finalSizes[index] - startSizes[index]);
|
||||
duration = scaleDuration(duration, pixelDistance);
|
||||
const duration = scaleDuration(baseDuration, pixelDistance);
|
||||
|
||||
// 8. Render the start state
|
||||
this.layoutViews();
|
||||
|
||||
// 9. Parse easing for JS evaluation
|
||||
const [x1, y1, x2, y2] = parseCubicBezier(easing);
|
||||
// 9. Easing curve is pre-parsed - ready for JS evaluation
|
||||
|
||||
// Helper: snap all sizes to final state and clean up
|
||||
const applyFinalState = () => {
|
||||
@@ -928,6 +947,7 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
tokenListener.dispose();
|
||||
if (rafId !== undefined) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = undefined;
|
||||
@@ -940,6 +960,9 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
};
|
||||
this._cleanupMotion = () => cleanup(false);
|
||||
|
||||
// Listen to the cancellation token so the caller can stop the animation
|
||||
const tokenListener = token.onCancellationRequested(() => cleanup(false));
|
||||
|
||||
// 10. Animate via requestAnimationFrame
|
||||
const startTime = performance.now();
|
||||
const totalSize = this.size;
|
||||
@@ -951,7 +974,7 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const easedT = solveCubicBezier(x1, y1, x2, y2, t);
|
||||
const easedT = easing.solve(t);
|
||||
|
||||
// Interpolate opacity for fade effect
|
||||
container.style.opacity = String(visible ? easedT : 1 - easedT);
|
||||
@@ -981,8 +1004,6 @@ export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutCo
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(animate);
|
||||
|
||||
return toDisposable(() => cleanup(false));
|
||||
}
|
||||
|
||||
private _cleanupMotion: (() => void) | undefined;
|
||||
|
||||
Reference in New Issue
Block a user