Skip to content

Commit

Permalink
feat: add quizStartStrokeNum option to allow quizzing final stroke (#281
Browse files Browse the repository at this point in the history
)
  • Loading branch information
chanind committed Oct 26, 2022
1 parent 15d37e8 commit c0c08b0
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 12 deletions.
6 changes: 3 additions & 3 deletions src/HanziWriter.ts
Expand Up @@ -7,7 +7,7 @@ import canvasRenderer from './renderers/canvas';
import defaultOptions from './defaultOptions';
import LoadingManager from './LoadingManager';
import * as characterActions from './characterActions';
import { trim, colorStringToVals } from './utils';
import { trim, colorStringToVals, selectIndex, fixIndex } from './utils';
import Character from './models/Character';
import HanziWriterRendererBase, {
HanziWriterRendererConstructor,
Expand Down Expand Up @@ -209,7 +209,7 @@ export default class HanziWriter {
characterActions.animateSingleStroke(
'main',
this._character!,
strokeNum,
fixIndex(strokeNum, this._character!.strokes.length),
this._options.strokeAnimationSpeed,
),
)
Expand All @@ -234,7 +234,7 @@ export default class HanziWriter {
return this._renderState
.run(
characterActions.highlightStroke(
this._character.strokes[strokeNum],
selectIndex(this._character.strokes, strokeNum),
colorStringToVals(this._options.highlightColor),
this._options.strokeHighlightSpeed,
),
Expand Down
14 changes: 11 additions & 3 deletions src/Quiz.ts
@@ -1,7 +1,7 @@
import strokeMatches, { StrokeMatchResultMeta } from './strokeMatches';
import UserStroke from './models/UserStroke';
import Positioner from './Positioner';
import { counter, colorStringToVals } from './utils';
import { counter, colorStringToVals, fixIndex } from './utils';
import * as quizActions from './quizActions';
import * as geometry from './geometry';
import * as characterActions from './characterActions';
Expand Down Expand Up @@ -38,12 +38,20 @@ export default class Quiz {
startQuiz(options: ParsedHanziWriterOptions) {
this._isActive = true;
this._options = options;
this._currentStrokeIndex = 0;
const startIndex = fixIndex(
options.quizStartStrokeNum,
this._character.strokes.length,
);
this._currentStrokeIndex = Math.min(startIndex, this._character.strokes.length - 1);
this._mistakesOnStroke = 0;
this._totalMistakes = 0;

return this._renderState.run(
quizActions.startQuiz(this._character, options.strokeFadeDuration),
quizActions.startQuiz(
this._character,
options.strokeFadeDuration,
this._currentStrokeIndex,
),
);
}

Expand Down
90 changes: 90 additions & 0 deletions src/__tests__/HanziWriter-test.ts
Expand Up @@ -454,6 +454,45 @@ describe('HanziWriter', () => {
expect(onComplete).toHaveBeenCalledWith({ canceled: false });
});

it('supports negative indices', async () => {
document.body.innerHTML = '<div id="target"></div>';
const writer = HanziWriter.create('target', '人', {
showCharacter: true,
charDataLoader,
});
await writer._withDataPromise;

let isResolved = false;
let resolvedVal;
const onComplete = jest.fn();

writer.animateStroke(-1, { onComplete }).then((result) => {
isResolved = true;
resolvedVal = result;
});

await resolvePromises();

expect(writer._renderState!.state.character.main.opacity).toBe(1);
expect(writer._renderState!.state.character.main.strokes[0].opacity).toBe(1);
expect(writer._renderState!.state.character.main.strokes[1].opacity).toBe(1);

expect(writer._renderState!.state.character.main.strokes[0].displayPortion).toBe(1);
expect(writer._renderState!.state.character.main.strokes[1].displayPortion).toBe(0);
expect(isResolved).toBe(false);
expect(onComplete).not.toHaveBeenCalled();

clock.tick(1000);
await resolvePromises();

expect(writer._renderState!.state.character.main.strokes[0].displayPortion).toBe(1);
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 });
});

it('keeps other stroke opacities where they were originally', async () => {
document.body.innerHTML = '<div id="target"></div>';
const writer = HanziWriter.create('target', '人', {
Expand Down Expand Up @@ -618,6 +657,57 @@ describe('HanziWriter', () => {
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith({ canceled: false });
});

it('works with negative indices', async () => {
document.body.innerHTML = '<div id="target"></div>';
const writer = HanziWriter.create('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', () => {
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/Quiz-test.ts
Expand Up @@ -55,6 +55,7 @@ const opts: any = {
showHintAfterMisses: 3,
highlightOnComplete: true,
highlightCompleteColor: null,
quizStartStrokeNum: 0,

// undocumented obscure options

Expand Down Expand Up @@ -121,6 +122,64 @@ describe('Quiz', () => {
expect(renderState.state.character.highlight.strokes[strokeNum].opacity).toBe(0);
});
});

it('starts at the stroke set by quizStartStrokeNum', async () => {
const renderState = createRenderState();
renderState.updateState({
character: {
highlight: {
opacity: 0,
strokes: {
0: { opacity: 1 },
1: { opacity: 1 },
},
},
},
});

const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
quiz.startQuiz(Object.assign({}, opts, { quizStartStrokeNum: 1 }));
expect(quiz._currentStrokeIndex).toBe(1);
clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.opacity).toBe(1);
expect(renderState.state.character.main.strokes[0].opacity).toBe(1);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
});

it('respects negative numbers passed to quizStartStrokeNum', async () => {
const renderState = createRenderState();
renderState.updateState({
character: {
highlight: {
opacity: 0,
strokes: {
0: { opacity: 1 },
1: { opacity: 1 },
},
},
},
});

const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
quiz.startQuiz(Object.assign({}, opts, { quizStartStrokeNum: -1 }));
expect(quiz._currentStrokeIndex).toBe(1);
clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.opacity).toBe(1);
expect(renderState.state.character.main.strokes[0].opacity).toBe(1);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
});
});

describe('cancel', () => {
Expand Down
1 change: 1 addition & 0 deletions src/defaultOptions.ts
Expand Up @@ -39,6 +39,7 @@ const defaultOptions: HanziWriterOptions = {
highlightOnComplete: true,
highlightCompleteColor: null,
acceptBackwardsStrokes: false,
quizStartStrokeNum: 0,

// undocumented obscure options

Expand Down
12 changes: 9 additions & 3 deletions src/quizActions.ts
@@ -1,10 +1,14 @@
import Mutation, { MutationChain } from './Mutation';
import * as characterActions from './characterActions';
import { objRepeat } from './utils';
import { objRepeat, objRepeatCb } from './utils';
import Character from './models/Character';
import { Point } from './typings/types';

export const startQuiz = (character: Character, fadeDuration: number): MutationChain => {
export const startQuiz = (
character: Character,
fadeDuration: number,
startStrokeNum: number,
): MutationChain => {
return [
...characterActions.hideCharacter('main', character, fadeDuration),
new Mutation(
Expand All @@ -19,7 +23,9 @@ export const startQuiz = (character: Character, fadeDuration: number): MutationC
'character.main',
{
opacity: 1,
strokes: objRepeat({ opacity: 0 }, character.strokes.length),
strokes: objRepeatCb(character.strokes.length, (i) => ({
opacity: i < startStrokeNum ? 1 : 0,
})),
},
{ force: true },
),
Expand Down
2 changes: 2 additions & 0 deletions src/typings/types.ts
Expand Up @@ -68,6 +68,8 @@ export type QuizOptions = {
highlightOnComplete: boolean;
/** Whether to treat strokes which are correct besides their direction as correct. */
acceptBackwardsStrokes: boolean;
/** Begin quiz on this stroke number rather than stroke 0 */
quizStartStrokeNum: number;
onMistake?: (strokeData: StrokeData) => void;
onCorrectStroke?: (strokeData: StrokeData) => void;
/** Callback when the quiz completes */
Expand Down
22 changes: 22 additions & 0 deletions src/utils.ts
Expand Up @@ -31,6 +31,19 @@ export function arrLast<TValue>(arr: Array<TValue>) {
return arr[arr.length - 1];
}

export const fixIndex = (index: number, length: number) => {
// helper to handle negative indexes in array indices
if (index < 0) {
return length + index;
}
return index;
};

export const selectIndex = <T>(arr: Array<T>, index: number) => {
// helper to select item from array at index, supporting negative indexes
return arr[fixIndex(index, arr.length)];
};

export function copyAndMergeDeep<T>(base: T, override: RecursivePartial<T> | undefined) {
const output = { ...base };
for (const key in override) {
Expand Down Expand Up @@ -134,6 +147,15 @@ export function objRepeat<T>(item: T, times: number) {
return obj;
}

// similar to objRepeat, but takes in a callback which is called for each index in the object
export function objRepeatCb<T>(times: number, cb: (i: number) => T) {
const obj: Record<number, T> = {};
for (let i = 0; i < times; i++) {
obj[i] = cb(i);
}
return obj;
}

const ua = globalObj.navigator?.userAgent || '';

export const isMsBrowser =
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Expand Up @@ -2605,9 +2605,9 @@ camelcase@^6.0.0:
integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==

caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001154, caniuse-lite@^1.0.30001156, caniuse-lite@^1.0.30001173:
version "1.0.30001243"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz"
integrity sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==
version "1.0.30001425"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001425.tgz"
integrity sha512-/pzFv0OmNG6W0ym80P3NtapU0QEiDS3VuYAZMGoLLqiC7f6FJFe1MjpQDREGApeenD9wloeytmVDj+JLXPC6qw==

capture-exit@^2.0.0:
version "2.0.0"
Expand Down

0 comments on commit c0c08b0

Please sign in to comment.