Repository: chanind/hanzi-writer (ts)

Issue:
Consider adding highlight method to quiz
Awesome project!

I really like the highlight feature on the quiz it gives the user a hint after so many incorrect attempts. For my use case, I'm looking to use the quiz as more of a learning tool where it highlights each stroke and has the user trace it.

I tried using the animateStroke method but there's no way to really un-animate it to give the user an opportunity to trace it. The highlightStroke method in quiz actions isn't exposed so I can't directly use that.

What are your thoughts on adding a public method to highlight the current stroke in a quiz? I think that it'd be easy to add. I'd be happy to add it myself if that's something that you would be interested in. If not, no big deal, I can fork it - but I'd rather not if not necessary.

Pull request description:
feat: adding writer.highlightStroke() method
This PR adds a `writer.highlightStroke(strokeNum, options = {})` method which can be used to manually highlight a stroke identically to what occurs in character quizzes. Thanks to @killia15 for the suggestion!

fixes #145

Patch:
diff --git a/src/HanziWriter.js b/src/HanziWriter.js
index c87b8498..24d302cd 100644
--- a/src/HanziWriter.js
+++ b/src/HanziWriter.js
@@ -124,6 +124,15 @@ HanziWriter.prototype.animateStroke = function(strokeNum, options = {}) {
     )).then(res => callIfExists(options.onComplete, res))
   ));
 };
+HanziWriter.prototype.highlightStroke = function(strokeNum, options = {}) {
+  return this._withData(() => (
+    this._renderState.run(characterActions.highlightStroke(
+      this._character.strokes[strokeNum],
+      this._options.highlightColor,
+      this._options.strokeHighlightSpeed,
+    )).then(res => callIfExists(options.onComplete, res))
+  ));
+};
 HanziWriter.prototype.loopCharacterAnimation = function(options = {}) {
   this.cancelQuiz();
   return this._withData(() => (
diff --git a/src/characterActions.js b/src/characterActions.js
index 7655e6ff..b0c73d65 100644
--- a/src/characterActions.js
+++ b/src/characterActions.js
@@ -30,6 +30,28 @@ const updateColor = (colorName, colorVal, duration) => {
   return [new Mutation(`options.${colorName}`, colorVal, { duration })];
 };
 
+const highlightStroke = (stroke, color, speed) => {
+  const strokeNum = stroke.strokeNum;
+  const duration = (stroke.getLength() + 600) / (3 * speed);
+  return [
+    new Mutation('character.highlight.strokeColor', color),
+    new Mutation('character.highlight', {
+      opacity: 1,
+      strokes: {
+        [strokeNum]: {
+          displayPortion: 0,
+          opacity: 0,
+        },
+      },
+    }),
+    new Mutation(`character.highlight.strokes.${strokeNum}`, {
+      displayPortion: 1,
+      opacity: 1,
+    }, { duration }),
+    new Mutation(`character.highlight.strokes.${strokeNum}.opacity`, 0, { duration }),
+  ];
+};
+
 const animateStroke = (charName, stroke, speed) => {
   const strokeNum = stroke.strokeNum;
   const duration = (stroke.getLength() + 600) / (3 * speed);
@@ -99,6 +121,7 @@ module.exports = {
   showStrokes,
   showCharacter,
   hideCharacter,
+  highlightStroke,
   animateCharacter,
   animateCharacterLoop,
   animateStroke,
diff --git a/src/quizActions.js b/src/quizActions.js
index 9befae2e..468757d7 100644
--- a/src/quizActions.js
+++ b/src/quizActions.js
@@ -39,28 +39,6 @@ const removeUserStroke = (userStrokeId, duration) => {
   ];
 };
 
-const highlightStroke = (stroke, color, speed) => {
-  const strokeNum = stroke.strokeNum;
-  const duration = (stroke.getLength() + 600) / (3 * speed);
-  return [
-    new Mutation('character.highlight.strokeColor', color),
-    new Mutation('character.highlight', {
-      opacity: 1,
-      strokes: {
-        [strokeNum]: {
-          displayPortion: 0,
-          opacity: 0,
-        },
-      },
-    }),
-    new Mutation(`character.highlight.strokes.${strokeNum}`, {
-      displayPortion: 1,
-      opacity: 1,
-    }, { duration }),
-    new Mutation(`character.highlight.strokes.${strokeNum}.opacity`, 0, { duration }),
-  ];
-};
-
 const highlightCompleteChar = (character, color, duration) => {
   return [
     new Mutation('character.highlight.strokeColor', color),
@@ -72,7 +50,7 @@ const highlightCompleteChar = (character, color, duration) => {
 
 module.exports = {
   highlightCompleteChar,
-  highlightStroke,
+  highlightStroke: characterActions.highlightStroke,
   startQuiz,
   startUserStroke,
   updateUserStroke,


Test patch:
diff --git a/src/__tests__/HanziWriter-test.js b/src/__tests__/HanziWriter-test.js
index 6165ef36..038b7497 100644
--- a/src/__tests__/HanziWriter-test.js
+++ b/src/__tests__/HanziWriter-test.js
@@ -347,6 +347,50 @@ describe('HanziWriter', () => {
     });
   });
 
+  describe('highlightStroke', () => {
+    it('highlights a single stroke', 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();
+
+      writer.highlightStroke(1, { onComplete }).then(result => {
+        isResolved = true;
+        resolvedVal = result;
+      });
+
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.highlight.opacity).toBe(1);
+      expect(writer._renderState.state.character.highlight.strokes[0].opacity).toBe(0);
+      expect(writer._renderState.state.character.highlight.strokes[1].opacity).toBe(0);
+
+      expect(writer._renderState.state.character.highlight.strokes[1].displayPortion).toBe(0);
+      expect(isResolved).toBe(false);
+      expect(onComplete).not.toHaveBeenCalled();
+
+      clock.tick(1000);
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.highlight.strokes[1].displayPortion).toBe(1);
+      expect(writer._renderState.state.character.highlight.strokes[1].opacity).toBe(1);
+
+      clock.tick(1000);
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.highlight.strokes[1].displayPortion).toBe(1);
+      expect(writer._renderState.state.character.highlight.strokes[1].opacity).toBe(0);
+
+      expect(isResolved).toBe(true);
+      expect(resolvedVal).toEqual({ canceled: false });
+      expect(onComplete).toHaveBeenCalledTimes(1);
+      expect(onComplete).toHaveBeenCalledWith({ canceled: false });
+    });
+  });
+
   describe('loopCharacterAnimation', () => {
     it('animates and then repeats until something else stops it', async () => {
       document.body.innerHTML = '<div id="target"></div>';