[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:
Steve Ruiz 2023-05-30 16:22:49 +01:00 committed by GitHub
parent 0dc0587bea
commit a220b2eff1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 127 additions and 10 deletions

View file

@ -71,6 +71,8 @@
"action.toggle-auto-size": "Toggle auto size", "action.toggle-auto-size": "Toggle auto size",
"action.toggle-dark-mode.menu": "Dark mode", "action.toggle-dark-mode.menu": "Dark mode",
"action.toggle-dark-mode": "Toggle 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.menu": "Debug mode",
"action.toggle-debug-mode": "Toggle debug mode", "action.toggle-debug-mode": "Toggle debug mode",
"action.toggle-focus-mode.menu": "Focus mode", "action.toggle-focus-mode.menu": "Focus mode",

View file

@ -135,6 +135,8 @@ export class App extends EventEmitter<TLEventMap> {
}): this; }): this;
// (undocumented) // (undocumented)
animateToShape(shapeId: TLShapeId, opts?: AnimationOptions): this; animateToShape(shapeId: TLShapeId, opts?: AnimationOptions): this;
// (undocumented)
get animationSpeed(): number;
// @internal (undocumented) // @internal (undocumented)
annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: { annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: {
origin: string; origin: string;
@ -449,6 +451,8 @@ export class App extends EventEmitter<TLEventMap> {
selectNone(): this; selectNone(): this;
sendBackward(ids?: TLShapeId[]): this; sendBackward(ids?: TLShapeId[]): this;
sendToBack(ids?: TLShapeId[]): this; sendToBack(ids?: TLShapeId[]): this;
// (undocumented)
setAnimationSpeed(animationSpeed: number): this;
setBrush(brush?: Box2dModel | null): this; setBrush(brush?: Box2dModel | null): this;
setCamera(x: number, y: number, z?: number, { stopFollowing }?: ViewportOptions): this; setCamera(x: number, y: number, z?: number, { stopFollowing }?: ViewportOptions): this;
// (undocumented) // (undocumented)
@ -492,7 +496,7 @@ export class App extends EventEmitter<TLEventMap> {
direction: Vec2d; direction: Vec2d;
friction: number; friction: number;
speedThreshold?: number | undefined; speedThreshold?: number | undefined;
}): this; }): this | undefined;
readonly snaps: SnapManager; readonly snaps: SnapManager;
get sortedShapesArray(): TLShape[]; get sortedShapesArray(): TLShape[];
stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this; stackShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[], gap?: number): this;

View file

@ -1516,6 +1516,17 @@ export class App extends EventEmitter<TLEventMap> {
return this return this
} }
get animationSpeed() {
return this.user.animationSpeed
}
setAnimationSpeed(animationSpeed: number) {
if (animationSpeed !== this.animationSpeed) {
this.user.updateUserPreferences({ animationSpeed })
}
return this
}
get isFocusMode() { get isFocusMode() {
return this.instanceState.isFocusMode return this.instanceState.isFocusMode
} }
@ -8378,21 +8389,33 @@ export class App extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
private _animateToViewport(targetViewportPage: Box2d, opts = {} as AnimationOptions) { private _animateToViewport(targetViewportPage: Box2d, opts = {} as AnimationOptions) {
const { duration = 0, easing = EASINGS.easeInOutCubic } = opts 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() this.stopCameraAnimation()
if (this.instanceState.followingUserId) { if (this.instanceState.followingUserId) {
this.stopFollowingUser() 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 = { this._viewportAnimation = {
elapsed: 0, elapsed: 0,
duration, duration: duration / animationSpeed,
easing, easing,
start: startViewport, start: viewportPageBounds.clone(),
end: targetViewportPage, end: targetViewportPage,
} }
// On each tick, animate the viewport
this.addListener('tick', this._animateViewport) this.addListener('tick', this._animateViewport)
return this return this
@ -8408,11 +8431,15 @@ export class App extends EventEmitter<TLEventMap> {
) { ) {
if (!this.canMoveCamera) return this if (!this.canMoveCamera) return this
const { speed, direction, friction, speedThreshold = 0.01 } = opts
let currentSpeed = speed
this.stopCameraAnimation() 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 = () => { const cancel = () => {
this.removeListener('tick', moveCamera) this.removeListener('tick', moveCamera)
this.removeListener('stop-camera-animation', cancel) this.removeListener('stop-camera-animation', cancel)

View file

@ -15,6 +15,10 @@ export class UserPreferencesManager {
return this.editor.config.userPreferences.value.isDarkMode return this.editor.config.userPreferences.value.isDarkMode
} }
get animationSpeed() {
return this.editor.config.userPreferences.value.animationSpeed
}
get id() { get id() {
return this.editor.config.userPreferences.value.id return this.editor.config.userPreferences.value.id
} }

View file

@ -17,6 +17,7 @@ export interface TLUserPreferences {
locale: string locale: string
color: string color: string
isDarkMode: boolean isDarkMode: boolean
animationSpeed: number
} }
interface UserDataSnapshot { interface UserDataSnapshot {
@ -30,15 +31,35 @@ interface UserChangeBroadcastMessage {
data: UserDataSnapshot data: UserDataSnapshot
} }
const userTypeValidator: T.Validator<TLUserPreferences> = T.object({ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({
id: T.string, id: T.string,
name: T.string, name: T.string,
locale: T.string, locale: T.string,
color: T.string, color: T.string,
isDarkMode: T.boolean, 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 */ /** @internal */
export const USER_COLORS = [ export const USER_COLORS = [
@ -68,8 +89,10 @@ function getFreshUserPreferences(): TLUserPreferences {
color: getRandomColor(), color: getRandomColor(),
// TODO: detect dark mode // TODO: detect dark mode
isDarkMode: false, isDarkMode: false,
animationSpeed: 1,
} }
} }
function migrateUserPreferences(userData: unknown) { function migrateUserPreferences(userData: unknown) {
if (userData === null || typeof userData !== 'object') { if (userData === null || typeof userData !== 'object') {
return getFreshUserPreferences() return getFreshUserPreferences()

View 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

View file

@ -797,6 +797,17 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
}, },
checkbox: true, 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', id: 'toggle-transparent',
label: 'action.toggle-transparent', label: 'action.toggle-transparent',

View file

@ -77,6 +77,7 @@ export interface TLUiEventMap {
'toggle-dark-mode': null 'toggle-dark-mode': null
'toggle-focus-mode': null 'toggle-focus-mode': null
'toggle-debug-mode': null 'toggle-debug-mode': null
'toggle-reduce-motion': null
'exit-pen-mode': null 'exit-pen-mode': null
'stop-following': null 'stop-following': null
} }

View file

@ -50,6 +50,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
const isMobile = breakpoint < 5 const isMobile = breakpoint < 5
const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app]) const isDarkMode = useValue('isDarkMode', () => app.isDarkMode, [app])
const animationSpeed = useValue('animationSpeed', () => app.animationSpeed, [app])
const isGridMode = useValue('isGridMode', () => app.userDocumentSettings.isGridMode, [app]) const isGridMode = useValue('isGridMode', () => app.userDocumentSettings.isGridMode, [app])
const isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app]) const isSnapMode = useValue('isSnapMode', () => app.userDocumentSettings.isSnapMode, [app])
const isToolLock = useValue('isToolLock', () => app.instanceState.isToolLocked, [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-grid'], { checked: isGridMode }),
menuItem(actions['toggle-dark-mode'], { checked: isDarkMode }), menuItem(actions['toggle-dark-mode'], { checked: isDarkMode }),
menuItem(actions['toggle-focus-mode'], { checked: isFocusMode }), menuItem(actions['toggle-focus-mode'], { checked: isFocusMode }),
menuItem(actions['toggle-reduce-motion'], { checked: animationSpeed === 0 }),
menuItem(actions['toggle-debug-mode'], { checked: isDebugMode }) menuItem(actions['toggle-debug-mode'], { checked: isDebugMode })
) )
), ),
@ -206,6 +208,7 @@ export function MenuSchemaProvider({ overrides, children }: MenuSchemaProviderPr
noneSelected, noneSelected,
canUndo, canUndo,
canRedo, canRedo,
animationSpeed,
isDarkMode, isDarkMode,
isGridMode, isGridMode,
isSnapMode, isSnapMode,

View file

@ -75,6 +75,8 @@ export type TLTranslationKey =
| 'action.toggle-auto-size' | 'action.toggle-auto-size'
| 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode.menu'
| 'action.toggle-dark-mode' | 'action.toggle-dark-mode'
| 'action.toggle-reduce-motion.menu'
| 'action.toggle-reduce-motion'
| 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode.menu'
| 'action.toggle-debug-mode' | 'action.toggle-debug-mode'
| 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode.menu'

View file

@ -75,6 +75,8 @@ export const DEFAULT_TRANSLATION = {
'action.toggle-auto-size': 'Toggle auto size', 'action.toggle-auto-size': 'Toggle auto size',
'action.toggle-dark-mode.menu': 'Dark mode', 'action.toggle-dark-mode.menu': 'Dark mode',
'action.toggle-dark-mode': 'Toggle 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.menu': 'Debug mode',
'action.toggle-debug-mode': 'Toggle debug mode', 'action.toggle-debug-mode': 'Toggle debug mode',
'action.toggle-focus-mode.menu': 'Focus mode', 'action.toggle-focus-mode.menu': 'Focus mode',