[feature] reduce motion (#1485)
This PR adds a user preference to reduce motion. When enabled the app will not animate the camera (and perhaps skip other animations in the future). It's actual implementation is as an `animateSpeed` property, so we can also use it to speed up or slow down our animations if that's something we want to do! ### Change Type - [x] `minor` — New Feature ### Test Plan 1. Turn on reduce motion 2. Use minimap / camera features to zoom in / out / etc - [x] Unit Tests ### Release Notes - [editor] Add `reduceMotion` user preference - Add reduce motion option to preferences
This commit is contained in:
parent
0dc0587bea
commit
a220b2eff1
12 changed files with 127 additions and 10 deletions
|
@ -71,6 +71,8 @@
|
|||
"action.toggle-auto-size": "Toggle auto size",
|
||||
"action.toggle-dark-mode.menu": "Dark mode",
|
||||
"action.toggle-dark-mode": "Toggle dark mode",
|
||||
"action.toggle-reduce-motion.menu": "Reduce motion",
|
||||
"action.toggle-reduce-motion": "Toggle reduce motion",
|
||||
"action.toggle-debug-mode.menu": "Debug mode",
|
||||
"action.toggle-debug-mode": "Toggle debug mode",
|
||||
"action.toggle-focus-mode.menu": "Focus mode",
|
||||
|
|
|
@ -135,6 +135,8 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
}): this;
|
||||
// (undocumented)
|
||||
animateToShape(shapeId: TLShapeId, opts?: AnimationOptions): this;
|
||||
// (undocumented)
|
||||
get animationSpeed(): number;
|
||||
// @internal (undocumented)
|
||||
annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: {
|
||||
origin: string;
|
||||
|
@ -449,6 +451,8 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
selectNone(): this;
|
||||
sendBackward(ids?: TLShapeId[]): this;
|
||||
sendToBack(ids?: TLShapeId[]): this;
|
||||
// (undocumented)
|
||||
setAnimationSpeed(animationSpeed: number): this;
|
||||
setBrush(brush?: Box2dModel | null): this;
|
||||
setCamera(x: number, y: number, z?: number, { stopFollowing }?: ViewportOptions): this;
|
||||
// (undocumented)
|
||||
|
@ -492,7 +496,7 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
direction: Vec2d;
|
||||
friction: number;
|
||||
speedThreshold?: number | undefined;
|
||||
}): this;
|
||||
}): this | undefined;
|
||||
readonly snaps: SnapManager;
|
||||
get sortedShapesArray(): TLShape[];
|
||||
stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this;
|
||||
|
|
|
@ -1516,6 +1516,17 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
return this
|
||||
}
|
||||
|
||||
get animationSpeed() {
|
||||
return this.user.animationSpeed
|
||||
}
|
||||
|
||||
setAnimationSpeed(animationSpeed: number) {
|
||||
if (animationSpeed !== this.animationSpeed) {
|
||||
this.user.updateUserPreferences({ animationSpeed })
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
get isFocusMode() {
|
||||
return this.instanceState.isFocusMode
|
||||
}
|
||||
|
@ -8378,21 +8389,33 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
/** @internal */
|
||||
private _animateToViewport(targetViewportPage: Box2d, opts = {} as AnimationOptions) {
|
||||
const { duration = 0, easing = EASINGS.easeInOutCubic } = opts
|
||||
const startViewport = this.viewportPageBounds.clone()
|
||||
const { animationSpeed, viewportPageBounds } = this
|
||||
|
||||
// If we have an existing animation, then stop it; also stop following any user
|
||||
this.stopCameraAnimation()
|
||||
if (this.instanceState.followingUserId) {
|
||||
this.stopFollowingUser()
|
||||
}
|
||||
|
||||
if (duration === 0 || animationSpeed === 0) {
|
||||
// If we have no animation, then skip the animation and just set the camera
|
||||
return this._setCamera(
|
||||
-targetViewportPage.x,
|
||||
-targetViewportPage.y,
|
||||
this.viewportScreenBounds.width / targetViewportPage.width
|
||||
)
|
||||
}
|
||||
|
||||
// Set our viewport animation
|
||||
this._viewportAnimation = {
|
||||
elapsed: 0,
|
||||
duration,
|
||||
duration: duration / animationSpeed,
|
||||
easing,
|
||||
start: startViewport,
|
||||
start: viewportPageBounds.clone(),
|
||||
end: targetViewportPage,
|
||||
}
|
||||
|
||||
// On each tick, animate the viewport
|
||||
this.addListener('tick', this._animateViewport)
|
||||
|
||||
return this
|
||||
|
@ -8408,11 +8431,15 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
) {
|
||||
if (!this.canMoveCamera) return this
|
||||
|
||||
const { speed, direction, friction, speedThreshold = 0.01 } = opts
|
||||
let currentSpeed = speed
|
||||
|
||||
this.stopCameraAnimation()
|
||||
|
||||
const { animationSpeed } = this
|
||||
|
||||
if (animationSpeed === 0) return
|
||||
|
||||
const { speed, friction, direction, speedThreshold = 0.01 } = opts
|
||||
let currentSpeed = Math.min(speed, 1)
|
||||
|
||||
const cancel = () => {
|
||||
this.removeListener('tick', moveCamera)
|
||||
this.removeListener('stop-camera-animation', cancel)
|
||||
|
|
|
@ -15,6 +15,10 @@ export class UserPreferencesManager {
|
|||
return this.editor.config.userPreferences.value.isDarkMode
|
||||
}
|
||||
|
||||
get animationSpeed() {
|
||||
return this.editor.config.userPreferences.value.animationSpeed
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.editor.config.userPreferences.value.id
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface TLUserPreferences {
|
|||
locale: string
|
||||
color: string
|
||||
isDarkMode: boolean
|
||||
animationSpeed: number
|
||||
}
|
||||
|
||||
interface UserDataSnapshot {
|
||||
|
@ -30,15 +31,35 @@ interface UserChangeBroadcastMessage {
|
|||
data: UserDataSnapshot
|
||||
}
|
||||
|
||||
const userTypeValidator: T.Validator<TLUserPreferences> = T.object({
|
||||
const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({
|
||||
id: T.string,
|
||||
name: T.string,
|
||||
locale: T.string,
|
||||
color: T.string,
|
||||
isDarkMode: T.boolean,
|
||||
animationSpeed: T.number,
|
||||
})
|
||||
|
||||
const userTypeMigrations = defineMigrations({})
|
||||
const Versions = {
|
||||
AddAnimationSpeed: 1,
|
||||
} as const
|
||||
|
||||
const userTypeMigrations = defineMigrations({
|
||||
currentVersion: 1,
|
||||
migrators: {
|
||||
[Versions.AddAnimationSpeed]: {
|
||||
up: (user) => {
|
||||
return {
|
||||
...user,
|
||||
animationSpeed: 1,
|
||||
}
|
||||
},
|
||||
down: ({ animationSpeed: _, ...user }) => {
|
||||
return user
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** @internal */
|
||||
export const USER_COLORS = [
|
||||
|
@ -68,8 +89,10 @@ function getFreshUserPreferences(): TLUserPreferences {
|
|||
color: getRandomColor(),
|
||||
// TODO: detect dark mode
|
||||
isDarkMode: false,
|
||||
animationSpeed: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateUserPreferences(userData: unknown) {
|
||||
if (userData === null || typeof userData !== 'object') {
|
||||
return getFreshUserPreferences()
|
||||
|
|
38
packages/editor/src/lib/test/commands/animationSpeed.test.ts
Normal file
38
packages/editor/src/lib/test/commands/animationSpeed.test.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { TestApp } from '../TestApp'
|
||||
|
||||
let app: TestApp
|
||||
|
||||
beforeEach(() => {
|
||||
app = new TestApp()
|
||||
})
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
it('zooms in gradually when duration is present and animtion speed is default', () => {
|
||||
expect(app.zoomLevel).toBe(1)
|
||||
app.setAnimationSpeed(1) // default
|
||||
app.zoomIn(undefined, { duration: 100 })
|
||||
app.emit('tick', 25) // <-- quarter way
|
||||
expect(app.zoomLevel).not.toBe(2)
|
||||
app.emit('tick', 25) // 50 <-- half way
|
||||
expect(app.zoomLevel).not.toBe(2)
|
||||
app.emit('tick', 50) // 50 <-- done!
|
||||
expect(app.zoomLevel).toBe(2)
|
||||
})
|
||||
|
||||
it('zooms in gradually when duration is present and animtion speed is off', () => {
|
||||
expect(app.zoomLevel).toBe(1)
|
||||
app.setAnimationSpeed(0) // none
|
||||
app.zoomIn(undefined, { duration: 100 })
|
||||
expect(app.zoomLevel).toBe(2) // <-- Should skip!
|
||||
})
|
||||
|
||||
it('zooms in gradually when duration is present and animtion speed is double', () => {
|
||||
expect(app.zoomLevel).toBe(1)
|
||||
app.setAnimationSpeed(2) // default
|
||||
app.zoomIn(undefined, { duration: 100 })
|
||||
app.emit('tick', 25) // <-- half way
|
||||
expect(app.zoomLevel).not.toBe(2)
|
||||
app.emit('tick', 25) // 50 <-- should finish
|
||||
expect(app.zoomLevel).toBe(2)
|
||||
})
|
File diff suppressed because one or more lines are too long
|
@ -797,6 +797,17 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
},
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle-reduce-motion',
|
||||
label: 'action.toggle-reduce-motion',
|
||||
menuLabel: 'action.toggle-reduce-motion.menu',
|
||||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('toggle-reduce-motion', { source })
|
||||
app.setAnimationSpeed(app.animationSpeed === 0 ? 1 : 0)
|
||||
},
|
||||
checkbox: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle-transparent',
|
||||
label: 'action.toggle-transparent',
|
||||
|
|
|
@ -77,6 +77,7 @@ export interface TLUiEventMap {
|
|||
'toggle-dark-mode': null
|
||||
'toggle-focus-mode': null
|
||||
'toggle-debug-mode': null
|
||||
'toggle-reduce-motion': null
|
||||
'exit-pen-mode': null
|
||||
'stop-following': null
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
|
|||
const isMobile = breakpoint < 5
|
||||
|
||||
const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app])
|
||||
const animationSpeed = useValue('animationSpeed', () => app.animationSpeed, [app])
|
||||
const isGridMode = useValue('isGridMode', () => app.userDocumentSettings.isGridMode, [app])
|
||||
const isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app])
|
||||
const isToolLock = useValue('isToolLock', () => app.instanceState.isToolLocked, [app])
|
||||
|
@ -171,6 +172,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
|
|||
menuItem(actions['toggle-grid'], { checked: isGridMode }),
|
||||
menuItem(actions['toggle-dark-mode'], { checked: isDarkMode }),
|
||||
menuItem(actions['toggle-focus-mode'], { checked: isFocusMode }),
|
||||
menuItem(actions['toggle-reduce-motion'], { checked: animationSpeed === 0 }),
|
||||
menuItem(actions['toggle-debug-mode'], { checked: isDebugMode })
|
||||
)
|
||||
),
|
||||
|
@ -206,6 +208,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
|
|||
noneSelected,
|
||||
canUndo,
|
||||
canRedo,
|
||||
animationSpeed,
|
||||
isDarkMode,
|
||||
isGridMode,
|
||||
isSnapMode,
|
||||
|
|
|
@ -75,6 +75,8 @@ export type TLTranslationKey =
|
|||
| 'action.toggle-auto-size'
|
||||
| 'action.toggle-dark-mode.menu'
|
||||
| 'action.toggle-dark-mode'
|
||||
| 'action.toggle-reduce-motion.menu'
|
||||
| 'action.toggle-reduce-motion'
|
||||
| 'action.toggle-debug-mode.menu'
|
||||
| 'action.toggle-debug-mode'
|
||||
| 'action.toggle-focus-mode.menu'
|
||||
|
|
|
@ -75,6 +75,8 @@ export const DEFAULT_TRANSLATION = {
|
|||
'action.toggle-auto-size': 'Toggle auto size',
|
||||
'action.toggle-dark-mode.menu': 'Dark mode',
|
||||
'action.toggle-dark-mode': 'Toggle dark mode',
|
||||
'action.toggle-reduce-motion.menu': 'Reduce motion',
|
||||
'action.toggle-reduce-motion': 'Toggle reduce motion',
|
||||
'action.toggle-debug-mode.menu': 'Debug mode',
|
||||
'action.toggle-debug-mode': 'Toggle debug mode',
|
||||
'action.toggle-focus-mode.menu': 'Focus mode',
|
||||
|
|
Loading…
Reference in a new issue