Repository: chanind/hanzi-writer (ts)

Issue:
是否可以增加暫停功能呢 ?
開發者你好

目前播放中需要有暫停的功能
但 API 裡面似乎沒有看到 stop 和 pause 

之後是否會新增呢 ?

或是要自己新增此功能
建議修改哪些 js 呢 ?

謝謝 !

Pull request:
feat: pauseAnimation and resumeAnimation
This PR adds a `writer.pauseAnimation()` method and `writer.resumeAnimation()` method. These will pause and resume any currently running animation, respectively.

Thanks @silent717120 for the feature request!

closes #155

Patch:
diff --git a/src/HanziWriter.js b/src/HanziWriter.js
index 24d302cd..af05deba 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 5f09c886..8fb6bf21 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 5cba7601..3b9f7749 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/characterActions.js b/src/characterActions.js
index b0c73d65..63553ca3 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;
 };
 


Test patch:
diff --git a/src/__tests__/HanziWriter-test.js b/src/__tests__/HanziWriter-test.js
index 038b7497..eb847a2a 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 = '<div id="target"></div>';
+      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 = '<div id="target"></div>';
diff --git a/src/__tests__/Mutation-test.js b/src/__tests__/Mutation-test.js
index 7e4f0a0a..de9a4a84 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 35ca43c2..abaf5175 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;