[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-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",

View file

@ -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;

View file

@ -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)

View file

@ -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
}

View file

@ -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()

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,
},
{
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',

View file

@ -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
}

View file

@ -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,

View file

@ -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'

View file

@ -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',