[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-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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
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,
|
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',
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue