From ed7fe1bfa4c6a36cdf2e7664e6128c569b43440e Mon Sep 17 00:00:00 2001 From: chanind Date: Sun, 12 Jan 2020 16:43:34 +0000 Subject: [PATCH] feat: pauseAnimation and resumeAnimation (#156) * feat: adding pauseAnimation and resumeAnimation * fixing linting --- src/HanziWriter.js | 8 +++ src/Mutation.js | 47 ++++++++++++++-- src/RenderState.js | 14 +++++ src/__tests__/HanziWriter-test.js | 61 ++++++++++++++++++++ src/__tests__/Mutation-test.js | 94 +++++++++++++++++++++++++++++++ src/__tests__/RenderState-test.js | 4 +- src/characterActions.js | 4 +- 7 files changed, 222 insertions(+), 10 deletions(-) diff --git a/src/HanziWriter.js b/src/HanziWriter.js index 24d302cd6..af05deba7 100644 --- a/src/HanziWriter.js +++ b/src/HanziWriter.js @@ -147,6 +147,14 @@ HanziWriter.prototype.loopCharacterAnimation = function(options = {}) { )); }; +HanziWriter.prototype.pauseAnimation = function() { + return this._withData(() => this._renderState.pauseAll()); +}; + +HanziWriter.prototype.resumeAnimation = function() { + return this._withData(() => this._renderState.resumeAll()); +}; + HanziWriter.prototype.showOutline = function(options = {}) { this._options.showOutline = true; return this._withData(() => ( diff --git a/src/Mutation.js b/src/Mutation.js index 5f09c886c..8fb6bf214 100644 --- a/src/Mutation.js +++ b/src/Mutation.js @@ -46,7 +46,9 @@ function Mutation(scope, valuesOrCallable, options = {}) { this._valuesOrCallable = valuesOrCallable; this._duration = options.duration || 0; this._force = options.force; + this._pausedDuration = 0; this._tickBound = this._tick.bind(this); + this._startPauseTime = null; } @@ -65,8 +67,22 @@ Mutation.prototype.run = function(renderState) { }); }; +Mutation.prototype.pause = function() { + if (this._startPauseTime !== null) return; + if (this._frameHandle) cancelAnimationFrame(this._frameHandle); + this._startPauseTime = performanceNow(); +}; + +Mutation.prototype.resume = function() { + if (this._startPauseTime === null) return; + this._frameHandle = requestAnimationFrame(this._tickBound); + this._pausedDuration += performanceNow() - this._startPauseTime; + this._startPauseTime = null; +}; + Mutation.prototype._tick = function(timing) { - const progress = Math.min(1, (timing - this._startTime) / this._duration); + if (this._startPauseTime !== null) return; + const progress = Math.min(1, (timing - this._startTime - this._pausedDuration) / this._duration); if (progress === 1) { this._renderState.updateState(this._values); this._frameHandle = null; @@ -97,27 +113,46 @@ Mutation.prototype.cancel = function(renderState) { } }; -// ------ Mutation.Pause Class -------- +// ------ Mutation.Delay Class -------- -function Pause(duration) { +function Delay(duration) { this._duration = duration; + this._startTime = null; + this._paused = false; } -Pause.prototype.run = function() { +Delay.prototype.pause = function() { + if (this._paused) return; + // to pause, clear the timeout and rewrite this._duration with whatever time is remaining + const elapsedDelay = performanceNow() - this._startTime; + this._duration = Math.max(0, this._duration - elapsedDelay); + clearTimeout(this._timeout); + this._paused = true; +}; + +Delay.prototype.resume = function() { + if (!this._paused) return; + this._startTime = performanceNow(); + this._timeout = setTimeout(() => this.cancel(), this._duration); + this._paused = false; +}; + +Delay.prototype.run = function() { const timeoutPromise = new Promise((resolve) => { this._resolve = resolve; }); + this._startTime = performanceNow(); this._timeout = setTimeout(() => this.cancel(), this._duration); return timeoutPromise; }; -Pause.prototype.cancel = function() { +Delay.prototype.cancel = function() { clearTimeout(this._timeout); if (this._resolve) this._resolve(); this._resolve = false; }; -Mutation.Pause = Pause; +Mutation.Delay = Delay; // ------------------------------------- diff --git a/src/RenderState.js b/src/RenderState.js index 5cba76019..3b9f77496 100644 --- a/src/RenderState.js +++ b/src/RenderState.js @@ -92,6 +92,20 @@ RenderState.prototype._run = function(mutationChain) { }); }; +RenderState.prototype._getActiveMutations = function() { + return this._mutationChains.map(chain => { + return chain._mutations[chain._index]; + }); +}; + +RenderState.prototype.pauseAll = function() { + this._getActiveMutations().forEach(mutation => mutation.pause()); +}; + +RenderState.prototype.resumeAll = function() { + this._getActiveMutations().forEach(mutation => mutation.resume()); +}; + RenderState.prototype.cancelMutations = function(scopes) { this._mutationChains.forEach(chain => { chain._scopes.forEach(chainScope => { diff --git a/src/__tests__/HanziWriter-test.js b/src/__tests__/HanziWriter-test.js index 038b74977..eb847a2aa 100644 --- a/src/__tests__/HanziWriter-test.js +++ b/src/__tests__/HanziWriter-test.js @@ -347,6 +347,67 @@ describe('HanziWriter', () => { }); }); + describe('pauseAnimation and resumeAnimation', () => { + it('pauses and resumes the currently running animations', async () => { + document.body.innerHTML = '
'; + const writer = new HanziWriter('target', '人', { showCharacter: true, charDataLoader }); + await writer._withDataPromise; + + let isResolved = false; + let resolvedVal; + const onComplete = jest.fn(); + + clock.tick(50); + await resolvePromises(); + + writer.animateStroke(1, { onComplete }).then(result => { + isResolved = true; + resolvedVal = result; + }); + + await resolvePromises(); + expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(0); + + clock.tick(50); + await resolvePromises(); + + const pausedDisplayPortion = writer._renderState.state.character.main.strokes[1].displayPortion; + expect(pausedDisplayPortion).toBeGreaterThan(0); + expect(pausedDisplayPortion).toBeLessThan(1); + expect(isResolved).toBe(false); + + writer.pauseAnimation(); + await resolvePromises(); + + clock.tick(2000); + await resolvePromises(); + + expect(isResolved).toBe(false); + expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(pausedDisplayPortion); + + writer.resumeAnimation(); + await resolvePromises(); + + clock.tick(50); + await resolvePromises(); + + const newDisplayPortion = writer._renderState.state.character.main.strokes[1].displayPortion; + expect(newDisplayPortion).not.toBe(pausedDisplayPortion); + expect(newDisplayPortion).toBeGreaterThan(0); + expect(newDisplayPortion).toBeLessThan(1); + expect(isResolved).toBe(false); + + clock.tick(2000); + await resolvePromises(); + + expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(1); + expect(isResolved).toBe(true); + expect(resolvedVal).toEqual({ canceled: false }); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ canceled: false }); + }); + }); + describe('highlightStroke', () => { it('highlights a single stroke', async () => { document.body.innerHTML = '
'; diff --git a/src/__tests__/Mutation-test.js b/src/__tests__/Mutation-test.js index 7e4f0a0a0..de9a4a841 100644 --- a/src/__tests__/Mutation-test.js +++ b/src/__tests__/Mutation-test.js @@ -76,6 +76,57 @@ describe('Mutation', () => { expect(isResolved).toBe(true); }); + it('can pause and resume during the tween', async () => { + const renderState = { + state: {a: {b: 10 } }, + updateState: jest.fn(), + }; + + const mut = new Mutation('a.b', 20, { duration: 50 }); + let isResolved = false; + + mut.run(renderState).then(() => { + isResolved = true; + }); + + await Promise.resolve(); + expect(isResolved).toBe(false); + expect(renderState.updateState).not.toHaveBeenCalled(); + + clock.tick(45); + const partialTweenVals = renderState.updateState.mock.calls.map(call => call[0].a.b); + + expect(isResolved).toBe(false); + expect(renderState.updateState).toHaveBeenCalled(); + renderState.updateState.mock.calls.forEach(mockCall => { + expect(mockCall[0].a.b).toBeGreaterThan(10); + expect(mockCall[0].a.b).toBeLessThan(20); + }); + + mut.pause(); + await Promise.resolve(); + + clock.tick(1000); + await Promise.resolve(); + + expect(partialTweenVals.length).toBe(renderState.updateState.mock.calls.length); + expect(isResolved).toBe(false); + expect(renderState.updateState).toHaveBeenCalled(); + renderState.updateState.mock.calls.forEach(mockCall => { + expect(mockCall[0].a.b).toBeGreaterThan(10); + expect(mockCall[0].a.b).toBeLessThan(20); + }); + + mut.resume(); + await Promise.resolve(); + + clock.tick(25); + expect(renderState.updateState).toHaveBeenLastCalledWith({ a: { b: 20 } }); + + await Promise.resolve(); + expect(isResolved).toBe(true); + }); + it('updates state on cancel if force: true', async () => { const renderState = { state: {a: {b: 7 } }, @@ -101,3 +152,46 @@ describe('Mutation', () => { expect(renderState.updateState).not.toHaveBeenCalled(); }); }); + +describe('Mutation.Delay', () => { + it('can pause and resume during the delay', async () => { + + const delay = new Mutation.Delay(1000); + let isResolved = false; + + delay.run().then(() => { + isResolved = true; + }); + + await Promise.resolve(); + expect(isResolved).toBe(false); + + clock.tick(200); + await Promise.resolve(); + + expect(isResolved).toBe(false); + + delay.pause(); + await Promise.resolve(); + + clock.tick(2000); + await Promise.resolve(); + + expect(isResolved).toBe(false); + + delay.resume(); + await Promise.resolve(); + + expect(isResolved).toBe(false); + + clock.tick(500); + await Promise.resolve(); + + expect(isResolved).toBe(false); + + clock.tick(500); + await Promise.resolve(); + + expect(isResolved).toBe(true); + }); +}); diff --git a/src/__tests__/RenderState-test.js b/src/__tests__/RenderState-test.js index 35ca43c28..abaf51759 100644 --- a/src/__tests__/RenderState-test.js +++ b/src/__tests__/RenderState-test.js @@ -89,7 +89,7 @@ describe('RenderState', () => { renderState.run([ new Mutation('character.main.opacity', 0.3), new Mutation('character.main.opacity', 0.9, { duration: 50 }), - new Mutation.Pause(100), + new Mutation.Delay(100), new Mutation('character.main.opacity', 0, { duration: 50 }), ]).then(result => { isResolved = true; @@ -137,7 +137,7 @@ describe('RenderState', () => { renderState.run([ new Mutation('character.main.opacity', 0.3), new Mutation('character.main.opacity', 0.9, { duration: 50 }), - new Mutation.Pause(100), + new Mutation.Delay(100), new Mutation('character.main.opacity', 0, { duration: 50 }), ]).then(result => { isResolved = true; diff --git a/src/characterActions.js b/src/characterActions.js index b0c73d65d..63553ca34 100644 --- a/src/characterActions.js +++ b/src/characterActions.js @@ -105,7 +105,7 @@ const animateCharacter = (charName, character, fadeDuration, speed, delayBetween strokes: objRepeat({ opacity: 0 }, character.strokes.length), }, { force: true })); character.strokes.forEach((stroke, i) => { - if (i > 0) mutations.push(new Mutation.Pause(delayBetweenStrokes)); + if (i > 0) mutations.push(new Mutation.Delay(delayBetweenStrokes)); mutations = mutations.concat(animateStroke(charName, stroke, speed)); }); return mutations; @@ -113,7 +113,7 @@ const animateCharacter = (charName, character, fadeDuration, speed, delayBetween const animateCharacterLoop = (charName, character, fadeDuration, speed, delayBetweenStrokes, delayBetweenLoops) => { const mutations = animateCharacter(charName, character, fadeDuration, speed, delayBetweenStrokes); - mutations.push(new Mutation.Pause(delayBetweenLoops)); + mutations.push(new Mutation.Delay(delayBetweenLoops)); return mutations; };